Skip to content

Commit ea6e9cc

Browse files
author
Inderjeet Singh
committed
feat(recurrence): full recurring task lifecycle across TaskC, Replica & Classic modes
1 parent 8b792d9 commit ea6e9cc

9 files changed

Lines changed: 341 additions & 91 deletions

File tree

lib/app/modules/home/views/show_tasks.dart

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import 'package:taskwarrior/app/utils/constants/taskwarrior_colors.dart';
99
import 'package:taskwarrior/app/utils/constants/taskwarrior_fonts.dart';
1010
import 'package:taskwarrior/app/utils/themes/theme_extension.dart';
1111
import 'package:taskwarrior/app/utils/language/sentence_manager.dart';
12+
import 'package:taskwarrior/app/utils/taskfunctions/datetime_differences.dart';
1213
import 'package:taskwarrior/app/v3/db/task_database.dart';
1314
import 'package:taskwarrior/app/v3/models/task.dart';
15+
import 'package:taskwarrior/app/v3/net/add_task.dart';
1416
import 'package:taskwarrior/app/v3/net/complete.dart';
1517
import 'package:taskwarrior/app/v3/net/delete.dart';
1618

@@ -115,13 +117,13 @@ class TaskViewBuilder extends StatelessWidget {
115117
TaskForC task = tasks[index];
116118
final bool isRecurring =
117119
task.recur != null && task.recur!.trim().isNotEmpty;
118-
final String nextDueText = isRecurring
119-
? (() {
120-
final parsed = DateTime.tryParse(task.due ?? '');
121-
if (parsed == null) return '';
122-
return ' | Next: ${parsed.toLocal().toString().split('.').first}';
123-
})()
124-
: '';
120+
final String dueDateText = (() {
121+
final dueStr = task.due;
122+
if (dueStr == null || dueStr.isEmpty) return '';
123+
final parsed = DateTime.tryParse(dueStr);
124+
if (parsed == null) return '';
125+
return ' | ${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.homePageDue}: ${when(parsed.toLocal())}';
126+
})();
125127
return Slidable(
126128
startActionPane: ActionPane(
127129
motion: const BehindMotion(),
@@ -219,7 +221,7 @@ class TaskViewBuilder extends StatelessWidget {
219221
),
220222
),
221223
subtitle: Text(
222-
'${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.detailPageUrgency}: ${task.urgency!.floorToDouble()} | ${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.detailPageStatus}: ${task.status}$nextDueText',
224+
'${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.detailPageUrgency}: ${task.urgency!.floorToDouble()} | ${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.detailPageStatus}: ${task.status}$dueDateText',
223225
style: GoogleFonts.poppins(
224226
color: tColors.secondaryTextColor,
225227
),
@@ -244,7 +246,8 @@ class TaskViewBuilder extends StatelessWidget {
244246
children: [
245247
Icon(Icons.repeat,
246248
size: 14,
247-
color: tColors.secondaryTextColor),
249+
color:
250+
tColors.secondaryTextColor),
248251
const SizedBox(width: 3),
249252
Flexible(
250253
child: Text(
@@ -253,7 +256,8 @@ class TaskViewBuilder extends StatelessWidget {
253256
overflow: TextOverflow.ellipsis,
254257
style: GoogleFonts.poppins(
255258
fontSize: 10,
256-
color: tColors.secondaryTextColor,
259+
color:
260+
tColors.secondaryTextColor,
257261
),
258262
),
259263
),
@@ -278,6 +282,18 @@ class TaskViewBuilder extends StatelessWidget {
278282
await taskDatabase.open();
279283
await taskDatabase.markTaskAsCompleted(uuid);
280284
completeTask('email', uuid);
285+
// Push any newly spawned recurring child task to the server immediately
286+
// so it is visible there without waiting for the next manual sync.
287+
try {
288+
final children = await taskDatabase.getTasksByParent(uuid);
289+
for (final child in children) {
290+
if (child.status == 'pending') {
291+
await pushNewTaskToServer(child);
292+
}
293+
}
294+
} catch (e) {
295+
debugPrint('Failed to push recurring child tasks to server: $e');
296+
}
281297
await Get.find<HomeController>().fetchTasksFromDB();
282298
}
283299

lib/app/modules/home/views/show_tasks_replica.dart

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import 'package:taskwarrior/app/utils/app_settings/app_settings.dart';
99
import 'package:taskwarrior/app/utils/constants/taskwarrior_fonts.dart';
1010
import 'package:taskwarrior/app/utils/themes/theme_extension.dart';
1111
import 'package:taskwarrior/app/utils/language/sentence_manager.dart';
12+
import 'package:taskwarrior/app/utils/taskfunctions/datetime_differences.dart';
1213
import 'package:taskwarrior/app/v3/champion/replica.dart';
1314
import 'package:taskwarrior/app/v3/champion/models/task_for_replica.dart';
1415

@@ -100,13 +101,13 @@ class TaskReplicaViewBuilder extends StatelessWidget {
100101
final task = tasks[index];
101102
final bool isRecurring =
102103
task.recur != null && task.recur!.trim().isNotEmpty;
103-
final String nextDueText = isRecurring
104-
? (() {
105-
final parsed = DateTime.tryParse(task.due ?? '');
106-
if (parsed == null) return '';
107-
return ' | Next: ${parsed.toLocal().toString().split('.').first}';
108-
})()
109-
: '';
104+
final String dueDateText = (() {
105+
final dueStr = task.due;
106+
if (dueStr == null || dueStr.isEmpty) return '';
107+
final parsed = DateTime.tryParse(dueStr);
108+
if (parsed == null) return '';
109+
return ' | ${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.homePageDue}: ${when(parsed.toLocal())}';
110+
})();
110111
// Determine if due is within 24 hours or already past (only for pending filter)
111112
final bool isDueSoon = (() {
112113
if (!pendingFilter) return false;
@@ -157,7 +158,7 @@ class TaskReplicaViewBuilder extends StatelessWidget {
157158
),
158159
),
159160
subtitle: Text(
160-
'${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.detailPageStatus}: ${task.status ?? ''}$nextDueText',
161+
'${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.detailPageStatus}: ${task.status ?? ''}$dueDateText',
161162
style: GoogleFonts.poppins(
162163
color: tColors.secondaryTextColor,
163164
),
@@ -267,7 +268,8 @@ class TaskReplicaViewBuilder extends StatelessWidget {
267268
}
268269

269270
void completeTask(TaskForReplica task) async {
270-
Replica.cancelNotificationsForTask(task);
271+
// modifyTaskInReplica handles notification cancellation internally;
272+
// no need to cancel here separately.
271273
await Replica.modifyTaskInReplica(task.copyWith(status: 'completed'));
272274
Get.find<HomeController>().refreshReplicaTaskList();
273275
}

lib/app/services/notification_services.dart

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,72 @@ class NotificationService {
136136
void cancelNotification(int notificationId) async {
137137
await _flutterLocalNotificationsPlugin.cancel(notificationId);
138138
}
139+
140+
/// Schedule an advance-warning notification 24 hours before [due] for a
141+
/// recurring task. Uses an ID offset of +4 over the base due-notification
142+
/// ID so it never collides with the due (+0) or wait (+2) notifications.
143+
void sendRecurrenceAdvanceNotification(
144+
DateTime due, String taskname, DateTime entryTime) async {
145+
final advanceTime = due.subtract(const Duration(hours: 24));
146+
if (advanceTime.isBefore(DateTime.now().toUtc())) return;
147+
148+
tz.initializeTimeZones();
149+
final tz.TZDateTime scheduledAt = tz.TZDateTime.from(advanceTime, tz.local);
150+
151+
final int baseId = calculateNotificationId(due, taskname, false, entryTime);
152+
final int notificationId = (baseId + 4) % 2147483647;
153+
154+
AndroidNotificationDetails androidDetails =
155+
const AndroidNotificationDetails('channelId', 'TaskReminder',
156+
icon: "taskwarrior",
157+
importance: Importance.max,
158+
priority: Priority.max);
159+
160+
DarwinNotificationDetails iosDetails = const DarwinNotificationDetails(
161+
presentAlert: true,
162+
presentBadge: true,
163+
presentSound: true,
164+
);
165+
166+
DarwinNotificationDetails macDetails = const DarwinNotificationDetails(
167+
presentAlert: true,
168+
presentBadge: true,
169+
presentSound: true,
170+
);
171+
172+
NotificationDetails notificationDetails = NotificationDetails(
173+
android: androidDetails,
174+
iOS: iosDetails,
175+
macOS: macDetails,
176+
);
177+
178+
await _flutterLocalNotificationsPlugin
179+
.zonedSchedule(
180+
notificationId,
181+
'Taskwarrior Reminder',
182+
"Hey! Your recurring task '$taskname' is due tomorrow",
183+
scheduledAt,
184+
notificationDetails,
185+
uiLocalNotificationDateInterpretation:
186+
UILocalNotificationDateInterpretation.absoluteTime,
187+
androidScheduleMode: AndroidScheduleMode.alarmClock)
188+
.then((_) {
189+
if (kDebugMode) {
190+
print('Advance-warning notification scheduled for $taskname');
191+
}
192+
}).catchError((error) {
193+
if (kDebugMode) {
194+
print('Error scheduling advance-warning notification: $error');
195+
}
196+
});
197+
}
198+
199+
/// Cancel the advance-warning notification previously scheduled for a
200+
/// recurring task identified by [due] + [taskname] + [entryTime].
201+
void cancelRecurrenceAdvanceNotification(
202+
DateTime due, String taskname, DateTime entryTime) {
203+
final int baseId = calculateNotificationId(due, taskname, false, entryTime);
204+
final int notificationId = (baseId + 4) % 2147483647;
205+
cancelNotification(notificationId);
206+
}
139207
}
Lines changed: 75 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,61 @@
11
/// Utility class for calculating next recurrence dates.
2+
///
3+
/// Supports all standard Taskwarrior recurrence keywords plus arbitrary
4+
/// Nd / Nw / Nm / Ny patterns.
25
class RecurrenceEngine {
36
/// Given [oldDate] and a [recur] pattern string, returns the next occurrence date.
4-
/// Supports: daily/1d, weekly/1w, monthly/1m, yearly/1y.
57
static DateTime? calculateNextDate(DateTime oldDate, String recur) {
68
final r = recur.toLowerCase().trim();
79
switch (r) {
10+
// ── Daily ────────────────────────────────────────────────────────────────
811
case 'daily':
912
case '1d':
1013
return oldDate.add(const Duration(days: 1));
14+
15+
// ── Weekdays (Mon–Fri only) ───────────────────────────────────────────
16+
case 'weekdays':
17+
return _nextWeekday(oldDate);
18+
19+
// ── Weekly ───────────────────────────────────────────────────────────
1120
case 'weekly':
1221
case '1w':
22+
case 'sennight':
1323
return oldDate.add(const Duration(days: 7));
24+
25+
// ── Biweekly / Fortnight ─────────────────────────────────────────────
26+
case 'biweekly':
27+
case 'fortnight':
28+
case '2w':
29+
return oldDate.add(const Duration(days: 14));
30+
31+
// ── Monthly ──────────────────────────────────────────────────────────
1432
case 'monthly':
1533
case '1m':
16-
return DateTime(
17-
oldDate.year,
18-
oldDate.month + 1,
19-
oldDate.day,
20-
oldDate.hour,
21-
oldDate.minute,
22-
oldDate.second,
23-
);
34+
return _addMonths(oldDate, 1);
35+
36+
// ── Bimonthly ────────────────────────────────────────────────────────
37+
case 'bimonthly':
38+
case '2m':
39+
return _addMonths(oldDate, 2);
40+
41+
// ── Quarterly ────────────────────────────────────────────────────────
42+
case 'quarterly':
43+
case '3m':
44+
return _addMonths(oldDate, 3);
45+
46+
// ── Semi-annual ──────────────────────────────────────────────────────
47+
case 'semiannual':
48+
case '6m':
49+
return _addMonths(oldDate, 6);
50+
51+
// ── Yearly / Annual ──────────────────────────────────────────────────
2452
case 'yearly':
53+
case 'annual':
2554
case '1y':
26-
return DateTime(
27-
oldDate.year + 1,
28-
oldDate.month,
29-
oldDate.day,
30-
oldDate.hour,
31-
oldDate.minute,
32-
oldDate.second,
33-
);
55+
return _addYears(oldDate, 1);
56+
3457
default:
35-
// Try to parse patterns like "2d", "3w", "6m", "2y"
58+
// Try to parse generic patterns like "2d", "3w", "6m", "2y"
3659
final match = RegExp(r'^(\d+)([dwmy])$').firstMatch(r);
3760
if (match != null) {
3861
final count = int.parse(match.group(1)!);
@@ -43,26 +66,44 @@ class RecurrenceEngine {
4366
case 'w':
4467
return oldDate.add(Duration(days: count * 7));
4568
case 'm':
46-
return DateTime(
47-
oldDate.year,
48-
oldDate.month + count,
49-
oldDate.day,
50-
oldDate.hour,
51-
oldDate.minute,
52-
oldDate.second,
53-
);
69+
return _addMonths(oldDate, count);
5470
case 'y':
55-
return DateTime(
56-
oldDate.year + count,
57-
oldDate.month,
58-
oldDate.day,
59-
oldDate.hour,
60-
oldDate.minute,
61-
oldDate.second,
62-
);
71+
return _addYears(oldDate, count);
6372
}
6473
}
6574
return null;
6675
}
6776
}
77+
78+
// ── Private helpers ────────────────────────────────────────────────────────
79+
80+
/// Advance [date] by [months], clamping to valid day-of-month.
81+
static DateTime _addMonths(DateTime date, int months) {
82+
int newMonth = date.month + months;
83+
int newYear = date.year + (newMonth - 1) ~/ 12;
84+
newMonth = ((newMonth - 1) % 12) + 1;
85+
final lastDay = DateTime(newYear, newMonth + 1, 0).day;
86+
final day = date.day.clamp(1, lastDay);
87+
return DateTime(
88+
newYear, newMonth, day, date.hour, date.minute, date.second);
89+
}
90+
91+
/// Advance [date] by [years], clamping to valid day-of-month (Feb 29 → Feb 28).
92+
static DateTime _addYears(DateTime date, int years) {
93+
final newYear = date.year + years;
94+
final lastDay = DateTime(newYear, date.month + 1, 0).day;
95+
final day = date.day.clamp(1, lastDay);
96+
return DateTime(
97+
newYear, date.month, day, date.hour, date.minute, date.second);
98+
}
99+
100+
/// Return the next weekday (Mon–Fri) strictly after [date].
101+
static DateTime _nextWeekday(DateTime date) {
102+
DateTime next = date.add(const Duration(days: 1));
103+
while (
104+
next.weekday == DateTime.saturday || next.weekday == DateTime.sunday) {
105+
next = next.add(const Duration(days: 1));
106+
}
107+
return next;
108+
}
68109
}

lib/app/v3/champion/replica.dart

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,14 @@ class Replica {
8989
if (nextDue != null) {
9090
var newMap = HashMap<String, dynamic>();
9191
newMap['description'] = newTask.description;
92-
newMap['project'] = newTask.project;
93-
newMap['priority'] = newTask.priority;
94-
newMap['tags'] = newTask.tags;
92+
if (newTask.project != null) newMap['project'] = newTask.project;
93+
if (newTask.priority != null) newMap['priority'] = newTask.priority;
94+
if (newTask.tags != null && newTask.tags!.isNotEmpty)
95+
newMap['tags'] = newTask.tags;
9596
newMap['recur'] = newTask.recur;
96-
newMap['rtype'] = newTask.rtype;
97-
newMap['mask'] = newTask.mask;
98-
newMap['imask'] = newTask.imask;
97+
if (newTask.rtype != null) newMap['rtype'] = newTask.rtype;
98+
if (newTask.mask != null) newMap['mask'] = newTask.mask;
99+
if (newTask.imask != null) newMap['imask'] = newTask.imask;
99100
newMap['parent'] = newTask.uuid;
100101
newMap['entry'] = DateTime.now().toUtc().toIso8601String();
101102
newMap['status'] = 'pending';
@@ -106,6 +107,17 @@ class Replica {
106107

107108
debugPrint("Creating next recurring replica task: $newMap");
108109
await addTaskToReplica(newMap);
110+
// Schedule a 24-hour advance-warning notification for the new occurrence
111+
try {
112+
final entryTime = DateTime.parse(newMap['entry'] as String? ??
113+
DateTime.now().toUtc().toIso8601String());
114+
NotificationService().sendRecurrenceAdvanceNotification(
115+
nextDue.toUtc(),
116+
newMap['description'] as String? ?? '',
117+
entryTime);
118+
} catch (e) {
119+
debugPrint('Error scheduling advance-warning for replica task: $e');
120+
}
109121
}
110122
} catch (e) {
111123
debugPrint("Error creating recurring replica task: $e");
@@ -265,6 +277,9 @@ class Replica {
265277
final id = notificationService.calculateNotificationId(
266278
due, description, false, entryTime);
267279
notificationService.cancelNotification(id);
280+
// Also cancel any advance-warning notification
281+
notificationService.cancelRecurrenceAdvanceNotification(
282+
due, description, entryTime);
268283
}
269284
if (wait != null) {
270285
final id = notificationService.calculateNotificationId(

0 commit comments

Comments
 (0)