feat(todo): Reporting, with goals, streaks and what was done (ADR 0033, D36)
A daily and a weekly goal, each with its progress, streak and chart, days off and vacation mode, and the history of completed tasks by project, all worked out on the device from what the tasks keep. Today shows a quiet card once a day when the goal is reached. No karma, nothing to share. Whats-New: Reporting: set a daily and a weekly goal, follow your streaks and see what you've done, with days off and vacation, under Browse. Whats-New-De: Auswertung: Lege ein Tages- und ein Wochenziel fest, verfolge deine Serien und sieh, was du erledigt hast, mit freien Tagen und Urlaub, in der Übersicht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
016b74ff74
commit
bd1b19ed32
19 changed files with 1651 additions and 3 deletions
File diff suppressed because one or more lines are too long
|
|
@ -64,6 +64,33 @@
|
|||
"commentDelete": "Kommentar löschen",
|
||||
"commentDeleteTitle": "Kommentar löschen?",
|
||||
"commentEdited": "{when}, bearbeitet",
|
||||
"reportingTitle": "Auswertung",
|
||||
"reportingHint": "Ziele, Serien und was du erledigt hast",
|
||||
"reportingDaily": "Täglich",
|
||||
"reportingWeekly": "Wöchentlich",
|
||||
"reportingHistory": "Verlauf",
|
||||
"reportingLast7Days": "Erledigt in den letzten 7 Tagen",
|
||||
"reportingLast4Weeks": "Erledigt in den letzten 4 Wochen",
|
||||
"historyProject": "Projekt",
|
||||
"historyAllProjects": "Alle Projekte",
|
||||
"historyNothing": "Noch nichts erledigt.",
|
||||
"goalsDaily": "Aufgaben am Tag",
|
||||
"goalsWeekly": "Aufgaben in der Woche",
|
||||
"goalsDaysOff": "Freie Tage",
|
||||
"goalsVacation": "Urlaub",
|
||||
"goalsVacationHint": "Serien warten, bis du zurück bist",
|
||||
"goalsEdit": "Ziele bearbeiten",
|
||||
"goalsDailyTitle": "Tagesziel",
|
||||
"goalsWeeklyTitle": "Wochenziel",
|
||||
"goalsDailyStreak": "Tagesserie",
|
||||
"goalsWeeklyStreak": "Wochenserie",
|
||||
"goalsOnVacation": "Im Urlaub",
|
||||
"goalsProgress": "{done} von {goal} Aufgaben",
|
||||
"goalsLongest": "Längste: {length} ({from} bis {to})",
|
||||
"goalsDays": "{count, plural, =1{1 Tag} other{{count} Tage}}",
|
||||
"goalsWeeks": "{count, plural, =1{1 Woche} other{{count} Wochen}}",
|
||||
"reportingTasks": "{count, plural, =1{1 Aufgabe} other{{count} Aufgaben}}",
|
||||
"goalsReached": "Tagesziel erreicht: heute {count, plural, =1{1 Aufgabe} other{{count} Aufgaben}} erledigt.",
|
||||
"labelsTitle": "Labels",
|
||||
"labelsNone": "Noch keine Labels.",
|
||||
"labelsNoneSet": "Keine Labels",
|
||||
|
|
|
|||
|
|
@ -132,6 +132,84 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"reportingTitle": "Reporting",
|
||||
"reportingHint": "Goals, streaks and what you've done",
|
||||
"reportingDaily": "Daily",
|
||||
"reportingWeekly": "Weekly",
|
||||
"reportingHistory": "History",
|
||||
"reportingLast7Days": "Done in the last 7 days",
|
||||
"reportingLast4Weeks": "Done in the last 4 weeks",
|
||||
"historyProject": "Project",
|
||||
"historyAllProjects": "All projects",
|
||||
"historyNothing": "Nothing done yet.",
|
||||
"goalsDaily": "Tasks a day",
|
||||
"goalsWeekly": "Tasks a week",
|
||||
"goalsDaysOff": "Days off",
|
||||
"goalsVacation": "Vacation",
|
||||
"goalsVacationHint": "Streaks wait until you're back",
|
||||
"goalsEdit": "Edit goals",
|
||||
"goalsDailyTitle": "Daily goal",
|
||||
"goalsWeeklyTitle": "Weekly goal",
|
||||
"goalsDailyStreak": "Daily streak",
|
||||
"goalsWeeklyStreak": "Weekly streak",
|
||||
"goalsOnVacation": "On vacation",
|
||||
"goalsProgress": "{done} of {goal} tasks",
|
||||
"@goalsProgress": {
|
||||
"placeholders": {
|
||||
"done": {
|
||||
"type": "int"
|
||||
},
|
||||
"goal": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"goalsLongest": "Longest: {length} ({from} to {to})",
|
||||
"@goalsLongest": {
|
||||
"placeholders": {
|
||||
"length": {
|
||||
"type": "String"
|
||||
},
|
||||
"from": {
|
||||
"type": "String"
|
||||
},
|
||||
"to": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"goalsDays": "{count, plural, =1{1 day} other{{count} days}}",
|
||||
"@goalsDays": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"goalsWeeks": "{count, plural, =1{1 week} other{{count} weeks}}",
|
||||
"@goalsWeeks": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reportingTasks": "{count, plural, =1{1 task} other{{count} tasks}}",
|
||||
"@reportingTasks": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"goalsReached": "Daily goal reached: {count, plural, =1{1 task} other{{count} tasks}} done today.",
|
||||
"@goalsReached": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"labelsTitle": "Labels",
|
||||
"labelsNone": "No labels yet.",
|
||||
"labelsNoneSet": "No labels",
|
||||
|
|
|
|||
|
|
@ -484,6 +484,168 @@ abstract class AppLocalizations {
|
|||
/// **'{when}, edited'**
|
||||
String commentEdited(String when);
|
||||
|
||||
/// No description provided for @reportingTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reporting'**
|
||||
String get reportingTitle;
|
||||
|
||||
/// No description provided for @reportingHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Goals, streaks and what you\'ve done'**
|
||||
String get reportingHint;
|
||||
|
||||
/// No description provided for @reportingDaily.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Daily'**
|
||||
String get reportingDaily;
|
||||
|
||||
/// No description provided for @reportingWeekly.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Weekly'**
|
||||
String get reportingWeekly;
|
||||
|
||||
/// No description provided for @reportingHistory.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'History'**
|
||||
String get reportingHistory;
|
||||
|
||||
/// No description provided for @reportingLast7Days.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Done in the last 7 days'**
|
||||
String get reportingLast7Days;
|
||||
|
||||
/// No description provided for @reportingLast4Weeks.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Done in the last 4 weeks'**
|
||||
String get reportingLast4Weeks;
|
||||
|
||||
/// No description provided for @historyProject.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Project'**
|
||||
String get historyProject;
|
||||
|
||||
/// No description provided for @historyAllProjects.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All projects'**
|
||||
String get historyAllProjects;
|
||||
|
||||
/// No description provided for @historyNothing.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nothing done yet.'**
|
||||
String get historyNothing;
|
||||
|
||||
/// No description provided for @goalsDaily.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tasks a day'**
|
||||
String get goalsDaily;
|
||||
|
||||
/// No description provided for @goalsWeekly.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tasks a week'**
|
||||
String get goalsWeekly;
|
||||
|
||||
/// No description provided for @goalsDaysOff.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Days off'**
|
||||
String get goalsDaysOff;
|
||||
|
||||
/// No description provided for @goalsVacation.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Vacation'**
|
||||
String get goalsVacation;
|
||||
|
||||
/// No description provided for @goalsVacationHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Streaks wait until you\'re back'**
|
||||
String get goalsVacationHint;
|
||||
|
||||
/// No description provided for @goalsEdit.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Edit goals'**
|
||||
String get goalsEdit;
|
||||
|
||||
/// No description provided for @goalsDailyTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Daily goal'**
|
||||
String get goalsDailyTitle;
|
||||
|
||||
/// No description provided for @goalsWeeklyTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Weekly goal'**
|
||||
String get goalsWeeklyTitle;
|
||||
|
||||
/// No description provided for @goalsDailyStreak.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Daily streak'**
|
||||
String get goalsDailyStreak;
|
||||
|
||||
/// No description provided for @goalsWeeklyStreak.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Weekly streak'**
|
||||
String get goalsWeeklyStreak;
|
||||
|
||||
/// No description provided for @goalsOnVacation.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'On vacation'**
|
||||
String get goalsOnVacation;
|
||||
|
||||
/// No description provided for @goalsProgress.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{done} of {goal} tasks'**
|
||||
String goalsProgress(int done, int goal);
|
||||
|
||||
/// No description provided for @goalsLongest.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Longest: {length} ({from} to {to})'**
|
||||
String goalsLongest(String length, String from, String to);
|
||||
|
||||
/// No description provided for @goalsDays.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 day} other{{count} days}}'**
|
||||
String goalsDays(int count);
|
||||
|
||||
/// No description provided for @goalsWeeks.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 week} other{{count} weeks}}'**
|
||||
String goalsWeeks(int count);
|
||||
|
||||
/// No description provided for @reportingTasks.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 task} other{{count} tasks}}'**
|
||||
String reportingTasks(int count);
|
||||
|
||||
/// No description provided for @goalsReached.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Daily goal reached: {count, plural, =1{1 task} other{{count} tasks}} done today.'**
|
||||
String goalsReached(int count);
|
||||
|
||||
/// No description provided for @labelsTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -232,6 +232,123 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
return '$when, bearbeitet';
|
||||
}
|
||||
|
||||
@override
|
||||
String get reportingTitle => 'Auswertung';
|
||||
|
||||
@override
|
||||
String get reportingHint => 'Ziele, Serien und was du erledigt hast';
|
||||
|
||||
@override
|
||||
String get reportingDaily => 'Täglich';
|
||||
|
||||
@override
|
||||
String get reportingWeekly => 'Wöchentlich';
|
||||
|
||||
@override
|
||||
String get reportingHistory => 'Verlauf';
|
||||
|
||||
@override
|
||||
String get reportingLast7Days => 'Erledigt in den letzten 7 Tagen';
|
||||
|
||||
@override
|
||||
String get reportingLast4Weeks => 'Erledigt in den letzten 4 Wochen';
|
||||
|
||||
@override
|
||||
String get historyProject => 'Projekt';
|
||||
|
||||
@override
|
||||
String get historyAllProjects => 'Alle Projekte';
|
||||
|
||||
@override
|
||||
String get historyNothing => 'Noch nichts erledigt.';
|
||||
|
||||
@override
|
||||
String get goalsDaily => 'Aufgaben am Tag';
|
||||
|
||||
@override
|
||||
String get goalsWeekly => 'Aufgaben in der Woche';
|
||||
|
||||
@override
|
||||
String get goalsDaysOff => 'Freie Tage';
|
||||
|
||||
@override
|
||||
String get goalsVacation => 'Urlaub';
|
||||
|
||||
@override
|
||||
String get goalsVacationHint => 'Serien warten, bis du zurück bist';
|
||||
|
||||
@override
|
||||
String get goalsEdit => 'Ziele bearbeiten';
|
||||
|
||||
@override
|
||||
String get goalsDailyTitle => 'Tagesziel';
|
||||
|
||||
@override
|
||||
String get goalsWeeklyTitle => 'Wochenziel';
|
||||
|
||||
@override
|
||||
String get goalsDailyStreak => 'Tagesserie';
|
||||
|
||||
@override
|
||||
String get goalsWeeklyStreak => 'Wochenserie';
|
||||
|
||||
@override
|
||||
String get goalsOnVacation => 'Im Urlaub';
|
||||
|
||||
@override
|
||||
String goalsProgress(int done, int goal) {
|
||||
return '$done von $goal Aufgaben';
|
||||
}
|
||||
|
||||
@override
|
||||
String goalsLongest(String length, String from, String to) {
|
||||
return 'Längste: $length ($from bis $to)';
|
||||
}
|
||||
|
||||
@override
|
||||
String goalsDays(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Tage',
|
||||
one: '1 Tag',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String goalsWeeks(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Wochen',
|
||||
one: '1 Woche',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String reportingTasks(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Aufgaben',
|
||||
one: '1 Aufgabe',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String goalsReached(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Aufgaben',
|
||||
one: '1 Aufgabe',
|
||||
);
|
||||
return 'Tagesziel erreicht: heute $_temp0 erledigt.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get labelsTitle => 'Labels';
|
||||
|
||||
|
|
|
|||
|
|
@ -232,6 +232,123 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
return '$when, edited';
|
||||
}
|
||||
|
||||
@override
|
||||
String get reportingTitle => 'Reporting';
|
||||
|
||||
@override
|
||||
String get reportingHint => 'Goals, streaks and what you\'ve done';
|
||||
|
||||
@override
|
||||
String get reportingDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get reportingWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get reportingHistory => 'History';
|
||||
|
||||
@override
|
||||
String get reportingLast7Days => 'Done in the last 7 days';
|
||||
|
||||
@override
|
||||
String get reportingLast4Weeks => 'Done in the last 4 weeks';
|
||||
|
||||
@override
|
||||
String get historyProject => 'Project';
|
||||
|
||||
@override
|
||||
String get historyAllProjects => 'All projects';
|
||||
|
||||
@override
|
||||
String get historyNothing => 'Nothing done yet.';
|
||||
|
||||
@override
|
||||
String get goalsDaily => 'Tasks a day';
|
||||
|
||||
@override
|
||||
String get goalsWeekly => 'Tasks a week';
|
||||
|
||||
@override
|
||||
String get goalsDaysOff => 'Days off';
|
||||
|
||||
@override
|
||||
String get goalsVacation => 'Vacation';
|
||||
|
||||
@override
|
||||
String get goalsVacationHint => 'Streaks wait until you\'re back';
|
||||
|
||||
@override
|
||||
String get goalsEdit => 'Edit goals';
|
||||
|
||||
@override
|
||||
String get goalsDailyTitle => 'Daily goal';
|
||||
|
||||
@override
|
||||
String get goalsWeeklyTitle => 'Weekly goal';
|
||||
|
||||
@override
|
||||
String get goalsDailyStreak => 'Daily streak';
|
||||
|
||||
@override
|
||||
String get goalsWeeklyStreak => 'Weekly streak';
|
||||
|
||||
@override
|
||||
String get goalsOnVacation => 'On vacation';
|
||||
|
||||
@override
|
||||
String goalsProgress(int done, int goal) {
|
||||
return '$done of $goal tasks';
|
||||
}
|
||||
|
||||
@override
|
||||
String goalsLongest(String length, String from, String to) {
|
||||
return 'Longest: $length ($from to $to)';
|
||||
}
|
||||
|
||||
@override
|
||||
String goalsDays(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count days',
|
||||
one: '1 day',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String goalsWeeks(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count weeks',
|
||||
one: '1 week',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String reportingTasks(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tasks',
|
||||
one: '1 task',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String goalsReached(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tasks',
|
||||
one: '1 task',
|
||||
);
|
||||
return 'Daily goal reached: $_temp0 done today.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get labelsTitle => 'Labels';
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ abstract final class TodoPaths() {
|
|||
/// A project's screen, in Browse.
|
||||
static String project(String id) => '/browse/projects/$id';
|
||||
|
||||
/// Reporting (D36), in Browse.
|
||||
static const reporting = '/browse/reporting';
|
||||
|
||||
/// Filters and labels (D35), in Browse.
|
||||
static const filters = '/browse/filters';
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:halkyon_todo_flutter/src/app/todo_paths.dart';
|
|||
import 'package:halkyon_todo_flutter/src/feature/filter/widget/filter_screen.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/filter/widget/filters_screen.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_screen.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/report/widget/reporting_screen.dart';
|
||||
import 'package:halkyon_todo_flutter/src/settings/settings_screen.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/browse_tab.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/inbox_tab.dart';
|
||||
|
|
@ -60,6 +61,10 @@ GoRouter buildTodoRouter() => GoRouter(
|
|||
builder: (context, state) =>
|
||||
ProjectScreen(projectId: state.pathParameters['id']!),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'reporting',
|
||||
builder: (context, state) => const ReportingScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'filters',
|
||||
builder: (context, state) => const FiltersScreen(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
|
||||
|
||||
/// Something done: the task, and when (a recurring task's every time).
|
||||
typedef CompletedEntry = ({OpenTask task, DateTime at});
|
||||
|
||||
/// A run of goals reached, from its first day to its last.
|
||||
typedef StreakRun = ({int length, Day from, Day to});
|
||||
|
||||
/// The streak running now (zero for none) and the longest
|
||||
/// there has been, none before any.
|
||||
typedef Streak = ({int current, StreakRun? longest});
|
||||
|
||||
/// Everything [tasks] say was done, the latest first (ADR 0033, D36): a task
|
||||
/// done, and each time a recurring one was, from what the tasks keep, so
|
||||
/// nothing is logged apart from them and a task deleted leaves the history.
|
||||
List<CompletedEntry> completedEntries(Iterable<OpenTask> tasks) => [
|
||||
for (final task in tasks) ...[
|
||||
if (task.content.completedAt case final at?) (task: task, at: at),
|
||||
for (final at in task.content.completions) (task: task, at: at),
|
||||
],
|
||||
]..sort((a, b) => b.at.compareTo(a.at));
|
||||
|
||||
/// How many tasks were done on each day, by the device's time.
|
||||
Map<Day, int> completedPerDay(Iterable<OpenTask> tasks) {
|
||||
final counts = <Day, int>{};
|
||||
for (final (task: _, :at) in completedEntries(tasks)) {
|
||||
counts.update(Day.of(at.toLocal()), (n) => n + 1, ifAbsent: () => 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/// The [count] days up to [today] with what was done on each, oldest first.
|
||||
List<(Day, int)> lastDays(Map<Day, int> counts, Day today, int count) => [
|
||||
for (var back = count - 1; back >= 0; back -= 1)
|
||||
(today.plus(-back), counts[today.plus(-back)] ?? 0),
|
||||
];
|
||||
|
||||
/// The [count] weeks up to [thisWeek]'s, by their first days, with what was
|
||||
/// done in each, oldest first.
|
||||
List<(Day, int)> lastWeeks(Map<Day, int> counts, Day thisWeek, int count) => [
|
||||
for (var back = count - 1; back >= 0; back -= 1)
|
||||
(thisWeek.plus(-7 * back), _weekCount(counts, thisWeek.plus(-7 * back))),
|
||||
];
|
||||
|
||||
int _weekCount(Map<Day, int> counts, Day week) => [
|
||||
for (var day = 0; day < 7; day += 1) counts[week.plus(day)] ?? 0,
|
||||
].fold(0, (sum, count) => sum + count);
|
||||
|
||||
/// A streak of steps from [first] to [last]: [reached] ones lengthen it,
|
||||
/// [excused] ones and the unfinished [last] leave it standing, any other
|
||||
/// ends it.
|
||||
Streak _streak(
|
||||
Day first,
|
||||
Day last,
|
||||
int step, {
|
||||
required bool Function(Day) reached,
|
||||
required bool Function(Day) excused,
|
||||
}) {
|
||||
var run = 0;
|
||||
Day? from;
|
||||
Day? to;
|
||||
StreakRun? longest;
|
||||
for (var at = first; at.compareTo(last) <= 0; at = at.plus(step)) {
|
||||
if (reached(at)) {
|
||||
run += 1;
|
||||
from ??= at;
|
||||
to = at;
|
||||
if (run > (longest?.length ?? 0)) {
|
||||
longest = (length: run, from: from, to: to);
|
||||
}
|
||||
} else if (!excused(at) && at != last) {
|
||||
run = 0;
|
||||
from = null;
|
||||
}
|
||||
}
|
||||
return (current: run, longest: longest);
|
||||
}
|
||||
|
||||
/// The streak of days the daily goal was reached on (D36): days off and
|
||||
/// vacation break nothing and count when the goal was reached anyway; today
|
||||
/// doesn't break it while it lasts.
|
||||
Streak dailyStreak(Map<Day, int> counts, GoalSettings goals, Day today) {
|
||||
if (counts.isEmpty) {
|
||||
return (current: 0, longest: null);
|
||||
}
|
||||
final days = counts.keys.toList()..sort();
|
||||
return _streak(
|
||||
days.first,
|
||||
today,
|
||||
1,
|
||||
reached: (day) => (counts[day] ?? 0) >= goals.daily,
|
||||
excused: goals.isRestDay,
|
||||
);
|
||||
}
|
||||
|
||||
/// The streak of weeks the weekly goal was reached in (D36), each by its
|
||||
/// first day from [thisWeek]'s back: a week of rest alone breaks nothing,
|
||||
/// and this one doesn't while it lasts.
|
||||
Streak weeklyStreak(Map<Day, int> counts, GoalSettings goals, Day thisWeek) {
|
||||
if (counts.isEmpty) {
|
||||
return (current: 0, longest: null);
|
||||
}
|
||||
final days = counts.keys.toList()..sort();
|
||||
final behind = (days.first.daysUntil(thisWeek) / 7).ceil();
|
||||
return _streak(
|
||||
thisWeek.plus(-7 * behind),
|
||||
thisWeek,
|
||||
7,
|
||||
reached: (week) => _weekCount(counts, week) >= goals.weekly,
|
||||
excused: (week) => [
|
||||
for (var day = 0; day < 7; day += 1) week.plus(day),
|
||||
].every(goals.isRestDay),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The key of the editor's field of the daily goal.
|
||||
const dailyGoalKey = ValueKey('goal-daily');
|
||||
|
||||
/// The key of the editor's field of the weekly goal.
|
||||
const weeklyGoalKey = ValueKey('goal-weekly');
|
||||
|
||||
/// The key of the switch of vacation mode.
|
||||
const vacationKey = ValueKey('goal-vacation');
|
||||
|
||||
/// Asks for the person's goals (ADR 0033, D36): tasks a day and a week, the
|
||||
/// weekdays off and whether they're away. Null where they changed their
|
||||
/// mind.
|
||||
Future<GoalSettings?> editGoals(BuildContext context, GoalSettings current) =>
|
||||
showDialog<GoalSettings>(
|
||||
context: context,
|
||||
builder: (context) => _GoalEditor(current: current),
|
||||
);
|
||||
|
||||
class const _GoalEditor({required final GoalSettings current})
|
||||
extends StatefulWidget {
|
||||
@override
|
||||
State<_GoalEditor> createState() => _GoalEditorState();
|
||||
}
|
||||
|
||||
class _GoalEditorState() extends State<_GoalEditor> {
|
||||
late final _daily = TextEditingController(text: '${widget.current.daily}');
|
||||
late final _weekly = TextEditingController(text: '${widget.current.weekly}');
|
||||
late final _daysOff = {...widget.current.daysOff};
|
||||
late var _vacation = widget.current.onVacation;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_daily.dispose();
|
||||
_weekly.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int? _goal(TextEditingController field) => switch (int.tryParse(field.text)) {
|
||||
final count? when count > 0 => count,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
bool get _ready => _goal(_daily) != null && _goal(_weekly) != null;
|
||||
|
||||
void _save() => Navigator.of(context).pop(
|
||||
widget.current
|
||||
.copyWith(
|
||||
daily: _goal(_daily),
|
||||
weekly: _goal(_weekly),
|
||||
daysOff: _daysOff,
|
||||
)
|
||||
.withVacation(on: _vacation, today: Day.of(clock.now())),
|
||||
);
|
||||
|
||||
Widget _number(Key key, TextEditingController field, String label) =>
|
||||
TextField(
|
||||
key: key,
|
||||
controller: field,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: InputDecoration(labelText: label),
|
||||
onChanged: (_) => setState(() {}),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final weekday = DateFormat.E(Localizations.localeOf(context).toString());
|
||||
// 2 Nov 2026 is a Monday.
|
||||
String name(int day) => weekday.format(DateTime(2026, 11, 1 + day));
|
||||
return AlertDialog(
|
||||
title: Text(l10n.goalsEdit),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_number(dailyGoalKey, _daily, l10n.goalsDaily),
|
||||
const SizedBox(height: 12),
|
||||
_number(weeklyGoalKey, _weekly, l10n.goalsWeekly),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.goalsDaysOff,
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (var day = DateTime.monday; day <= DateTime.sunday; day++)
|
||||
FilterChip(
|
||||
label: Text(name(day)),
|
||||
selected: _daysOff.contains(day),
|
||||
onSelected: (on) => setState(
|
||||
() => on ? _daysOff.add(day) : _daysOff.remove(day),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SwitchListTile(
|
||||
key: vacationKey,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.goalsVacation),
|
||||
subtitle: Text(l10n.goalsVacationHint),
|
||||
value: _vacation,
|
||||
onChanged: (on) => setState(() => _vacation = on),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _ready ? _save : null,
|
||||
child: Text(l10n.save),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,434 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/report/model/productivity.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/report/widget/goal_editor.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/week_strip.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
enum _Page() {
|
||||
daily,
|
||||
weekly,
|
||||
history,
|
||||
}
|
||||
|
||||
/// Reporting (ADR 0033, D36): the daily and the weekly goal, each with its
|
||||
/// progress, its streak and what was done lately, and the history of what
|
||||
/// was done, by project. Worked out on the device from the tasks kept: no
|
||||
/// karma (D8), nothing to share.
|
||||
class const ReportingScreen({super.key}) extends StatefulWidget {
|
||||
@override
|
||||
State<ReportingScreen> createState() => _ReportingScreenState();
|
||||
}
|
||||
|
||||
class _ReportingScreenState() extends State<ReportingScreen> {
|
||||
var _page = _Page.daily;
|
||||
|
||||
/// The project the history keeps to; none: all.
|
||||
String? _projectId;
|
||||
|
||||
Widget _goal(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required int done,
|
||||
required int goal,
|
||||
}) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final goals = context.read<TodoCubit>().state.settings;
|
||||
return SectionCard(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.goalsProgress(done, goal),
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
if (goals.goals.onVacation)
|
||||
Text(
|
||||
l10n.goalsOnVacation,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(padding: EdgeInsets.zero),
|
||||
onPressed: () async {
|
||||
final todo = PersonScope.of<TodoPerson>(context);
|
||||
if (await editGoals(context, goals.goals)
|
||||
case final edited?) {
|
||||
await todo.settings.write(goals.copyWith(goals: edited));
|
||||
}
|
||||
},
|
||||
child: Text(l10n.goalsEdit),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox.square(
|
||||
dimension: 64,
|
||||
child: CircularProgressIndicator(
|
||||
value: (done / goal).clamp(0, 1),
|
||||
strokeWidth: 6,
|
||||
backgroundColor: theme.colorScheme.surfaceContainerHighest,
|
||||
semanticsLabel: l10n.goalsProgress(done, goal),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _streak(
|
||||
BuildContext context,
|
||||
Streak streak, {
|
||||
required String title,
|
||||
required String Function(int) length,
|
||||
}) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final today = Day.of(clock.now());
|
||||
return SectionCard(
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(length(streak.current)),
|
||||
if (streak.longest case final longest?)
|
||||
Text(
|
||||
l10n.goalsLongest(
|
||||
length(longest.length),
|
||||
dayText(context, longest.from, today),
|
||||
dayText(context, longest.to, today),
|
||||
),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bars(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required List<(String, int)> rows,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final most = rows.fold(1, (most, row) => row.$2 > most ? row.$2 : most);
|
||||
return SectionCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
for (final (label, count) in rows)
|
||||
_Bar(label: label, count: count, share: count / most),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _history(BuildContext context, TodoState state) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final muted = theme.colorScheme.onSurfaceVariant;
|
||||
final today = Day.of(clock.now());
|
||||
final entries = [
|
||||
for (final entry in completedEntries(state.tasks))
|
||||
if (_projectId == null || entry.task.content.projectId == _projectId)
|
||||
entry,
|
||||
];
|
||||
Day? day;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DropdownButtonFormField<String?>(
|
||||
initialValue: _projectId,
|
||||
decoration: InputDecoration(labelText: l10n.historyProject),
|
||||
items: [
|
||||
DropdownMenuItem(child: Text(l10n.historyAllProjects)),
|
||||
for (final project in state.projects)
|
||||
if (project.content case final content?)
|
||||
DropdownMenuItem(
|
||||
value: project.id,
|
||||
child: Text(
|
||||
project.inbox ? l10n.shellTabInbox : content.name,
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (id) => setState(() => _projectId = id),
|
||||
),
|
||||
if (entries.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(
|
||||
l10n.historyNothing,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: muted),
|
||||
),
|
||||
),
|
||||
for (final entry in entries) ...[
|
||||
if (Day.of(entry.at.toLocal()) case final on when on != day) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 16, 4, 4),
|
||||
child: Text(
|
||||
dayText(context, day = on, today),
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
dense: true,
|
||||
leading: Icon(LucideIcons.circleCheck, color: muted),
|
||||
title: Text(entry.task.content.title),
|
||||
subtitle: Text(
|
||||
[
|
||||
timeText(
|
||||
context,
|
||||
entry.at.toLocal().hour * 60 + entry.at.toLocal().minute,
|
||||
),
|
||||
switch (state.projectOf(entry.task.content.projectId)) {
|
||||
final project? when project.inbox => l10n.shellTabInbox,
|
||||
final project? => project.content!.name,
|
||||
null => '',
|
||||
},
|
||||
].where((part) => part.isNotEmpty).join(' · '),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final state = context.watch<TodoCubit>().state;
|
||||
final goals = state.settings.goals;
|
||||
final today = Day.of(clock.now());
|
||||
final thisWeek = WeekStrip.weekOf(today, firstWeekdayOf(context));
|
||||
final counts = completedPerDay(state.tasks);
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
final weekday = DateFormat.E(locale);
|
||||
final dayMonth = DateFormat.Md(locale);
|
||||
final thisWeekCount = lastWeeks(counts, thisWeek, 1).single.$2;
|
||||
const gap = SizedBox(height: 12);
|
||||
return TabScreen(
|
||||
title: l10n.reportingTitle,
|
||||
body: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
|
||||
child: ListView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
12,
|
||||
8,
|
||||
12,
|
||||
24 + FloatingNavigationBar.coverOf(context),
|
||||
),
|
||||
children: [
|
||||
SegmentedButton<_Page>(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: _Page.daily,
|
||||
label: Text(l10n.reportingDaily),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _Page.weekly,
|
||||
label: Text(l10n.reportingWeekly),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _Page.history,
|
||||
label: Text(l10n.reportingHistory),
|
||||
),
|
||||
],
|
||||
selected: {_page},
|
||||
showSelectedIcon: false,
|
||||
onSelectionChanged: (picked) =>
|
||||
setState(() => _page = picked.single),
|
||||
),
|
||||
gap,
|
||||
...switch (_page) {
|
||||
_Page.daily => [
|
||||
_goal(
|
||||
context,
|
||||
title: l10n.goalsDailyTitle,
|
||||
done: counts[today] ?? 0,
|
||||
goal: goals.daily,
|
||||
),
|
||||
gap,
|
||||
_streak(
|
||||
context,
|
||||
dailyStreak(counts, goals, today),
|
||||
title: l10n.goalsDailyStreak,
|
||||
length: l10n.goalsDays,
|
||||
),
|
||||
gap,
|
||||
_bars(
|
||||
context,
|
||||
title: l10n.reportingLast7Days,
|
||||
rows: [
|
||||
for (final (day, count) in lastDays(counts, today, 7))
|
||||
(weekday.format(day.toDate()), count),
|
||||
],
|
||||
),
|
||||
],
|
||||
_Page.weekly => [
|
||||
_goal(
|
||||
context,
|
||||
title: l10n.goalsWeeklyTitle,
|
||||
done: thisWeekCount,
|
||||
goal: goals.weekly,
|
||||
),
|
||||
gap,
|
||||
_streak(
|
||||
context,
|
||||
weeklyStreak(counts, goals, thisWeek),
|
||||
title: l10n.goalsWeeklyStreak,
|
||||
length: l10n.goalsWeeks,
|
||||
),
|
||||
gap,
|
||||
_bars(
|
||||
context,
|
||||
title: l10n.reportingLast4Weeks,
|
||||
rows: [
|
||||
for (final (week, count) in lastWeeks(
|
||||
counts,
|
||||
thisWeek,
|
||||
4,
|
||||
))
|
||||
(dayMonth.format(week.toDate()), count),
|
||||
],
|
||||
),
|
||||
],
|
||||
_Page.history => [_history(context, state)],
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of a chart: its [label], one thin bar growing from the left, its
|
||||
/// end rounded, to [share] of the width, and its [count] after it, in the
|
||||
/// text's colour. A single series: the card's title names it.
|
||||
class const _Bar({
|
||||
required final String label,
|
||||
required final int count,
|
||||
required final double share,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final small = theme.textTheme.bodySmall;
|
||||
return Tooltip(
|
||||
message: l10n.reportingTasks(count),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 48, child: Text(label, style: small)),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Room for the number after the longest bar.
|
||||
final room = constraints.maxWidth - 32;
|
||||
return Row(
|
||||
children: [
|
||||
if (count > 0) ...[
|
||||
Container(
|
||||
width: room * share,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary,
|
||||
borderRadius: const BorderRadius.horizontal(
|
||||
right: Radius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Text('$count', style: small),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A quiet card on Today once the daily goal is reached (D36), until it is
|
||||
/// dismissed that day: no sound, no confetti, nothing to share.
|
||||
class const GoalReachedCard({super.key}) extends StatelessWidget {
|
||||
static const dismissKey = ValueKey('goal-reached-dismiss');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final state = context.watch<TodoCubit>().state;
|
||||
final settings = state.settings;
|
||||
final today = Day.of(clock.now());
|
||||
final done = completedPerDay(state.tasks)[today] ?? 0;
|
||||
if (!state.loaded ||
|
||||
done < settings.goals.daily ||
|
||||
settings.goals.celebrated == today) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
|
||||
child: SectionCard(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.goalsReached(done),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
key: dismissKey,
|
||||
tooltip: l10n.clear,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: () =>
|
||||
PersonScope.of<TodoPerson>(context).settings.write(
|
||||
settings.copyWith(
|
||||
goals: settings.goals.copyWith(celebrated: today),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import 'dart:typed_data';
|
|||
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
|
||||
/// The schema of the settings' payload this app writes and reads.
|
||||
const settingsSchemaVersion = 1;
|
||||
|
|
@ -30,6 +31,17 @@ ByteData encodeSettings(TodoSettings settings) =>
|
|||
if (settings.reminders.snooze != const ReminderSettings().snooze)
|
||||
'snooze': settings.reminders.snooze,
|
||||
},
|
||||
if (settings.goals != const GoalSettings())
|
||||
'goals': {
|
||||
'daily': settings.goals.daily,
|
||||
'weekly': settings.goals.weekly,
|
||||
'daysOff': (settings.goals.daysOff.toList()..sort()),
|
||||
'vacations': [
|
||||
for (final vacation in settings.goals.vacations)
|
||||
{'from': '${vacation.from}', 'to': ?vacation.to?.toString()},
|
||||
],
|
||||
'celebrated': ?settings.goals.celebrated?.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
Map<String, Object?> _encodeUpcoming(UpcomingView view) => {
|
||||
|
|
@ -49,9 +61,46 @@ TodoSettings? decodeSettings(ByteData payload) => decodeJson(
|
|||
today: _decodeToday(json['today']),
|
||||
tasks: _decodeTasks(json['tasks']),
|
||||
reminders: _decodeReminders(json['reminders']),
|
||||
goals: _decodeGoals(json['goals']),
|
||||
),
|
||||
);
|
||||
|
||||
GoalSettings _decodeGoals(Object? json) {
|
||||
if (json is! Map<String, Object?>) {
|
||||
return const GoalSettings();
|
||||
}
|
||||
int goal(String key, int fallback) => switch (json[key]) {
|
||||
final int count when count > 0 => count,
|
||||
_ => fallback,
|
||||
};
|
||||
Day? day(Object? text) => text is String ? Day.tryParse(text) : null;
|
||||
const first = GoalSettings();
|
||||
return GoalSettings(
|
||||
daily: goal('daily', first.daily),
|
||||
weekly: goal('weekly', first.weekly),
|
||||
daysOff: switch (json['daysOff']) {
|
||||
final List<Object?> days => {
|
||||
for (final day in days)
|
||||
if (day case final int weekday
|
||||
when weekday >= DateTime.monday && weekday <= DateTime.sunday)
|
||||
weekday,
|
||||
},
|
||||
_ => first.daysOff,
|
||||
},
|
||||
vacations: switch (json['vacations']) {
|
||||
final List<Object?> vacations => [
|
||||
for (final vacation in vacations)
|
||||
if (vacation case {'from': final from}
|
||||
when vacation is Map<String, Object?>)
|
||||
if (day(from) case final start?)
|
||||
Vacation(start, day(vacation['to'])),
|
||||
],
|
||||
_ => const [],
|
||||
},
|
||||
celebrated: day(json['celebrated']),
|
||||
);
|
||||
}
|
||||
|
||||
TodayView _decodeToday(Object? json) {
|
||||
if (json is! Map<String, Object?>) {
|
||||
return const TodayView();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
|
||||
/// How Upcoming lays out its days (ADR 0033, D17, as Todoist's).
|
||||
enum UpcomingLayout() {
|
||||
|
|
@ -108,27 +109,96 @@ final class const ReminderSettings({
|
|||
List<Object?> get props => [automatic, snooze];
|
||||
}
|
||||
|
||||
/// A time away (D36): from a day, to one once it ended.
|
||||
final class const Vacation(final Day from, [final Day? to]) with Equatable {
|
||||
/// Whether [day] falls in it.
|
||||
bool holds(Day day) =>
|
||||
day.compareTo(from) >= 0 && (to == null || day.compareTo(to!) <= 0);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [from, to];
|
||||
}
|
||||
|
||||
/// The person's goals (D36): tasks a day and a week, the weekdays off
|
||||
/// (`DateTime.monday` to `sunday`), their vacations, which stay kept so a
|
||||
/// streak over one still holds afterwards, and the day whose goal was last
|
||||
/// marked (Today's card, shown once a day).
|
||||
final class const GoalSettings({
|
||||
final int daily = 5,
|
||||
final int weekly = 30,
|
||||
final Set<int> daysOff = const {DateTime.saturday, DateTime.sunday},
|
||||
final List<Vacation> vacations = const [],
|
||||
final Day? celebrated,
|
||||
}) with Equatable {
|
||||
/// Whether the person is away now.
|
||||
bool get onVacation =>
|
||||
vacations.lastOrNull?.to == null && vacations.isNotEmpty;
|
||||
|
||||
/// Whether no goal is asked on [day].
|
||||
bool isRestDay(Day day) =>
|
||||
daysOff.contains(day.weekday) ||
|
||||
vacations.any((vacation) => vacation.holds(day));
|
||||
|
||||
/// Vacation started on [today], or the one running ended the day before
|
||||
/// (the same day: as if it had not been).
|
||||
GoalSettings withVacation({required bool on, required Day today}) {
|
||||
if (on == onVacation) {
|
||||
return this;
|
||||
}
|
||||
final earlier = [...vacations];
|
||||
if (on) {
|
||||
return copyWith(vacations: [...earlier, Vacation(today)]);
|
||||
}
|
||||
final running = earlier.removeLast();
|
||||
return copyWith(
|
||||
vacations: running.from == today
|
||||
? earlier
|
||||
: [...earlier, Vacation(running.from, today.plus(-1))],
|
||||
);
|
||||
}
|
||||
|
||||
GoalSettings copyWith({
|
||||
int? daily,
|
||||
int? weekly,
|
||||
Set<int>? daysOff,
|
||||
List<Vacation>? vacations,
|
||||
Day? celebrated,
|
||||
}) => GoalSettings(
|
||||
daily: daily ?? this.daily,
|
||||
weekly: weekly ?? this.weekly,
|
||||
daysOff: daysOff ?? this.daysOff,
|
||||
vacations: vacations ?? this.vacations,
|
||||
celebrated: celebrated ?? this.celebrated,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [daily, weekly, daysOff, vacations, celebrated];
|
||||
}
|
||||
|
||||
/// The person's settings of Todo's (D4, D17): one sealed row of theirs,
|
||||
/// synced to every device (`todo_settings`): [upcoming]'s and [today]'s
|
||||
/// Displays, and the settings of [tasks] and [reminders].
|
||||
/// Displays, the settings of [tasks] and [reminders], and their [goals].
|
||||
final class const TodoSettings({
|
||||
final UpcomingView upcoming = const UpcomingView(),
|
||||
final TodayView today = const TodayView(),
|
||||
final TaskSettings tasks = const TaskSettings(),
|
||||
final ReminderSettings reminders = const ReminderSettings(),
|
||||
final GoalSettings goals = const GoalSettings(),
|
||||
}) with Equatable {
|
||||
TodoSettings copyWith({
|
||||
UpcomingView? upcoming,
|
||||
TodayView? today,
|
||||
TaskSettings? tasks,
|
||||
ReminderSettings? reminders,
|
||||
GoalSettings? goals,
|
||||
}) => TodoSettings(
|
||||
upcoming: upcoming ?? this.upcoming,
|
||||
today: today ?? this.today,
|
||||
tasks: tasks ?? this.tasks,
|
||||
reminders: reminders ?? this.reminders,
|
||||
goals: goals ?? this.goals,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [upcoming, today, tasks, reminders];
|
||||
List<Object?> get props => [upcoming, today, tasks, reminders, goals];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ import 'package:halkyon_ui/halkyon_ui.dart';
|
|||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The key of Browse's row to Reporting.
|
||||
const reportingEntryKey = ValueKey('reporting-entry');
|
||||
|
||||
/// The key of Browse's row to filters and labels.
|
||||
const filtersEntryKey = ValueKey('filters-entry');
|
||||
|
||||
|
|
@ -234,6 +237,18 @@ class _BrowseTabState() extends State<BrowseTab> {
|
|||
onTap: () => context.go(TodoPaths.filters),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SectionCard(
|
||||
padding: EdgeInsets.zero,
|
||||
child: ListTile(
|
||||
key: reportingEntryKey,
|
||||
leading: const Icon(LucideIcons.chartColumn),
|
||||
title: Text(l10n.reportingTitle),
|
||||
subtitle: Text(l10n.reportingHint),
|
||||
trailing: const Icon(LucideIcons.chevronRight),
|
||||
onTap: () => context.go(TodoPaths.reporting),
|
||||
),
|
||||
),
|
||||
if (state.locked > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
|||
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_calendar.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/display_sheet.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/report/widget/reporting_screen.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
|
|
@ -77,6 +78,7 @@ class _TodayTabState() extends State<TodayTab> with PlanHost<TodayTab> {
|
|||
body: Column(
|
||||
children: [
|
||||
const KeysResetBanner(),
|
||||
const GoalReachedCard(),
|
||||
Expanded(
|
||||
child: !state.loaded
|
||||
? const Center(child: AppLoadingIndicator())
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import 'package:halkyon_todo_flutter/src/feature/project/widget/month_grid.dart'
|
|||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_board.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_calendar_view.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_table.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/report/widget/goal_editor.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/report/widget/reporting_screen.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
|
|
@ -411,6 +413,98 @@ void main() {
|
|||
expect(find.text('No labels yet.'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('Reporting (D36)', () {
|
||||
Future<void> doneToday(WidgetTester tester, int count) async {
|
||||
await tester.runAsync(() async {
|
||||
final inbox = await ProjectRepository(
|
||||
projectRows,
|
||||
keys,
|
||||
).ensureInbox();
|
||||
final repository = TaskRepository(taskRows, keys);
|
||||
for (var index = 0; index < count; index += 1) {
|
||||
await repository.add(
|
||||
TaskContent(
|
||||
title: 'Done $index',
|
||||
projectId: inbox,
|
||||
completedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
projectRows.bringRemoteChanges();
|
||||
taskRows.bringRemoteChanges();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> openReporting(WidgetTester tester) async {
|
||||
await goTab(tester, 'Browse');
|
||||
await tester.ensureVisible(find.byKey(reportingEntryKey));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(reportingEntryKey));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('shows the goal, what was done today, and the history by '
|
||||
'project', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await doneToday(tester, 2);
|
||||
await openReporting(tester);
|
||||
|
||||
expect(find.text('2 of 5 tasks'), findsOneWidget);
|
||||
expect(find.text('Done in the last 7 days'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('History'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Done 0'), findsOneWidget);
|
||||
expect(find.text('Done 1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("a goal of nothing can't be kept; one changed is, with the "
|
||||
'days off and vacation', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await openReporting(tester);
|
||||
|
||||
await tester.tap(find.text('Edit goals'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(dailyGoalKey), '0');
|
||||
await tester.pumpAndSettle();
|
||||
FilledButton save() => tester.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, 'Save'),
|
||||
);
|
||||
expect(save().onPressed, isNull);
|
||||
|
||||
await tester.enterText(find.byKey(dailyGoalKey), '3');
|
||||
await tester.tap(find.byKey(vacationKey));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final kept = await tester.runAsync(
|
||||
() => SettingsRepository(settingsRows, keys).read(),
|
||||
);
|
||||
expect(kept!.goals.daily, 3);
|
||||
expect(kept.goals.onVacation, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('the goal reached shows one quiet card on Today, and once '
|
||||
'dismissed none again that day', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await doneToday(tester, 4);
|
||||
expect(find.byType(GoalReachedCard), findsOneWidget);
|
||||
expect(find.byKey(GoalReachedCard.dismissKey), findsNothing);
|
||||
|
||||
await doneToday(tester, 1);
|
||||
expect(find.byKey(GoalReachedCard.dismissKey), findsOneWidget);
|
||||
expect(find.textContaining('Daily goal reached'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(GoalReachedCard.dismissKey));
|
||||
await tester.pumpAndSettle();
|
||||
settingsRows.bringRemoteChanges();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(GoalReachedCard.dismissKey), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('comments (D36)', () {
|
||||
Future<void> openTask(WidgetTester tester) async {
|
||||
await tester.runAsync(() async {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/report/model/productivity.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
|
||||
|
||||
/// Reporting (ADR 0033, D36), on what the tasks keep. 6 Nov 2026 is a Friday.
|
||||
void main() {
|
||||
const today = Day(2026, 11, 6);
|
||||
const monday = Day(2026, 11, 2);
|
||||
const noDaysOff = GoalSettings(daily: 2, weekly: 5, daysOff: {});
|
||||
|
||||
group('what was done', () {
|
||||
OpenTask task(String id, {DateTime? done, List<DateTime>? times}) => (
|
||||
id: id,
|
||||
content: TaskContent(
|
||||
title: id,
|
||||
projectId: 'p',
|
||||
completedAt: done,
|
||||
completions: times ?? const [],
|
||||
),
|
||||
);
|
||||
|
||||
test('counts a task done, and every time a recurring one was', () {
|
||||
final morning = DateTime(2026, 11, 6, 9);
|
||||
final tasks = [
|
||||
task('once', done: morning),
|
||||
task('open'),
|
||||
task(
|
||||
'daily',
|
||||
times: [DateTime(2026, 11, 5, 9), DateTime(2026, 11, 6, 8)],
|
||||
),
|
||||
];
|
||||
|
||||
expect(completedPerDay(tasks), {
|
||||
const Day(2026, 11, 5): 1,
|
||||
const Day(2026, 11, 6): 2,
|
||||
});
|
||||
expect(
|
||||
completedEntries(tasks).map((entry) => entry.task.id),
|
||||
['once', 'daily', 'daily'],
|
||||
);
|
||||
});
|
||||
|
||||
test('lists the last days and weeks, nothing done as zero', () {
|
||||
final counts = {const Day(2026, 11, 5): 2, const Day(2026, 10, 27): 4};
|
||||
|
||||
expect(lastDays(counts, today, 3), [
|
||||
(const Day(2026, 11, 4), 0),
|
||||
(const Day(2026, 11, 5), 2),
|
||||
(today, 0),
|
||||
]);
|
||||
expect(lastWeeks(counts, monday, 2), [
|
||||
(const Day(2026, 10, 26), 4),
|
||||
(monday, 2),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
group('the daily streak', () {
|
||||
test('counts the days in a row the goal was reached', () {
|
||||
final streak = dailyStreak(
|
||||
{
|
||||
const Day(2026, 11, 3): 2,
|
||||
const Day(2026, 11, 4): 3,
|
||||
const Day(2026, 11, 5): 2,
|
||||
today: 2,
|
||||
},
|
||||
noDaysOff,
|
||||
today,
|
||||
);
|
||||
|
||||
expect(streak.current, 4);
|
||||
expect(streak.longest?.from, const Day(2026, 11, 3));
|
||||
expect(streak.longest?.to, today);
|
||||
});
|
||||
|
||||
test('today not reached yet leaves it standing', () {
|
||||
expect(
|
||||
dailyStreak({const Day(2026, 11, 5): 2}, noDaysOff, today).current,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('a day missed ends it, and the longest stays', () {
|
||||
final streak = dailyStreak(
|
||||
{
|
||||
const Day(2026, 11, 1): 2,
|
||||
const Day(2026, 11, 2): 2,
|
||||
const Day(2026, 11, 4): 1,
|
||||
const Day(2026, 11, 5): 2,
|
||||
},
|
||||
noDaysOff,
|
||||
today,
|
||||
);
|
||||
|
||||
expect(streak.current, 1);
|
||||
expect(streak.longest?.length, 2);
|
||||
});
|
||||
|
||||
test('a day off breaks nothing, and counts where it was reached', () {
|
||||
// 1 Nov is a Sunday, 31 Oct a Saturday.
|
||||
const weekendOff = GoalSettings(daily: 2);
|
||||
final counts = {
|
||||
const Day(2026, 10, 30): 2,
|
||||
const Day(2026, 11, 1): 2,
|
||||
const Day(2026, 11, 2): 2,
|
||||
};
|
||||
|
||||
expect(dailyStreak(counts, weekendOff, monday).current, 3);
|
||||
});
|
||||
|
||||
test('a vacation over breaks nothing either', () {
|
||||
const away = GoalSettings(
|
||||
daily: 2,
|
||||
daysOff: {},
|
||||
vacations: [Vacation(Day(2026, 11, 2), Day(2026, 11, 4))],
|
||||
);
|
||||
final counts = {
|
||||
const Day(2026, 11, 1): 2,
|
||||
const Day(2026, 11, 5): 2,
|
||||
};
|
||||
|
||||
expect(dailyStreak(counts, away, today).current, 2);
|
||||
});
|
||||
|
||||
test('nothing done is no streak', () {
|
||||
expect(dailyStreak({}, noDaysOff, today), (current: 0, longest: null));
|
||||
});
|
||||
});
|
||||
|
||||
group('the weekly streak', () {
|
||||
test('counts the weeks in a row, this one not breaking it', () {
|
||||
final counts = {
|
||||
const Day(2026, 10, 20): 5,
|
||||
const Day(2026, 10, 27): 3,
|
||||
const Day(2026, 10, 29): 2,
|
||||
today: 1,
|
||||
};
|
||||
|
||||
expect(weeklyStreak(counts, noDaysOff, monday).current, 2);
|
||||
});
|
||||
|
||||
test('a week short ends it', () {
|
||||
final counts = {const Day(2026, 10, 20): 5, today: 1};
|
||||
|
||||
expect(weeklyStreak(counts, noDaysOff, monday).current, 0);
|
||||
});
|
||||
|
||||
test('a week wholly away breaks nothing', () {
|
||||
const away = GoalSettings(
|
||||
weekly: 5,
|
||||
daysOff: {},
|
||||
vacations: [Vacation(Day(2026, 10, 26), Day(2026, 11, 1))],
|
||||
);
|
||||
|
||||
expect(
|
||||
weeklyStreak({const Day(2026, 10, 20): 5}, away, monday).current,
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('a vacation', () {
|
||||
test('runs from the day it starts, and ends the day before', () {
|
||||
final away = const GoalSettings().withVacation(on: true, today: monday);
|
||||
expect(away.onVacation, isTrue);
|
||||
expect(away.isRestDay(today), isTrue);
|
||||
|
||||
final back = away.withVacation(on: false, today: today);
|
||||
expect(back.onVacation, isFalse);
|
||||
expect(back.vacations, [
|
||||
const Vacation(monday, Day(2026, 11, 5)),
|
||||
]);
|
||||
expect(back.isRestDay(today), isFalse);
|
||||
});
|
||||
|
||||
test('ended the day it started leaves nothing behind', () {
|
||||
expect(
|
||||
const GoalSettings()
|
||||
.withVacation(on: true, today: today)
|
||||
.withVacation(on: false, today: today)
|
||||
.vacations,
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -25,6 +25,43 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('goals, days off and vacations read back as they were set', () {
|
||||
const settings = TodoSettings(
|
||||
goals: GoalSettings(
|
||||
daily: 3,
|
||||
weekly: 12,
|
||||
daysOff: {DateTime.sunday},
|
||||
vacations: [
|
||||
Vacation(Day(2026, 8, 1), Day(2026, 8, 14)),
|
||||
Vacation(Day(2026, 11, 1)),
|
||||
],
|
||||
celebrated: Day(2026, 11, 6),
|
||||
),
|
||||
);
|
||||
|
||||
expect(decodeSettings(encodeSettings(settings)), settings);
|
||||
});
|
||||
|
||||
test('a goal of nothing or a weekday out of range reads as at first', () {
|
||||
expect(
|
||||
decodeSettings(
|
||||
payload({
|
||||
'schemaVersion': 1,
|
||||
'goals': {
|
||||
'daily': 0,
|
||||
'weekly': -2,
|
||||
'daysOff': [9, DateTime.monday],
|
||||
'vacations': [
|
||||
{'from': 'soon'},
|
||||
3,
|
||||
],
|
||||
},
|
||||
}),
|
||||
)?.goals,
|
||||
const GoalSettings(daysOff: {DateTime.monday}),
|
||||
);
|
||||
});
|
||||
|
||||
test("Upcoming's Display reads back as it was set", () {
|
||||
const settings = TodoSettings(
|
||||
upcoming: UpcomingView(
|
||||
|
|
|
|||
|
|
@ -729,6 +729,8 @@ The table's rows aren't dragged and its columns aren't chosen: the list is where
|
|||
|
||||
Comments are kept in their own sealed table, `task_comment`, and which task one belongs to is sealed with it, so the instance can't tell how much is said about what. A task deleted takes its comments along on the device that deletes it. Links are http and https alone, opened in the browser: a comment can't start anything else on a device, whatever it says. Links show in comments, not yet in a task's description, which is an editing field.
|
||||
|
||||
Reporting is worked out on the device from what tasks keep (`completedAt`, `completions`), so no event log exists and a task deleted leaves the history and the counts, which can shorten a streak already shown. Goals start at 5 a day and 30 a week with Saturday and Sunday off, as Todoist's, and live in the sealed settings row, synced. Days off and a vacation break no streak, and a goal reached on one still counts; vacations stay kept once over, or a streak across one would break the day it ended. Days off changed apply to the past as well, since only one set is kept. A day counts by the device's time zone at reading. The card on Today says the goal was reached once a day, marked in the settings so a second device doesn't repeat it; it plays nothing and shares nothing.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **The frame**: go_router and `NavigationFrame` with the four tabs (empty screens that
|
||||
|
|
|
|||
Loading…
Reference in a new issue