diff --git a/android/app/build.gradle b/android/app/build.gradle index ada7789..498e3ba 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -10,16 +10,17 @@ plugins { android { namespace = "edu.todo.fall20240" - compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + compileSdk = 34 + ndkVersion = "27.0.12077973" compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + coreLibraryDesugaringEnabled true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8 + jvmTarget = JavaVersion.VERSION_17 } defaultConfig { @@ -30,6 +31,7 @@ android { targetSdk = 34 versionCode = flutter.versionCode versionName = flutter.versionName + multiDexEnabled true } buildTypes { @@ -44,3 +46,7 @@ android { flutter { source = "../.." } + +dependencies { + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5bd846d..10bf27c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,28 +1,29 @@ + + + android:icon="@mipmap/ic_launcher" + android:label="todo_fall_2024_0"> + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" /> - - + + - - + + diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index e1ca574..afa1e8e 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 7fb86d7..8d679a6 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -18,11 +18,11 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.3.0" apply false + id "com.android.application" version "8.7.1" apply false // START: FlutterFire Configuration - id "com.google.gms.google-services" version "4.3.15" apply false + id "com.google.gms.google-services" version "4.4.2" apply false // END: FlutterFire Configuration - id "org.jetbrains.kotlin.android" version "1.7.10" apply false + id "org.jetbrains.kotlin.android" version "1.9.22" apply false } include ":app" diff --git a/lib/data/model/todo.dart b/lib/data/model/todo.dart index 7172c13..a6914d6 100644 --- a/lib/data/model/todo.dart +++ b/lib/data/model/todo.dart @@ -1,11 +1,11 @@ import 'package:cloud_firestore/cloud_firestore.dart'; -// TODO: Use https://pub.dev/packages/equatable class Todo { final String id; final DateTime? completedAt; final String text; final DateTime? timestamp; + final DateTime? dueDate; final String userId; Todo({ @@ -13,6 +13,7 @@ class Todo { required this.completedAt, required this.text, required this.timestamp, + required this.dueDate, required this.userId, }); @@ -22,6 +23,7 @@ class Todo { completedAt: (map['completedAt'] as Timestamp?)?.toDate(), text: map['text']?.toString() ?? '', timestamp: (map['timestamp'] as Timestamp?)?.toDate(), + dueDate: (map['dueDate'] as Timestamp?)?.toDate(), userId: map['userId']?.toString() ?? '', ); } @@ -31,6 +33,7 @@ class Todo { 'completedAt': completedAt == null ? null : Timestamp.fromDate(completedAt!), 'text': text, 'timestamp': timestamp == null ? null : Timestamp.fromDate(timestamp!), + 'dueDate': dueDate == null ? null : Timestamp.fromDate(dueDate!), 'userId': userId, }; } diff --git a/lib/main.dart b/lib/main.dart index 4eeb88c..973f521 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,11 +2,13 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; import 'package:todo_fall_2024_0/firebase_options.dart'; import 'package:todo_fall_2024_0/screen/router/router_screen.dart'; +import 'package:timezone/data/latest.dart' as tz; void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + tz.initializeTimeZones(); runApp(const MyApp()); } diff --git a/lib/screen/details/details_screen.dart b/lib/screen/details/details_screen.dart index d832fcc..9cddab3 100644 --- a/lib/screen/details/details_screen.dart +++ b/lib/screen/details/details_screen.dart @@ -1,10 +1,15 @@ import 'dart:developer'; +import 'package:app_settings/app_settings.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:timezone/timezone.dart' as tz; import 'package:todo_fall_2024_0/data/model/todo.dart'; import 'package:todo_fall_2024_0/screen/home/home_screen.dart'; +final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + class DetailsScreen extends StatefulWidget { const DetailsScreen({ required this.todo, @@ -21,11 +26,13 @@ class _DetailsScreenState extends State { TextEditingController? _controller; String? text; + DateTime? dueDate; @override void initState() { super.initState(); text = widget.todo.text; + dueDate = widget.todo.dueDate; _controller = TextEditingController(text: text); } @@ -74,6 +81,72 @@ class _DetailsScreenState extends State { }); }, ), + SizedBox(height: 16), + ListTile( + title: Text('Due date'), + subtitle: dueDate == null ? null : Text(dueDate?.toString() ?? ''), + trailing: dueDate == null + ? IconButton( + onPressed: () async { + final isGranted = await _requestNotificationPermission(); + if (!context.mounted) return; + + if (!isGranted) { + _showPermissionDeniedSnackbar(context); + return; + } + + await _initializeNotifications(); + if (!context.mounted) return; + + final pickedDate = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime.now(), + lastDate: DateTime(2100), + ); + if (!context.mounted) return; + + if (pickedDate != null) { + final pickedTime = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(DateTime.now()), + ); + + if (pickedTime != null) { + final dueDate = DateTime( + pickedDate.year, + pickedDate.month, + pickedDate.day, + pickedTime.hour, + pickedTime.minute, + ); + _setDueDate(widget.todo.id, dueDate); + setState(() { + this.dueDate = dueDate; + }); + + await _scheduleNotification( + widget.todo.id, + dueDate, + widget.todo.text, + ); + } + } + }, + icon: Icon(Icons.add), + ) + : IconButton( + onPressed: () { + _setDueDate(widget.todo.id, null); + setState(() { + dueDate = null; + }); + _cancelNotification(widget.todo.id); + }, + icon: Icon(Icons.close), + ), + ), ], ), ), @@ -96,6 +169,83 @@ Future _updateTodo(String todoId, String text) async { 'text': text, }); } catch (e, st) { - log('Error deleting todo', error: e, stackTrace: st); + log('Error updating todo', error: e, stackTrace: st); } } + +Future _setDueDate(String todoId, DateTime? dueDate) async { + try { + await FirebaseFirestore.instance.collection(collectionTodo).doc(todoId).update({ + 'dueDate': dueDate, + }); + } catch (e, st) { + log('Error setting due date', error: e, stackTrace: st); + } +} + +Future _requestNotificationPermission() async { + final isGranted = await flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation() + ?.requestNotificationsPermission() ?? + false; + return isGranted; +} + +void _showPermissionDeniedSnackbar(BuildContext context) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'You need to enable notifications to set due date.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.white), + ), + backgroundColor: Colors.redAccent, + duration: Duration(seconds: 10), + action: SnackBarAction( + label: 'Open Settings', + textColor: Colors.white, + onPressed: () { + AppSettings.openAppSettings( + type: AppSettingsType.notification, + ); + }, + ), + ), + ); +} + +Future _initializeNotifications() async { + final initializationSettingsAndroid = AndroidInitializationSettings('@mipmap/ic_launcher'); + final InitializationSettings initializationSettings = InitializationSettings( + android: initializationSettingsAndroid, + ); + await flutterLocalNotificationsPlugin.initialize( + initializationSettings, + ); +} + +Future _scheduleNotification( + String todoId, + DateTime dueDate, + String text, +) async { + final tzDateTime = tz.TZDateTime.from(dueDate, tz.local); + await flutterLocalNotificationsPlugin.zonedSchedule( + todoId.hashCode, + 'Task due', + text, + tzDateTime, + const NotificationDetails( + android: AndroidNotificationDetails( + 'general_channel', + 'General Notifications', + ), + ), + androidScheduleMode: AndroidScheduleMode.inexact, + uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, + matchDateTimeComponents: DateTimeComponents.dateAndTime, + ); +} + +Future _cancelNotification(String todoId) async { + await flutterLocalNotificationsPlugin.cancel(todoId.hashCode); +} diff --git a/lib/screen/home/widgets/todos_list.dart b/lib/screen/home/widgets/todos_list.dart index cca4f5c..48d0879 100644 --- a/lib/screen/home/widgets/todos_list.dart +++ b/lib/screen/home/widgets/todos_list.dart @@ -34,6 +34,7 @@ class TodosList extends StatelessWidget { decoration: isCompleted ? TextDecoration.lineThrough : null, ), ), + subtitle: todo.dueDate == null ? null : Text(todo.dueDate.toString()), trailing: IconButton( icon: Icon(Icons.chevron_right), onPressed: () { diff --git a/pubspec.lock b/pubspec.lock index 4d49004..1615713 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.44" + app_settings: + dependency: "direct main" + description: + name: app_settings + sha256: "09bc7fe0313a507087bec1a3baf555f0576e816a760cbb31813a88890a09d9e5" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + args: + dependency: transitive + description: + name: args + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + url: "https://pub.dev" + source: hosted + version: "2.6.0" async: dependency: transitive description: @@ -73,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + dbus: + dependency: transitive + description: + name: dbus + sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" + url: "https://pub.dev" + source: hosted + version: "0.7.10" fake_async: dependency: transitive description: @@ -81,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" firebase_auth: dependency: "direct main" description: @@ -142,6 +174,30 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610 + url: "https://pub.dev" + source: hosted + version: "18.0.1" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52" + url: "https://pub.dev" + source: hosted + version: "8.0.0" flutter_test: dependency: "direct dev" description: flutter @@ -288,6 +344,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" plugin_platform_interface: dependency: transitive description: @@ -357,6 +421,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.2" + timezone: + dependency: "direct main" + description: + name: timezone + sha256: ffc9d5f4d1193534ef051f9254063fa53d588609418c84299956c3db9383587d + url: "https://pub.dev" + source: hosted + version: "0.10.0" typed_data: dependency: transitive description: @@ -389,6 +461,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" sdks: dart: ">=3.5.0 <4.0.0" flutter: ">=3.22.0" diff --git a/pubspec.yaml b/pubspec.yaml index 58903f5..00dac0c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,6 +15,9 @@ dependencies: sign_in_button: ^3.2.0 google_sign_in: ^6.2.1 cloud_firestore: ^5.4.4 + flutter_local_notifications: ^18.0.1 + app_settings: ^5.1.1 + timezone: ^0.10.0 dev_dependencies: