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..5fcaf3e0b 100644 --- a/lib/app/modules/detailRoute/controllers/detail_route_controller.dart +++ b/lib/app/modules/detailRoute/controllers/detail_route_controller.dart @@ -3,61 +3,198 @@ 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/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/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, 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. + 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 +204,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 +216,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 +342,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 +398,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 +416,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..bde077790 --- /dev/null +++ b/lib/app/modules/detailRoute/views/attribute_card.dart @@ -0,0 +1,81 @@ +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, + 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; + + @override + Widget build(BuildContext context) { + TaskwarriorColorTheme tColors = + Theme.of(context).extension()!; + final Color? textColor = isEditable + ? tColors.primaryTextColor + : tColors.primaryDisabledTextColor; + + return Card( + key: cardKey, + color: tColors.secondaryBackgroundColor, + child: ListTile( + enabled: isEditable, + 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..c1d1700f4 --- /dev/null +++ b/lib/app/modules/detailRoute/views/attribute_widget.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.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'; +import 'package:taskwarrior/app/utils/taskfunctions/format_attribute_value.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; + + void _onChanged(dynamic newValue) => + controller.setAttribute(attribute, newValue); + + @override + Widget build(BuildContext context) { + final String name = attribute.name; + final String? displayValue = + formatAttributeValue(value, use24HourFormat: use24HourFormat); + + 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..79a29607a --- /dev/null +++ b/lib/app/modules/detailRoute/views/review_changes_dialog.dart @@ -0,0 +1,58 @@ +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( + backgroundColor: tColors.dialogBackgroundColor, + 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.primaryTextColor, + 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..c49236870 100644 --- a/lib/app/modules/detailRoute/views/tags_widget.dart +++ b/lib/app/modules/detailRoute/views/tags_widget.dart @@ -1,313 +1,31 @@ -// 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/utils/themes/theme_extension.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'; +/// 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, + 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/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 new file mode 100644 index 000000000..bdd774c51 --- /dev/null +++ b/test/modules/detail_route/detail_route_controller_test.dart @@ -0,0 +1,206 @@ +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('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'); + 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..4bbebb270 --- /dev/null +++ b/test/modules/detail_route/detail_route_view_test.dart @@ -0,0 +1,250 @@ +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('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); + 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); + }); +}