From fc3f92f41c6f786b30106158b7683abf4a8871bc Mon Sep 17 00:00:00 2001 From: Chinmay Chaudhari Date: Thu, 1 Oct 2026 01:10:40 +0530 Subject: [PATCH 1/2] refactor(detail): modularize the task detail page and fix its save flow Split DetailRouteView into page components (app bar, body, save button, dialogs) matching the settings/about modules, and moved the page logic into DetailRouteController. All widgets in the module are now stateless; the tag editor's state lives in the controller. Bug fixes: - The save button now appears as soon as an edit is made (it was only evaluated on a full page rebuild). - Submit in the review dialog now saves and closes the page. Get.back() after Get.snackbar only dismissed the snackbar, leaving the dialog open. - Save/"task not found" messages use ScaffoldMessenger: Get.snackbar throws "No Overlay widget found" on current Flutter versions. - Editing an existing due/wait/until date works again; the picker parsed the display string with a mismatched format and threw FormatException. - Opening the page with bad arguments or an unknown task closes it with a message instead of crashing. - Saving is wrapped in error handling and reports failures. Cleanup: - Shared AttributeCard replaces the per-widget card boilerplate; each editor has its own file (dateTimePicker.dart -> date_time_widget.dart, start/project/tags route split out). Dead code and StatusWidgetData removed. - TaskAttribute enum replaces raw attribute-name strings. - WillPopScope -> PopScope; the tour starts once instead of every build. - New AppLogger (tagged, levelled, persisted via debugPrint) replaces the mix of print/debugPrint/loggy on this page. - Hardcoded strings localized; adds taskUpdateFailed, taskNotFound and pleaseEnterATag in all 8 languages. - Text controllers for project/tag editing are owned and disposed by the controller. Adds 26 controller and widget tests under test/modules/detail_route. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/app/models/task_attribute.dart | 25 ++ .../controllers/detail_route_controller.dart | 354 ++++++++++++---- .../detailRoute/views/attribute_card.dart | 88 ++++ .../detailRoute/views/attribute_widget.dart | 118 ++++++ .../detailRoute/views/dateTimePicker.dart | 302 -------------- .../detailRoute/views/date_time_widget.dart | 98 +++++ .../detailRoute/views/description_widget.dart | 324 +++------------ .../views/detail_route_dialog_action.dart | 26 ++ .../views/detail_route_page_app_bar.dart | 26 ++ .../views/detail_route_page_body.dart | 37 ++ ...ail_route_page_floating_action_button.dart | 34 ++ .../detailRoute/views/detail_route_view.dart | 392 ++---------------- .../detailRoute/views/priority_widget.dart | 76 +--- .../detailRoute/views/project_widget.dart | 84 ++++ .../views/review_changes_dialog.dart | 57 +++ .../detailRoute/views/start_widget.dart | 42 ++ .../detailRoute/views/status_widget.dart | 91 +--- .../modules/detailRoute/views/tags_route.dart | 146 +++++++ .../detailRoute/views/tags_widget.dart | 305 +------------- .../views/unsaved_changes_dialog.dart | 53 +++ lib/app/utils/debug_logger/app_logger.dart | 39 ++ lib/app/utils/language/bengali_sentences.dart | 7 + lib/app/utils/language/english_sentences.dart | 7 + lib/app/utils/language/french_sentences.dart | 7 + lib/app/utils/language/german_sentences.dart | 7 + lib/app/utils/language/hindi_sentences.dart | 7 + lib/app/utils/language/marathi_sentences.dart | 7 + lib/app/utils/language/sentences.dart | 4 + lib/app/utils/language/spanish_sentences.dart | 7 + lib/app/utils/language/urdu_sentences.dart | 7 + .../detail_route_controller_test.dart | 193 +++++++++ .../detail_route/detail_route_view_test.dart | 207 +++++++++ 32 files changed, 1749 insertions(+), 1428 deletions(-) create mode 100644 lib/app/models/task_attribute.dart create mode 100644 lib/app/modules/detailRoute/views/attribute_card.dart create mode 100644 lib/app/modules/detailRoute/views/attribute_widget.dart delete mode 100644 lib/app/modules/detailRoute/views/dateTimePicker.dart create mode 100644 lib/app/modules/detailRoute/views/date_time_widget.dart create mode 100644 lib/app/modules/detailRoute/views/detail_route_dialog_action.dart create mode 100644 lib/app/modules/detailRoute/views/detail_route_page_app_bar.dart create mode 100644 lib/app/modules/detailRoute/views/detail_route_page_body.dart create mode 100644 lib/app/modules/detailRoute/views/detail_route_page_floating_action_button.dart create mode 100644 lib/app/modules/detailRoute/views/project_widget.dart create mode 100644 lib/app/modules/detailRoute/views/review_changes_dialog.dart create mode 100644 lib/app/modules/detailRoute/views/start_widget.dart create mode 100644 lib/app/modules/detailRoute/views/tags_route.dart create mode 100644 lib/app/modules/detailRoute/views/unsaved_changes_dialog.dart create mode 100644 lib/app/utils/debug_logger/app_logger.dart create mode 100644 test/modules/detail_route/detail_route_controller_test.dart create mode 100644 test/modules/detail_route/detail_route_view_test.dart diff --git a/lib/app/models/task_attribute.dart b/lib/app/models/task_attribute.dart new file mode 100644 index 000000000..1c985adc9 --- /dev/null +++ b/lib/app/models/task_attribute.dart @@ -0,0 +1,25 @@ +/// Task attributes shown on the detail page, in display order. +/// +/// [name] is the Taskwarrior field name, used both as the on-screen label and +/// as the key passed to `Modify.set`. +enum TaskAttribute { + description, + status, + entry, + modified, + start, + end, + due, + wait, + until, + priority, + project, + tags, + urgency; + + /// Attributes that are only ever displayed, never edited. + bool get isDisplayOnly => + this == TaskAttribute.entry || + this == TaskAttribute.modified || + this == TaskAttribute.urgency; +} diff --git a/lib/app/modules/detailRoute/controllers/detail_route_controller.dart b/lib/app/modules/detailRoute/controllers/detail_route_controller.dart index d29c15a34..a312f2397 100644 --- a/lib/app/modules/detailRoute/controllers/detail_route_controller.dart +++ b/lib/app/modules/detailRoute/controllers/detail_route_controller.dart @@ -3,61 +3,190 @@ import 'package:built_collection/built_collection.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:taskwarrior/app/models/tag_meta_data.dart'; +import 'package:taskwarrior/app/models/task_attribute.dart'; import 'package:taskwarrior/app/modules/home/controllers/home_controller.dart'; import 'package:taskwarrior/app/tour/details_page_tour.dart'; +import 'package:taskwarrior/app/tour/safe_tour.dart'; +import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; import 'package:taskwarrior/app/utils/constants/taskwarrior_colors.dart'; +import 'package:taskwarrior/app/utils/debug_logger/app_logger.dart'; +import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; +import 'package:taskwarrior/app/utils/language/sentences.dart'; import 'package:taskwarrior/app/utils/taskfunctions/modify.dart'; import 'package:taskwarrior/app/utils/taskfunctions/urgency.dart'; -import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; +import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; import 'package:tutorial_coach_mark/tutorial_coach_mark.dart'; -import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; -import 'package:taskwarrior/app/tour/safe_tour.dart'; class DetailRouteController extends GetxController { + /// [arguments], [createModify] and [knownTags] default to the route + /// arguments and the [HomeController] task storage; tests pass their own. + DetailRouteController({ + Object? arguments, + Modify Function(String uuid)? createModify, + Map Function()? knownTags, + }) : _arguments = arguments, + _createModify = createModify, + _knownTags = knownTags; + + static const AppLogger _log = AppLogger('DetailRoute'); + + final Object? _arguments; + final Modify Function(String uuid)? _createModify; + final Map Function()? _knownTags; + late String uuid; late Modify modify; - var onEdit = false.obs; - var isReadOnly = false.obs; - // Description Edit State + /// False when the page was opened without a loadable task; the view then + /// renders an empty page and [onReady] closes it. + bool hasTask = false; + + final RxBool onEdit = false.obs; + final RxBool isReadOnly = false.obs; + + /// Whether the draft differs from the saved task. Refreshed after every + /// edit and save so the save button can react to it. + final RxBool hasPendingChanges = false.obs; + + // Description and project edit state final descriptionController = TextEditingController(); - var descriptionErrorText = Rxn(); + final Rxn descriptionErrorText = Rxn(); + final projectController = TextEditingController(); + final tagController = TextEditingController(); + // Track whether user explicitly selected a start date bool startEdited = false; + final RxString descriptionValue = ''.obs; + final RxString statusValue = ''.obs; + final Rx entryValue = Rx(null); + final Rx modifiedValue = Rx(null); + final Rx startValue = Rx(null); + final Rx endValue = Rx(null); + final Rx dueValue = Rx(null); + final Rx waitValue = Rx(null); + final Rx untilValue = Rx(null); + final Rxn priorityValue = Rxn(null); + final Rxn projectValue = Rxn(null); + final Rxn> tagsValue = Rxn>(null); + final RxDouble urgencyValue = 0.0.obs; + @override void onInit() { super.onInit(); - var arguments = Get.arguments; - uuid = arguments[1] as String; - // uuid = Get.arguments['uuid']; - var storageWidget = Get.find(); - modify = Modify( + _loadTask(); + } + + @override + void onReady() { + super.onReady(); + if (!hasTask) { + Get.back(); + _notify(sentences.taskNotFound); + } + } + + /// Extracts the uuid from the `["uuid", ]` route arguments. + static String? uuidFromArguments(Object? arguments) { + if (arguments is List && arguments.length > 1) { + final Object? uuid = arguments[1]; + if (uuid is String && uuid.isNotEmpty) return uuid; + } + return null; + } + + void _loadTask() { + final Object? arguments = _arguments ?? Get.arguments; + final String? taskUuid = uuidFromArguments(arguments); + if (taskUuid == null) { + _log.error('Opened with invalid route arguments: $arguments'); + return; + } + try { + modify = (_createModify ?? _modifyFromHome)(taskUuid); + } catch (e, trace) { + _log.error('Could not load task $taskUuid', e, trace); + return; + } + uuid = taskUuid; + hasTask = true; + initValues(); + + // Check if task is completed or deleted and set read-only state + isReadOnly.value = _isClosedStatus(modify.draft.status); + _log.info('Opened task $uuid (status: ${modify.draft.status}, ' + 'read-only: ${isReadOnly.value})'); + } + + Modify _modifyFromHome(String uuid) { + final storageWidget = Get.find(); + return Modify( getTask: storageWidget.getTask, mergeTask: storageWidget.mergeTask, uuid: uuid, ); - initValues(); + } - // Check if task is completed or deleted and set read-only state - isReadOnly.value = (modify.draft.status == 'completed' || - modify.draft.status == 'deleted'); + static bool _isClosedStatus(dynamic status) => + status == 'completed' || status == 'deleted'; + + Sentences get sentences => + SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences; + + String get appBarTitle { + final int? id = modify.original.id; + return '${sentences.detailPageID}: ${(id == null || id == 0) ? '-' : id}'; } - void setAttribute(String name, dynamic newValue) { - if (isReadOnly.value && name != 'status') { + /// Human-readable old/new summary of every pending change. + String get changesSummary => modify.changes.entries + .map((entry) => '${entry.key}:\n' + ' ${sentences.oldChanges}: ${entry.value['old']}\n' + ' ${sentences.newChanges}: ${entry.value['new']}') + .join('\n'); + + /// Current value of every attribute, in display order. Reads the reactive + /// values, so calling this inside an [Obx] rebuilds on every edit. + Map get attributes => { + TaskAttribute.description: descriptionValue.value, + TaskAttribute.status: statusValue.value, + TaskAttribute.entry: entryValue.value, + TaskAttribute.modified: modifiedValue.value, + TaskAttribute.start: startValue.value, + TaskAttribute.end: endValue.value, + TaskAttribute.due: dueValue.value, + TaskAttribute.wait: waitValue.value, + TaskAttribute.until: untilValue.value, + TaskAttribute.priority: priorityValue.value, + TaskAttribute.project: projectValue.value, + TaskAttribute.tags: tagsValue.value, + TaskAttribute.urgency: urgencyValue.value, + }; + + /// Status is always editable; everything else respects [isReadOnly]. + bool isAttributeEditable(TaskAttribute attribute) => + !isReadOnly.value || attribute == TaskAttribute.status; + + void setAttribute(TaskAttribute attribute, dynamic newValue) { + if (!isAttributeEditable(attribute)) { + _log.warning('Ignored edit of ${attribute.name} on read-only task $uuid'); return; } + _log.debug('Edit ${attribute.name}: ' + '${attributes[attribute]} -> $newValue (task $uuid)'); - modify.set(name, newValue); + modify.set(attribute.name, newValue); onEdit.value = true; // If status is being changed, update read-only state - if (name == 'status') { - isReadOnly.value = (newValue == 'completed' || newValue == 'deleted'); + if (attribute == TaskAttribute.status) { + isReadOnly.value = _isClosedStatus(newValue); + _log.info('Task $uuid status -> $newValue ' + '(read-only: ${isReadOnly.value})'); } - if (name == 'start') { + if (attribute == TaskAttribute.start) { startEdited = true; // MARK AS USER-SELECTED startValue.value = newValue; } @@ -67,10 +196,7 @@ class DetailRouteController extends GetxController { // Validation Logic for Description bool validateDescription() { if (descriptionController.text.trim().isEmpty) { - descriptionErrorText.value = - SentenceManager(currentLanguage: AppSettings.selectedLanguage) - .sentences - .descriprtionCannotBeEmpty; + descriptionErrorText.value = sentences.descriprtionCannotBeEmpty; return false; } descriptionErrorText.value = null; @@ -82,56 +208,108 @@ class DetailRouteController extends GetxController { descriptionErrorText.value = null; } - Future saveChanges() async { - // If start was never edited AND backend auto-generated it (start == entry) - if (!startEdited && - modify.original.start != null && - modify.original.start!.isAtSameMomentAs(modify.original.entry)) { - modify.set('start', null); // remove auto start + void prepareProjectEdit(String? initialValue) { + projectController.text = initialValue ?? ''; + } + + /// Saves the draft, reports the outcome with a snackbar and returns whether + /// it succeeded. Navigation is left to the caller. + bool saveChanges() { + final String changedKeys = modify.changes.keys.join(', '); + try { + // If start was never edited AND backend auto-generated it (start == entry) + if (!startEdited && + modify.original.start != null && + modify.original.start!.isAtSameMomentAs(modify.original.entry)) { + modify.set('start', null); // remove auto start + } + final now = DateTime.now().toUtc(); + modify.save(modified: () => now); + } catch (e, trace) { + _log.error('Failed to save task $uuid', e, trace); + _notify(sentences.taskUpdateFailed); + return false; } - var now = DateTime.now().toUtc(); - modify.save(modified: () => now); onEdit.value = false; + hasPendingChanges.value = modify.changes.isNotEmpty; + _log.info('Saved task $uuid (changed: $changedKeys)'); + _notify(sentences.taskUpdated); + return true; + } - // Show snackbar - Get.snackbar( - 'Task Updated', - '', - snackPosition: SnackPosition.BOTTOM, - duration: const Duration(seconds: 2), + /// Shows [message] on the app-wide [ScaffoldMessenger], so it stays visible + /// after the page closes. Used instead of `Get.snackbar`, which cannot find + /// its overlay on current Flutter versions. + void _notify(String message) { + final BuildContext? context = Get.context; + if (context == null) return; + final ScaffoldMessengerState? messenger = + ScaffoldMessenger.maybeOf(context); + if (messenger == null) return; + final TaskwarriorColorTheme? tColors = + Theme.of(context).extension(); + messenger.showSnackBar( + SnackBar( + content: Text( + message, + style: TextStyle(color: tColors?.primaryTextColor), + ), + backgroundColor: tColors?.primaryBackgroundColor, + duration: const Duration(seconds: 2), + ), ); + } + + // Tag editing + + BuiltList get currentTags => tagsValue.value ?? BuiltList(); - // Navigate back immediately after showing snackbar - Get.back(); - } - - // 'description': controller.modify.draft.description, - // 'status': controller.modify.draft.status, - // 'entry': controller.modify.draft.entry, - // 'modified': controller.modify.draft.modified, - // 'start': controller.modify.draft.start, - // 'end': controller.modify.draft.end, - // 'due': controller.dueValue.value, - // 'wait': controller.modify.draft.wait, - // 'until': controller.modify.draft.until, - // 'priority': controller.modify.draft.priority, - // 'project': controller.modify.draft.project, - // 'tags': controller.modify.draft.tags, - // 'urgency': urgency(controller.modify.draft - - late RxString descriptionValue = ''.obs; - late RxString statusValue = ''.obs; - late Rx entryValue = Rx(null); - late Rx modifiedValue = Rx(null); - late Rx startValue = Rx(null); - late Rx endValue = Rx(null); - late Rx dueValue = Rx(null); - late Rx waitValue = Rx(null); - late Rx untilValue = Rx(null); - late Rxn? priorityValue = Rxn(null); - late Rxn? projectValue = Rxn(null); - late Rxn>? tagsValue = Rxn>(null); - late RxDouble urgencyValue = 0.0.obs; + /// Tags used across all tasks, with their frequency. Reactive when backed + /// by the [HomeController]. + Map get knownTags => + (_knownTags ?? () => Get.find().pendingTags)(); + + static List parseTags(String input) { + return input + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(); + } + + /// Form validator for the add-tag field. + String? validateTags(String? value) { + final tags = parseTags(value ?? ''); + if (tags.isEmpty) { + return sentences.pleaseEnterATag; + } + for (final tag in tags) { + if (tag.contains(' ')) { + return sentences.tagShouldNotContainSpaces; + } + if (currentTags.contains(tag)) { + return sentences.tagAlreadyExists; + } + } + return null; + } + + void prepareTagEdit() => tagController.clear(); + + void addTags(List tags) { + if (tags.isEmpty) return; + final builder = currentTags.toBuilder(); + for (final tag in tags) { + if (!builder.build().contains(tag)) { + builder.add(tag); + } + } + setAttribute(TaskAttribute.tags, builder); + } + + void removeTag(String tag) { + setAttribute(TaskAttribute.tags, currentTags.toBuilder()..remove(tag)); + } void initValues() { descriptionValue.value = modify.draft.description; @@ -156,20 +334,39 @@ class DetailRouteController extends GetxController { dueValue.value = modify.draft.due; waitValue.value = modify.draft.wait; untilValue.value = modify.draft.until; - priorityValue?.value = modify.draft.priority; - projectValue?.value = modify.draft.project; - tagsValue?.value = modify.draft.tags; + priorityValue.value = modify.draft.priority; + projectValue.value = modify.draft.project; + tagsValue.value = modify.draft.tags; urgencyValue.value = urgency(modify.draft); + hasPendingChanges.value = modify.changes.isNotEmpty; } late TutorialCoachMark tutorialCoachMark; + bool _tourStarted = false; final GlobalKey dueKey = GlobalKey(); final GlobalKey untilKey = GlobalKey(); - final GlobalKey waitKey = GlobalKey(); final GlobalKey priorityKey = GlobalKey(); + /// Tour anchor for the attribute card, if that attribute is part of the tour. + GlobalKey? tourKeyFor(TaskAttribute attribute) => switch (attribute) { + TaskAttribute.due => dueKey, + TaskAttribute.wait => waitKey, + TaskAttribute.until => untilKey, + TaskAttribute.priority => priorityKey, + _ => null, + }; + + /// Builds and schedules the tour the first time the page is built; later + /// rebuilds are no-ops. + void startTourOnce(BuildContext context) { + if (_tourStarted) return; + _tourStarted = true; + initDetailsPageTour(); + showDetailsPageTour(context); + } + void initDetailsPageTour() { tutorialCoachMark = TutorialCoachMark( targets: addDetailsPage( @@ -193,6 +390,11 @@ class DetailRouteController extends GetxController { const Duration(milliseconds: 500), () async { if (await SaveTourStatus.getDetailsTourStatus()) return; + if (!context.mounted) { + // Same as safeShowTour's unmounted path: never retry a lost tour. + await SaveTourStatus.saveDetailsTourStatus(true); + return; + } await safeShowTour( tutorialCoachMark: tutorialCoachMark, context: context, @@ -206,6 +408,8 @@ class DetailRouteController extends GetxController { @override void onClose() { descriptionController.dispose(); + projectController.dispose(); + tagController.dispose(); super.onClose(); } } diff --git a/lib/app/modules/detailRoute/views/attribute_card.dart b/lib/app/modules/detailRoute/views/attribute_card.dart new file mode 100644 index 000000000..9690275a4 --- /dev/null +++ b/lib/app/modules/detailRoute/views/attribute_card.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; +import 'package:taskwarrior/app/utils/constants/constants.dart'; +import 'package:taskwarrior/app/utils/gen/fonts.gen.dart'; +import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; +import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; + +/// The `name: value` card every attribute on the detail page is drawn with. +/// Editors only supply the tap behaviour. +class AttributeCard extends StatelessWidget { + const AttributeCard({ + required this.name, + required this.value, + this.isEditable = true, + this.onTap, + this.onLongPress, + this.cardKey, + this.cardColor, + this.tileColor, + super.key, + }); + + final String name; + + /// Shown as-is; `null` renders the localized "not selected" text. + final String? value; + final bool isEditable; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + + /// Key placed on the [Card], e.g. a tour anchor. + final Key? cardKey; + + /// Defaults to the theme's secondary background colour. + final Color? cardColor; + final Color? tileColor; + + @override + Widget build(BuildContext context) { + TaskwarriorColorTheme tColors = + Theme.of(context).extension()!; + final Color? textColor = isEditable + ? tColors.primaryTextColor + : tColors.primaryDisabledTextColor; + + return Card( + key: cardKey, + color: cardColor ?? tColors.secondaryBackgroundColor, + child: ListTile( + enabled: isEditable, + tileColor: tileColor, + textColor: textColor, + title: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: '$name:'.padRight(13), + style: TextStyle( + fontFamily: FontFamily.poppins, + fontWeight: TaskWarriorFonts.bold, + fontSize: TaskWarriorFonts.fontSizeMedium, + color: textColor, + ), + ), + TextSpan( + text: value ?? + SentenceManager( + currentLanguage: AppSettings.selectedLanguage) + .sentences + .notSelected, + style: TextStyle( + fontFamily: FontFamily.poppins, + fontSize: TaskWarriorFonts.fontSizeMedium, + color: textColor, + ), + ), + ], + ), + ), + ), + onTap: onTap, + onLongPress: onLongPress, + ), + ); + } +} diff --git a/lib/app/modules/detailRoute/views/attribute_widget.dart b/lib/app/modules/detailRoute/views/attribute_widget.dart new file mode 100644 index 000000000..3ddff73e9 --- /dev/null +++ b/lib/app/modules/detailRoute/views/attribute_widget.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:taskwarrior/app/models/task_attribute.dart'; +import 'package:taskwarrior/app/modules/detailRoute/controllers/detail_route_controller.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/attribute_card.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/date_time_widget.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/description_widget.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/priority_widget.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/project_widget.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/start_widget.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/status_widget.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/tags_widget.dart'; + +/// Picks the editor widget for a single task attribute. +class AttributeWidget extends StatelessWidget { + const AttributeWidget({ + required this.controller, + required this.attribute, + required this.value, + required this.isEditable, + required this.use24HourFormat, + super.key, + }); + + final DetailRouteController controller; + final TaskAttribute attribute; + final dynamic value; + final bool isEditable; + final bool use24HourFormat; + + String? _formatValue() { + final value = this.value; + if (value == null) return null; + if (value is DateTime) { + return DateFormat(use24HourFormat + ? 'EEE, yyyy-MM-dd HH:mm:ss' + : 'EEE, yyyy-MM-dd hh:mm:ss a') + .format(value.toLocal()); + } + return value.toString(); + } + + void _onChanged(dynamic newValue) => + controller.setAttribute(attribute, newValue); + + @override + Widget build(BuildContext context) { + final String name = attribute.name; + final String? displayValue = _formatValue(); + + switch (attribute) { + case TaskAttribute.description: + return DescriptionWidget( + controller: controller, + name: name, + value: displayValue, + callback: _onChanged, + isEditable: isEditable, + ); + case TaskAttribute.status: + return StatusWidget( + name: name, + value: displayValue, + callback: _onChanged, + ); + case TaskAttribute.start: + return StartWidget( + name: name, + value: displayValue, + callback: _onChanged, + isEditable: isEditable, + ); + case TaskAttribute.due: + case TaskAttribute.wait: + case TaskAttribute.until: + return DateTimeWidget( + name: name, + value: value as DateTime?, + displayValue: displayValue, + callback: _onChanged, + globalKey: controller.tourKeyFor(attribute)!, + isEditable: isEditable, + ); + case TaskAttribute.priority: + return PriorityWidget( + name: name, + value: displayValue, + callback: _onChanged, + globalKey: controller.tourKeyFor(attribute)!, + isEditable: isEditable, + ); + case TaskAttribute.project: + return ProjectWidget( + controller: controller, + name: name, + value: displayValue, + callback: _onChanged, + isEditable: isEditable, + ); + case TaskAttribute.tags: + return TagsWidget( + controller: controller, + name: name, + value: displayValue, + isEditable: isEditable, + ); + case TaskAttribute.entry: + case TaskAttribute.modified: + case TaskAttribute.end: + case TaskAttribute.urgency: + return AttributeCard( + name: name, + value: displayValue, + isEditable: isEditable && !attribute.isDisplayOnly, + ); + } + } +} diff --git a/lib/app/modules/detailRoute/views/dateTimePicker.dart b/lib/app/modules/detailRoute/views/dateTimePicker.dart deleted file mode 100644 index 8601e8482..000000000 --- a/lib/app/modules/detailRoute/views/dateTimePicker.dart +++ /dev/null @@ -1,302 +0,0 @@ -// ignore_for_file: file_names, use_build_context_synchronously - -import 'package:flutter/material.dart'; - -import 'package:intl/intl.dart'; -import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; - -import 'package:taskwarrior/app/utils/constants/constants.dart'; -import 'package:taskwarrior/app/utils/gen/fonts.gen.dart'; -import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; -import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; - -class DateTimeWidget extends StatelessWidget { - const DateTimeWidget({ - super.key, - required this.name, - required this.value, - required this.callback, - required this.globalKey, - this.isEditable = true, - }); - - final String name; - - final dynamic value; - final void Function(dynamic) callback; - final GlobalKey globalKey; - final bool isEditable; - - @override - Widget build(BuildContext context) { - TaskwarriorColorTheme tColors = - Theme.of(context).extension()!; - return Card( - key: globalKey, - color: tColors.secondaryBackgroundColor, - child: ListTile( - enabled: isEditable, - textColor: isEditable - ? tColors.primaryTextColor - : tColors.primaryDisabledTextColor, - title: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - RichText( - text: TextSpan( - children: [ - TextSpan( - text: '$name:'.padRight(13), - // style: GoogleFonts.poppins( - // fontWeight: TaskWarriorFonts.bold, - // fontSize: TaskWarriorFonts.fontSizeMedium, - // color: AppSettings.isDarkMode - // ? Colors.white - // : Colors.black, - // ), - style: TextStyle( - fontFamily: FontFamily.poppins, - fontWeight: TaskWarriorFonts.bold, - fontSize: TaskWarriorFonts.fontSizeMedium, - color: isEditable - ? tColors.primaryTextColor - : tColors.primaryDisabledTextColor, - ), - ), - TextSpan( - text: value ?? - SentenceManager( - currentLanguage: AppSettings.selectedLanguage) - .sentences.notSelected, - // style: GoogleFonts.poppins( - // fontSize: TaskWarriorFonts.fontSizeMedium, - // color: AppSettings.isDarkMode - // ? Colors.white - // : Colors.black, - // ), - style: TextStyle( - fontFamily: FontFamily.poppins, - fontSize: TaskWarriorFonts.fontSizeMedium, - color: isEditable - ? tColors.primaryTextColor - : tColors.primaryDisabledTextColor, - ), - ), - ], - ), - ), - ], - ), - ), - // onTap: () async { - // var initialDate = DateFormat("E, M/d/y h:mm:ss a").parse( - // value?.replaceAll(RegExp(r'\s+'), ' ') ?? - // DateFormat("E, M/d/y h:mm:ss a").format(DateTime.now())); - - // var date = await showDatePicker( - // context: context, - // initialDate: initialDate, - // firstDate: DateTime - // .now(), // sets the earliest selectable date to the current date. This prevents the user from selecting a date in the past. - // lastDate: DateTime(2037, 12, 31), // < 2038-01-19T03:14:08.000Z - // ); - // if (date != null) { - // var time = await showTimePicker( - // context: context, - // initialTime: TimeOfDay.now(), - // ); - // if (time != null) { - // var dateTime = date.add( - // Duration( - // hours: time.hour, - // minutes: time.minute, - // ), - // ); - // dateTime = dateTime.add( - // Duration( - // hours: time.hour - dateTime.hour, - // ), - // ); - // // Check if the selected time is in the past - // if (dateTime.isBefore(DateTime.now())) { - // // Show a message that past times can't be set - // ScaffoldMessenger.of(context).showSnackBar( - // SnackBar( - // content: Text( - // "Can't set times in the past", - // style: TextStyle( - // color: AppSettings.isDarkMode - // ? TaskWarriorColors.kprimaryTextColor - // : TaskWarriorColors.kLightPrimaryTextColor, - // ), - // ), - // backgroundColor: AppSettings.isDarkMode - // ? TaskWarriorColors.ksecondaryBackgroundColor - // : TaskWarriorColors.kLightSecondaryBackgroundColor, - // duration: const Duration(seconds: 2), - // ), - // ); - // } else { - // // If the time is not in the past, proceed as usual - // return callback(dateTime.toUtc()); - // } - // } - // } - // }, - onTap: () async { - var parsedDate = DateFormat("E, M/d/y h:mm:ss a").parse( - value?.replaceAll(RegExp(r'\s+'), ' ') ?? - DateFormat("E, M/d/y h:mm:ss a").format(DateTime.now())); - - var now = DateTime.now(); - var initialDate = parsedDate.isBefore(now) ? now : parsedDate; - - var date = await showDatePicker( - context: context, - initialDate: initialDate, - firstDate: now, - lastDate: DateTime(2037, 12, 31), // < 2038-01-19T03:14:08.000Z - ); - - if (date != null) { - var time = await showTimePicker( - context: context, - initialTime: TimeOfDay.now(), - builder: (BuildContext context, Widget? child) { - return MediaQuery( - data: MediaQuery.of(context).copyWith( - alwaysUse24HourFormat: AppSettings.use24HourFormatRx.value, - ), - child: child!, - ); - }, - ); - if (time != null) { - var dateTime = date.add( - Duration( - hours: time.hour, - minutes: time.minute, - ), - ); - - if (dateTime.isBefore(DateTime.now())) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - SentenceManager( - currentLanguage: AppSettings.selectedLanguage, - ).sentences.cantSetTimeinPast, - style: TextStyle( - color: tColors.primaryTextColor, - ), - ), - backgroundColor: tColors.primaryBackgroundColor, - duration: const Duration(seconds: 2), - ), - ); - } else { - return callback(dateTime.toUtc()); - } - } - } - }, - onLongPress: () => callback(null), - ), - ); - } -} - -class StartWidget extends StatelessWidget { - const StartWidget({ - required this.name, - required this.value, - required this.callback, - this.isEditable = true, - super.key, - }); - - final String name; - final dynamic value; - final bool isEditable; - final void Function(dynamic) callback; - - @override - Widget build(BuildContext context) { - TaskwarriorColorTheme tColors = - Theme.of(context).extension()!; - return Card( - color: tColors.secondaryBackgroundColor, - child: ListTile( - enabled: isEditable, - textColor: isEditable - ? tColors.primaryTextColor - : tColors.primaryDisabledTextColor, - title: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - RichText( - text: TextSpan( - children: [ - TextSpan( - text: '$name:'.padRight(13), - // style: GoogleFonts.poppins( - // fontWeight: TaskWarriorFonts.bold, - // fontSize: TaskWarriorFonts.fontSizeMedium, - // color: AppSettings.isDarkMode - // ? Colors.white - // : Colors.black, - // ), - style: TextStyle( - fontFamily: FontFamily.poppins, - fontWeight: TaskWarriorFonts.bold, - fontSize: TaskWarriorFonts.fontSizeMedium, - color: isEditable - ? tColors.primaryTextColor - : tColors.primaryDisabledTextColor, - ), - ), - TextSpan( - text: value ?? SentenceManager( - currentLanguage: AppSettings.selectedLanguage) - .sentences.notSelected, - // style: GoogleFonts.poppins( - // fontSize: TaskWarriorFonts.fontSizeMedium, - // color: AppSettings.isDarkMode - // ? Colors.white - // : Colors.black, - // ), - style: TextStyle( - fontFamily: FontFamily.poppins, - fontSize: TaskWarriorFonts.fontSizeMedium, - color: isEditable - ? tColors.primaryTextColor - : tColors.primaryDisabledTextColor, - ), - ), - ], - ), - ), - ], - ), - ), - onTap: () { - if (value != null) { - callback(null); - } else { - var now = DateTime.now().toUtc(); - callback(DateTime.utc( - now.year, - now.month, - now.day, - now.hour, - now.minute, - now.second, - )); - } - }, - ), - ); - } -} diff --git a/lib/app/modules/detailRoute/views/date_time_widget.dart b/lib/app/modules/detailRoute/views/date_time_widget.dart new file mode 100644 index 000000000..4e07acf4b --- /dev/null +++ b/lib/app/modules/detailRoute/views/date_time_widget.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/attribute_card.dart'; +import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; +import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; +import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; + +/// Due / wait / until editor: tap picks a future date and time, long-press +/// clears it. +class DateTimeWidget extends StatelessWidget { + const DateTimeWidget({ + super.key, + required this.name, + required this.value, + required this.displayValue, + required this.callback, + required this.globalKey, + this.isEditable = true, + }); + + final String name; + + /// The stored date, used to seed the picker. + final DateTime? value; + + /// [value] formatted for display. + final String? displayValue; + final void Function(dynamic) callback; + final GlobalKey globalKey; + final bool isEditable; + + Future _pickDateTime(BuildContext context) async { + TaskwarriorColorTheme tColors = + Theme.of(context).extension()!; + var now = DateTime.now(); + var current = value?.toLocal() ?? now; + var initialDate = current.isBefore(now) ? now : current; + + var date = await showDatePicker( + context: context, + initialDate: initialDate, + firstDate: now, + lastDate: DateTime(2037, 12, 31), // < 2038-01-19T03:14:08.000Z + ); + if (date == null || !context.mounted) return; + + var time = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + builder: (BuildContext context, Widget? child) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + alwaysUse24HourFormat: AppSettings.use24HourFormatRx.value, + ), + child: child!, + ); + }, + ); + if (time == null || !context.mounted) return; + + var dateTime = date.add( + Duration( + hours: time.hour, + minutes: time.minute, + ), + ); + + if (dateTime.isBefore(DateTime.now())) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + SentenceManager( + currentLanguage: AppSettings.selectedLanguage, + ).sentences.cantSetTimeinPast, + style: TextStyle( + color: tColors.primaryTextColor, + ), + ), + backgroundColor: tColors.primaryBackgroundColor, + duration: const Duration(seconds: 2), + ), + ); + } else { + callback(dateTime.toUtc()); + } + } + + @override + Widget build(BuildContext context) { + return AttributeCard( + cardKey: globalKey, + name: name, + value: displayValue, + isEditable: isEditable, + onTap: () => _pickDateTime(context), + onLongPress: () => callback(null), + ); + } +} diff --git a/lib/app/modules/detailRoute/views/description_widget.dart b/lib/app/modules/detailRoute/views/description_widget.dart index 33d848d3b..c2acf5f9f 100644 --- a/lib/app/modules/detailRoute/views/description_widget.dart +++ b/lib/app/modules/detailRoute/views/description_widget.dart @@ -1,17 +1,15 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; - -import 'package:loggy/loggy.dart'; -import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; -import 'package:taskwarrior/app/utils/constants/constants.dart'; +import 'package:taskwarrior/app/modules/detailRoute/controllers/detail_route_controller.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/attribute_card.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/detail_route_dialog_action.dart'; import 'package:taskwarrior/app/utils/constants/utilites.dart'; -import 'package:taskwarrior/app/utils/gen/fonts.gen.dart'; import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; -import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; -import 'package:taskwarrior/app/modules/detailRoute/controllers/detail_route_controller.dart'; +/// Edits the description in a dialog; an empty description is rejected. class DescriptionWidget extends StatelessWidget { const DescriptionWidget({ + required this.controller, required this.name, required this.value, required this.callback, @@ -19,283 +17,71 @@ class DescriptionWidget extends StatelessWidget { super.key, }); + final DetailRouteController controller; final String name; - final dynamic value; + final String? value; final void Function(dynamic) callback; final bool isEditable; - @override - Widget build(BuildContext context) { - final controller = Get.find(); + void _showEditDialog(BuildContext context) { TaskwarriorColorTheme tColors = Theme.of(context).extension()!; - return Card( - color: tColors.secondaryBackgroundColor, - child: ListTile( - enabled: isEditable, - textColor: isEditable - ? tColors.primaryTextColor - : tColors.primaryDisabledTextColor, - title: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - RichText( - text: TextSpan( - children: [ - TextSpan( - text: '$name:'.padRight(13), - // style: GoogleFonts.poppins( - // fontWeight: TaskWarriorFonts.bold, - // fontSize: TaskWarriorFonts.fontSizeMedium, - // color: AppSettings.isDarkMode - // ? Colors.white - // : Colors.black, - // ), - style: TextStyle( - fontFamily: FontFamily.poppins, - fontWeight: TaskWarriorFonts.bold, - fontSize: TaskWarriorFonts.fontSizeMedium, - color: isEditable - ? tColors.primaryTextColor - : tColors.primaryDisabledTextColor, - ), - ), - TextSpan( - text: value ?? - SentenceManager( - currentLanguage: AppSettings.selectedLanguage) - .sentences - .notSelected, - // style: GoogleFonts.poppins( - // fontSize: TaskWarriorFonts.fontSizeMedium, - // color: AppSettings.isDarkMode - // ? Colors.white - // : Colors.black, - // ), - style: TextStyle( - fontFamily: FontFamily.poppins, - fontSize: TaskWarriorFonts.fontSizeMedium, - color: isEditable - ? tColors.primaryTextColor - : tColors.primaryDisabledTextColor, - ), - ), - ], - ), - ), - ], + final sentences = controller.sentences; + controller.prepareDescriptionEdit(value ?? ''); + showDialog( + context: context, + builder: (context) => Obx( + () => Utils.showAlertDialog( + scrollable: true, + title: Text( + sentences.editDescription, + style: TextStyle( + color: tColors.primaryTextColor, + ), ), - ), - onTap: () { - controller.prepareDescriptionEdit(value ?? ''); - showDialog( - context: context, - builder: (context) => Obx( - () => Utils.showAlertDialog( - scrollable: true, - title: Text( - SentenceManager(currentLanguage: AppSettings.selectedLanguage) - .sentences - .editDescription, - style: TextStyle( - color: tColors.primaryTextColor, - ), - ), - content: TextField( - style: TextStyle( - color: tColors.primaryTextColor, - ), - decoration: InputDecoration( - errorText: controller.descriptionErrorText.value, - errorStyle: const TextStyle( - color: Colors.red, - ), - ), - autofocus: true, - maxLines: null, - controller: controller.descriptionController, - ), - actions: [ - TextButton( - onPressed: () => Get.back(), - child: Text( - SentenceManager( - currentLanguage: AppSettings.selectedLanguage) - .sentences - .cancel, - style: TextStyle( - color: tColors.primaryTextColor, - ), - ), - ), - TextButton( - onPressed: () { - if (controller.validateDescription()) { - callback(controller.descriptionController.text); - Get.back(); - } - }, - child: Text( - SentenceManager( - currentLanguage: AppSettings.selectedLanguage) - .sentences - .submit, - style: TextStyle( - color: tColors.primaryTextColor, - ), - ), - ), - ], + content: TextField( + style: TextStyle( + color: tColors.primaryTextColor, + ), + decoration: InputDecoration( + errorText: controller.descriptionErrorText.value, + errorStyle: const TextStyle( + color: Colors.red, ), ), - ); - }, + autofocus: true, + maxLines: null, + controller: controller.descriptionController, + ), + actions: [ + DetailRouteDialogAction( + label: sentences.cancel, + color: tColors.primaryTextColor, + onPressed: () => Navigator.of(context).pop(), + ), + DetailRouteDialogAction( + label: sentences.submit, + color: tColors.primaryTextColor, + onPressed: () { + if (controller.validateDescription()) { + callback(controller.descriptionController.text); + Navigator.of(context).pop(); + } + }, + ), + ], + ), ), ); } -} - -class ProjectWidget extends StatelessWidget { - const ProjectWidget({ - required this.name, - required this.value, - required this.callback, - this.isEditable = true, - super.key, - }); - - final String name; - final dynamic value; - final void Function(dynamic) callback; - final bool isEditable; @override Widget build(BuildContext context) { - TaskwarriorColorTheme tColors = - Theme.of(context).extension()!; - return Card( - color: tColors.secondaryBackgroundColor, - child: ListTile( - enabled: isEditable, - textColor: isEditable - ? tColors.primaryTextColor - : tColors.primaryDisabledTextColor, - title: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - RichText( - text: TextSpan( - children: [ - TextSpan( - text: '$name:'.padRight(13), - // style: GoogleFonts.poppins( - // fontWeight: TaskWarriorFonts.bold, - // fontSize: TaskWarriorFonts.fontSizeMedium, - // color: AppSettings.isDarkMode - // ? Colors.white - // : Colors.black, - // ), - style: TextStyle( - fontFamily: FontFamily.poppins, - fontWeight: TaskWarriorFonts.bold, - fontSize: TaskWarriorFonts.fontSizeMedium, - color: isEditable - ? tColors.primaryTextColor - : tColors.primaryDisabledTextColor, - ), - ), - TextSpan( - text: value ?? - SentenceManager( - currentLanguage: AppSettings.selectedLanguage) - .sentences - .notSelected, - // style: GoogleFonts.poppins( - // fontSize: TaskWarriorFonts.fontSizeMedium, - // color: AppSettings.isDarkMode - // ? Colors.white - // : Colors.black, - // ), - style: TextStyle( - fontFamily: FontFamily.poppins, - fontSize: TaskWarriorFonts.fontSizeMedium, - color: isEditable - ? tColors.primaryTextColor - : tColors.primaryDisabledTextColor, - ), - ), - ], - ), - ), - ], - ), - ), - onTap: () { - var controller = TextEditingController( - text: value, - ); - showDialog( - context: context, - builder: (context) => Utils.showAlertDialog( - scrollable: true, - title: Text( - SentenceManager(currentLanguage: AppSettings.selectedLanguage) - .sentences - .editProject, - style: TextStyle( - color: tColors.primaryTextColor, - ), - ), - content: TextField( - style: TextStyle( - color: tColors.primaryTextColor, - ), - autofocus: true, - maxLines: null, - controller: controller, - ), - actions: [ - TextButton( - onPressed: () { - Get.back(); - }, - child: Text( - SentenceManager( - currentLanguage: AppSettings.selectedLanguage) - .sentences - .cancel, - style: TextStyle( - color: tColors.primaryTextColor, - ), - ), - ), - TextButton( - onPressed: () { - try { - callback( - (controller.text == '') ? null : controller.text); - Get.back(); - } on FormatException catch (e, trace) { - logError(e, trace); - } - }, - child: Text( - SentenceManager( - currentLanguage: AppSettings.selectedLanguage) - .sentences - .submit, - style: TextStyle( - color: tColors.primaryTextColor, - ), - ), - ), - ], - ), - ); - }, - ), + return AttributeCard( + name: name, + value: value, + isEditable: isEditable, + onTap: () => _showEditDialog(context), ); } } diff --git a/lib/app/modules/detailRoute/views/detail_route_dialog_action.dart b/lib/app/modules/detailRoute/views/detail_route_dialog_action.dart new file mode 100644 index 000000000..0f3de0c51 --- /dev/null +++ b/lib/app/modules/detailRoute/views/detail_route_dialog_action.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +/// Text-only action button shared by the detail page dialogs. +class DetailRouteDialogAction extends StatelessWidget { + final String label; + final Color? color; + final VoidCallback onPressed; + + const DetailRouteDialogAction({ + required this.label, + required this.color, + required this.onPressed, + super.key, + }); + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: onPressed, + child: Text( + label, + style: TextStyle(color: color), + ), + ); + } +} diff --git a/lib/app/modules/detailRoute/views/detail_route_page_app_bar.dart b/lib/app/modules/detailRoute/views/detail_route_page_app_bar.dart new file mode 100644 index 000000000..1d088690d --- /dev/null +++ b/lib/app/modules/detailRoute/views/detail_route_page_app_bar.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:taskwarrior/app/modules/detailRoute/controllers/detail_route_controller.dart'; +import 'package:taskwarrior/app/utils/constants/constants.dart'; + +class DetailRoutePageAppBar extends StatelessWidget + implements PreferredSizeWidget { + final DetailRouteController controller; + const DetailRoutePageAppBar({required this.controller, super.key}); + + @override + Widget build(BuildContext context) { + return AppBar( + leading: BackButton(color: TaskWarriorColors.white), + backgroundColor: Palette.kToDark, + title: Text( + controller.appBarTitle, + style: TextStyle( + color: TaskWarriorColors.white, + ), + ), + ); + } + + @override + Size get preferredSize => AppBar().preferredSize; +} diff --git a/lib/app/modules/detailRoute/views/detail_route_page_body.dart b/lib/app/modules/detailRoute/views/detail_route_page_body.dart new file mode 100644 index 000000000..99a214021 --- /dev/null +++ b/lib/app/modules/detailRoute/views/detail_route_page_body.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:taskwarrior/app/modules/detailRoute/controllers/detail_route_controller.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/attribute_widget.dart'; +import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; + +class DetailRoutePageBody extends StatelessWidget { + final DetailRouteController controller; + const DetailRoutePageBody({required this.controller, super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 8.0, right: 8.0), + // Every reactive value the cards depend on is read here, so the list + // rebuilds on edits, read-only changes and time-format changes alike. + child: Obx(() { + final attributes = controller.attributes; + final bool use24HourFormat = AppSettings.use24HourFormatRx.value; + return ListView( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 2), + children: [ + for (var entry in attributes.entries) + AttributeWidget( + controller: controller, + attribute: entry.key, + value: entry.value, + // Reads isReadOnly, so this Obx subscribes to it. + isEditable: controller.isAttributeEditable(entry.key), + use24HourFormat: use24HourFormat, + ), + ], + ); + }), + ); + } +} diff --git a/lib/app/modules/detailRoute/views/detail_route_page_floating_action_button.dart b/lib/app/modules/detailRoute/views/detail_route_page_floating_action_button.dart new file mode 100644 index 000000000..057d479ab --- /dev/null +++ b/lib/app/modules/detailRoute/views/detail_route_page_floating_action_button.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:taskwarrior/app/modules/detailRoute/controllers/detail_route_controller.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/review_changes_dialog.dart'; +import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; + +class DetailRoutePageFloatingActionButton extends StatelessWidget { + final DetailRouteController controller; + const DetailRoutePageFloatingActionButton( + {required this.controller, super.key}); + + @override + Widget build(BuildContext context) { + TaskwarriorColorTheme tColors = + Theme.of(context).extension()!; + return Obx( + () => controller.hasPendingChanges.value + ? FloatingActionButton( + backgroundColor: tColors.primaryTextColor, + foregroundColor: tColors.secondaryBackgroundColor, + splashColor: tColors.primaryTextColor, + heroTag: "btn1", + onPressed: () async { + if (await ReviewChangesDialog.show(context, controller) && + context.mounted) { + Navigator.of(context).pop(); + } + }, + child: const Icon(Icons.save), + ) + : const SizedBox.shrink(), + ); + } +} diff --git a/lib/app/modules/detailRoute/views/detail_route_view.dart b/lib/app/modules/detailRoute/views/detail_route_view.dart index dab0aac2f..31aa64516 100644 --- a/lib/app/modules/detailRoute/views/detail_route_view.dart +++ b/lib/app/modules/detailRoute/views/detail_route_view.dart @@ -1,19 +1,10 @@ -// ignore_for_file: depend_on_referenced_packages, deprecated_member_use - -import 'package:built_collection/built_collection.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:intl/intl.dart'; import 'package:taskwarrior/app/modules/detailRoute/controllers/detail_route_controller.dart'; -import 'package:taskwarrior/app/modules/detailRoute/views/dateTimePicker.dart'; -import 'package:taskwarrior/app/modules/detailRoute/views/description_widget.dart'; -import 'package:taskwarrior/app/modules/detailRoute/views/priority_widget.dart'; -import 'package:taskwarrior/app/modules/detailRoute/views/status_widget.dart'; -import 'package:taskwarrior/app/modules/detailRoute/views/tags_widget.dart'; -import 'package:taskwarrior/app/utils/constants/constants.dart'; -import 'package:taskwarrior/app/utils/gen/fonts.gen.dart'; -import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; -import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/detail_route_page_app_bar.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/detail_route_page_body.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/detail_route_page_floating_action_button.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/unsaved_changes_dialog.dart'; import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; class DetailRouteView extends GetView { @@ -21,367 +12,36 @@ class DetailRouteView extends GetView { @override Widget build(BuildContext context) { - controller.initDetailsPageTour(); - controller.showDetailsPageTour(context); TaskwarriorColorTheme tColors = Theme.of(context).extension()!; - return WillPopScope( - onWillPop: () async { - if (!controller.onEdit.value) { - debugPrint( - 'DetailRouteView: No edits made, navigating back without prompt.'); - // Get.offAll(() => const HomeView()); + if (!controller.hasTask) { + // The controller closes the page in onReady. + return Scaffold(backgroundColor: tColors.primaryBackgroundColor); + } + controller.startTourOnce(context); + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) async { + if (didPop) return; + if (await _confirmLeave(context) && context.mounted) { Navigator.of(context).pop(); - // Get.toNamed(Routes.HOME); - return false; } - debugPrint( - 'DetailRouteView: Unsaved edits detected, prompting user for action.'); - - bool? save = await showDialog( - context: context, - builder: (context) { - return AlertDialog( - backgroundColor: tColors.dialogBackgroundColor, - title: Text( - SentenceManager(currentLanguage: AppSettings.selectedLanguage) - .sentences - .saveChangesConfirmation, - style: TextStyle( - color: tColors.primaryTextColor, - ), - ), - actions: [ - // YES → save and pop - TextButton( - onPressed: () { - // Get.back(); // Close the dialog first - // // Wait for dialog to fully close before showing snackbar - // Future.delayed(const Duration(milliseconds: 100), () { - // controller.saveChanges(); - // }); - - controller.saveChanges(); - Navigator.of(context).pop(true); - }, - child: Text( - SentenceManager( - currentLanguage: AppSettings.selectedLanguage) - .sentences - .yes, - style: TextStyle( - color: tColors.primaryTextColor, - ), - ), - ), - - // NO → discard and pop - TextButton( - onPressed: () { - // Get.offAll(() => const HomeView()); - Navigator.of(context).pop(false); - }, - child: Text( - SentenceManager( - currentLanguage: AppSettings.selectedLanguage) - .sentences - .no, - style: TextStyle( - color: tColors.primaryTextColor, - ), - ), - ), - - // CANCEL → stay on page - TextButton( - onPressed: () { - Navigator.of(context).pop(null); - }, - child: Text( - SentenceManager( - currentLanguage: AppSettings.selectedLanguage) - .sentences - .cancel, - style: TextStyle( - color: tColors.primaryTextColor, - ), - ), - ), - ], - ); - }, - ); - if (save == null) { - // Cancel → stay - return false; - } - // Yes (true) or No (false) → both allow popping the screen - return true; }, child: Scaffold( - backgroundColor: tColors.primaryBackgroundColor, - appBar: AppBar( - leading: BackButton(color: TaskWarriorColors.white), - backgroundColor: Palette.kToDark, - title: Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.detailPageID}: ${(controller.modify.id == 0) ? '-' : controller.modify.id}', - style: TextStyle( - color: TaskWarriorColors.white, - ), - )), - body: Padding( - padding: const EdgeInsets.only(left: 8.0, right: 8.0), - child: Obx( - () => ListView( - padding: - const EdgeInsets.symmetric(vertical: 4, horizontal: 2), - children: [ - for (var entry in { - 'description': controller.descriptionValue.value, - 'status': controller.statusValue.value, - 'entry': controller.entryValue.value, - 'modified': controller.modifiedValue.value, - 'start': controller.startValue.value, - 'end': controller.endValue.value, - 'due': controller.dueValue.value, - 'wait': controller.waitValue.value, - 'until': controller.untilValue.value, - 'priority': controller.priorityValue?.value, - 'project': controller.projectValue?.value, - 'tags': controller.tagsValue?.value, - 'urgency': controller.urgencyValue.value, - }.entries) - AttributeWidget( - name: entry.key, - value: entry.value, - callback: (newValue) => - controller.setAttribute(entry.key, newValue), - waitKey: controller.waitKey, - dueKey: controller.dueKey, - untilKey: controller.untilKey, - priorityKey: controller.priorityKey, - ), - ], - ), - )), - floatingActionButton: controller.modify.changes.isEmpty - ? const SizedBox.shrink() - : FloatingActionButton( - backgroundColor: tColors.primaryTextColor, - foregroundColor: tColors.secondaryBackgroundColor, - splashColor: tColors.primaryTextColor, - heroTag: "btn1", - onPressed: () { - showDialog( - context: context, - builder: (context) { - return AlertDialog( - scrollable: true, - title: Text( - '${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.reviewChanges}:', - style: TextStyle( - color: tColors.primaryTextColor, - ), - ), - content: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Text( - controller.modify.changes.entries - .map((entry) => '${entry.key}:\n' - ' ${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.oldChanges}: ${entry.value['old']}\n' - ' ${SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.newChanges}: ${entry.value['new']}') - .toList() - .join('\n'), - style: TextStyle( - color: tColors.primaryTextColor, - ), - ), - ), - actions: [ - TextButton( - onPressed: () { - Get.back(); - }, - child: Text( - SentenceManager( - currentLanguage: - AppSettings.selectedLanguage) - .sentences - .cancel, - style: TextStyle( - color: tColors.primaryTextColor, - ), - ), - ), - TextButton( - onPressed: () { - controller.saveChanges(); - }, - child: Text( - SentenceManager( - currentLanguage: - AppSettings.selectedLanguage) - .sentences - .submit, - style: TextStyle( - color: tColors.primaryBackgroundColor, - ), - ), - ), - ], - ); - }, - ); - }, - child: const Icon(Icons.save), - )), + backgroundColor: tColors.primaryBackgroundColor, + appBar: DetailRoutePageAppBar(controller: controller), + body: DetailRoutePageBody(controller: controller), + floatingActionButton: + DetailRoutePageFloatingActionButton(controller: controller), + ), ); } -} - -class AttributeWidget extends StatelessWidget { - const AttributeWidget({ - required this.name, - required this.value, - required this.callback, - required this.waitKey, - required this.dueKey, - required this.priorityKey, - required this.untilKey, - super.key, - }); - final String name; - final dynamic value; - final void Function(dynamic) callback; - final GlobalKey waitKey; - final GlobalKey dueKey; - final GlobalKey untilKey; - final GlobalKey priorityKey; - - @override - Widget build(BuildContext context) { - var localValue = (value is DateTime) - ? DateFormat(AppSettings.use24HourFormatRx.value - ? 'EEE, yyyy-MM-dd HH:mm:ss' - : 'EEE, yyyy-MM-dd hh:mm:ss a') - .format(value.toLocal()) - : ((value is BuiltList) ? (value).toBuilder() : value); - TaskwarriorColorTheme tColors = - Theme.of(context).extension()!; - // Get the controller to check if the task is read-only - final DetailRouteController controller = Get.find(); - - // Always allow status to be edited, but respect read-only for other attributes - final bool isEditable = !controller.isReadOnly.value || name == 'status'; - - switch (name) { - case 'description': - return DescriptionWidget( - name: name, - value: localValue, - callback: callback, - isEditable: isEditable, - ); - case 'status': - return StatusWidget( - name: name, - value: localValue, - callback: callback, - ); - case 'start': - return StartWidget( - name: name, - value: localValue, - callback: callback, - isEditable: isEditable, - ); - case 'due': - return DateTimeWidget( - name: name, - value: localValue, - callback: callback, - globalKey: dueKey, - isEditable: isEditable, - ); - case 'wait': - return DateTimeWidget( - name: name, - value: localValue, - callback: callback, - globalKey: waitKey, - isEditable: isEditable, - ); - case 'until': - return DateTimeWidget( - name: name, - value: localValue, - callback: callback, - globalKey: untilKey, - isEditable: isEditable, - ); - case 'priority': - return PriorityWidget( - name: name, - value: localValue, - callback: callback, - globalKey: priorityKey, - isEditable: isEditable, - ); - case 'project': - return ProjectWidget( - name: name, - value: localValue, - callback: callback, - isEditable: isEditable, - ); - case 'tags': - return TagsWidget( - name: name, - value: localValue, - callback: callback, - isEditable: isEditable, - ); - default: - final Color? textColor = - (isEditable && !['entry', 'modified', 'urgency'].contains(name)) - ? tColors.primaryTextColor - : tColors.primaryDisabledTextColor; - - return Card( - color: tColors.secondaryBackgroundColor, - child: ListTile( - textColor: tColors.primaryTextColor, - title: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - Text( - '$name:'.padRight(13), - style: TextStyle( - fontFamily: FontFamily.poppins, - fontWeight: TaskWarriorFonts.bold, - fontSize: TaskWarriorFonts.fontSizeMedium, - color: textColor, - ), - ), - Text( - localValue?.toString() ?? - SentenceManager( - currentLanguage: AppSettings.selectedLanguage) - .sentences - .notSelected, - style: TextStyle( - fontFamily: FontFamily.poppins, - fontSize: TaskWarriorFonts.fontSizeMedium, - color: textColor, - ), - ), - ], - ), - ), - ), - ); - } + /// Whether the page may close; asks first when there are unsaved edits. + Future _confirmLeave(BuildContext context) async { + if (!controller.onEdit.value) return true; + final bool? saved = await UnsavedChangesDialog.show(context, controller); + // Yes (true) or No (false) both leave the page; Cancel (null) stays. + return saved != null; } } diff --git a/lib/app/modules/detailRoute/views/priority_widget.dart b/lib/app/modules/detailRoute/views/priority_widget.dart index 9835ff3ce..5151cc334 100644 --- a/lib/app/modules/detailRoute/views/priority_widget.dart +++ b/lib/app/modules/detailRoute/views/priority_widget.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:taskwarrior/app/utils/constants/constants.dart'; -import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/attribute_card.dart'; +/// Cycles H -> M -> L -> none -> H on tap. class PriorityWidget extends StatelessWidget { const PriorityWidget({ required this.name, @@ -14,65 +13,30 @@ class PriorityWidget extends StatelessWidget { }); final String name; - final dynamic value; + final String? value; final void Function(dynamic) callback; final GlobalKey globalKey; final bool isEditable; @override Widget build(BuildContext context) { - TaskwarriorColorTheme tColors = Theme.of(context).extension()!; - final Color? textColor = isEditable - ? tColors.primaryTextColor - : tColors.primaryDisabledTextColor; - - return Card( - key: globalKey, - color: tColors.secondaryBackgroundColor, - child: ListTile( - enabled: isEditable, - textColor: tColors.primaryTextColor, - title: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - RichText( - text: TextSpan( - children: [ - TextSpan( - text: '$name:'.padRight(13), - style: GoogleFonts.poppins( - fontWeight: TaskWarriorFonts.bold, - fontSize: TaskWarriorFonts.fontSizeMedium, - color: textColor, - ), - ), - TextSpan( - text: value ?? "not selected", - style: GoogleFonts.poppins( - fontSize: TaskWarriorFonts.fontSizeMedium, - color: textColor, - ), - ), - ], - ), - ), - ], - ), - ), - onTap: () { - switch (value) { - case 'H': - return callback('M'); - case 'M': - return callback('L'); - case 'L': - return callback(null); - default: - return callback('H'); - } - }, - ), + return AttributeCard( + cardKey: globalKey, + name: name, + value: value, + isEditable: isEditable, + onTap: () { + switch (value) { + case 'H': + return callback('M'); + case 'M': + return callback('L'); + case 'L': + return callback(null); + default: + return callback('H'); + } + }, ); } } diff --git a/lib/app/modules/detailRoute/views/project_widget.dart b/lib/app/modules/detailRoute/views/project_widget.dart new file mode 100644 index 000000000..79434a722 --- /dev/null +++ b/lib/app/modules/detailRoute/views/project_widget.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:taskwarrior/app/modules/detailRoute/controllers/detail_route_controller.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/attribute_card.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/detail_route_dialog_action.dart'; +import 'package:taskwarrior/app/utils/constants/utilites.dart'; +import 'package:taskwarrior/app/utils/debug_logger/app_logger.dart'; +import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; + +/// Edits the project in a dialog; an empty project clears it. +class ProjectWidget extends StatelessWidget { + const ProjectWidget({ + required this.controller, + required this.name, + required this.value, + required this.callback, + this.isEditable = true, + super.key, + }); + + static const AppLogger _log = AppLogger('DetailRoute'); + + final DetailRouteController controller; + final String? value; + final String name; + final void Function(dynamic) callback; + final bool isEditable; + + void _showEditDialog(BuildContext context) { + TaskwarriorColorTheme tColors = + Theme.of(context).extension()!; + final sentences = controller.sentences; + controller.prepareProjectEdit(value); + showDialog( + context: context, + builder: (context) => Utils.showAlertDialog( + scrollable: true, + title: Text( + sentences.editProject, + style: TextStyle( + color: tColors.primaryTextColor, + ), + ), + content: TextField( + style: TextStyle( + color: tColors.primaryTextColor, + ), + autofocus: true, + maxLines: null, + controller: controller.projectController, + ), + actions: [ + DetailRouteDialogAction( + label: sentences.cancel, + color: tColors.primaryTextColor, + onPressed: () => Navigator.of(context).pop(), + ), + DetailRouteDialogAction( + label: sentences.submit, + color: tColors.primaryTextColor, + onPressed: () { + final text = controller.projectController.text; + try { + callback((text == '') ? null : text); + Navigator.of(context).pop(); + } on FormatException catch (e, trace) { + _log.warning('Rejected project "$text"', e, trace); + } + }, + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return AttributeCard( + name: name, + value: value, + isEditable: isEditable, + onTap: () => _showEditDialog(context), + ); + } +} diff --git a/lib/app/modules/detailRoute/views/review_changes_dialog.dart b/lib/app/modules/detailRoute/views/review_changes_dialog.dart new file mode 100644 index 000000000..aebe93b94 --- /dev/null +++ b/lib/app/modules/detailRoute/views/review_changes_dialog.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:taskwarrior/app/modules/detailRoute/controllers/detail_route_controller.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/detail_route_dialog_action.dart'; +import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; + +/// Lists the pending old/new values and lets the user submit them. +class ReviewChangesDialog extends StatelessWidget { + final DetailRouteController controller; + const ReviewChangesDialog({required this.controller, super.key}); + + /// Resolves to `true` when the changes were submitted and saved. + static Future show( + BuildContext context, DetailRouteController controller) async { + final bool? saved = await showDialog( + context: context, + builder: (context) => ReviewChangesDialog(controller: controller), + ); + return saved ?? false; + } + + @override + Widget build(BuildContext context) { + TaskwarriorColorTheme tColors = + Theme.of(context).extension()!; + final sentences = controller.sentences; + return AlertDialog( + scrollable: true, + title: Text( + '${sentences.reviewChanges}:', + style: TextStyle( + color: tColors.primaryTextColor, + ), + ), + content: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Text( + controller.changesSummary, + style: TextStyle( + color: tColors.primaryTextColor, + ), + ), + ), + actions: [ + DetailRouteDialogAction( + label: sentences.cancel, + color: tColors.primaryTextColor, + onPressed: () => Navigator.of(context).pop(false), + ), + DetailRouteDialogAction( + label: sentences.submit, + color: tColors.primaryBackgroundColor, + onPressed: () => Navigator.of(context).pop(controller.saveChanges()), + ), + ], + ); + } +} diff --git a/lib/app/modules/detailRoute/views/start_widget.dart b/lib/app/modules/detailRoute/views/start_widget.dart new file mode 100644 index 000000000..c81f78424 --- /dev/null +++ b/lib/app/modules/detailRoute/views/start_widget.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/attribute_card.dart'; + +/// Toggles the start time: clears it when set, otherwise starts the task now. +class StartWidget extends StatelessWidget { + const StartWidget({ + required this.name, + required this.value, + required this.callback, + this.isEditable = true, + super.key, + }); + + final String name; + final String? value; + final bool isEditable; + final void Function(dynamic) callback; + + @override + Widget build(BuildContext context) { + return AttributeCard( + name: name, + value: value, + isEditable: isEditable, + onTap: () { + if (value != null) { + callback(null); + } else { + var now = DateTime.now().toUtc(); + callback(DateTime.utc( + now.year, + now.month, + now.day, + now.hour, + now.minute, + now.second, + )); + } + }, + ); + } +} diff --git a/lib/app/modules/detailRoute/views/status_widget.dart b/lib/app/modules/detailRoute/views/status_widget.dart index ac75f5b60..3b392d287 100644 --- a/lib/app/modules/detailRoute/views/status_widget.dart +++ b/lib/app/modules/detailRoute/views/status_widget.dart @@ -1,81 +1,34 @@ -// ignore_for_file: unnecessary_getters_setters - -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; -import 'package:taskwarrior/app/utils/constants/constants.dart'; -import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; -import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; - +import 'package:taskwarrior/app/modules/detailRoute/views/attribute_card.dart'; +/// Cycles pending -> completed -> deleted -> pending on tap. Always editable. class StatusWidget extends StatelessWidget { - const StatusWidget( - {required this.name, - required this.value, - required this.callback, - super.key}); + const StatusWidget({ + required this.name, + required this.value, + required this.callback, + super.key, + }); final String name; - final dynamic value; + final String? value; final void Function(dynamic) callback; @override Widget build(BuildContext context) { - TaskwarriorColorTheme tColors = Theme.of(context).extension()!; - return Card( - color: tColors.secondaryBackgroundColor, - child: ListTile( - textColor: tColors.primaryTextColor, - title: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - RichText( - text: TextSpan( - children: [ - TextSpan( - text: '$name:'.padRight(13), - style: GoogleFonts.poppins( - fontWeight: TaskWarriorFonts.bold, - fontSize: TaskWarriorFonts.fontSizeMedium, - color: tColors.primaryTextColor, - ), - ), - TextSpan( - text: value ?? SentenceManager(currentLanguage: AppSettings.selectedLanguage).sentences.notSelected, - style: GoogleFonts.poppins( - fontSize: TaskWarriorFonts.fontSizeMedium, - color: tColors.primaryTextColor, - ), - ), - ], - ), - ), - ], - ), - ), - onTap: () { - if (kDebugMode) { - print(value); - } - switch (value) { - case 'pending': - return callback('completed'); - case 'completed': - return callback('deleted'); - case 'deleted': - return callback('pending'); - } - }, - ), + return AttributeCard( + name: name, + value: value, + onTap: () { + switch (value) { + case 'pending': + return callback('completed'); + case 'completed': + return callback('deleted'); + case 'deleted': + return callback('pending'); + } + }, ); } } - -class StatusWidgetData { - //for accessing the value of the status widget from another class or another dart file - static dynamic _value; - static dynamic get value => _value; - static set value(dynamic newValue) => _value = newValue; -} diff --git a/lib/app/modules/detailRoute/views/tags_route.dart b/lib/app/modules/detailRoute/views/tags_route.dart new file mode 100644 index 000000000..a98024893 --- /dev/null +++ b/lib/app/modules/detailRoute/views/tags_route.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:taskwarrior/app/modules/detailRoute/controllers/detail_route_controller.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/detail_route_dialog_action.dart'; +import 'package:taskwarrior/app/utils/constants/constants.dart'; +import 'package:taskwarrior/app/utils/constants/utilites.dart'; +import 'package:taskwarrior/app/utils/gen/fonts.gen.dart'; +import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; + +/// Full-screen tag editor: tap a chip to remove it, tap a known tag to add +/// it, or add new ones with the button. All state lives in [controller]. +class TagsRoute extends StatelessWidget { + const TagsRoute({required this.controller, super.key}); + + final DetailRouteController controller; + + void _showAddTagDialog(BuildContext context) { + TaskwarriorColorTheme tColors = + Theme.of(context).extension()!; + final sentences = controller.sentences; + final formKey = GlobalKey(); + controller.prepareTagEdit(); + showDialog( + context: context, + builder: (context) => Utils.showAlertDialog( + scrollable: true, + title: Text( + sentences.addTag, + style: TextStyle( + color: tColors.primaryTextColor, + ), + ), + content: Form( + key: formKey, + child: TextFormField( + style: TextStyle( + color: tColors.primaryTextColor, + ), + validator: controller.validateTags, + autofocus: true, + controller: controller.tagController, + ), + ), + actions: [ + DetailRouteDialogAction( + label: sentences.cancel, + color: tColors.primaryTextColor, + onPressed: () => Navigator.of(context).pop(), + ), + DetailRouteDialogAction( + label: sentences.submit, + color: tColors.primaryTextColor, + onPressed: () { + if (formKey.currentState!.validate()) { + controller.addTags(DetailRouteController.parseTags( + controller.tagController.text)); + Navigator.of(context).pop(); + } + }, + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + TaskwarriorColorTheme tColors = + Theme.of(context).extension()!; + return Scaffold( + appBar: AppBar( + backgroundColor: TaskWarriorColors.kprimaryBackgroundColor, + title: Text( + controller.sentences.tags, + style: TextStyle( + fontFamily: FontFamily.poppins, + color: TaskWarriorColors.white, + ), + ), + leading: BackButton( + color: TaskWarriorColors.white, + ), + ), + backgroundColor: tColors.secondaryBackgroundColor, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(4), + child: SingleChildScrollView( + padding: + const EdgeInsets.only(left: 10, top: 10, right: 10, bottom: 0), + child: Obx(() { + final tags = controller.currentTags; + final knownTags = controller.knownTags; + return Wrap( + spacing: 8, + runSpacing: 4, + children: [ + for (var tag in tags) + FilterChip( + backgroundColor: TaskWarriorColors.lightGrey, + onSelected: (_) => controller.removeTag(tag), + label: Text( + '+$tag ${knownTags[tag]?.frequency ?? 0}', + ), + ), + if (tags.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(15, 18, 0, 10), + child: Text( + controller.sentences.addedTagsWillAppearHere, + style: TextStyle( + fontFamily: FontFamily.poppins, + fontStyle: FontStyle.italic, + color: tColors.primaryTextColor, + ), + ), + ), + Divider( + color: tColors.dividerColor, + ), + for (var tag in knownTags.entries + .where((tag) => !tags.contains(tag.key))) + FilterChip( + backgroundColor: TaskWarriorColors.grey, + onSelected: (_) => controller.addTags([tag.key]), + label: Text( + '${tag.key} ${tag.value.frequency}', + ), + ), + ], + ); + }), + ), + ), + ), + floatingActionButton: FloatingActionButton( + backgroundColor: tColors.primaryTextColor, + foregroundColor: tColors.secondaryBackgroundColor, + splashColor: tColors.primaryTextColor, + heroTag: "btn4", + onPressed: () => _showAddTagDialog(context), + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/lib/app/modules/detailRoute/views/tags_widget.dart b/lib/app/modules/detailRoute/views/tags_widget.dart index 125617537..28de40eac 100644 --- a/lib/app/modules/detailRoute/views/tags_widget.dart +++ b/lib/app/modules/detailRoute/views/tags_widget.dart @@ -1,313 +1,36 @@ -// ignore_for_file: depend_on_referenced_packages - import 'package:flutter/material.dart'; -import 'package:built_collection/built_collection.dart'; import 'package:get/get.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:loggy/loggy.dart'; -import 'package:taskwarrior/app/models/tag_meta_data.dart'; -import 'package:taskwarrior/app/modules/home/controllers/home_controller.dart'; -import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; - -import 'package:taskwarrior/app/utils/constants/constants.dart'; -import 'package:taskwarrior/app/utils/constants/utilites.dart'; -import 'package:taskwarrior/app/utils/gen/fonts.gen.dart'; -import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; -import 'package:taskwarrior/app/utils/taskfunctions/validate.dart'; +import 'package:taskwarrior/app/modules/detailRoute/controllers/detail_route_controller.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/attribute_card.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/tags_route.dart'; import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; +/// Shows the task's tags and opens [TagsRoute] to edit them. class TagsWidget extends StatelessWidget { const TagsWidget({ + required this.controller, required this.name, required this.value, - required this.callback, this.isEditable = true, super.key, }); + final DetailRouteController controller; final String name; - final dynamic value; - final void Function(dynamic) callback; + final String? value; final bool isEditable; @override Widget build(BuildContext context) { TaskwarriorColorTheme tColors = Theme.of(context).extension()!; - final Color? textColor = isEditable - ? tColors.primaryTextColor - : tColors.primaryDisabledTextColor; - - return Card( - color: tColors.primaryBackgroundColor, - child: ListTile( - enabled: isEditable, - tileColor: tColors.secondaryBackgroundColor, - textColor: textColor, - title: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - RichText( - text: TextSpan( - children: [ - TextSpan( - text: '$name:'.padRight(13), - style: TextStyle( - fontFamily: FontFamily.poppins, - fontWeight: TaskWarriorFonts.bold, - fontSize: TaskWarriorFonts.fontSizeMedium, - color: textColor, - ), - ), - TextSpan( - text: '${(value as ListBuilder?)?.build()}', - style: TextStyle( - fontFamily: FontFamily.poppins, - fontSize: TaskWarriorFonts.fontSizeMedium, - color: textColor, - ), - ), - ], - ), - ), - ], - ), - ), - onTap: () => Get.to( - TagsRoute( - value: value, - callback: callback, - ), - ), - ), - ); - } -} - -class TagsRoute extends StatefulWidget { - const TagsRoute({required this.value, required this.callback, super.key}); - - final ListBuilder? value; - final void Function(ListBuilder?) callback; - - @override - TagsRouteState createState() => TagsRouteState(); -} - -class TagsRouteState extends State { - Map? _pendingTags; - ListBuilder? draftTags; - - List _parseTags(String input) { - return input - .split(',') - .map((e) => e.trim()) - .where((e) => e.isNotEmpty) - .toList(); - } - - void _addTags(List tags) { - if (tags.isEmpty) return; - - draftTags ??= ListBuilder(); - - for (final tag in tags) { - if (!draftTags!.build().contains(tag)) { - draftTags!.add(tag); - } - } - - widget.callback(draftTags); - setState(() {}); - } - - void _removeTag(String tag) { - if (draftTags!.length == 1) { - draftTags!.remove(tag); - draftTags = null; - } else { - draftTags!.remove(tag); - } - widget.callback(draftTags ?? ListBuilder([])); - setState(() {}); - } - - @override - void initState() { - super.initState(); - draftTags = widget.value; - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _initialize(); - } - - Future _initialize() async { - _pendingTags = Get.find().pendingTags; - setState(() {}); - } - - @override - Widget build(BuildContext context) { - TaskwarriorColorTheme tColors = - Theme.of(context).extension()!; - return Scaffold( - appBar: AppBar( - backgroundColor: TaskWarriorColors.kprimaryBackgroundColor, - title: Text( - SentenceManager(currentLanguage: AppSettings.selectedLanguage) - .sentences - .tags, - style: GoogleFonts.poppins(color: TaskWarriorColors.white), - ), - leading: BackButton( - color: TaskWarriorColors.white, - ), - ), - backgroundColor: tColors.secondaryBackgroundColor, - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(4), - child: SingleChildScrollView( - padding: - const EdgeInsets.only(left: 10, top: 10, right: 10, bottom: 0), - child: Wrap( - spacing: 8, - runSpacing: 4, - children: [ - if (draftTags != null) - for (var tag in draftTags!.build()) - FilterChip( - backgroundColor: TaskWarriorColors.lightGrey, - onSelected: (_) => _removeTag(tag), - label: Text( - '+$tag ${_pendingTags?[tag]?.frequency ?? 0}', - ), - ), - if (draftTags == null) - Padding( - padding: const EdgeInsets.fromLTRB(15, 18, 0, 10), - child: Text( - SentenceManager( - currentLanguage: AppSettings.selectedLanguage, - ).sentences.addedTagsWillAppearHere, - style: GoogleFonts.poppins( - fontStyle: FontStyle.italic, - color: tColors.primaryTextColor), - ), - ), - Divider( - color: tColors.dividerColor, - ), - if (_pendingTags != null) - for (var tag in _pendingTags!.entries.where((tag) => - !(draftTags?.build().contains(tag.key) ?? false))) - FilterChip( - backgroundColor: TaskWarriorColors.grey, - onSelected: (_) => _addTags([tag.key]), - label: Text( - '${tag.key} ${tag.value.frequency}', - ), - ), - ], - ), - ), - ), - ), - floatingActionButton: FloatingActionButton( - backgroundColor: tColors.primaryTextColor, - foregroundColor: tColors.secondaryBackgroundColor, - splashColor: tColors.primaryTextColor, - heroTag: "btn4", - onPressed: () { - final formKey = GlobalKey(); - var controller = TextEditingController(); - showDialog( - context: context, - builder: (context) => Utils.showAlertDialog( - scrollable: true, - title: Text( - SentenceManager( - currentLanguage: AppSettings.selectedLanguage, - ).sentences.addTag, - style: TextStyle( - color: tColors.primaryTextColor, - ), - ), - content: Form( - key: formKey, - child: TextFormField( - style: TextStyle( - color: tColors.primaryTextColor, - ), - validator: (value) { - final tags = _parseTags(value ?? ''); - - if (tags.isEmpty) { - return "Please enter a tag"; - } - - for (final tag in tags) { - if (tag.contains(' ')) { - return "Tags cannot contain spaces"; - } - - if (draftTags?.build().contains(tag) ?? false) { - return "Tag already exists"; - } - } - - return null; - }, - autofocus: true, - controller: controller, - ), - ), - actions: [ - TextButton( - onPressed: () { - // Navigator.of(context).pop(); - Get.back(); - }, - child: Text( - SentenceManager( - currentLanguage: AppSettings.selectedLanguage, - ).sentences.cancel, - style: TextStyle( - color: tColors.primaryTextColor, - ), - ), - ), - TextButton( - onPressed: () { - if (formKey.currentState!.validate()) { - try { - final tags = _parseTags(controller.text); - _addTags(tags); - Get.back(); - } on FormatException catch (e, trace) { - logError(e, trace); - } - } - }, - child: Text( - SentenceManager( - currentLanguage: AppSettings.selectedLanguage, - ).sentences.submit, - style: TextStyle( - color: tColors.primaryTextColor, - ), - ), - ), - ], - ), - ); - }, - child: const Icon(Icons.add), - ), + return AttributeCard( + name: name, + value: value, + isEditable: isEditable, + cardColor: tColors.primaryBackgroundColor, + tileColor: tColors.secondaryBackgroundColor, + onTap: () => Get.to(() => TagsRoute(controller: controller)), ); } } diff --git a/lib/app/modules/detailRoute/views/unsaved_changes_dialog.dart b/lib/app/modules/detailRoute/views/unsaved_changes_dialog.dart new file mode 100644 index 000000000..b5704c1c6 --- /dev/null +++ b/lib/app/modules/detailRoute/views/unsaved_changes_dialog.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:taskwarrior/app/modules/detailRoute/controllers/detail_route_controller.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/detail_route_dialog_action.dart'; +import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; + +/// Asked when leaving the page with unsaved edits. Pops with `true` (saved), +/// `false` (discarded) or `null` (cancelled, or the save failed: stay). +class UnsavedChangesDialog extends StatelessWidget { + final DetailRouteController controller; + const UnsavedChangesDialog({required this.controller, super.key}); + + static Future show( + BuildContext context, DetailRouteController controller) { + return showDialog( + context: context, + builder: (context) => UnsavedChangesDialog(controller: controller), + ); + } + + @override + Widget build(BuildContext context) { + TaskwarriorColorTheme tColors = + Theme.of(context).extension()!; + final sentences = controller.sentences; + return AlertDialog( + backgroundColor: tColors.dialogBackgroundColor, + title: Text( + sentences.saveChangesConfirmation, + style: TextStyle( + color: tColors.primaryTextColor, + ), + ), + actions: [ + DetailRouteDialogAction( + label: sentences.yes, + color: tColors.primaryTextColor, + onPressed: () => + Navigator.of(context).pop(controller.saveChanges() ? true : null), + ), + DetailRouteDialogAction( + label: sentences.no, + color: tColors.primaryTextColor, + onPressed: () => Navigator.of(context).pop(false), + ), + DetailRouteDialogAction( + label: sentences.cancel, + color: tColors.primaryTextColor, + onPressed: () => Navigator.of(context).pop(null), + ), + ], + ); + } +} diff --git a/lib/app/utils/debug_logger/app_logger.dart b/lib/app/utils/debug_logger/app_logger.dart new file mode 100644 index 000000000..b92f7c0fe --- /dev/null +++ b/lib/app/utils/debug_logger/app_logger.dart @@ -0,0 +1,39 @@ +import 'package:flutter/foundation.dart'; + +enum LogLevel { debug, info, warning, error } + +/// Tagged, levelled logger. +/// +/// Everything goes through [debugPrint], which `main()` overrides to also +/// persist each line to the debug-log database shown on the Logs page. +/// [LogLevel.debug] lines are only emitted in debug builds so routine +/// interaction noise never reaches that database in release. +class AppLogger { + const AppLogger(this.tag); + + final String tag; + + void debug(String message) { + if (kDebugMode) _log(LogLevel.debug, message); + } + + void info(String message) => _log(LogLevel.info, message); + + void warning(String message, [Object? error, StackTrace? stackTrace]) => + _log(LogLevel.warning, message, error, stackTrace); + + void error(String message, [Object? error, StackTrace? stackTrace]) => + _log(LogLevel.error, message, error, stackTrace); + + void _log( + LogLevel level, + String message, [ + Object? error, + StackTrace? stackTrace, + ]) { + final line = StringBuffer('[${level.name.toUpperCase()}] [$tag] $message'); + if (error != null) line.write(': $error'); + debugPrint(line.toString()); + if (stackTrace != null) debugPrint(stackTrace.toString()); + } +} diff --git a/lib/app/utils/language/bengali_sentences.dart b/lib/app/utils/language/bengali_sentences.dart index 347f87325..012b1f44a 100644 --- a/lib/app/utils/language/bengali_sentences.dart +++ b/lib/app/utils/language/bengali_sentences.dart @@ -735,4 +735,11 @@ class BengaliSentences extends Sentences { String get storageAndData => 'স্টোরেজ এবং ডাটা'; @override String get advanced => 'উন্নত'; + // Detail page + @override + String get taskUpdateFailed => 'টাস্ক আপডেট করা যায়নি'; + @override + String get taskNotFound => 'টাস্ক পাওয়া যায়নি'; + @override + String get pleaseEnterATag => 'অনুগ্রহ করে একটি ট্যাগ লিখুন'; } diff --git a/lib/app/utils/language/english_sentences.dart b/lib/app/utils/language/english_sentences.dart index 43923f92a..98787c260 100644 --- a/lib/app/utils/language/english_sentences.dart +++ b/lib/app/utils/language/english_sentences.dart @@ -723,4 +723,11 @@ class EnglishSentences extends Sentences { String get storageAndData => 'Storage and Data'; @override String get advanced => 'Advanced'; + // Detail page + @override + String get taskUpdateFailed => "Couldn't update the task"; + @override + String get taskNotFound => 'Task not found'; + @override + String get pleaseEnterATag => 'Please enter a tag'; } diff --git a/lib/app/utils/language/french_sentences.dart b/lib/app/utils/language/french_sentences.dart index cec2b925f..036c2dae0 100644 --- a/lib/app/utils/language/french_sentences.dart +++ b/lib/app/utils/language/french_sentences.dart @@ -754,4 +754,11 @@ class FrenchSentences extends Sentences { String get storageAndData => 'Stockage et données'; @override String get advanced => 'Avancé'; + // Detail page + @override + String get taskUpdateFailed => 'Impossible de mettre à jour la tâche'; + @override + String get taskNotFound => 'Tâche introuvable'; + @override + String get pleaseEnterATag => 'Veuillez saisir une étiquette'; } diff --git a/lib/app/utils/language/german_sentences.dart b/lib/app/utils/language/german_sentences.dart index 5dfca840e..3c4c4c135 100644 --- a/lib/app/utils/language/german_sentences.dart +++ b/lib/app/utils/language/german_sentences.dart @@ -724,4 +724,11 @@ class GermanSentences extends Sentences { String get storageAndData => 'Speicher und Daten'; @override String get advanced => 'Fortgeschritten'; + // Detail page + @override + String get taskUpdateFailed => 'Aufgabe konnte nicht aktualisiert werden'; + @override + String get taskNotFound => 'Aufgabe nicht gefunden'; + @override + String get pleaseEnterATag => 'Bitte gib einen Tag ein'; } diff --git a/lib/app/utils/language/hindi_sentences.dart b/lib/app/utils/language/hindi_sentences.dart index 75e5b6206..5f60abc69 100644 --- a/lib/app/utils/language/hindi_sentences.dart +++ b/lib/app/utils/language/hindi_sentences.dart @@ -712,4 +712,11 @@ class HindiSentences extends Sentences { String get storageAndData => 'स्टोरेज और डेटा'; @override String get advanced => 'अड्वांस्ड'; + // Detail page + @override + String get taskUpdateFailed => 'कार्य अपडेट नहीं हो सका'; + @override + String get taskNotFound => 'कार्य नहीं मिला'; + @override + String get pleaseEnterATag => 'कृपया एक टैग दर्ज करें'; } diff --git a/lib/app/utils/language/marathi_sentences.dart b/lib/app/utils/language/marathi_sentences.dart index cc48d2163..b316ebbad 100644 --- a/lib/app/utils/language/marathi_sentences.dart +++ b/lib/app/utils/language/marathi_sentences.dart @@ -736,4 +736,11 @@ class MarathiSentences extends Sentences { String get storageAndData => 'स्टोरेज आणि डेटा'; @override String get advanced => 'अड्वांस्ड'; + // Detail page + @override + String get taskUpdateFailed => 'कार्य अद्यतनित करता आले नाही'; + @override + String get taskNotFound => 'कार्य सापडले नाही'; + @override + String get pleaseEnterATag => 'कृपया एक टॅग प्रविष्ट करा'; } diff --git a/lib/app/utils/language/sentences.dart b/lib/app/utils/language/sentences.dart index fdb50bcf6..42ae95c8b 100644 --- a/lib/app/utils/language/sentences.dart +++ b/lib/app/utils/language/sentences.dart @@ -383,4 +383,8 @@ abstract class Sentences { String get profilePageChangeProfileMode; String get profilePageSelectProfileMode; String get profilePageSuccessfullyChangedProfileModeTo; + // Detail page + String get taskUpdateFailed; + String get taskNotFound; + String get pleaseEnterATag; } diff --git a/lib/app/utils/language/spanish_sentences.dart b/lib/app/utils/language/spanish_sentences.dart index 007bf9a05..b60aacff0 100644 --- a/lib/app/utils/language/spanish_sentences.dart +++ b/lib/app/utils/language/spanish_sentences.dart @@ -741,4 +741,11 @@ class SpanishSentences extends Sentences { String get storageAndData => 'Almacenamiento y datos'; @override String get advanced => 'Avanzado'; + // Detail page + @override + String get taskUpdateFailed => 'No se pudo actualizar la tarea'; + @override + String get taskNotFound => 'Tarea no encontrada'; + @override + String get pleaseEnterATag => 'Introduce una etiqueta'; } diff --git a/lib/app/utils/language/urdu_sentences.dart b/lib/app/utils/language/urdu_sentences.dart index 1b5a43579..d362496d1 100644 --- a/lib/app/utils/language/urdu_sentences.dart +++ b/lib/app/utils/language/urdu_sentences.dart @@ -728,4 +728,11 @@ class UrduSentences extends Sentences { String get storageAndData => 'اسٹوریج اور ڈیٹا'; @override String get advanced => 'ایڈوانس'; + // Detail page + @override + String get taskUpdateFailed => 'کام اپ ڈیٹ نہیں ہو سکا'; + @override + String get taskNotFound => 'کام نہیں ملا'; + @override + String get pleaseEnterATag => 'براہ کرم ایک ٹیگ درج کریں'; } diff --git a/test/modules/detail_route/detail_route_controller_test.dart b/test/modules/detail_route/detail_route_controller_test.dart new file mode 100644 index 000000000..9162b821f --- /dev/null +++ b/test/modules/detail_route/detail_route_controller_test.dart @@ -0,0 +1,193 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:taskwarrior/app/models/models.dart'; +import 'package:taskwarrior/app/models/task_attribute.dart'; +import 'package:taskwarrior/app/modules/detailRoute/controllers/detail_route_controller.dart'; +import 'package:taskwarrior/app/utils/taskfunctions/modify.dart'; + +void main() { + const uuid = 'test-uuid'; + final entry = DateTime.utc(2026, 1, 1, 9); + late Task stored; + + Task buildTask({String status = 'pending', DateTime? start}) => Task((b) => b + ..id = 7 + ..uuid = uuid + ..description = 'Write tests' + ..status = status + ..entry = entry + ..start = start); + + DetailRouteController createController({Object? arguments}) { + final controller = DetailRouteController( + arguments: arguments ?? const ['uuid', uuid], + createModify: (id) => Modify( + getTask: (_) => stored, + mergeTask: (task) => stored = task, + uuid: id, + ), + knownTags: () => const {}, + ); + controller.onInit(); + return controller; + } + + setUp(() { + Get.testMode = true; + stored = buildTask(); + }); + + group('uuidFromArguments', () { + test('reads the uuid from ["uuid", ]', () { + expect(DetailRouteController.uuidFromArguments(['uuid', 'abc']), 'abc'); + }); + + test('rejects missing, short, empty or non-string arguments', () { + for (final args in [ + null, + 'abc', + [], + ['uuid'], + ['uuid', ''], + ['uuid', 3] + ]) { + expect(DetailRouteController.uuidFromArguments(args), isNull, + reason: '$args'); + } + }); + }); + + group('loading', () { + test('loads the task values', () { + final controller = createController(); + expect(controller.hasTask, isTrue); + expect(controller.descriptionValue.value, 'Write tests'); + expect(controller.statusValue.value, 'pending'); + expect(controller.isReadOnly.value, isFalse); + expect(controller.hasPendingChanges.value, isFalse); + }); + + test('invalid arguments leave the page without a task', () { + final controller = createController(arguments: const ['uuid']); + expect(controller.hasTask, isFalse); + }); + + test('a task that fails to load leaves the page without a task', () { + final controller = DetailRouteController( + arguments: const ['uuid', uuid], + createModify: (_) => throw StateError('no such task'), + )..onInit(); + expect(controller.hasTask, isFalse); + }); + + test('completed and deleted tasks open read-only', () { + for (final status in ['completed', 'deleted']) { + stored = buildTask(status: status); + expect(createController().isReadOnly.value, isTrue, reason: status); + } + }); + + test('hides a start the backend set to the entry time', () { + stored = buildTask(start: entry); + expect(createController().startValue.value, isNull); + }); + + test('keeps a real start time', () { + final start = entry.add(const Duration(hours: 1)); + stored = buildTask(start: start); + expect(createController().startValue.value, start); + }); + }); + + group('editing', () { + test('an edit marks the page edited and pending', () { + final controller = createController(); + controller.setAttribute(TaskAttribute.description, 'Changed'); + + expect(controller.descriptionValue.value, 'Changed'); + expect(controller.onEdit.value, isTrue); + expect(controller.hasPendingChanges.value, isTrue); + expect(controller.changesSummary, contains('description')); + }); + + test('reverting an edit clears the pending changes', () { + final controller = createController(); + controller.setAttribute(TaskAttribute.description, 'Changed'); + controller.setAttribute(TaskAttribute.description, 'Write tests'); + + expect(controller.hasPendingChanges.value, isFalse); + }); + + test('read-only tasks ignore edits except to status', () { + stored = buildTask(status: 'completed'); + final controller = createController(); + + expect( + controller.isAttributeEditable(TaskAttribute.description), isFalse); + expect(controller.isAttributeEditable(TaskAttribute.status), isTrue); + + controller.setAttribute(TaskAttribute.description, 'Changed'); + expect(controller.descriptionValue.value, 'Write tests'); + expect(controller.onEdit.value, isFalse); + + controller.setAttribute(TaskAttribute.status, 'pending'); + expect(controller.isReadOnly.value, isFalse); + }); + + test('completing a task makes it read-only', () { + final controller = createController(); + controller.setAttribute(TaskAttribute.status, 'completed'); + expect(controller.isReadOnly.value, isTrue); + }); + }); + + group('tags', () { + test('parseTags splits on commas and drops blanks', () { + expect(DetailRouteController.parseTags(' a, b ,,c '), ['a', 'b', 'c']); + }); + + test('addTags appends only new tags', () { + final controller = createController(); + controller.addTags(['a', 'b']); + controller.addTags(['b', 'c']); + expect(controller.currentTags, ['a', 'b', 'c']); + expect(controller.hasPendingChanges.value, isTrue); + }); + + test('removeTag removes it', () { + final controller = createController(); + controller.addTags(['a', 'b']); + controller.removeTag('a'); + expect(controller.currentTags, ['b']); + }); + + test('validateTags rejects empty, spaced and duplicate tags', () { + final controller = createController(); + controller.addTags(['a']); + final s = controller.sentences; + expect(controller.validateTags(' '), s.pleaseEnterATag); + expect(controller.validateTags('x y'), s.tagShouldNotContainSpaces); + expect(controller.validateTags('b, a'), s.tagAlreadyExists); + expect(controller.validateTags('b, c'), isNull); + }); + }); + + test('tour keys exist only for the toured attributes', () { + final controller = createController(); + expect(controller.tourKeyFor(TaskAttribute.due), controller.dueKey); + expect(controller.tourKeyFor(TaskAttribute.wait), controller.waitKey); + expect(controller.tourKeyFor(TaskAttribute.until), controller.untilKey); + expect( + controller.tourKeyFor(TaskAttribute.priority), controller.priorityKey); + expect(controller.tourKeyFor(TaskAttribute.description), isNull); + }); + + test('app bar shows "-" for a task without an id', () { + stored = Task((b) => b + ..uuid = uuid + ..description = 'No id' + ..status = 'pending' + ..entry = entry); + expect(createController().appBarTitle, endsWith(': -')); + }); +} diff --git a/test/modules/detail_route/detail_route_view_test.dart b/test/modules/detail_route/detail_route_view_test.dart new file mode 100644 index 000000000..ffa4d2332 --- /dev/null +++ b/test/modules/detail_route/detail_route_view_test.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:taskwarrior/app/models/models.dart'; +import 'package:taskwarrior/app/models/tag_meta_data.dart'; +import 'package:taskwarrior/app/models/task_attribute.dart'; +import 'package:taskwarrior/app/modules/detailRoute/controllers/detail_route_controller.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/attribute_widget.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/detail_route_view.dart'; +import 'package:taskwarrior/app/modules/detailRoute/views/tags_route.dart'; +import 'package:taskwarrior/app/utils/app_settings/app_settings.dart'; +import 'package:taskwarrior/app/utils/taskfunctions/modify.dart'; +import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; + +void main() { + const uuid = 'test-uuid'; + late Task stored; + late DetailRouteController controller; + + ThemeData theme() => ThemeData(extensions: [ + TaskwarriorColorTheme( + dialogBackgroundColor: Colors.white, + primaryBackgroundColor: Colors.white, + primaryDisabledTextColor: Colors.grey, + primaryTextColor: Colors.black, + secondaryBackgroundColor: Colors.grey[200], + secondaryTextColor: Colors.black54, + dividerColor: Colors.grey, + purpleShade: Colors.purple, + greyShade: Colors.grey, + icons: Icons.star, + dimCol: Colors.grey, + ), + ]); + + setUp(() async { + // Mark the tour as seen so it never overlays the page under test. + SharedPreferences.setMockInitialValues({'details_tour': true}); + await SaveTourStatus.init(); + stored = Task((b) => b + ..id = 1 + ..uuid = uuid + ..description = 'Write tests' + ..status = 'pending' + ..entry = DateTime.utc(2026, 1, 1)); + controller = DetailRouteController( + arguments: const ['uuid', uuid], + createModify: (id) => Modify( + getTask: (_) => stored, + mergeTask: (task) => stored = task, + uuid: id, + ), + knownTags: () => { + 'work': TagMetadata( + lastModified: DateTime(2026), frequency: 3, selected: false) + }, + ); + }); + + tearDown(Get.reset); + + /// Opens the detail page on top of a placeholder home page. The controller + /// is registered only after the app is mounted: GetX ties it to the current + /// route, and the previous test's routes are torn down by [pumpWidget]. + Future openDetailPage(WidgetTester tester, + {DetailRouteController Function()? lazyController}) async { + await tester.pumpWidget(GetMaterialApp( + theme: theme(), + home: const Scaffold(body: Text('home')), + )); + if (lazyController != null) { + // Lazy, like DetailRouteBinding, so onReady runs once the page is built. + Get.lazyPut(lazyController); + } else { + Get.put(controller); + } + Get.to(() => const DetailRouteView()); + await tester.pumpAndSettle(); + // Let the tour's start-up delay run out. + await tester.pump(const Duration(seconds: 1)); + } + + testWidgets('save button appears after an edit and hides on revert', + (tester) async { + await openDetailPage(tester); + expect(find.byType(FloatingActionButton), findsNothing); + + controller.setAttribute(TaskAttribute.description, 'Changed'); + await tester.pump(); + expect(find.byType(FloatingActionButton), findsOneWidget); + + controller.setAttribute(TaskAttribute.description, 'Write tests'); + await tester.pump(); + expect(find.byType(FloatingActionButton), findsNothing); + }); + + testWidgets('completing the task greys out the other cards', (tester) async { + await openDetailPage(tester); + AttributeWidget card(TaskAttribute attribute) => tester + .widgetList(find.byType(AttributeWidget)) + .firstWhere((w) => w.attribute == attribute); + expect(card(TaskAttribute.description).isEditable, isTrue); + + controller.isReadOnly.value = true; + await tester.pump(); + expect(card(TaskAttribute.description).isEditable, isFalse); + expect(card(TaskAttribute.status).isEditable, isTrue); + }); + + testWidgets('dates follow the 24-hour setting live', (tester) async { + stored = + stored.rebuild((b) => b..due = DateTime(2026, 5, 4, 15, 30).toUtc()); + AppSettings.use24HourFormatRx.value = false; + await openDetailPage(tester); + expect(find.textContaining('03:30:00 PM'), findsOneWidget); + + AppSettings.use24HourFormatRx.value = true; + await tester.pump(); + expect(find.textContaining('15:30:00'), findsOneWidget); + AppSettings.use24HourFormatRx.value = false; + }); + + testWidgets('submitting the review dialog saves and closes the page', + (tester) async { + await openDetailPage(tester); + controller.setAttribute(TaskAttribute.description, 'Changed'); + await tester.pump(); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Submit')); + await tester.pumpAndSettle(); + + expect(stored.description, 'Changed'); + expect(find.byType(DetailRouteView), findsNothing); + expect(find.text('home'), findsOneWidget); + expect(find.text('Task Updated'), findsOneWidget); + }); + + testWidgets('a task that fails to load closes the page', (tester) async { + await openDetailPage( + tester, + lazyController: () => DetailRouteController( + arguments: const ['uuid', uuid], + createModify: (_) => throw StateError('no such task'), + ), + ); + + expect(find.byType(DetailRouteView), findsNothing); + expect(find.text('home'), findsOneWidget); + expect(find.text('Task not found'), findsOneWidget); + }); + + testWidgets('tags screen adds and removes tags through the controller', + (tester) async { + await openDetailPage(tester); + final tagsCard = find.textContaining('tags:'); + await tester.scrollUntilVisible( + tagsCard, + 200, + // Each card also scrolls horizontally; target the page's list. + scrollable: find + .descendant( + of: find.byType(ListView), matching: find.byType(Scrollable)) + .first, + ); + await tester.ensureVisible(tagsCard); + await tester.pumpAndSettle(); + await tester.tap(tagsCard); + await tester.pumpAndSettle(); + expect(find.byType(TagsRoute), findsOneWidget); + + await tester.tap(find.text('work 3')); + await tester.pump(); + expect(controller.currentTags, ['work']); + expect(find.text('+work 3'), findsOneWidget); + + await tester.tap(find.text('+work 3')); + await tester.pump(); + expect(controller.currentTags, isEmpty); + expect(find.text('work 3'), findsOneWidget); + }); + + testWidgets('back with unsaved edits asks first; cancel stays', + (tester) async { + await openDetailPage(tester); + controller.setAttribute(TaskAttribute.description, 'Changed'); + await tester.pump(); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(find.byType(DetailRouteView), findsOneWidget); + expect(stored.description, 'Write tests'); + }); + + testWidgets('back without edits leaves immediately', (tester) async { + await openDetailPage(tester); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.byType(DetailRouteView), findsNothing); + }); +} From ac8dc6acacbeedf51586004226587d1bbfd55872 Mon Sep 17 00:00:00 2001 From: Chinmay Chaudhari Date: Thu, 1 Oct 2026 21:35:43 +0530 Subject: [PATCH 2/2] fix(detail): readable dialog buttons and consistent value display Found while testing the refactored detail page on a device: - The review dialog's Submit button was drawn in primaryBackgroundColor (carried over from the old view), making it nearly invisible on the dialog. It now uses primaryTextColor, and the dialog uses dialogBackgroundColor like the unsaved-changes dialog. - The tags card used a different card/tile colour from every other card, showing square corners and a mismatched background. All cards now share the same style. - The review dialog printed raw values ("null", "2026-10-20 21:14:00.000"). Values are now formatted exactly like the cards via a shared formatAttributeValue helper. Adds regression tests for dialog button colours, card styling and the summary formatting. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../controllers/detail_route_controller.dart | 20 ++++++--- .../detailRoute/views/attribute_card.dart | 9 +--- .../detailRoute/views/attribute_widget.dart | 17 ++------ .../views/review_changes_dialog.dart | 3 +- .../detailRoute/views/tags_widget.dart | 5 --- .../taskfunctions/format_attribute_value.dart | 14 ++++++ .../detail_route_controller_test.dart | 13 ++++++ .../detail_route/detail_route_view_test.dart | 43 +++++++++++++++++++ 8 files changed, 90 insertions(+), 34 deletions(-) create mode 100644 lib/app/utils/taskfunctions/format_attribute_value.dart diff --git a/lib/app/modules/detailRoute/controllers/detail_route_controller.dart b/lib/app/modules/detailRoute/controllers/detail_route_controller.dart index a312f2397..5fcaf3e0b 100644 --- a/lib/app/modules/detailRoute/controllers/detail_route_controller.dart +++ b/lib/app/modules/detailRoute/controllers/detail_route_controller.dart @@ -13,6 +13,7 @@ import 'package:taskwarrior/app/utils/constants/taskwarrior_colors.dart'; import 'package:taskwarrior/app/utils/debug_logger/app_logger.dart'; import 'package:taskwarrior/app/utils/language/sentence_manager.dart'; import 'package:taskwarrior/app/utils/language/sentences.dart'; +import 'package:taskwarrior/app/utils/taskfunctions/format_attribute_value.dart'; import 'package:taskwarrior/app/utils/taskfunctions/modify.dart'; import 'package:taskwarrior/app/utils/taskfunctions/urgency.dart'; import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; @@ -139,12 +140,19 @@ class DetailRouteController extends GetxController { return '${sentences.detailPageID}: ${(id == null || id == 0) ? '-' : id}'; } - /// Human-readable old/new summary of every pending change. - String get changesSummary => modify.changes.entries - .map((entry) => '${entry.key}:\n' - ' ${sentences.oldChanges}: ${entry.value['old']}\n' - ' ${sentences.newChanges}: ${entry.value['new']}') - .join('\n'); + /// Human-readable old/new summary of every pending change, formatted the + /// same way as the attribute cards. + String get changesSummary { + String show(Object? value) => + formatAttributeValue(value, + use24HourFormat: AppSettings.use24HourFormatRx.value) ?? + sentences.notSelected; + return modify.changes.entries + .map((entry) => '${entry.key}:\n' + ' ${sentences.oldChanges}: ${show(entry.value['old'])}\n' + ' ${sentences.newChanges}: ${show(entry.value['new'])}') + .join('\n'); + } /// Current value of every attribute, in display order. Reads the reactive /// values, so calling this inside an [Obx] rebuilds on every edit. diff --git a/lib/app/modules/detailRoute/views/attribute_card.dart b/lib/app/modules/detailRoute/views/attribute_card.dart index 9690275a4..bde077790 100644 --- a/lib/app/modules/detailRoute/views/attribute_card.dart +++ b/lib/app/modules/detailRoute/views/attribute_card.dart @@ -15,8 +15,6 @@ class AttributeCard extends StatelessWidget { this.onTap, this.onLongPress, this.cardKey, - this.cardColor, - this.tileColor, super.key, }); @@ -31,10 +29,6 @@ class AttributeCard extends StatelessWidget { /// Key placed on the [Card], e.g. a tour anchor. final Key? cardKey; - /// Defaults to the theme's secondary background colour. - final Color? cardColor; - final Color? tileColor; - @override Widget build(BuildContext context) { TaskwarriorColorTheme tColors = @@ -45,10 +39,9 @@ class AttributeCard extends StatelessWidget { return Card( key: cardKey, - color: cardColor ?? tColors.secondaryBackgroundColor, + color: tColors.secondaryBackgroundColor, child: ListTile( enabled: isEditable, - tileColor: tileColor, textColor: textColor, title: SingleChildScrollView( scrollDirection: Axis.horizontal, diff --git a/lib/app/modules/detailRoute/views/attribute_widget.dart b/lib/app/modules/detailRoute/views/attribute_widget.dart index 3ddff73e9..c1d1700f4 100644 --- a/lib/app/modules/detailRoute/views/attribute_widget.dart +++ b/lib/app/modules/detailRoute/views/attribute_widget.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import 'package:taskwarrior/app/models/task_attribute.dart'; import 'package:taskwarrior/app/modules/detailRoute/controllers/detail_route_controller.dart'; import 'package:taskwarrior/app/modules/detailRoute/views/attribute_card.dart'; @@ -10,6 +9,7 @@ import 'package:taskwarrior/app/modules/detailRoute/views/project_widget.dart'; import 'package:taskwarrior/app/modules/detailRoute/views/start_widget.dart'; import 'package:taskwarrior/app/modules/detailRoute/views/status_widget.dart'; import 'package:taskwarrior/app/modules/detailRoute/views/tags_widget.dart'; +import 'package:taskwarrior/app/utils/taskfunctions/format_attribute_value.dart'; /// Picks the editor widget for a single task attribute. class AttributeWidget extends StatelessWidget { @@ -28,25 +28,14 @@ class AttributeWidget extends StatelessWidget { final bool isEditable; final bool use24HourFormat; - String? _formatValue() { - final value = this.value; - if (value == null) return null; - if (value is DateTime) { - return DateFormat(use24HourFormat - ? 'EEE, yyyy-MM-dd HH:mm:ss' - : 'EEE, yyyy-MM-dd hh:mm:ss a') - .format(value.toLocal()); - } - return value.toString(); - } - void _onChanged(dynamic newValue) => controller.setAttribute(attribute, newValue); @override Widget build(BuildContext context) { final String name = attribute.name; - final String? displayValue = _formatValue(); + final String? displayValue = + formatAttributeValue(value, use24HourFormat: use24HourFormat); switch (attribute) { case TaskAttribute.description: diff --git a/lib/app/modules/detailRoute/views/review_changes_dialog.dart b/lib/app/modules/detailRoute/views/review_changes_dialog.dart index aebe93b94..79a29607a 100644 --- a/lib/app/modules/detailRoute/views/review_changes_dialog.dart +++ b/lib/app/modules/detailRoute/views/review_changes_dialog.dart @@ -24,6 +24,7 @@ class ReviewChangesDialog extends StatelessWidget { Theme.of(context).extension()!; final sentences = controller.sentences; return AlertDialog( + backgroundColor: tColors.dialogBackgroundColor, scrollable: true, title: Text( '${sentences.reviewChanges}:', @@ -48,7 +49,7 @@ class ReviewChangesDialog extends StatelessWidget { ), DetailRouteDialogAction( label: sentences.submit, - color: tColors.primaryBackgroundColor, + color: tColors.primaryTextColor, onPressed: () => Navigator.of(context).pop(controller.saveChanges()), ), ], diff --git a/lib/app/modules/detailRoute/views/tags_widget.dart b/lib/app/modules/detailRoute/views/tags_widget.dart index 28de40eac..c49236870 100644 --- a/lib/app/modules/detailRoute/views/tags_widget.dart +++ b/lib/app/modules/detailRoute/views/tags_widget.dart @@ -3,7 +3,6 @@ import 'package:get/get.dart'; import 'package:taskwarrior/app/modules/detailRoute/controllers/detail_route_controller.dart'; import 'package:taskwarrior/app/modules/detailRoute/views/attribute_card.dart'; import 'package:taskwarrior/app/modules/detailRoute/views/tags_route.dart'; -import 'package:taskwarrior/app/utils/themes/theme_extension.dart'; /// Shows the task's tags and opens [TagsRoute] to edit them. class TagsWidget extends StatelessWidget { @@ -22,14 +21,10 @@ class TagsWidget extends StatelessWidget { @override Widget build(BuildContext context) { - TaskwarriorColorTheme tColors = - Theme.of(context).extension()!; return AttributeCard( name: name, value: value, isEditable: isEditable, - cardColor: tColors.primaryBackgroundColor, - tileColor: tColors.secondaryBackgroundColor, onTap: () => Get.to(() => TagsRoute(controller: controller)), ); } diff --git a/lib/app/utils/taskfunctions/format_attribute_value.dart b/lib/app/utils/taskfunctions/format_attribute_value.dart new file mode 100644 index 000000000..110c975f4 --- /dev/null +++ b/lib/app/utils/taskfunctions/format_attribute_value.dart @@ -0,0 +1,14 @@ +import 'package:intl/intl.dart'; + +/// Formats a task attribute value for display; `null` stays `null` so callers +/// can show their own "not selected" text. Dates are shown in local time. +String? formatAttributeValue(Object? value, {required bool use24HourFormat}) { + if (value == null) return null; + if (value is DateTime) { + return DateFormat(use24HourFormat + ? 'EEE, yyyy-MM-dd HH:mm:ss' + : 'EEE, yyyy-MM-dd hh:mm:ss a') + .format(value.toLocal()); + } + return value.toString(); +} diff --git a/test/modules/detail_route/detail_route_controller_test.dart b/test/modules/detail_route/detail_route_controller_test.dart index 9162b821f..bdd774c51 100644 --- a/test/modules/detail_route/detail_route_controller_test.dart +++ b/test/modules/detail_route/detail_route_controller_test.dart @@ -110,6 +110,19 @@ void main() { expect(controller.changesSummary, contains('description')); }); + test('changesSummary formats values like the cards', () { + final controller = createController(); + controller.setAttribute( + TaskAttribute.due, DateTime.utc(2026, 10, 20, 15, 44)); + final summary = controller.changesSummary; + final s = controller.sentences; + + expect(summary, contains('${s.oldChanges}: ${s.notSelected}')); + expect(summary, isNot(contains('null'))); + expect(summary, contains('2026-10-20')); + expect(summary, isNot(contains('.000'))); + }); + test('reverting an edit clears the pending changes', () { final controller = createController(); controller.setAttribute(TaskAttribute.description, 'Changed'); diff --git a/test/modules/detail_route/detail_route_view_test.dart b/test/modules/detail_route/detail_route_view_test.dart index ffa4d2332..4bbebb270 100644 --- a/test/modules/detail_route/detail_route_view_test.dart +++ b/test/modules/detail_route/detail_route_view_test.dart @@ -182,6 +182,49 @@ void main() { expect(find.text('work 3'), findsOneWidget); }); + testWidgets('dialog buttons use the readable text colour', (tester) async { + await openDetailPage(tester); + final tColors = theme().extension()!; + Color? colorOf(String label) => + tester.widget(find.text(label)).style?.color; + + controller.setAttribute(TaskAttribute.description, 'Changed'); + await tester.pump(); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + for (final label in ['Cancel', 'Submit']) { + expect(colorOf(label), tColors.primaryTextColor, reason: label); + } + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + for (final label in ['Yes', 'No', 'Cancel']) { + expect(colorOf(label), tColors.primaryTextColor, reason: label); + } + }); + + testWidgets('every attribute card is styled the same', (tester) async { + await openDetailPage(tester); + final tColors = theme().extension()!; + final listView = find.byType(ListView); + // Scroll through so the lazily built cards (tags is last) get checked. + for (var i = 0; i < 4; i++) { + for (final card in tester.widgetList( + find.descendant(of: listView, matching: find.byType(Card)))) { + expect(card.color, tColors.secondaryBackgroundColor); + } + for (final tile in tester.widgetList( + find.descendant(of: listView, matching: find.byType(ListTile)))) { + expect(tile.tileColor, isNull); + } + await tester.drag(listView, const Offset(0, -400)); + await tester.pumpAndSettle(); + } + }); + testWidgets('back with unsaved edits asks first; cancel stays', (tester) async { await openDetailPage(tester);