diff --git a/lib/app/modules/home/controllers/home_controller.dart b/lib/app/modules/home/controllers/home_controller.dart index 78575731e..cb8b8ded9 100644 --- a/lib/app/modules/home/controllers/home_controller.dart +++ b/lib/app/modules/home/controllers/home_controller.dart @@ -29,6 +29,7 @@ import 'package:taskwarrior/app/utils/taskfunctions/comparator.dart'; import 'package:taskwarrior/app/utils/taskfunctions/projects.dart'; import 'package:taskwarrior/app/utils/taskfunctions/query.dart'; import 'package:taskwarrior/app/utils/taskfunctions/tags.dart'; +import 'package:taskwarrior/app/utils/taskfunctions/waiting.dart'; import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; import 'package:taskwarrior/app/v3/champion/replica.dart'; import 'package:taskwarrior/app/v3/champion/models/task_for_replica.dart'; @@ -43,6 +44,7 @@ class HomeController extends GetxController { final SplashController splashController = Get.find(); late Storage storage; final RxBool pendingFilter = false.obs; + /// Which status the list is filtered to: pending / completed / deleted / /// recurring. /// Supersedes [pendingFilter], which can only express the first two; that @@ -263,11 +265,12 @@ class HomeController extends GetxController { queriedTasks.value = storage.data.completedData(); } + // The switch is labelled "Hide Waiting" while on, so on means waiting + // tasks are left out (Taskwarrior's default), not that only they show. if (waitingFilter.value) { var currentTime = DateTime.now(); - queriedTasks.value = queriedTasks - .where((task) => task.wait != null && task.wait!.isAfter(currentTime)) - .toList(); + queriedTasks.value = + queriedTasks.where((task) => !isWaiting(task, currentTime)).toList(); } if (projectFilter.value.isNotEmpty) { @@ -736,8 +739,12 @@ class HomeController extends GetxController { HomeWidget.saveWidgetData( "themeMode", AppSettings.isDarkMode ? "dark" : "light"); HomeWidget.updateWidget( - androidName: "TaskWarriorWidgetProvider", - iOSName: "TaskWarriorWidgets"); + qualifiedAndroidName: kAndroidWidgetProvider, + iOSName: "TaskWarriorWidgets") + .catchError((Object e) { + debugPrint('Error updating widget theme: $e'); + return null; + }); // print("called and value is${isDarkModeOn.value}"); } @@ -795,8 +802,7 @@ class HomeController extends GetxController { /// Which projects column the filter drawer is currently showing. The two sit /// behind mutually exclusive `Visibility` widgets, so only one of /// [projectsKey] / [projectsKeyTaskc] is ever laid out. - bool get usesTaskchampionProjects => - taskchampion.value || taskReplica.value; + bool get usesTaskchampionProjects => taskchampion.value || taskReplica.value; void initFilterDrawerTour() { tutorialCoachMark = TutorialCoachMark( diff --git a/lib/app/modules/home/controllers/widget.controller.dart b/lib/app/modules/home/controllers/widget.controller.dart index 4137f137f..2c3cc1c74 100644 --- a/lib/app/modules/home/controllers/widget.controller.dart +++ b/lib/app/modules/home/controllers/widget.controller.dart @@ -16,6 +16,15 @@ import 'package:taskwarrior/app/modules/splash/controllers/splash_controller.dar import 'package:taskwarrior/app/utils/taskfunctions/urgency.dart'; // import 'package:taskwarrior/widgets/taskfunctions/datetime_differences.dart'; +/// Fully qualified home-screen widget provider class. +/// +/// home_widget otherwise prefixes the provider name with the application id, +/// which is `com.ccextractor.taskwarriorflutter.nightly` for the nightly +/// flavor, while the Kotlin class always lives in the base package; the +/// lookup then fails with ClassNotFoundException. +const String kAndroidWidgetProvider = + 'com.ccextractor.taskwarriorflutter.TaskWarriorWidgetProvider'; + class WidgetController extends GetxController { final HomeController storageWidget = Get.find(); late Storage storage; @@ -181,8 +190,11 @@ class WidgetController extends GetxController { Future updateWidget() async { try { - return HomeWidget.updateWidget( - name: 'TaskWarriorWidgetProvider', iOSName: 'TaskWarriorWidgets'); + // Awaited so a failure is caught here instead of surfacing as an + // unhandled exception. + return await HomeWidget.updateWidget( + qualifiedAndroidName: kAndroidWidgetProvider, + iOSName: 'TaskWarriorWidgets'); } on PlatformException catch (exception) { debugPrint('Error Updating Widget. $exception'); } 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 e5fef026b..f133af6f1 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 @@ -391,7 +391,9 @@ class AddTaskBottomSheet extends StatelessWidget { ..priority = homeController.priority.value == 'X' ? null : homeController.priority.value) - .rebuild((t) => t..project = homeController.projectcontroller.text) + .rebuild((t) => t + ..project = resolveProject( + homeController.projectcontroller.text, t.project)) .rebuild((t) => t..wait = getWaitDate(homeController.selectedDates)?.toUtc()) .rebuild((t) => diff --git a/lib/app/modules/home/views/home_page_app_bar.dart b/lib/app/modules/home/views/home_page_app_bar.dart index 0e3023dda..a77218f20 100644 --- a/lib/app/modules/home/views/home_page_app_bar.dart +++ b/lib/app/modules/home/views/home_page_app_bar.dart @@ -136,8 +136,11 @@ class HomePageAppBar extends StatelessWidget implements PreferredSizeWidget { var c = await CredentialsStorage.getClientId(); var e = await CredentialsStorage.getEncryptionSecret(); - debugPrint( - "controller.taskReplica.value ${controller.taskReplica.value} Replica Credentials: c=$c e=$e"); + // Never log the credentials themselves; debugPrint + // is persisted to the in-app Logs page. + debugPrint("Replica refresh " + "(clientId set: ${c != null}, " + "encryptionSecret set: ${e != null})"); if (c == null || e == null) { _showResultSnackBar( context, diff --git a/lib/app/utils/taskfunctions/add_task_dialog_utils.dart b/lib/app/utils/taskfunctions/add_task_dialog_utils.dart index b79242ce0..75392b37b 100644 --- a/lib/app/utils/taskfunctions/add_task_dialog_utils.dart +++ b/lib/app/utils/taskfunctions/add_task_dialog_utils.dart @@ -40,3 +40,10 @@ DateTime? getSchedDate(List dates) { DateTime? getUntilDate(List dates) { return dates[3]; } + +/// The project for a new task: the Project field when it has text, otherwise +/// whatever the description set (e.g. `project:home`), otherwise none. +String? resolveProject(String fieldText, String? parsedProject) { + final String project = fieldText.trim(); + return project.isEmpty ? parsedProject : project; +} diff --git a/lib/app/utils/taskfunctions/datetime_differences.dart b/lib/app/utils/taskfunctions/datetime_differences.dart index 41586ced6..bc29a3597 100644 --- a/lib/app/utils/taskfunctions/datetime_differences.dart +++ b/lib/app/utils/taskfunctions/datetime_differences.dart @@ -25,7 +25,8 @@ String age(DateTime dt, {bool? use24HourFormat}) { // Format the time part according to the format preference String timeFormat = format ? 'HH:mm' : 'hh:mm a'; - String formattedTime = DateFormat(timeFormat).format(dt); + // Task dates are stored in UTC; show the clock time the user expects. + String formattedTime = DateFormat(timeFormat).format(dt.toLocal()); return '$result ago ($formattedTime)'; } @@ -54,7 +55,8 @@ String when(DateTime dt, {bool? use24HourFormat}) { // Format the time part according to the format preference String timeFormat = format ? 'HH:mm' : 'hh:mm a'; - String formattedTime = DateFormat(timeFormat).format(dt); + // Task dates are stored in UTC; show the clock time the user expects. + String formattedTime = DateFormat(timeFormat).format(dt.toLocal()); return '$result ($formattedTime)'; } diff --git a/lib/app/utils/taskfunctions/waiting.dart b/lib/app/utils/taskfunctions/waiting.dart new file mode 100644 index 000000000..9bd187d94 --- /dev/null +++ b/lib/app/utils/taskfunctions/waiting.dart @@ -0,0 +1,5 @@ +import 'package:taskwarrior/app/models/models.dart'; + +/// Whether [task] is waiting: hidden until its wait date, as in Taskwarrior. +bool isWaiting(Task task, DateTime now) => + task.wait != null && task.wait!.isAfter(now); diff --git a/lib/app/v3/champion/replica.dart b/lib/app/v3/champion/replica.dart index 0e7ece48f..ae538f054 100644 --- a/lib/app/v3/champion/replica.dart +++ b/lib/app/v3/champion/replica.dart @@ -25,6 +25,7 @@ class Replica { // interprets. The desktop CLI is what acts on it. "recur", ]; + /// Create a task, returning null on success or the reason it was refused. /// /// This used to answer with a success/failure sentinel that no caller read — @@ -194,8 +195,11 @@ class Replica { var url = await CredentialsStorage.getApiUrl(); var clientId = await CredentialsStorage.getClientId(); var encryptionSecret = await CredentialsStorage.getEncryptionSecret(); - debugPrint( - "Syncing Replica with url=$url clientId=$clientId encryptionSecret=$encryptionSecret"); + // Never log the client id or encryption secret: debugPrint output is + // persisted to the debug-log database shown on the Logs page. + debugPrint("Syncing Replica with url=$url " + "(clientId set: ${clientId?.isNotEmpty ?? false}, " + "encryptionSecret set: ${encryptionSecret?.isNotEmpty ?? false})"); await sync_( taskdbDirPath: taskdbDirPath, url: url ?? "", diff --git a/test/utils/taskfunctions/datetime_differences_test.dart b/test/utils/taskfunctions/datetime_differences_test.dart index d0a5b8e40..e3a5db076 100644 --- a/test/utils/taskfunctions/datetime_differences_test.dart +++ b/test/utils/taskfunctions/datetime_differences_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:get/get.dart'; +import 'package:intl/intl.dart'; import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; import 'package:taskwarrior/app/utils/taskfunctions/datetime_differences.dart'; @@ -89,4 +90,19 @@ void main() { expect(when(dt), matches(r'.*\d{1,2}:\d{2}\)')); }); }); + + group('time of day is shown in local time', () { + // Task dates are stored in UTC. These only distinguish UTC from local + // time when the test machine is not itself on UTC. + final utc = DateTime.now().toUtc().subtract(const Duration(hours: 3)); + final local = DateFormat('hh:mm a').format(utc.toLocal()); + + test('age', () { + expect(age(utc), endsWith('($local)')); + }); + + test('when', () { + expect(when(utc), endsWith('($local)')); + }); + }); } diff --git a/test/utils/taskfunctions/resolve_project_test.dart b/test/utils/taskfunctions/resolve_project_test.dart new file mode 100644 index 000000000..34ad4dc14 --- /dev/null +++ b/test/utils/taskfunctions/resolve_project_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:taskwarrior/app/utils/taskfunctions/add_task_dialog_utils.dart'; +import 'package:taskwarrior/app/utils/taskfunctions/taskparser.dart'; + +void main() { + test('an empty Project field leaves the project unset', () { + expect(resolveProject('', null), isNull); + expect(resolveProject(' ', null), isNull); + }); + + test('the Project field is used when filled in', () { + expect(resolveProject('home', null), 'home'); + expect(resolveProject(' home ', 'work'), 'home'); + }); + + test('an empty field keeps a project typed in the description', () { + final parsed = taskParser('buy milk project:home'); + expect(resolveProject('', parsed.project), 'home'); + }); +} diff --git a/test/utils/taskfunctions/waiting_test.dart b/test/utils/taskfunctions/waiting_test.dart new file mode 100644 index 000000000..9bd93e169 --- /dev/null +++ b/test/utils/taskfunctions/waiting_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:taskwarrior/app/models/models.dart'; +import 'package:taskwarrior/app/utils/taskfunctions/waiting.dart'; + +void main() { + final now = DateTime.utc(2026, 10, 1, 12); + + Task task({DateTime? wait}) => Task((b) => b + ..uuid = 'u' + ..description = 'd' + ..status = 'pending' + ..entry = now + ..wait = wait); + + test('a task without a wait date is not waiting', () { + expect(isWaiting(task(), now), isFalse); + }); + + test('a task whose wait date is in the future is waiting', () { + expect( + isWaiting(task(wait: now.add(const Duration(days: 1))), now), isTrue); + }); + + test('a task whose wait date has passed is no longer waiting', () { + expect(isWaiting(task(wait: now.subtract(const Duration(days: 1))), now), + isFalse); + }); +}