diff --git a/lib/app/models/data.dart b/lib/app/models/data.dart index 3fe5c66c..77bef2a2 100644 --- a/lib/app/models/data.dart +++ b/lib/app/models/data.dart @@ -8,6 +8,7 @@ import 'package:taskwarrior/app/models/json/task.dart'; import 'package:taskwarrior/app/services/notification_services.dart'; import 'package:taskwarrior/app/utils/taskc/payload.dart'; import 'package:taskwarrior/app/utils/taskfunctions/urgency.dart'; + class Data { Data(this.home); @@ -176,6 +177,11 @@ class Data { task.due!, task.description, false, task.entry); notificationService.cancelNotification(notificationid); + if (task.wait != null) { + int waitNotificationId = notificationService.calculateNotificationId( + task.wait!, task.description, true, task.entry); + notificationService.cancelNotification(waitNotificationId); + } } _mergeTasks([task]); File('${home.path}/.task/backlog.data').writeAsStringSync( diff --git a/lib/app/modules/detailRoute/controllers/detail_route_controller.dart b/lib/app/modules/detailRoute/controllers/detail_route_controller.dart index 8b65bc1c..5aba9945 100644 --- a/lib/app/modules/detailRoute/controllers/detail_route_controller.dart +++ b/lib/app/modules/detailRoute/controllers/detail_route_controller.dart @@ -127,6 +127,7 @@ class DetailRouteController extends GetxController { late Rx dueValue = Rx(null); late Rx waitValue = Rx(null); late Rx untilValue = Rx(null); + late RxString recurValue = ''.obs; late Rxn? priorityValue = Rxn(null); late Rxn? projectValue = Rxn(null); late Rxn>? tagsValue = Rxn>(null); @@ -155,6 +156,7 @@ class DetailRouteController extends GetxController { dueValue.value = modify.draft.due; waitValue.value = modify.draft.wait; untilValue.value = modify.draft.until; + recurValue.value = modify.draft.recur ?? ''; priorityValue?.value = modify.draft.priority; projectValue?.value = modify.draft.project; tagsValue?.value = modify.draft.tags; diff --git a/lib/app/modules/detailRoute/views/detail_route_view.dart b/lib/app/modules/detailRoute/views/detail_route_view.dart index dab0aac2..3d1e0235 100644 --- a/lib/app/modules/detailRoute/views/detail_route_view.dart +++ b/lib/app/modules/detailRoute/views/detail_route_view.dart @@ -146,6 +146,7 @@ class DetailRouteView extends GetView { 'due': controller.dueValue.value, 'wait': controller.waitValue.value, 'until': controller.untilValue.value, + 'recur': controller.recurValue.value, 'priority': controller.priorityValue?.value, 'project': controller.projectValue?.value, 'tags': controller.tagsValue?.value, @@ -328,6 +329,97 @@ class AttributeWidget extends StatelessWidget { globalKey: priorityKey, isEditable: isEditable, ); + case 'recur': + final currentRecur = + (localValue == null || localValue.toString().trim().isEmpty) + ? 'None' + : localValue.toString(); + return Card( + color: tColors.secondaryBackgroundColor, + child: ListTile( + enabled: isEditable, + textColor: isEditable + ? tColors.primaryTextColor + : tColors.primaryDisabledTextColor, + title: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + Text( + 'recurrence:'.padRight(13), + style: TextStyle( + fontFamily: FontFamily.poppins, + fontWeight: TaskWarriorFonts.bold, + fontSize: TaskWarriorFonts.fontSizeMedium, + color: isEditable + ? tColors.primaryTextColor + : tColors.primaryDisabledTextColor, + ), + ), + Text( + currentRecur, + style: TextStyle( + fontFamily: FontFamily.poppins, + fontSize: TaskWarriorFonts.fontSizeMedium, + color: isEditable + ? tColors.primaryTextColor + : tColors.primaryDisabledTextColor, + ), + ), + ], + ), + ), + onTap: !isEditable + ? null + : () async { + final options = [ + 'None', + 'daily', + 'weekdays', + 'weekly', + 'biweekly', + 'monthly', + 'bimonthly', + 'quarterly', + 'semiannual', + 'yearly', + ]; + final selected = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text( + 'Select recurrence', + style: TextStyle(color: tColors.primaryTextColor), + ), + backgroundColor: tColors.dialogBackgroundColor, + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: options + .map( + (option) => ListTile( + title: Text( + option, + style: TextStyle( + color: tColors.primaryTextColor), + ), + onTap: () => Navigator.of(dialogContext) + .pop(option), + ), + ) + .toList(), + ), + ), + ); + }, + ); + if (selected != null) { + callback(selected == 'None' ? null : selected); + } + }, + ), + ); case 'project': return ProjectWidget( name: name, diff --git a/lib/app/modules/home/controllers/home_controller.dart b/lib/app/modules/home/controllers/home_controller.dart index 053f2bcc..87b502b5 100644 --- a/lib/app/modules/home/controllers/home_controller.dart +++ b/lib/app/modules/home/controllers/home_controller.dart @@ -577,8 +577,7 @@ class HomeController extends GetxController { await synchronize(context, false); } if (context.mounted) { - final tColors = - Theme.of(context).extension()!; + final tColors = Theme.of(context).extension()!; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( @@ -622,6 +621,8 @@ class HomeController extends GetxController { final tagcontroller = TextEditingController(); RxList tags = [].obs; + RxString recur = ''.obs; + RxBool dueAutoFromRecurrence = false.obs; RxBool inThePast = false.obs; Filters getFilters() { diff --git a/lib/app/modules/home/views/add_task_bottom_sheet_new.dart b/lib/app/modules/home/views/add_task_bottom_sheet_new.dart index 3442246f..0a10d50d 100644 --- a/lib/app/modules/home/views/add_task_bottom_sheet_new.dart +++ b/lib/app/modules/home/views/add_task_bottom_sheet_new.dart @@ -29,6 +29,25 @@ class AddTaskBottomSheet extends StatelessWidget { this.forTaskC = false, this.forReplica = false}); + void _closeComposer(BuildContext context, {required String result}) { + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.pop(result); + return; + } + final rootNavigator = Navigator.of(context, rootNavigator: true); + if (rootNavigator.canPop()) { + rootNavigator.pop(result); + return; + } + Get.back(result: result); + } + + DateTime _defaultDueDate() { + final now = DateTime.now(); + return DateTime(now.year, now.month, now.day, 23, 59); + } + @override Widget build(BuildContext context) { debugPrint( @@ -50,7 +69,7 @@ class AddTaskBottomSheet extends StatelessWidget { children: [ TextButton( onPressed: () { - Get.back(); + _closeComposer(context, result: "cancel"); }, child: Text(SentenceManager( currentLanguage: @@ -70,14 +89,14 @@ class AddTaskBottomSheet extends StatelessWidget { ), ), TextButton( - onPressed: () { + onPressed: () async { if (forTaskC) { - onSaveButtonClickedTaskC(context); + await onSaveButtonClickedTaskC(context); } else if (forReplica) { debugPrint("Saving to Replica"); - onSaveButtonClickedForReplica(context); + await onSaveButtonClickedForReplica(context); } else { - onSaveButtonClicked(context); + await onSaveButtonClicked(context); } }, child: Text(SentenceManager( @@ -130,6 +149,10 @@ class AddTaskBottomSheet extends StatelessWidget { padding: const EdgeInsets.all(padding), child: buildTagsInput(context), ), + Padding( + padding: const EdgeInsets.all(padding), + child: buildRecurrenceSelector(context), + ), const Padding(padding: EdgeInsets.all(20)), ], ), @@ -221,12 +244,24 @@ class AddTaskBottomSheet extends StatelessWidget { onTagsChanges: (p0) => homeController.tags.value = p0, ); - Widget buildDatePicker(BuildContext context) => AddTaskDatePickerInput( - onDateChanges: (List p0) { - homeController.selectedDates.value = p0; - }, - allowedIndexes: forReplica ? [0, 1] : [0, 1, 2, 3], - onlyDueDate: forTaskC, + Widget buildDatePicker(BuildContext context) => Obx( + () => AddTaskDatePickerInput( + key: ValueKey('date-picker-${homeController.recur.value}'), + initialDates: List.from(homeController.selectedDates), + onDateChanges: (List p0) { + final previousDue = homeController.selectedDates.isNotEmpty + ? homeController.selectedDates[0] + : null; + homeController.selectedDates.value = p0; + final nextDue = p0.isNotEmpty ? p0[0] : null; + if (previousDue != nextDue) { + homeController.dueAutoFromRecurrence.value = false; + } + }, + allowedIndexes: forReplica ? [0, 1] : [0, 1, 2, 3], + onlyDueDate: forTaskC, + disableDueDate: false, + ), ); Widget buildPriority(BuildContext context) => Column( @@ -308,6 +343,78 @@ class AddTaskBottomSheet extends StatelessWidget { ], ); + Widget buildRecurrenceSelector(BuildContext context) { + final options = [ + '', + 'daily', + 'weekdays', + 'weekly', + 'biweekly', + 'monthly', + 'bimonthly', + 'quarterly', + 'semiannual', + 'yearly', + ]; + final labels = [ + 'None', + 'Daily', + 'Weekdays', + 'Weekly', + 'Biweekly', + 'Monthly', + 'Bimonthly', + 'Quarterly', + 'Semiannual', + 'Yearly', + ]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Recurrence', + style: GoogleFonts.poppins( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + Obx(() => Wrap( + spacing: 8, + runSpacing: 4, + children: List.generate(options.length, (index) { + final isSelected = homeController.recur.value == options[index]; + return ChoiceChip( + label: Text(labels[index]), + selected: isSelected, + onSelected: (_) { + final selectedRecur = options[index]; + homeController.recur.value = selectedRecur; + if (selectedRecur.isNotEmpty) { + final updated = + List.from(homeController.selectedDates); + if (updated[0] == null) { + updated[0] = _defaultDueDate(); + homeController.dueAutoFromRecurrence.value = true; + } + homeController.selectedDates.value = updated; + return; + } + if (homeController.dueAutoFromRecurrence.value) { + final updated = + List.from(homeController.selectedDates); + updated[0] = null; + homeController.selectedDates.value = updated; + } + homeController.dueAutoFromRecurrence.value = false; + }, + ); + }), + )), + ], + ); + } + Set getProjects() { if (homeController.taskReplica.value) { return homeController.tasksFromReplica @@ -320,9 +427,12 @@ class AddTaskBottomSheet extends StatelessWidget { .fold({}, (aggregate, task) => aggregate..add(task.project!)); } - void onSaveButtonClickedTaskC(BuildContext context) async { + Future onSaveButtonClickedTaskC(BuildContext context) async { if (homeController.formKey.currentState!.validate()) { debugPrint("tags ${homeController.tags}"); + final messenger = ScaffoldMessenger.maybeOf(context); + final DateTime? dueDate = getDueDate(homeController.selectedDates) ?? + (homeController.recur.value.isNotEmpty ? _defaultDueDate() : null); var task = TaskForC( description: homeController.namecontroller.text.trim(), status: 'pending', @@ -334,14 +444,16 @@ class AddTaskBottomSheet extends StatelessWidget { : null, uuid: '', urgency: 0, - due: getDueDate(homeController.selectedDates).toString(), - end: '', - modified: 'r', + due: dueDate?.toUtc().toIso8601String() ?? '', + end: null, + modified: DateTime.now().toIso8601String(), tags: homeController.tags, - start: '', - wait: '', - rtype: '', - recur: '', + start: null, + wait: getWaitDate(homeController.selectedDates) + ?.toUtc() + .toIso8601String(), + rtype: homeController.recur.value.isNotEmpty ? 'periodic' : '', + recur: homeController.recur.value, depends: [], annotations: []); await homeController.taskdb.insertTask(task); @@ -349,7 +461,10 @@ class AddTaskBottomSheet extends StatelessWidget { homeController.due.value = null; homeController.priority.value = 'M'; homeController.projectcontroller.text = ''; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( + homeController.recur.value = ''; + homeController.dueAutoFromRecurrence.value = false; + _closeComposer(context, result: "saved"); + messenger?.showSnackBar(SnackBar( content: Text( SentenceManager( currentLanguage: homeController.selectedLanguage.value) @@ -365,16 +480,17 @@ class AddTaskBottomSheet extends StatelessWidget { ? TaskWarriorColors.ksecondaryBackgroundColor : TaskWarriorColors.kLightSecondaryBackgroundColor, duration: const Duration(seconds: 2))); - Navigator.of(context).pop(); } } - void onSaveButtonClicked(BuildContext context) async { + Future onSaveButtonClicked(BuildContext context) async { if (homeController.formKey.currentState!.validate()) { + final messenger = ScaffoldMessenger.maybeOf(context); try { + final DateTime? dueDate = getDueDate(homeController.selectedDates) ?? + (homeController.recur.value.isNotEmpty ? _defaultDueDate() : null); var task = taskParser(homeController.namecontroller.text.trim()) - .rebuild((b) => - b..due = getDueDate(homeController.selectedDates)?.toUtc()) + .rebuild((b) => b..due = dueDate?.toUtc()) .rebuild((p) => p..priority = homeController.priority.value) .rebuild((t) => t..project = homeController.projectcontroller.text) .rebuild((t) => @@ -382,8 +498,11 @@ class AddTaskBottomSheet extends StatelessWidget { .rebuild((t) => t..until = getUntilDate(homeController.selectedDates)?.toUtc()) .rebuild((t) => t - ..scheduled = - getSchedDate(homeController.selectedDates)?.toUtc()); + ..scheduled = getSchedDate(homeController.selectedDates)?.toUtc()) + .rebuild((t) => t + ..recur = homeController.recur.value.isEmpty + ? null + : homeController.recur.value); if (homeController.tags.isNotEmpty) { task = task.rebuild((t) => t..tags.replace(homeController.tags)); } @@ -394,8 +513,10 @@ class AddTaskBottomSheet extends StatelessWidget { homeController.priority.value = 'X'; homeController.tagcontroller.text = ''; homeController.tags.value = []; + homeController.recur.value = ''; + homeController.dueAutoFromRecurrence.value = false; homeController.update(); - Get.back(); + _closeComposer(context, result: "saved"); if (Platform.isAndroid) { WidgetController widgetController = Get.put(WidgetController()); widgetController.fetchAllData(); @@ -404,7 +525,7 @@ class AddTaskBottomSheet extends StatelessWidget { homeController.update(); - ScaffoldMessenger.of(context).showSnackBar(SnackBar( + messenger?.showSnackBar(SnackBar( content: Text( SentenceManager( currentLanguage: homeController.selectedLanguage.value) @@ -437,7 +558,7 @@ class AddTaskBottomSheet extends StatelessWidget { widgetController.update(); } } on FormatException catch (e) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( + messenger?.showSnackBar(SnackBar( content: Text( e.message, style: TextStyle( @@ -454,18 +575,25 @@ class AddTaskBottomSheet extends StatelessWidget { } } - void onSaveButtonClickedForReplica(BuildContext context) async { + Future onSaveButtonClickedForReplica(BuildContext context) async { if (homeController.formKey.currentState!.validate()) { + final messenger = ScaffoldMessenger.maybeOf(context); try { + final DateTime? dueDate = getDueDate(homeController.selectedDates) ?? + (homeController.recur.value.isNotEmpty ? _defaultDueDate() : null); await Replica.addTaskToReplica(HashMap.from({ "description": homeController.namecontroller.text.trim(), - "due": getDueDate(homeController.selectedDates)?.toUtc(), + "entry": DateTime.now().toUtc().toIso8601String(), + "due": dueDate?.toUtc(), "priority": homeController.priority.value, "project": homeController.projectcontroller.text != "" ? homeController.projectcontroller.text : null, "wait": getWaitDate(homeController.selectedDates)?.toUtc(), "tags": homeController.tags, + if (homeController.recur.value.isNotEmpty) + "recur": homeController.recur.value, + if (homeController.recur.value.isNotEmpty) "rtype": "periodic", })); homeController.namecontroller.text = ''; homeController.projectcontroller.text = ''; @@ -473,8 +601,10 @@ class AddTaskBottomSheet extends StatelessWidget { homeController.priority.value = 'X'; homeController.tagcontroller.text = ''; homeController.tags.value = []; + homeController.recur.value = ''; + homeController.dueAutoFromRecurrence.value = false; homeController.update(); - Get.back(); + _closeComposer(context, result: "saved"); if (Platform.isAndroid) { WidgetController widgetController = Get.put(WidgetController()); widgetController.fetchAllData(); @@ -482,7 +612,7 @@ class AddTaskBottomSheet extends StatelessWidget { } homeController.update(); - ScaffoldMessenger.of(context).showSnackBar(SnackBar( + messenger?.showSnackBar(SnackBar( content: Text( SentenceManager( currentLanguage: homeController.selectedLanguage.value) @@ -510,7 +640,7 @@ class AddTaskBottomSheet extends StatelessWidget { } await storageWidget.refreshReplicaTaskList(); } on FormatException catch (e) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( + messenger?.showSnackBar(SnackBar( content: Text( e.message, style: TextStyle( diff --git a/lib/app/modules/home/views/show_tasks.dart b/lib/app/modules/home/views/show_tasks.dart index 008b30fb..2b4de20b 100644 --- a/lib/app/modules/home/views/show_tasks.dart +++ b/lib/app/modules/home/views/show_tasks.dart @@ -9,8 +9,10 @@ import 'package:taskwarrior/app/utils/constants/taskwarrior_colors.dart'; import 'package:taskwarrior/app/utils/constants/taskwarrior_fonts.dart'; import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; +import 'package:taskwarrior/app/utils/taskfunctions/datetime_differences.dart'; import 'package:taskwarrior/app/v3/db/task_database.dart'; import 'package:taskwarrior/app/v3/models/task.dart'; +import 'package:taskwarrior/app/v3/net/add_task.dart'; import 'package:taskwarrior/app/v3/net/complete.dart'; import 'package:taskwarrior/app/v3/net/delete.dart'; @@ -113,6 +115,15 @@ class TaskViewBuilder extends StatelessWidget { itemCount: tasks.length, itemBuilder: (context, index) { TaskForC task = tasks[index]; + final bool isRecurring = + task.recur != null && task.recur!.trim().isNotEmpty; + final String dueDateText = (() { + final dueStr = task.due; + if (dueStr == null || dueStr.isEmpty) return ''; + final parsed = DateTime.tryParse(dueStr); + if (parsed == null) return ''; + return ' | ${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.homePageDue}: ${when(parsed.toLocal())}'; + })(); return Slidable( startActionPane: ActionPane( motion: const BehindMotion(), @@ -210,11 +221,51 @@ class TaskViewBuilder extends StatelessWidget { ), ), subtitle: Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.detailPageUrgency}: ${task.urgency!.floorToDouble()} | ${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.detailPageStatus}: ${task.status}', + '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.detailPageUrgency}: ${task.urgency!.floorToDouble()} | ${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.detailPageStatus}: ${task.status}$dueDateText', style: GoogleFonts.poppins( color: tColors.secondaryTextColor, ), ), + trailing: isRecurring + ? ConstrainedBox( + constraints: + const BoxConstraints(maxWidth: 96), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: tColors.secondaryTextColor!, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.repeat, + size: 14, + color: + tColors.secondaryTextColor), + const SizedBox(width: 3), + Flexible( + child: Text( + task.recur!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.poppins( + fontSize: 10, + color: + tColors.secondaryTextColor, + ), + ), + ), + ], + ), + ), + ) + : null, ), ), ), @@ -229,15 +280,29 @@ class TaskViewBuilder extends StatelessWidget { void _markTaskAsCompleted(String uuid) async { TaskDatabase taskDatabase = TaskDatabase(); await taskDatabase.open(); - taskDatabase.markTaskAsCompleted(uuid); + await taskDatabase.markTaskAsCompleted(uuid); completeTask('email', uuid); + // Push any newly spawned recurring child task to the server immediately + // so it is visible there without waiting for the next manual sync. + try { + final children = await taskDatabase.getTasksByParent(uuid); + for (final child in children) { + if (child.status == 'pending') { + await pushNewTaskToServer(child); + } + } + } catch (e) { + debugPrint('Failed to push recurring child tasks to server: $e'); + } + await Get.find().fetchTasksFromDB(); } void _markTaskAsDeleted(String uuid) async { TaskDatabase taskDatabase = TaskDatabase(); await taskDatabase.open(); - taskDatabase.markTaskAsDeleted(uuid); + await taskDatabase.markTaskAsDeleted(uuid); deleteTask('email', uuid); + await Get.find().fetchTasksFromDB(); } Color _getPriorityColor(String priority) { diff --git a/lib/app/modules/home/views/show_tasks_replica.dart b/lib/app/modules/home/views/show_tasks_replica.dart index 642fbdff..fc1265cb 100644 --- a/lib/app/modules/home/views/show_tasks_replica.dart +++ b/lib/app/modules/home/views/show_tasks_replica.dart @@ -9,6 +9,7 @@ import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; import 'package:taskwarrior/app/utils/constants/taskwarrior_fonts.dart'; import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; +import 'package:taskwarrior/app/utils/taskfunctions/datetime_differences.dart'; import 'package:taskwarrior/app/v3/champion/replica.dart'; import 'package:taskwarrior/app/v3/champion/models/task_for_replica.dart'; @@ -98,6 +99,15 @@ class TaskReplicaViewBuilder extends StatelessWidget { itemCount: tasks.length, itemBuilder: (context, index) { final task = tasks[index]; + final bool isRecurring = + task.recur != null && task.recur!.trim().isNotEmpty; + final String dueDateText = (() { + final dueStr = task.due; + if (dueStr == null || dueStr.isEmpty) return ''; + final parsed = DateTime.tryParse(dueStr); + if (parsed == null) return ''; + return ' | ${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.homePageDue}: ${when(parsed.toLocal())}'; + })(); // Determine if due is within 24 hours or already past (only for pending filter) final bool isDueSoon = (() { if (!pendingFilter) return false; @@ -148,11 +158,49 @@ class TaskReplicaViewBuilder extends StatelessWidget { ), ), subtitle: Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.detailPageStatus}: ${task.status ?? ''}', + '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.detailPageStatus}: ${task.status ?? ''}$dueDateText', style: GoogleFonts.poppins( color: tColors.secondaryTextColor, ), ), + trailing: isRecurring + ? ConstrainedBox( + constraints: + const BoxConstraints(maxWidth: 96), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: tColors.secondaryTextColor!, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.repeat, + size: 14, + color: tColors.secondaryTextColor), + const SizedBox(width: 3), + Flexible( + child: Text( + task.recur!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.poppins( + fontSize: 10, + color: tColors.secondaryTextColor, + ), + ), + ), + ], + ), + ), + ) + : null, ), ), ), @@ -220,11 +268,14 @@ class TaskReplicaViewBuilder extends StatelessWidget { } void completeTask(TaskForReplica task) async { + // modifyTaskInReplica handles notification cancellation internally; + // no need to cancel here separately. await Replica.modifyTaskInReplica(task.copyWith(status: 'completed')); Get.find().refreshReplicaTaskList(); } void deleteTask(TaskForReplica task) async { + Replica.cancelNotificationsForTask(task); await Replica.deleteTaskFromReplica(task.uuid); Get.find().refreshReplicaTaskList(); } diff --git a/lib/app/modules/home/views/tas_list_item.dart b/lib/app/modules/home/views/tas_list_item.dart index 6c6041bc..f12de205 100644 --- a/lib/app/modules/home/views/tas_list_item.dart +++ b/lib/app/modules/home/views/tas_list_item.dart @@ -29,7 +29,8 @@ class TaskListItem extends StatelessWidget { @override Widget build(BuildContext context) { - TaskwarriorColorTheme tColors = Theme.of(context).extension()!; + TaskwarriorColorTheme tColors = + Theme.of(context).extension()!; // ignore: unused_element void saveChanges() async { var now = DateTime.now().toUtc(); @@ -62,7 +63,7 @@ class TaskListItem extends StatelessWidget { } MaterialColor colours = Colors.grey; - Color colour =tColors.primaryTextColor!; + Color colour = tColors.primaryTextColor!; Color dimColor = tColors.dimCol!; if (task.priority == 'H') { colours = Colors.red; @@ -71,6 +72,8 @@ class TaskListItem extends StatelessWidget { } else if (task.priority == 'L') { colours = Colors.green; } + final hasRecurrence = task.recur != null && task.recur!.trim().isNotEmpty; + final hasAnnotations = task.annotations != null && task.annotations!.isNotEmpty; if ((task.status[0].toUpperCase()) == 'P') { // Pending tasks @@ -91,17 +94,16 @@ class TaskListItem extends StatelessWidget { ), child: ListTile( title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( + Expanded( + child: Row( children: [ CircleAvatar( backgroundColor: colours, radius: 8, ), const SizedBox(width: 8), - SizedBox( - width: MediaQuery.of(context).size.width * 0.70, + Expanded( child: Text( '${(task.id == 0) ? '#' : task.id}. ${task.description}', maxLines: 1, @@ -115,15 +117,49 @@ class TaskListItem extends StatelessWidget { ), ], ), - Text( - (task.annotations != null) - ? ' [${task.annotations!.length}]' - : '', - // style: GoogleFonts.poppins( - // color: colour, - // ), - style: TextStyle(fontFamily: FontFamily.poppins, color: colour), ), + if (hasRecurrence || hasAnnotations) const SizedBox(width: 8), + if (hasRecurrence) + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 96), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: colours.withAlpha(40), + border: Border.all(color: colours), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.repeat, size: 12, color: colour), + const SizedBox(width: 3), + Flexible( + child: Text( + task.recur!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: FontFamily.poppins, + fontSize: 10, + color: colour, + ), + ), + ), + ], + ), + ), + ), + if (hasAnnotations) ...[ + const SizedBox(width: 6), + Text( + '[${task.annotations!.length}]', + style: TextStyle(fontFamily: FontFamily.poppins, color: colour), + ), + ], ], ), subtitle: Row( @@ -161,17 +197,16 @@ class TaskListItem extends StatelessWidget { // Completed tasks return ListTile( title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( + Expanded( + child: Row( children: [ CircleAvatar( backgroundColor: colours, radius: 8, ), const SizedBox(width: 8), - SizedBox( - width: MediaQuery.of(context).size.width * 0.65, + Expanded( child: Text( '${(task.id == 0) ? '#' : task.id}. ${task.description}', maxLines: 1, @@ -185,6 +220,41 @@ class TaskListItem extends StatelessWidget { ), ], ), + ), + if (hasRecurrence) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: colours.withAlpha(40), + border: Border.all(color: colours), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.repeat, size: 12, color: colour), + const SizedBox(width: 3), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 96), + child: Text( + task.recur!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: FontFamily.poppins, + fontSize: 10, + color: colour, + ), + ), + ), + ], + ), + ), + ], ], ), subtitle: Row( diff --git a/lib/app/modules/taskc_details/controllers/taskc_details_controller.dart b/lib/app/modules/taskc_details/controllers/taskc_details_controller.dart index 6116cf7e..fa6aaeb1 100644 --- a/lib/app/modules/taskc_details/controllers/taskc_details_controller.dart +++ b/lib/app/modules/taskc_details/controllers/taskc_details_controller.dart @@ -61,8 +61,8 @@ class TaskcDetailsController extends GetxController { : [].obs; previousTags = tags.toList().obs; depends = "".split(",").obs; - rtype = "".obs; - recur = "".obs; + rtype = (task.rtype ?? '').obs; + recur = (task.recur ?? '').obs; annotations = [].obs; } else if (task is TaskForReplica) { description = (task.description ?? '').obs; @@ -82,8 +82,8 @@ class TaskcDetailsController extends GetxController { : [].obs; previousTags = tags.toList().obs; depends = "".split(",").obs; - rtype = "".obs; - recur = "".obs; + rtype = (task.rtype ?? '').obs; + recur = (task.recur ?? '').obs; annotations = [].obs; } else { // Fallback @@ -144,6 +144,19 @@ class TaskcDetailsController extends GetxController { } } + DateTime? _parseUiDate(String value, String pattern) { + if (value == 'None' || value.isEmpty) return null; + try { + return DateFormat(pattern).parse(value).toUtc(); + } catch (_) { + try { + return DateTime.parse(value).toUtc(); + } catch (_) { + return null; + } + } + } + void updateField(Rx field, T value) { if (field.value != value) { field.value = value; @@ -232,94 +245,75 @@ class TaskcDetailsController extends GetxController { final datePattern = is24hrFormat ? 'EEE, yyyy-MM-dd HH:mm:ss' : 'EEE, yyyy-MM-dd hh:mm:ss a'; + final dueDate = _parseUiDate(due.string, datePattern); + final waitDate = _parseUiDate(wait.string, datePattern); if (tags.length == 1 && tags[0] == "") { tags.clear(); } if (initialTask is TaskForC) { + final bool shouldSpawnRecurrence = status.string == 'completed' && + initialTask.status != 'completed' && + recur.string.isNotEmpty; await taskDatabase.saveEditedTaskInDB( initialTask.uuid!, description.string, project.string, status.string, priority.string, - DateTime.parse(due.string).toIso8601String(), + dueDate?.toIso8601String() ?? '', tags.toList(), + recur: recur.string.isNotEmpty ? recur.string : null, + rtype: recur.string.isNotEmpty ? 'periodic' : null, ); + if (shouldSpawnRecurrence) { + await taskDatabase.markTaskAsCompleted( + initialTask.uuid!, + forceRecurrence: true, + ); + } hasChanges.value = false; debugPrint('Task saved in local DB ${description.string}'); processTagsLists(); await modifyTaskOnTaskwarrior( description.string, project.string, - DateTime.parse(due.string).toIso8601String(), + dueDate?.toIso8601String() ?? '', priority.string, status.string, initialTask.uuid!, initialTask.id.toString(), tags.toList(), + recur: recur.string.isNotEmpty ? recur.string : null, + rtype: recur.string.isNotEmpty ? 'periodic' : null, ); + try { + await Get.find().fetchTasksFromDB(); + } catch (_) {} } else if (initialTask is TaskForReplica) { debugPrint( 'Saving replica task changes... status ${status.string} ${tags.join(", ")}'); final int nowEpoch = DateTime.now().millisecondsSinceEpoch ~/ 1000; final modifiedTask = TaskForReplica( modified: nowEpoch, - due: () { - if (due.string == 'None' || due.string.isEmpty) return null; - try { - final parsed = DateFormat(datePattern).parse(due.string); - return parsed.toUtc().toIso8601String(); - } catch (e) { - try { - final parsed2 = DateTime.parse(due.string); - return parsed2.toUtc().toIso8601String(); - } catch (_) { - debugPrint( - 'Could not parse due string for replica: ${due.string}'); - return null; - } - } - }(), + due: dueDate?.toIso8601String(), start: () { if (start.string == 'None' || start.string.isEmpty) return null; if (start.string == "stop") return "stop"; - try { - final parsed = DateFormat(datePattern).parse(start.string); - return parsed.toUtc().toIso8601String(); - } catch (e) { - try { - final parsed2 = DateTime.parse(start.string); - return parsed2.toUtc().toIso8601String(); - } catch (_) { - debugPrint( - 'Could not parse start string for replica: ${start.string}'); - return null; - } - } - }(), - wait: () { - if (wait.string == 'None' || wait.string.isEmpty) return null; - try { - final parsed = DateFormat(datePattern).parse(wait.string); - return parsed.toUtc().toIso8601String(); - } catch (e) { - try { - final parsed2 = DateTime.parse(wait.string); - return parsed2.toUtc().toIso8601String(); - } catch (_) { - debugPrint( - 'Could not parse wait string for replica: ${wait.string}'); - return null; - } - } + return _parseUiDate(start.string, datePattern)?.toIso8601String(); }(), + wait: waitDate?.toIso8601String(), status: status.string.isNotEmpty ? status.string : null, description: description.string.isNotEmpty ? description.string : null, tags: tags.isNotEmpty ? tags.toList() : null, uuid: initialTask.uuid ?? '', priority: priority.string.isNotEmpty ? priority.string : null, project: project.string != 'None' ? project.string : null, + recur: recur.string.isNotEmpty ? recur.string : null, + rtype: recur.string.isNotEmpty ? 'periodic' : null, + mask: (initialTask.mask ?? '').toString(), + imask: (initialTask.imask ?? '').toString(), + parent: (initialTask.parent ?? '').toString(), ); debugPrint('Modified replica task: $modifiedTask'); hasChanges.value = false; @@ -438,19 +432,21 @@ class TaskcDetailsController extends GetxController { '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.select} $label', style: TextStyle(color: tColors.primaryTextColor), ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: options.map((option) { - return RadioListTile( - title: Text( - option, - style: TextStyle(color: tColors.primaryTextColor), - ), - value: option, - groupValue: initialValue, - onChanged: (value) => Get.back(result: value), - ); - }).toList(), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: options.map((option) { + return RadioListTile( + title: Text( + option, + style: TextStyle(color: tColors.primaryTextColor), + ), + value: option, + groupValue: initialValue, + onChanged: (value) => Get.back(result: value), + ); + }).toList(), + ), ), ), ); diff --git a/lib/app/modules/taskc_details/views/taskc_details_view.dart b/lib/app/modules/taskc_details/views/taskc_details_view.dart index 2203274f..fdaf55cf 100644 --- a/lib/app/modules/taskc_details/views/taskc_details_view.dart +++ b/lib/app/modules/taskc_details/views/taskc_details_view.dart @@ -111,18 +111,36 @@ class TaskcDetailsView extends GetView { controller.tags.join(', '), (value) => controller.updateListField(controller.tags, value), ), - if (controller.isLocalTask) ...[ - _buildDetail( - context, - 'Rtype:', - controller.rtype.value, - ), - _buildDetail( - context, - 'Recur:', - controller.recur.value, - ), - ], + _buildSelectableDetail( + context, + 'Recurrence:', + controller.recur.value.isEmpty + ? 'None' + : controller.recur.value, + [ + 'None', + 'daily', + 'weekdays', + 'weekly', + 'biweekly', + 'monthly', + 'bimonthly', + 'quarterly', + 'semiannual', + 'yearly', + ], + (value) { + controller.updateField( + controller.recur, + value == 'None' ? '' : value, + ); + // Auto-set rtype + controller.updateField( + controller.rtype, + value == 'None' ? '' : 'periodic', + ); + }, + ), // Conditionally show fields that are only present on local tasks if (controller.isLocalTask) ...[ _buildDetail( diff --git a/lib/app/services/notification_services.dart b/lib/app/services/notification_services.dart index 9a24c7fc..93381892 100644 --- a/lib/app/services/notification_services.dart +++ b/lib/app/services/notification_services.dart @@ -136,4 +136,72 @@ class NotificationService { void cancelNotification(int notificationId) async { await _flutterLocalNotificationsPlugin.cancel(notificationId); } + + /// Schedule an advance-warning notification 24 hours before [due] for a + /// recurring task. Uses an ID offset of +4 over the base due-notification + /// ID so it never collides with the due (+0) or wait (+2) notifications. + void sendRecurrenceAdvanceNotification( + DateTime due, String taskname, DateTime entryTime) async { + final advanceTime = due.subtract(const Duration(hours: 24)); + if (advanceTime.isBefore(DateTime.now().toUtc())) return; + + tz.initializeTimeZones(); + final tz.TZDateTime scheduledAt = tz.TZDateTime.from(advanceTime, tz.local); + + final int baseId = calculateNotificationId(due, taskname, false, entryTime); + final int notificationId = (baseId + 4) % 2147483647; + + AndroidNotificationDetails androidDetails = + const AndroidNotificationDetails('channelId', 'TaskReminder', + icon: "taskwarrior", + importance: Importance.max, + priority: Priority.max); + + DarwinNotificationDetails iosDetails = const DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ); + + DarwinNotificationDetails macDetails = const DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ); + + NotificationDetails notificationDetails = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + macOS: macDetails, + ); + + await _flutterLocalNotificationsPlugin + .zonedSchedule( + notificationId, + 'Taskwarrior Reminder', + "Hey! Your recurring task '$taskname' is due tomorrow", + scheduledAt, + notificationDetails, + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + androidScheduleMode: AndroidScheduleMode.alarmClock) + .then((_) { + if (kDebugMode) { + print('Advance-warning notification scheduled for $taskname'); + } + }).catchError((error) { + if (kDebugMode) { + print('Error scheduling advance-warning notification: $error'); + } + }); + } + + /// Cancel the advance-warning notification previously scheduled for a + /// recurring task identified by [due] + [taskname] + [entryTime]. + void cancelRecurrenceAdvanceNotification( + DateTime due, String taskname, DateTime entryTime) { + final int baseId = calculateNotificationId(due, taskname, false, entryTime); + final int notificationId = (baseId + 4) % 2147483647; + cancelNotification(notificationId); + } } diff --git a/lib/app/utils/add_task_dialogue/date_picker_input.dart b/lib/app/utils/add_task_dialogue/date_picker_input.dart index 17a817d5..db11e081 100644 --- a/lib/app/utils/add_task_dialogue/date_picker_input.dart +++ b/lib/app/utils/add_task_dialogue/date_picker_input.dart @@ -7,11 +7,15 @@ class AddTaskDatePickerInput extends StatefulWidget { final Function(List)? onDateChanges; final bool onlyDueDate; final List allowedIndexes; + final bool disableDueDate; + final List initialDates; const AddTaskDatePickerInput( {super.key, this.onDateChanges, this.onlyDueDate = false, - this.allowedIndexes = const [0, 1, 2, 3]}); + this.allowedIndexes = const [0, 1, 2, 3], + this.disableDueDate = false, + this.initialDates = const [null, null, null, null]}); @override _AddTaskDatePickerInputState createState() => _AddTaskDatePickerInputState(); @@ -25,6 +29,62 @@ class _AddTaskDatePickerInputState extends State { final int length = 4; int currentIndex = 0; + List get _effectiveAllowedIndexes => widget.allowedIndexes + .where((index) => !(widget.disableDueDate && index == 0)) + .toList(); + + void _normalizeCurrentIndex() { + if (widget.onlyDueDate) { + currentIndex = 0; + return; + } + final allowed = _effectiveAllowedIndexes; + if (allowed.isEmpty) { + currentIndex = 0; + return; + } + if (!allowed.contains(currentIndex)) { + currentIndex = allowed.first; + } + } + + void _applyInitialDates(List values) { + for (var i = 0; i < length; i++) { + _selectedDates[i] = i < values.length ? values[i] : null; + } + } + + bool _sameDates(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + + @override + void initState() { + super.initState(); + _applyInitialDates(widget.initialDates); + } + + @override + void didUpdateWidget(covariant AddTaskDatePickerInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (!_sameDates(widget.initialDates, oldWidget.initialDates)) { + _applyInitialDates(widget.initialDates); + } + if (widget.disableDueDate != oldWidget.disableDueDate && + widget.disableDueDate) { + _selectedDates[0] = null; + _controllers[0].text = ''; + if (widget.onDateChanges != null) { + widget.onDateChanges!(List.from(_selectedDates)); + } + } + _normalizeCurrentIndex(); + } + @override void dispose() { for (var controller in _controllers) { @@ -35,6 +95,7 @@ class _AddTaskDatePickerInputState extends State { @override Widget build(BuildContext context) { + _normalizeCurrentIndex(); return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -46,9 +107,7 @@ class _AddTaskDatePickerInputState extends State { child: DropdownButton( value: currentIndex, items: [ - for (int index = 0; index < length; index++) - if (widget.allowedIndexes - .contains(index)) // Only add if allowed + for (int index in _effectiveAllowedIndexes) DropdownMenuItem( value: index, child: Row( @@ -79,21 +138,29 @@ class _AddTaskDatePickerInputState extends State { ? '' : dateToStringForAddTask(_selectedDates[forIndex]!); + final bool isDueField = forIndex == 0; + final bool isDisabled = isDueField && widget.disableDueDate; + return TextFormField( + enabled: !isDisabled, controller: _controllers[forIndex], decoration: InputDecoration( labelText: SentenceManager(currentLanguage: AppSettings.selectedLanguage) .sentences .date, - hintText: - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.select} ${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.date}', + hintText: isDisabled + ? 'Due date disabled for recurring tasks' + : '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.select} ${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.date}', suffixIcon: const Icon(Icons.calendar_today), border: const OutlineInputBorder(), ), validator: _validator, readOnly: true, onTap: () async { + if (isDisabled) { + return; + } final DateTime? picked = await showDatePicker( context: context, initialDate: _selectedDates[forIndex] ?? DateTime.now(), @@ -124,7 +191,7 @@ class _AddTaskDatePickerInputState extends State { dateToStringForAddTask(_selectedDates[forIndex]!); }); if (widget.onDateChanges != null) { - widget.onDateChanges!(_selectedDates); + widget.onDateChanges!(List.from(_selectedDates)); } return; } @@ -138,7 +205,7 @@ class _AddTaskDatePickerInputState extends State { dateToStringForAddTask(_selectedDates[forIndex]!); }); if (widget.onDateChanges != null) { - widget.onDateChanges!(_selectedDates); + widget.onDateChanges!(List.from(_selectedDates)); } }, ); diff --git a/lib/app/utils/taskfunctions/modify.dart b/lib/app/utils/taskfunctions/modify.dart index dd3ef3bc..1b923629 100644 --- a/lib/app/utils/taskfunctions/modify.dart +++ b/lib/app/utils/taskfunctions/modify.dart @@ -2,7 +2,9 @@ import 'package:collection/collection.dart'; import 'package:taskwarrior/app/models/models.dart'; +import 'package:taskwarrior/app/utils/taskfunctions/recurrence_engine.dart'; import 'package:taskwarrior/app/utils/taskfunctions/draft.dart'; +import 'package:uuid/uuid.dart'; class Modify { Modify({ @@ -38,6 +40,7 @@ class Modify { 'due', 'wait', 'until', + 'recur', 'priority', 'project', 'tags', @@ -76,9 +79,52 @@ class Modify { _draft.set(key, value); } + Task? _nextRecurringTask({ + required Task source, + required DateTime now, + }) { + final recur = source.recur; + if (recur == null || recur.trim().isEmpty) { + return null; + } + + final baseDue = source.due ?? now; + final nextDue = RecurrenceEngine.calculateNextDate(baseDue, recur); + if (nextDue == null) { + return null; + } + + final nextWait = source.wait != null + ? RecurrenceEngine.calculateNextDate(source.wait!, recur) + : null; + + return source.rebuild((b) { + b + ..id = null + ..uuid = const Uuid().v1() + ..status = 'pending' + ..entry = now + ..modified = now + ..start = null + ..end = null + ..due = nextDue.toUtc() + ..wait = nextWait?.toUtc(); + }); + } + void save({required DateTime Function() modified}) { - _draft.set('modified', modified()); + final now = modified(); + final wasCompleted = _draft.original.status == 'completed'; + final isNowCompleted = _draft.draft.status == 'completed'; + final Task? spawnedRecurring = (!wasCompleted && isNowCompleted) + ? _nextRecurringTask(source: _draft.draft, now: now) + : null; + + _draft.set('modified', now); _mergeTask(_draft.draft); + if (spawnedRecurring != null) { + _mergeTask(spawnedRecurring); + } _draft = Draft(_getTask(_uuid)); } } diff --git a/lib/app/utils/taskfunctions/patch.dart b/lib/app/utils/taskfunctions/patch.dart index 7f7e3aba..11c903b8 100644 --- a/lib/app/utils/taskfunctions/patch.dart +++ b/lib/app/utils/taskfunctions/patch.dart @@ -49,6 +49,9 @@ Task _patch(Task task, String key, dynamic value) { case 'project': b.project = value; break; + case 'recur': + b.recur = value; + break; case 'tags': b.tags = BuiltList( (value as ListBuilder).build().toList().cast()) diff --git a/lib/app/utils/taskfunctions/recurrence_engine.dart b/lib/app/utils/taskfunctions/recurrence_engine.dart new file mode 100644 index 00000000..5b8fb0cd --- /dev/null +++ b/lib/app/utils/taskfunctions/recurrence_engine.dart @@ -0,0 +1,109 @@ +/// Utility class for calculating next recurrence dates. +/// +/// Supports all standard Taskwarrior recurrence keywords plus arbitrary +/// Nd / Nw / Nm / Ny patterns. +class RecurrenceEngine { + /// Given [oldDate] and a [recur] pattern string, returns the next occurrence date. + static DateTime? calculateNextDate(DateTime oldDate, String recur) { + final r = recur.toLowerCase().trim(); + switch (r) { + // ── Daily ──────────────────────────────────────────────────────────────── + case 'daily': + case '1d': + return oldDate.add(const Duration(days: 1)); + + // ── Weekdays (Mon–Fri only) ─────────────────────────────────────────── + case 'weekdays': + return _nextWeekday(oldDate); + + // ── Weekly ─────────────────────────────────────────────────────────── + case 'weekly': + case '1w': + case 'sennight': + return oldDate.add(const Duration(days: 7)); + + // ── Biweekly / Fortnight ───────────────────────────────────────────── + case 'biweekly': + case 'fortnight': + case '2w': + return oldDate.add(const Duration(days: 14)); + + // ── Monthly ────────────────────────────────────────────────────────── + case 'monthly': + case '1m': + return _addMonths(oldDate, 1); + + // ── Bimonthly ──────────────────────────────────────────────────────── + case 'bimonthly': + case '2m': + return _addMonths(oldDate, 2); + + // ── Quarterly ──────────────────────────────────────────────────────── + case 'quarterly': + case '3m': + return _addMonths(oldDate, 3); + + // ── Semi-annual ────────────────────────────────────────────────────── + case 'semiannual': + case '6m': + return _addMonths(oldDate, 6); + + // ── Yearly / Annual ────────────────────────────────────────────────── + case 'yearly': + case 'annual': + case '1y': + return _addYears(oldDate, 1); + + default: + // Try to parse generic patterns like "2d", "3w", "6m", "2y" + final match = RegExp(r'^(\d+)([dwmy])$').firstMatch(r); + if (match != null) { + final count = int.parse(match.group(1)!); + final unit = match.group(2)!; + switch (unit) { + case 'd': + return oldDate.add(Duration(days: count)); + case 'w': + return oldDate.add(Duration(days: count * 7)); + case 'm': + return _addMonths(oldDate, count); + case 'y': + return _addYears(oldDate, count); + } + } + return null; + } + } + + // ── Private helpers ──────────────────────────────────────────────────────── + + /// Advance [date] by [months], clamping to valid day-of-month. + static DateTime _addMonths(DateTime date, int months) { + int newMonth = date.month + months; + int newYear = date.year + (newMonth - 1) ~/ 12; + newMonth = ((newMonth - 1) % 12) + 1; + final lastDay = DateTime(newYear, newMonth + 1, 0).day; + final day = date.day.clamp(1, lastDay); + return DateTime( + newYear, newMonth, day, date.hour, date.minute, date.second); + } + + /// Advance [date] by [years], clamping to valid day-of-month (Feb 29 → Feb 28). + static DateTime _addYears(DateTime date, int years) { + final newYear = date.year + years; + final lastDay = DateTime(newYear, date.month + 1, 0).day; + final day = date.day.clamp(1, lastDay); + return DateTime( + newYear, date.month, day, date.hour, date.minute, date.second); + } + + /// Return the next weekday (Mon–Fri) strictly after [date]. + static DateTime _nextWeekday(DateTime date) { + DateTime next = date.add(const Duration(days: 1)); + while ( + next.weekday == DateTime.saturday || next.weekday == DateTime.sunday) { + next = next.add(const Duration(days: 1)); + } + return next; + } +} diff --git a/lib/app/v3/champion/models/task_for_replica.dart b/lib/app/v3/champion/models/task_for_replica.dart index c1e966c8..28965d67 100644 --- a/lib/app/v3/champion/models/task_for_replica.dart +++ b/lib/app/v3/champion/models/task_for_replica.dart @@ -8,11 +8,19 @@ class TaskForReplica { final String? status; final String? description; + final String? entry; final List? tags; final String uuid; final String? priority; final String? project; + // Recurrence fields + final String? recur; + final String? rtype; + final String? mask; + final String? imask; + final String? parent; + TaskForReplica({ this.modified, this.due, @@ -20,40 +28,43 @@ class TaskForReplica { this.wait, this.status, this.description, + this.entry, this.tags, required this.uuid, this.priority, this.project, + this.recur, + this.rtype, + this.mask, + this.imask, + this.parent, }); + static String? _parseDate(dynamic value) { + if (value == null) return null; + final raw = value.toString().trim(); + if (raw.isEmpty) return null; + final epoch = int.tryParse(raw); + if (epoch != null) { + return DateTime.fromMillisecondsSinceEpoch(epoch * 1000, isUtc: true) + .toUtc() + .toIso8601String(); + } + final parsed = DateTime.tryParse(raw); + return parsed?.toUtc().toIso8601String(); + } + factory TaskForReplica.fromJson(Map json) { return TaskForReplica( modified: json['modified'] is int ? json['modified'] as int : int.tryParse('${json['modified']}'), - due: json['due'] != null - ? DateTime.fromMillisecondsSinceEpoch( - (int.tryParse(json['due'].toString()) ?? 0) * 1000, - isUtc: true) - .toUtc() - .toString() - : null, - start: json['start'] != null - ? DateTime.fromMillisecondsSinceEpoch( - (int.tryParse(json['start'].toString()) ?? 0) * 1000, - isUtc: true) - .toUtc() - .toString() - : null, - wait: json['wait'] != null - ? DateTime.fromMillisecondsSinceEpoch( - (int.tryParse(json['wait'].toString()) ?? 0) * 1000, - isUtc: true) - .toUtc() - .toString() - : null, + due: _parseDate(json['due']), + start: _parseDate(json['start']), + wait: _parseDate(json['wait']), status: json['status']?.toString(), description: json['description']?.toString(), + entry: _parseDate(json['entry']), tags: (json['tags'] is List) ? (json['tags'] as List).map((e) => e.toString()).toList() : (json['tags'] is String && json['tags'].toString().isNotEmpty) @@ -62,6 +73,11 @@ class TaskForReplica { uuid: json['uuid']?.toString() ?? '', priority: json['priority']?.toString(), project: json['project']?.toString(), + recur: json['recur']?.toString(), + rtype: json['rtype']?.toString(), + mask: json['mask']?.toString(), + imask: json['imask']?.toString(), + parent: json['parent']?.toString(), ); } @@ -73,10 +89,16 @@ class TaskForReplica { if (wait != null) 'wait': wait, if (status != null) 'status': status, if (description != null) 'description': description, + if (entry != null) 'entry': entry, if (tags != null) 'tags': tags, 'uuid': uuid, if (priority != null) 'priority': priority, if (project != null) 'project': project, + if (recur != null) 'recur': recur, + if (rtype != null) 'rtype': rtype, + if (mask != null) 'mask': mask, + if (imask != null) 'imask': imask, + if (parent != null) 'parent': parent, }; } @@ -87,9 +109,16 @@ class TaskForReplica { String? wait, String? status, String? description, + String? entry, List? tags, String? uuid, String? priority, + String? project, + String? recur, + String? rtype, + String? mask, + String? imask, + String? parent, }) { return TaskForReplica( modified: modified ?? this.modified, @@ -98,10 +127,16 @@ class TaskForReplica { wait: wait ?? this.wait, status: status ?? this.status, description: description ?? this.description, + entry: entry ?? this.entry, tags: tags ?? this.tags, uuid: uuid ?? this.uuid, priority: priority ?? this.priority, - project: project ?? project, + project: project ?? this.project, + recur: recur ?? this.recur, + rtype: rtype ?? this.rtype, + mask: mask ?? this.mask, + imask: imask ?? this.imask, + parent: parent ?? this.parent, ); } @@ -118,14 +153,28 @@ class TaskForReplica { other.wait == wait && other.status == status && other.description == description && + other.entry == entry && _listEquals(other.tags, tags) && other.uuid == uuid && - other.priority == priority; + other.priority == priority && + other.recur == recur && + other.rtype == rtype; } @override - int get hashCode => Object.hash(modified, due, status, description, uuid, - priority, tags == null ? 0 : tags.hashCode, start, wait); + int get hashCode => Object.hash( + modified, + due, + status, + description, + uuid, + priority, + tags == null ? 0 : tags.hashCode, + start, + wait, + recur, + rtype, + entry); static bool _listEquals(List? a, List? b) { if (a == null && b == null) return true; diff --git a/lib/app/v3/champion/replica.dart b/lib/app/v3/champion/replica.dart index ea19ba45..1b5968d3 100644 --- a/lib/app/v3/champion/replica.dart +++ b/lib/app/v3/champion/replica.dart @@ -5,10 +5,10 @@ import 'dart:collection'; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:taskwarrior/app/models/models.dart'; +import 'package:taskwarrior/app/services/notification_services.dart'; import 'package:taskwarrior/app/utils/taskchampion/credentials_storage.dart'; import 'package:taskwarrior/app/v3/champion/models/task_for_replica.dart'; -import 'package:taskwarrior/app/v3/models/task.dart'; +import 'package:taskwarrior/app/utils/taskfunctions/recurrence_engine.dart'; import 'package:taskwarrior/rust_bridge/api.dart'; import 'package:uuid/v4.dart'; @@ -20,8 +20,15 @@ class Replica { "wait", "priority", "project", - "status" + "entry", + "status", + "recur", + "mask", + "imask", + "parent", + "rtype" ]; + static Future addTaskToReplica( HashMap newTask) async { var taskdbDirPath = await getReplicaPath(); @@ -44,6 +51,7 @@ class Replica { map['uuid'] = UuidV4().generate(); try { await addTask(taskdbDirPath: taskdbDirPath, map: map); + _scheduleNotificationsForMap(map); await getAllTasksFromReplica(); //to update the db } catch (e, s) { debugPrint(e.toString()); @@ -59,6 +67,63 @@ class Replica { if (newTask.uuid.isEmpty) { return "err"; } + + // Handle Recurrence on Completion + if (newTask.status == 'completed' && + newTask.recur != null && + newTask.recur!.isNotEmpty) { + try { + DateTime? due = + newTask.due != null ? DateTime.tryParse(newTask.due!) : null; + DateTime? wait = + newTask.wait != null ? DateTime.tryParse(newTask.wait!) : null; + + due ??= DateTime.now(); + + DateTime? nextDue = + RecurrenceEngine.calculateNextDate(due, newTask.recur!); + DateTime? nextWait = wait != null + ? RecurrenceEngine.calculateNextDate(wait, newTask.recur!) + : null; + + if (nextDue != null) { + var newMap = HashMap(); + newMap['description'] = newTask.description; + if (newTask.project != null) newMap['project'] = newTask.project; + if (newTask.priority != null) newMap['priority'] = newTask.priority; + if (newTask.tags != null && newTask.tags!.isNotEmpty) + newMap['tags'] = newTask.tags; + newMap['recur'] = newTask.recur; + if (newTask.rtype != null) newMap['rtype'] = newTask.rtype; + if (newTask.mask != null) newMap['mask'] = newTask.mask; + if (newTask.imask != null) newMap['imask'] = newTask.imask; + newMap['parent'] = newTask.uuid; + newMap['entry'] = DateTime.now().toUtc().toIso8601String(); + newMap['status'] = 'pending'; + newMap['due'] = nextDue.toUtc().toIso8601String(); + if (nextWait != null) { + newMap['wait'] = nextWait.toUtc().toIso8601String(); + } + + debugPrint("Creating next recurring replica task: $newMap"); + await addTaskToReplica(newMap); + // Schedule a 24-hour advance-warning notification for the new occurrence + try { + final entryTime = DateTime.parse(newMap['entry'] as String? ?? + DateTime.now().toUtc().toIso8601String()); + NotificationService().sendRecurrenceAdvanceNotification( + nextDue.toUtc(), + newMap['description'] as String? ?? '', + entryTime); + } catch (e) { + debugPrint('Error scheduling advance-warning for replica task: $e'); + } + } + } catch (e) { + debugPrint("Error creating recurring replica task: $e"); + } + } + String tags = ""; if (newTask.tags != null) { tags = newTask.tags!.join(" "); @@ -71,8 +136,15 @@ class Replica { } debugPrint("Modifying Replica Task JSON the map: $map"); try { + final status = (newTask.status ?? '').toLowerCase(); + if (status == 'completed' || status == 'deleted') { + cancelNotificationsForTask(newTask); + } await updateTask( uuidSt: newTask.uuid, taskdbDirPath: taskdbDirPath, map: map); + if (status == 'pending' || status.isEmpty) { + _scheduleNotificationsForMap(map); + } } catch (e, s) { debugPrint(e.toString()); debugPrint(s.toString()); @@ -152,4 +224,70 @@ class Replica { static Future getDefaultDirectory() async { return await getApplicationDocumentsDirectory(); } + + static DateTime? _parseUtc(String? value) { + if (value == null || value.trim().isEmpty) { + return null; + } + try { + return DateTime.parse(value).toUtc(); + } catch (_) { + return null; + } + } + + static DateTime _entryFromMap(HashMap map) { + return _parseUtc(map['entry']) ?? DateTime.now().toUtc(); + } + + static void _scheduleNotificationsForMap(HashMap map) { + try { + final status = (map['status'] ?? 'pending').toLowerCase(); + if (status != 'pending') return; + final description = map['description'] ?? 'Task'; + final entryTime = _entryFromMap(map); + final due = _parseUtc(map['due']); + final wait = _parseUtc(map['wait']); + final now = DateTime.now().toUtc(); + final notificationService = NotificationService(); + notificationService.initiliazeNotification(); + + if (due != null && due.isAfter(now)) { + notificationService.sendNotification( + due, description, false, entryTime); + } + if (wait != null && wait.isAfter(now)) { + notificationService.sendNotification( + wait, description, true, entryTime); + } + } catch (e) { + debugPrint("Skipping replica notification scheduling: $e"); + } + } + + static void cancelNotificationsForTask(TaskForReplica task) { + try { + final entryTime = _parseUtc(task.entry) ?? DateTime.now().toUtc(); + final due = _parseUtc(task.due); + final wait = _parseUtc(task.wait); + final description = task.description ?? 'Task'; + final notificationService = NotificationService(); + notificationService.initiliazeNotification(); + if (due != null) { + final id = notificationService.calculateNotificationId( + due, description, false, entryTime); + notificationService.cancelNotification(id); + // Also cancel any advance-warning notification + notificationService.cancelRecurrenceAdvanceNotification( + due, description, entryTime); + } + if (wait != null) { + final id = notificationService.calculateNotificationId( + wait, description, true, entryTime); + notificationService.cancelNotification(id); + } + } catch (e) { + debugPrint("Skipping replica notification cancel: $e"); + } + } } diff --git a/lib/app/v3/db/task_database.dart b/lib/app/v3/db/task_database.dart index dc1b97b2..a68d48ec 100644 --- a/lib/app/v3/db/task_database.dart +++ b/lib/app/v3/db/task_database.dart @@ -5,10 +5,73 @@ import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; +import 'package:taskwarrior/app/services/notification_services.dart'; import 'package:taskwarrior/app/v3/models/task.dart'; +import 'package:taskwarrior/app/utils/taskfunctions/recurrence_engine.dart'; +import 'package:uuid/uuid.dart'; class TaskDatabase { Database? _database; + final NotificationService _notificationService = NotificationService(); + + DateTime? _parseUtc(String? value) { + if (value == null || value.trim().isEmpty) return null; + try { + return DateTime.parse(value).toUtc(); + } catch (_) { + return null; + } + } + + void _cancelTaskNotifications(TaskForC task) { + try { + final entryTime = _parseUtc(task.entry) ?? DateTime.now().toUtc(); + final due = _parseUtc(task.due); + final wait = _parseUtc(task.wait); + if (due != null) { + final id = _notificationService.calculateNotificationId( + due, task.description, false, entryTime); + _notificationService.cancelNotification(id); + // Also cancel any advance-warning notification for this task + _notificationService.cancelRecurrenceAdvanceNotification( + due, task.description, entryTime); + } + if (wait != null) { + final id = _notificationService.calculateNotificationId( + wait, task.description, true, entryTime); + _notificationService.cancelNotification(id); + } + } catch (e) { + debugPrint("Skipping notification cancellation for ${task.uuid}: $e"); + } + } + + void _syncTaskNotifications(TaskForC task, {TaskForC? previous}) { + try { + _notificationService.initiliazeNotification(); + if (previous != null) { + _cancelTaskNotifications(previous); + } + _cancelTaskNotifications(task); + if (task.status != 'pending') return; + + final entryTime = _parseUtc(task.entry) ?? DateTime.now().toUtc(); + final now = DateTime.now().toUtc(); + final due = _parseUtc(task.due); + final wait = _parseUtc(task.wait); + + if (due != null && due.isAfter(now)) { + _notificationService.sendNotification( + due, task.description, false, entryTime); + } + if (wait != null && wait.isAfter(now)) { + _notificationService.sendNotification( + wait, task.description, true, entryTime); + } + } catch (e) { + debugPrint("Skipping notification sync for ${task.uuid}: $e"); + } + } Future open() async { String path = await getDatabasePathForCurrentProfile(); @@ -17,7 +80,7 @@ class TaskDatabase { Future _open(path) async { debugPrint("called _open with $path"); - _database = await openDatabase(path, version: 2, + _database = await openDatabase(path, version: 3, onCreate: (Database db, version) async { await db.execute(''' CREATE TABLE Tasks ( @@ -35,7 +98,10 @@ class TaskDatabase { start TEXT, wait TEXT, rtype TEXT, - recur TEXT + recur TEXT, + parent TEXT, + until TEXT, + scheduled TEXT ) '''); await db.execute(''' @@ -75,13 +141,22 @@ class TaskDatabase { ON UPDATE CASCADE ) '''); + }, onUpgrade: (Database db, int oldVersion, int newVersion) async { + if (oldVersion < 3) { + // Add recurrence chain tracking columns + await db.execute('ALTER TABLE Tasks ADD COLUMN parent TEXT'); + await db.execute('ALTER TABLE Tasks ADD COLUMN until TEXT'); + await db.execute('ALTER TABLE Tasks ADD COLUMN scheduled TEXT'); + debugPrint( + 'DB upgraded from v$oldVersion to v3: added parent/until/scheduled columns'); + } }); debugPrint("Database opened at $path"); } Future openForProfile(String profile) async { String path = await getDatabasePathForProfile(profile); - _open(path); + await _open(path); } Future ensureDatabaseIsOpen() async { @@ -161,15 +236,19 @@ class TaskDatabase { await setAnnotationsForTask( task.uuid ?? '', task.id, taskAnnotations.toList()); } + _syncTaskNotifications(task); } Future updateTask(TaskForC task) async { await ensureDatabaseIsOpen(); + final TaskForC? previousTask = (task.uuid == null || task.uuid!.isEmpty) + ? null + : await getTaskByUuid(task.uuid!); debugPrint("Database update"); List taskTags = task.tags?.map((e) => e.toString()).toList() ?? []; debugPrint("Database update $taskTags"); List taskDepends = - task.tags?.map((e) => e.toString()).toList() ?? []; + task.depends?.map((e) => e.toString()).toList() ?? []; debugPrint("Database update $taskDepends"); List> taskAnnotations = task.annotations != null ? task.annotations! @@ -186,16 +265,11 @@ class TaskDatabase { where: 'uuid = ?', whereArgs: [task.uuid], ); - if (taskTags.isNotEmpty) { - await setTagsForTask(task.uuid ?? '', task.id, taskTags.toList()); - } - if (taskDepends.isNotEmpty) { - await setDependsForTask(task.uuid ?? '', task.id, taskDepends.toList()); - } - if (taskAnnotations.isNotEmpty) { - await setAnnotationsForTask( - task.uuid ?? '', task.id, taskAnnotations.toList()); - } + await setTagsForTask(task.uuid ?? '', task.id, taskTags.toList()); + await setDependsForTask(task.uuid ?? '', task.id, taskDepends.toList()); + await setAnnotationsForTask( + task.uuid ?? '', task.id, taskAnnotations.toList()); + _syncTaskNotifications(task, previous: previousTask); } Future getTaskByUuid(String uuid) async { @@ -214,28 +288,95 @@ class TaskDatabase { } } - Future markTaskAsCompleted(String uuid) async { + Future markTaskAsCompleted(String uuid, + {bool forceRecurrence = false}) async { await ensureDatabaseIsOpen(); + // Check if this is a recurring task and spawn next instance + TaskForC? task = await getTaskByUuid(uuid); + if (task != null && + (forceRecurrence || task.status != 'completed') && + task.recur != null && + task.recur!.isNotEmpty) { + await _handleRecurrenceForCompletedTask(task); + } + + final nowIso = DateTime.now().toIso8601String(); await _database!.update( 'Tasks', - {'modified': (DateTime.now()).toIso8601String(), 'status': 'completed'}, + {'modified': nowIso, 'status': 'completed', 'end': nowIso}, where: 'uuid = ?', whereArgs: [uuid], ); + if (task != null) { + _cancelTaskNotifications(task); + } debugPrint('task${uuid}completed'); - debugPrint({DateTime.now().toIso8601String()}.toString()); + } + + Future _handleRecurrenceForCompletedTask(TaskForC task) async { + String recur = task.recur!; + DateTime? due = task.due != null ? DateTime.tryParse(task.due!) : null; + DateTime? wait = task.wait != null ? DateTime.tryParse(task.wait!) : null; + + due ??= DateTime.now(); + + DateTime? nextDue = RecurrenceEngine.calculateNextDate(due, recur); + DateTime? nextWait = + wait != null ? RecurrenceEngine.calculateNextDate(wait, recur) : null; + + if (nextDue != null) { + TaskForC newTask = TaskForC( + id: 0, + description: task.description, + project: task.project, + status: 'pending', + uuid: const Uuid().v4(), + urgency: task.urgency, + priority: task.priority, + due: nextDue.toIso8601String(), + end: null, + entry: DateTime.now().toIso8601String(), + modified: DateTime.now().toIso8601String(), + tags: task.tags, + start: null, + wait: nextWait?.toIso8601String(), + rtype: task.rtype, + recur: task.recur, + depends: task.depends ?? [], + annotations: task.annotations ?? [], + parent: task.uuid, + until: task.until, + scheduled: task.scheduled, + ); + await insertTask(newTask); + // Schedule 24-hour advance-warning notification for the new occurrence. + // Use the task's own entry time so the ID matches when cancelling later. + try { + final entryTime = _parseUtc(newTask.entry) ?? DateTime.now().toUtc(); + _notificationService.sendRecurrenceAdvanceNotification( + nextDue.toUtc(), newTask.description, entryTime); + } catch (e) { + debugPrint('Error scheduling advance-warning notification: $e'); + } + debugPrint( + 'Created next recurring task: ${newTask.uuid} due: ${newTask.due}'); + } } Future markTaskAsDeleted(String uuid) async { await ensureDatabaseIsOpen(); + TaskForC? task = await getTaskByUuid(uuid); await _database!.update( 'Tasks', - {'status': 'deleted'}, + {'status': 'deleted', 'modified': DateTime.now().toIso8601String()}, where: 'uuid = ?', whereArgs: [uuid], ); + if (task != null) { + _cancelTaskNotifications(task); + } debugPrint('task${uuid}deleted'); } @@ -246,28 +387,35 @@ class TaskDatabase { String newStatus, String newPriority, String newDue, - List newTags, - ) async { + List newTags, { + String? recur, + String? rtype, + }) async { await ensureDatabaseIsOpen(); debugPrint('task in saveEditedTaskInDB: $uuid with due $newDue'); + final updateMap = { + 'description': newDescription, + 'project': newProject, + 'status': newStatus, + 'priority': newPriority, + 'due': newDue, + 'modified': DateTime.now().toIso8601String(), + }; + if (recur != null) updateMap['recur'] = recur; + if (rtype != null) updateMap['rtype'] = rtype; + await _database!.update( 'Tasks', - { - 'description': newDescription, - 'project': newProject, - 'status': newStatus, - 'priority': newPriority, - 'due': newDue, - 'modified': DateTime.now().toIso8601String(), - }, + updateMap, where: 'uuid = ?', whereArgs: [uuid], ); debugPrint('task${uuid}edited'); - if (newTags.isNotEmpty) { - TaskForC? task = await getTaskByUuid(uuid); - await setTagsForTask(uuid, task?.id ?? 0, newTags.toList()); + TaskForC? task = await getTaskByUuid(uuid); + await setTagsForTask(uuid, task?.id ?? 0, newTags.toList()); + if (task != null) { + _syncTaskNotifications(task); } } @@ -285,6 +433,21 @@ class TaskDatabase { ); } + /// Returns all tasks whose [parent] field equals [parentUuid]. + /// Used to locate recurring child tasks spawned when a recurring task is + /// completed, so they can be pushed to the server immediately. + Future> getTasksByParent(String parentUuid) async { + await ensureDatabaseIsOpen(); + final List> maps = await _database!.query( + 'Tasks', + where: 'parent = ?', + whereArgs: [parentUuid], + ); + return await Future.wait( + maps.map((mapItem) => getObjectForTask(mapItem)).toList(), + ); + } + Future> getTasksByProject(String project) async { List> maps = await _database!.query( 'Tasks', diff --git a/lib/app/v3/db/update.dart b/lib/app/v3/db/update.dart index 7d0f549f..9a6b16e2 100644 --- a/lib/app/v3/db/update.dart +++ b/lib/app/v3/db/update.dart @@ -5,7 +5,6 @@ import 'package:taskwarrior/app/v3/net/add_task.dart'; import 'package:taskwarrior/app/v3/net/complete.dart'; import 'package:taskwarrior/app/v3/net/delete.dart'; import 'package:taskwarrior/app/v3/net/modify.dart'; -import 'package:timezone/timezone.dart'; Future updateTasksInDatabase(List tasks) async { debugPrint( @@ -23,7 +22,9 @@ Future updateTasksInDatabase(List tasks) async { task.project != null ? task.project! : '', task.due!, task.priority!, - task.tags != null ? task.tags! : []); + task.tags != null ? task.tags! : [], + recur: task.recur, + rtype: task.rtype); } catch (e) { debugPrint( 'Failed to add task without UUID to server: $e ${task.tags} ${task.project}'); @@ -65,21 +66,27 @@ Future updateTasksInDatabase(List tasks) async { // local task is newer, update server debugPrint( 'Updating task on server: ${localTask.description}, modified: ${localTask.modified}'); - await modifyTaskOnTaskwarrior( - localTask.description, - localTask.project!, - localTask.due!, - localTask.priority!, - localTask.status, - localTask.uuid!, - localTask.id.toString(), - localTask.tags != null - ? localTask.tags!.map((e) => e.toString()).toList() - : []); - if (localTask.status == 'completed') { - completeTask('email', localTask.uuid!); - } else if (localTask.status == 'deleted') { - deleteTask('email', localTask.uuid!); + try { + await modifyTaskOnTaskwarrior( + localTask.description, + localTask.project ?? '', + localTask.due ?? '', + localTask.priority ?? '', + localTask.status, + localTask.uuid!, + localTask.id.toString(), + localTask.tags != null + ? localTask.tags!.map((e) => e.toString()).toList() + : [], + recur: localTask.recur, + rtype: localTask.rtype); + if (localTask.status == 'completed') { + completeTask('email', localTask.uuid!); + } else if (localTask.status == 'deleted') { + deleteTask('email', localTask.uuid!); + } + } catch (e) { + debugPrint('Failed to sync local-newer task ${localTask.uuid}: $e'); } } } diff --git a/lib/app/v3/models/task.dart b/lib/app/v3/models/task.dart index 91c64045..bcb46af7 100644 --- a/lib/app/v3/models/task.dart +++ b/lib/app/v3/models/task.dart @@ -21,6 +21,10 @@ class TaskForC { final String? recur; final List? depends; final List? annotations; + // Recurrence chain tracking + final String? parent; + final String? until; + final String? scheduled; TaskForC({ required this.id, @@ -41,6 +45,9 @@ class TaskForC { required this.recur, required this.depends, required this.annotations, + this.parent, + this.until, + this.scheduled, }); factory TaskForC.fromJson(Map json) { @@ -64,7 +71,10 @@ class TaskForC { recur: json['recur'], depends: json['depends']?.map((d) => d.toString()).toList() ?? [], - annotations: []); + annotations: [], + parent: json['parent'], + until: json['until'], + scheduled: json['scheduled']); } Map toJson() { @@ -90,6 +100,9 @@ class TaskForC { 'annotations': annotations != null ? annotations?.map((a) => a.toJson()).toList() : >[], + 'parent': parent, + 'until': until, + 'scheduled': scheduled, }; } diff --git a/lib/app/v3/net/add_task.dart b/lib/app/v3/net/add_task.dart index 370a5c27..2f92084d 100644 --- a/lib/app/v3/net/add_task.dart +++ b/lib/app/v3/net/add_task.dart @@ -3,9 +3,52 @@ import 'package:http/http.dart' as http; import 'package:flutter/material.dart'; import 'package:taskwarrior/app/utils/taskchampion/credentials_storage.dart'; import 'package:taskwarrior/app/v3/db/task_database.dart'; +import 'package:taskwarrior/app/v3/models/task.dart'; -Future addTaskAndDeleteFromDatabase(String description, String project, - String due, String priority, List tags) async { +/// Push a newly-spawned recurring task to the Taskchampion server. +/// Unlike [addTaskAndDeleteFromDatabase], this does NOT delete the local +/// record because the task was created with a proper UUID and is already +/// visible to the user. +Future pushNewTaskToServer(TaskForC task) async { + try { + var baseUrl = await CredentialsStorage.getApiUrl(); + String apiUrl = '$baseUrl/add-task'; + var c = await CredentialsStorage.getClientId(); + var e = await CredentialsStorage.getEncryptionSecret(); + final bodyMap = { + 'email': 'email', + 'encryptionSecret': e, + 'UUID': c, + 'description': task.description, + 'project': task.project ?? '', + 'due': task.due ?? '', + 'priority': task.priority ?? '', + 'tags': task.tags ?? [], + }; + if (task.recur != null && task.recur!.isNotEmpty) + bodyMap['recur'] = task.recur; + if (task.rtype != null && task.rtype!.isNotEmpty) + bodyMap['rtype'] = task.rtype; + await http.post( + Uri.parse(apiUrl), + headers: {'Content-Type': 'text/plain'}, + body: jsonEncode(bodyMap), + ); + debugPrint('Pushed recurring child task to server: ${task.description}'); + } catch (e) { + debugPrint('Failed to push recurring child task to server: $e'); + } +} + +Future addTaskAndDeleteFromDatabase( + String description, + String project, + String due, + String priority, + List tags, { + String? recur, + String? rtype, +}) async { var baseUrl = await CredentialsStorage.getApiUrl(); String apiUrl = '$baseUrl/add-task'; var c = await CredentialsStorage.getClientId(); @@ -13,21 +56,24 @@ Future addTaskAndDeleteFromDatabase(String description, String project, debugPrint("Database Adding Tags $tags $description"); debugPrint(c); debugPrint(e); + final bodyMap = { + 'email': 'email', + 'encryptionSecret': e, + 'UUID': c, + 'description': description, + 'project': project, + 'due': due, + 'priority': priority, + 'tags': tags, + }; + if (recur != null && recur.isNotEmpty) bodyMap['recur'] = recur; + if (rtype != null && rtype.isNotEmpty) bodyMap['rtype'] = rtype; var res = await http.post( Uri.parse(apiUrl), headers: { 'Content-Type': 'text/plain', }, - body: jsonEncode({ - 'email': 'email', - 'encryptionSecret': e, - 'UUID': c, - 'description': description, - 'project': project, - 'due': due, - 'priority': priority, - 'tags': tags - }), + body: jsonEncode(bodyMap), ); debugPrint('Database res ${res.body}'); var taskDatabase = TaskDatabase(); diff --git a/lib/app/v3/net/modify.dart b/lib/app/v3/net/modify.dart index 1d32976a..0206db23 100644 --- a/lib/app/v3/net/modify.dart +++ b/lib/app/v3/net/modify.dart @@ -7,14 +7,17 @@ import 'package:taskwarrior/app/utils/taskchampion/credentials_storage.dart'; import 'package:taskwarrior/app/v3/db/task_database.dart'; Future modifyTaskOnTaskwarrior( - String description, - String project, - String due, - String priority, - String status, - String taskuuid, - String id, - List newTags) async { + String description, + String project, + String due, + String priority, + String status, + String taskuuid, + String id, + List newTags, { + String? recur, + String? rtype, +}) async { var baseUrl = await CredentialsStorage.getApiUrl(); var c = await CredentialsStorage.getClientId(); var e = await CredentialsStorage.getEncryptionSecret(); @@ -22,39 +25,29 @@ Future modifyTaskOnTaskwarrior( debugPrint(c); debugPrint(e); debugPrint("modifyTaskOnTaskwarrior called"); - debugPrint("description: $description project: $project due: $due " - "priority: $priority status: $status taskuuid: $taskuuid id: $id tags: $newTags" - "body: ${jsonEncode({ - "email": "e", - "encryptionSecret": e, - "UUID": c, - "description": description, - "priority": priority, - "project": project, - "due": due, - "status": status, - "taskuuid": taskuuid, - "taskId": id, - "tags": newTags.isNotEmpty ? newTags : null - })}"); + final bodyMap = { + "email": "e", + "encryptionSecret": e, + "UUID": c, + "description": description, + "priority": priority, + "project": project, + "due": due, + "status": status, + "taskuuid": taskuuid, + "taskId": id, + "tags": newTags.isNotEmpty ? newTags : null, + }; + if (recur != null && recur.isNotEmpty) bodyMap["recur"] = recur; + if (rtype != null && rtype.isNotEmpty) bodyMap["rtype"] = rtype; + + debugPrint("body: ${jsonEncode(bodyMap)}"); final response = await http.post( Uri.parse(apiUrl), headers: { 'Content-Type': 'text/plain', }, - body: jsonEncode({ - "email": "e", - "encryptionSecret": e, - "UUID": c, - "description": description, - "priority": priority, - "project": project, - "due": due, - "status": status, - "taskuuid": taskuuid, - "taskId": id, - "tags": newTags.isNotEmpty ? newTags : null - }), + body: jsonEncode(bodyMap), ); debugPrint('Modify task response body: ${response.body}'); if (response.statusCode < 200 || response.statusCode >= 300) { diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 13807bb2..e20e7221 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -17,6 +18,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_timezone_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterTimezonePlugin"); flutter_timezone_plugin_register_with_registrar(flutter_timezone_registrar); + g_autoptr(FlPluginRegistrar) gtk_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); + gtk_plugin_register_with_registrar(gtk_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index b01d1fd9..fe92fa4d 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux flutter_timezone + gtk url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cb98b370..91b0fc33 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import app_links import connectivity_plus import file_picker import file_picker_writable @@ -18,6 +19,7 @@ import sqflite_darwin import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FilePickerWritablePlugin.register(with: registry.registrar(forPlugin: "FilePickerWritablePlugin")) diff --git a/pubspec.lock b/pubspec.lock index c7182030..ce41967d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d url: "https://pub.dev" source: hosted - version: "85.0.0" + version: "91.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 url: "https://pub.dev" source: hosted - version: "7.7.1" + version: "8.4.1" ansicolor: dependency: transitive description: @@ -25,6 +25,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" archive: dependency: transitive description: @@ -65,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 + url: "https://pub.dev" + source: hosted + version: "2.1.1" build_config: dependency: transitive description: @@ -125,10 +165,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -253,10 +293,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.3" dartx: dependency: transitive description: @@ -519,6 +559,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.32" + flutter_rust_bridge: + dependency: "direct main" + description: + name: flutter_rust_bridge + sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e" + url: "https://pub.dev" + source: hosted + version: "2.11.1" flutter_slidable: dependency: "direct main" description: @@ -601,6 +649,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + gtk: + dependency: transitive + description: + name: gtk + sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + url: "https://pub.dev" + source: hosted + version: "2.1.0" hashcodes: dependency: transitive description: @@ -721,14 +777,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" json_annotation: dependency: transitive description: @@ -797,18 +845,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -829,10 +877,10 @@ packages: dependency: "direct main" description: name: mockito - sha256: "2314cbe9165bcd16106513df9cf3c3224713087f09723b128928dc11a4379f99" + sha256: a45d1aa065b796922db7b9e7e7e45f921aed17adf3a8318a1f47097e7e695566 url: "https://pub.dev" source: hosted - version: "5.5.0" + version: "5.6.3" nm: dependency: transitive description: @@ -1141,10 +1189,10 @@ packages: dependency: transitive description: name: shelf_web_socket - sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "3.0.0" sizer: dependency: "direct main" description: @@ -1330,26 +1378,26 @@ packages: dependency: "direct main" description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "77cc98ea27006c84e71a7356cf3daf9ddbde2d91d84f77dbfe64cf0e4d9611ae" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.28.0" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.8" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: f1072617a6657e5fc09662e721307f7fb009b4ed89b19f47175d11d5254a62d4 url: "https://pub.dev" source: hosted - version: "0.6.12" + version: "0.6.14" textfield_tags: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 12e80726..0fdcc04d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,7 +62,7 @@ dependencies: tuple: ^2.0.0 tutorial_coach_mark: ^1.2.11 url_launcher: ^6.3.1 - uuid: ^4.2.2 + uuid: ^4.5.2 built_collection: ^5.1.1 textfield_tags: ^3.0.1 path_provider: ^2.1.5 diff --git a/rust/src/api.rs b/rust/src/api.rs index 5ee13205..08fdb7d8 100644 --- a/rust/src/api.rs +++ b/rust/src/api.rs @@ -141,6 +141,24 @@ pub fn update_task( "project" => { let _ = t.set_value("project", Some(value), &mut ops); } + "recur" => { + let _ = t.set_value("recur", Some(value), &mut ops); + } + "rtype" => { + let _ = t.set_value("rtype", Some(value), &mut ops); + } + "mask" => { + let _ = t.set_value("mask", Some(value), &mut ops); + } + "imask" => { + let _ = t.set_value("imask", Some(value), &mut ops); + } + "parent" => { + let _ = t.set_value("parent", Some(value), &mut ops); + } + "entry" => { + let _ = t.set_value("entry", Some(value), &mut ops); + } "status" => { let status = match value.as_str() { "pending" => taskchampion::Status::Pending, @@ -205,6 +223,24 @@ pub fn add_task(taskdb_dir_path: String, map: HashMap) -> i8 { "project" => { let _ = t.set_user_defined_attribute("project", value, &mut ops); } + "recur" => { + let _ = t.set_value("recur", Some(value), &mut ops); + } + "rtype" => { + let _ = t.set_value("rtype", Some(value), &mut ops); + } + "mask" => { + let _ = t.set_value("mask", Some(value), &mut ops); + } + "imask" => { + let _ = t.set_value("imask", Some(value), &mut ops); + } + "parent" => { + let _ = t.set_value("parent", Some(value), &mut ops); + } + "entry" => { + let _ = t.set_value("entry", Some(value), &mut ops); + } _ => {} } } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 9d16245a..7bbcffce 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -13,6 +14,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + AppLinksPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AppLinksPluginCApi")); ConnectivityPlusWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); FileSelectorWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 50ed42d3..79ba045e 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + app_links connectivity_plus file_selector_windows flutter_timezone