diff --git a/lib/core/models/task.dart b/lib/core/models/task.dart index 55cff8c4..9a57dc42 100644 --- a/lib/core/models/task.dart +++ b/lib/core/models/task.dart @@ -116,6 +116,20 @@ class Task { return Task.fromIssue(issue, slug); }); + /// Deletes the issue from the remote repository. + Future deleteRemote() async { + if (_issueNumber == null) { + Logger.logWarning( + "Cannot delete issue $_issueNumber, it does not exist", + _classId, + ); + return; + } + Logger.logInfo("Deleting issue $_issueNumber", _classId); + await (await GithubModel.github).issues.deleteIssue(_slug, _issueNumber); + Logger.logInfo("Successfully deleted issue $_issueNumber", _classId); + } + /// Creates a copy of the current instance. Task copy() => Task( title: title, diff --git a/lib/core/task_handler.dart b/lib/core/task_handler.dart index e083821a..4fde4c31 100644 --- a/lib/core/task_handler.dart +++ b/lib/core/task_handler.dart @@ -98,8 +98,7 @@ class TaskHandler extends ChangeNotifier { return task; } - /// Updates an existing task in the list of tasks. Notifies listeners - /// about the change. + /// Updates an existing task in the list of tasks. Notifies listeners about the change. /// Does not save the task to the remote repository! void updateLocalTask(final Task task) { final int index = _tasks.indexWhere( @@ -116,6 +115,21 @@ class TaskHandler extends ChangeNotifier { } } + /// Deletes a task from the list of tasks. Notifies listeners about the change. + /// Also deletes the task from the remote repository. + /// If the task does not exist in the remote repository, it will be removed from the local list. + Future deleteTask(final Task task) async { + try { + await task.deleteRemote(); + _tasks.removeWhere((final t) => t.issueNumber == task.issueNumber); + notifyListeners(); + return true; + } on Exception catch (e) { + Logger.logError("Failed to delete task", _classId, e); + } + return false; + } + Future> _fetchIssuesForRepository( final RepositoryDetails repo, ) async => (await GithubModel.github).issues diff --git a/lib/core/utils/snack_bar.dart b/lib/core/utils/snack_bar.dart new file mode 100644 index 00000000..3018cf12 --- /dev/null +++ b/lib/core/utils/snack_bar.dart @@ -0,0 +1,14 @@ +import "package:flutter/material.dart"; + +/// Utility class for showing Snack bars safely. +class SnackBarUtils { + /// Shows a SnackBar with the given [message]. + static void show({ + required final BuildContext context, + required final String message, + }) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); + } +} diff --git a/lib/ui/_widgets/app_bar.dart b/lib/ui/_widgets/app_bar.dart index d93134b6..e86b27fe 100644 --- a/lib/ui/_widgets/app_bar.dart +++ b/lib/ui/_widgets/app_bar.dart @@ -7,10 +7,17 @@ import "package:gitdone/ui/_widgets/app_title.dart"; /// A normal app bar that is used in most of the views. class NormalAppBar extends StatelessWidget implements PreferredSizeWidget { /// Creates a normal app bar with an optional back button. - const NormalAppBar({super.key, this.backVisible = true}); + const NormalAppBar({ + super.key, + this.backVisible = true, + this.menuItems = const [], + }); /// Whether the back button should be visible. - final dynamic backVisible; + final bool backVisible; + + /// The actions to be displayed in the app bar om PopupMenuButton. + final List menuItems; @override Widget build(final BuildContext context) => AppBar( @@ -26,7 +33,25 @@ class NormalAppBar extends StatelessWidget implements PreferredSizeWidget { onPressed: Navigation.navigateBack, ) : const SizedBox(width: 48), - actions: const [SizedBox(width: 48)], + actions: [ + if (menuItems.isNotEmpty) _buildMenu() else const SizedBox(width: 48), + ], + ); + + Widget _buildMenu() => MenuAnchor( + builder: (final context, final controller, final child) => IconButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + icon: const Icon(Icons.more_vert), + tooltip: "Show menu", + ), + alignmentOffset: const Offset(0, -4), // First item exactly below app bar + menuChildren: menuItems, ); @override diff --git a/lib/ui/_widgets/confirm_dialog.dart b/lib/ui/_widgets/confirm_dialog.dart new file mode 100644 index 00000000..5e01f985 --- /dev/null +++ b/lib/ui/_widgets/confirm_dialog.dart @@ -0,0 +1,73 @@ +import "package:flutter/material.dart"; +import "package:gitdone/core/utils/navigation.dart"; + +/// A dialog that asks the user to confirm an action. +class ConfirmDialog extends StatelessWidget { + /// Creates a [ConfirmDialog] widget with the given parameters. + const ConfirmDialog({ + required final Widget title, + required final Widget content, + required final String confirmText, + required final VoidCallback onConfirm, + required final String cancelText, + final VoidCallback? onCancel, + super.key, + }) : _title = title, + _content = content, + _confirmText = confirmText, + _cancelText = cancelText, + _onConfirm = onConfirm, + _onCancel = onCancel; + + final Widget _title; + final Widget _content; + final String _confirmText; + final String _cancelText; + final VoidCallback _onConfirm; + final VoidCallback? _onCancel; + + @override + Widget build(final BuildContext context) => AlertDialog( + title: _title, + content: _content, + actions: [ + TextButton( + onPressed: () { + Navigation.navigateBack(); + _onConfirm(); + }, + child: Text(_confirmText), + ), + TextButton( + onPressed: () { + Navigation.navigateBack(); + if (_onCancel != null) _onCancel(); + }, + child: Text(_cancelText), + ), + ], + ); + + /// Shows the ConfirmDialog as a dialog. + static Future show( + final BuildContext context, { + required final Widget title, + required final Widget content, + required final String confirmText, + required final VoidCallback onConfirm, + final String cancelText = "Cancel", + final VoidCallback? onCancel, + final bool barrierDismissible = true, + }) => showDialog( + context: context, + barrierDismissible: barrierDismissible, + builder: (final context) => ConfirmDialog( + title: title, + content: content, + confirmText: confirmText, + cancelText: cancelText, + onConfirm: onConfirm, + onCancel: onCancel, + ), + ); +} diff --git a/lib/ui/settings/widgets/repository_selector/repository_selector_view.dart b/lib/ui/settings/widgets/repository_selector/repository_selector_view.dart index d160e2d2..ad9bd8b9 100644 --- a/lib/ui/settings/widgets/repository_selector/repository_selector_view.dart +++ b/lib/ui/settings/widgets/repository_selector/repository_selector_view.dart @@ -1,5 +1,6 @@ import "package:flutter/material.dart"; import "package:gitdone/core/models/repository_details.dart"; +import "package:gitdone/core/utils/snack_bar.dart"; import "package:gitdone/ui/settings/widgets/repository_selector/repository_selector_view_model.dart"; import "package:provider/provider.dart"; @@ -46,9 +47,9 @@ class _RepositorySelectorState extends State { model ..selectRepository(repo) ..saveSelectedRepository(); - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Selected repository: ${repo?.name}")), + SnackBarUtils.show( + context: context, + message: "Selected repository: ${repo?.name}", ); }, ), diff --git a/lib/ui/task_details/task_details_view.dart b/lib/ui/task_details/task_details_view.dart index 388e7af8..f739bfcb 100644 --- a/lib/ui/task_details/task_details_view.dart +++ b/lib/ui/task_details/task_details_view.dart @@ -2,12 +2,13 @@ import "package:flutter/foundation.dart"; import "package:flutter/material.dart"; import "package:gitdone/core/models/task.dart"; import "package:gitdone/core/task_handler.dart"; -import "package:gitdone/core/utils/logger.dart"; import "package:gitdone/core/utils/navigation.dart"; +import "package:gitdone/core/utils/snack_bar.dart"; import "package:gitdone/ui/_widgets/app_bar.dart"; +import "package:gitdone/ui/_widgets/confirm_dialog.dart"; import "package:gitdone/ui/_widgets/page_title.dart"; import "package:gitdone/ui/_widgets/task_labels.dart"; -import "package:gitdone/ui/task_edit/task_edit_view.dart"; +import "package:gitdone/ui/task_details/task_details_view_model.dart"; import "package:intl/intl.dart"; import "package:markdown_widget/markdown_widget.dart"; import "package:provider/provider.dart"; @@ -31,51 +32,30 @@ class TaskDetailsView extends StatefulWidget { } class _TaskDetailsViewState extends State { - static const _classId = - "com.GitDone.gitdone.ui.task_details.task_details_view_model"; - @override Widget build(final BuildContext context) => ChangeNotifierProvider( - create: (_) => _TaskDetailsViewViewModel(widget.task), - child: Scaffold( - appBar: const NormalAppBar(), - body: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _renderTitle(), - _renderLabels(), - const Padding(padding: EdgeInsets.all(8)), - _renderDescription(), - const Padding(padding: EdgeInsets.all(8)), - _renderStatus(), - const Padding(padding: EdgeInsets.all(8)), - ], - ), - ), - floatingActionButton: Consumer<_TaskDetailsViewViewModel>( - builder: (final context, final viewModel, _) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - FloatingActionButton.small( - heroTag: "markTask", - onPressed: viewModel.task.state == IssueState.open.value - ? viewModel._markTaskAsDone - : viewModel._markTaskAsOpen, - child: viewModel.task.state == IssueState.open.value - ? const Icon(Icons.done) - : const Icon(Icons.undo), - ), - FloatingActionButton( - heroTag: "editTask", - onPressed: _editTask, - child: const Icon(Icons.edit), - ), - ], + create: (_) => TaskDetailsViewModel(widget.task), + child: Consumer( + builder: (final context, final viewModel, _) => Scaffold( + appBar: NormalAppBar(menuItems: [_deleteTaskButton(viewModel)]), + body: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _renderTitle(), + _renderLabels(), + const Padding(padding: EdgeInsets.all(8)), + _renderDescription(), + const Padding(padding: EdgeInsets.all(8)), + _renderStatus(), + const Padding(padding: EdgeInsets.all(8)), + ], + ), ), + floatingActionButton: _renderFloatingActionButton(viewModel), + floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, ), - floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, ), ); @@ -118,49 +98,61 @@ class _TaskDetailsViewState extends State { ), ); - Future _editTask() async { - Logger.log("Edit task: ${widget.task.title}", _classId, LogLevel.detailed); - final Task? updated = await Navigation.navigate(TaskEditView(widget.task)); - if (updated == null) { - Logger.log("Task edit cancelled or failed", _classId, LogLevel.detailed); - return; - } - setState(() { - widget.task.replace(updated); - }); - Logger.log( - "Task updated: ${widget.task.title}", - _classId, - LogLevel.detailed, - ); - } + Widget _renderFloatingActionButton(final TaskDetailsViewModel viewModel) => + Column( + mainAxisSize: MainAxisSize.min, + children: [ + FloatingActionButton.small( + heroTag: "markTask", + onPressed: viewModel.task.state == IssueState.open.value + ? viewModel.markTaskAsDone + : viewModel.markTaskAsOpen, + child: viewModel.task.state == IssueState.open.value + ? const Icon(Icons.done) + : const Icon(Icons.undo), + ), + FloatingActionButton( + heroTag: "editTask", + onPressed: viewModel.editTask, + child: const Icon(Icons.edit), + ), + ], + ); + + MenuItemButton _deleteTaskButton(final TaskDetailsViewModel viewModel) => + MenuItemButton( + onPressed: () => ConfirmDialog.show( + context, + title: const Text("Delete Task"), + content: const SingleChildScrollView( + child: ListBody( + children: [ + Text("Are you sure you want to delete this task?"), + Text("This action cannot be undone."), + ], + ), + ), + confirmText: "Delete", + onConfirm: () async { + final bool success = await viewModel.deleteTask(); + if (!mounted) return; + if (success) { + SnackBarUtils.show( + context: context, + message: "Task deleted successfully.", + ); + Navigation.navigateBack(); + } else { + SnackBarUtils.show( + context: context, + message: "Failed to delete task.", + ); + } + }, + ), + child: const Text("Delete Task"), + ); String _formatDateTime(final DateTime dateTime) => DateFormat("yyyy-MM-dd HH:mm:ss").format(dateTime.toLocal()); } - -class _TaskDetailsViewViewModel extends ChangeNotifier { - _TaskDetailsViewViewModel(this._task) { - Logger.log( - "Initialized TaskDetailsViewModel for task: ${_task.title}", - _classId, - LogLevel.detailed, - ); - } - static const _classId = - "com.GitDone.gitdone.ui.task_details.task_details_view_view_model"; - - Task _task; - - Task get task => _task; - - Future _markTaskAsDone() async { - _task = await TaskHandler().updateIssueState(_task, IssueState.closed); - notifyListeners(); - } - - Future _markTaskAsOpen() async { - _task = await TaskHandler().updateIssueState(_task, IssueState.open); - notifyListeners(); - } -} diff --git a/lib/ui/task_details/task_details_view_model.dart b/lib/ui/task_details/task_details_view_model.dart new file mode 100644 index 00000000..2565003e --- /dev/null +++ b/lib/ui/task_details/task_details_view_model.dart @@ -0,0 +1,57 @@ +import "package:flutter/foundation.dart"; +import "package:gitdone/core/models/task.dart"; +import "package:gitdone/core/task_handler.dart"; +import "package:gitdone/core/utils/logger.dart"; +import "package:gitdone/core/utils/navigation.dart"; +import "package:gitdone/ui/task_edit/task_edit_view.dart"; + +/// A view model for the task details view. +class TaskDetailsViewModel extends ChangeNotifier { + /// Creates a [TaskDetailsViewModel] with the given task item. + TaskDetailsViewModel(this.task); + + /// The task item to be displayed in the view. + final Task task; + + static const _classId = + "com.GitDone.gitdone.ui.task_details.task_details_view_model"; + + /// Starts editing the task. + Future editTask() async { + Logger.log("Edit task: ${task.title}", _classId, LogLevel.detailed); + final Task? updated = await Navigation.navigate(TaskEditView(task)); + if (updated == null) { + Logger.log("Task edit cancelled or failed", _classId, LogLevel.detailed); + return; + } + task.replace(updated); + notifyListeners(); + Logger.log("Task updated: ${task.title}", _classId, LogLevel.detailed); + } + + /// Deletes the task. + Future deleteTask() { + Logger.log("Delete task: ${task.title}", _classId, LogLevel.detailed); + return TaskHandler().deleteTask(task); + } + + /// Marks the task as done. + Future markTaskAsDone() async { + final Task updated = await TaskHandler().updateIssueState( + task, + IssueState.closed, + ); + task.replace(updated); + notifyListeners(); + } + + /// Marks the task as open. + Future markTaskAsOpen() async { + final Task updated = await TaskHandler().updateIssueState( + task, + IssueState.open, + ); + task.replace(updated); + notifyListeners(); + } +} diff --git a/pubspec.lock b/pubspec.lock index e73ba0e8..a6e62d77 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -97,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -207,6 +215,70 @@ packages: relative: true source: path version: "0.0.1" + gql: + dependency: transitive + description: + name: gql + sha256: "650e79ed60c21579ca3bd17ebae8a8c8d22cde267b03a19bf3b35996baaa843a" + url: "https://pub.dev" + source: hosted + version: "1.0.1-alpha+1730759315362" + gql_dedupe_link: + dependency: transitive + description: + name: gql_dedupe_link + sha256: "10bee0564d67c24e0c8bd08bd56e0682b64a135e58afabbeed30d85d5e9fea96" + url: "https://pub.dev" + source: hosted + version: "2.0.4-alpha+1715521079596" + gql_error_link: + dependency: transitive + description: + name: gql_error_link + sha256: "93901458f3c050e33386dedb0ca7173e08cebd7078e4e0deca4bf23ab7a71f63" + url: "https://pub.dev" + source: hosted + version: "1.0.0+1" + gql_exec: + dependency: transitive + description: + name: gql_exec + sha256: "394944626fae900f1d34343ecf2d62e44eb984826189c8979d305f0ae5846e38" + url: "https://pub.dev" + source: hosted + version: "1.1.1-alpha+1699813812660" + gql_http_link: + dependency: transitive + description: + name: gql_http_link + sha256: ef6ad24d31beb5a30113e9b919eec20876903cc4b0ee0d31550047aaaba7d5dd + url: "https://pub.dev" + source: hosted + version: "1.1.0" + gql_link: + dependency: transitive + description: + name: gql_link + sha256: c2b0adb2f6a60c2599b9128fb095316db5feb99ce444c86fb141a6964acedfa4 + url: "https://pub.dev" + source: hosted + version: "1.0.1-alpha+1730759315378" + gql_transform_link: + dependency: transitive + description: + name: gql_transform_link + sha256: "0645fdd874ca1be695fd327271fdfb24c0cd6fa40774a64b946062f321a59709" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + graphql: + dependency: transitive + description: + name: graphql + sha256: "735bbbaa4db10d38054932e726d291bdd46e46e0575cd482a74b0615b8622e1c" + url: "https://pub.dev" + source: hosted + version: "5.2.1" highlight: dependency: transitive description: @@ -215,6 +287,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.0" + hive: + dependency: transitive + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" http: dependency: "direct main" description: @@ -259,26 +339,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.1" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -351,6 +431,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + normalize: + dependency: transitive + description: + name: normalize + sha256: f78bf0552b9640c76369253f0b8fdabad4f3fbfc06bdae9359e71bee9a5b071b + url: "https://pub.dev" + source: hosted + version: "0.9.1" package_info_plus: dependency: "direct main" description: @@ -463,6 +551,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" scroll_to_index: dependency: transitive description: @@ -540,6 +636,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" stack_trace: dependency: transitive description: @@ -576,10 +680,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.6" typed_data: dependency: transitive description: @@ -652,6 +756,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.4" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" vector_graphics: dependency: transitive description: @@ -680,10 +792,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" visibility_detector: dependency: transitive description: @@ -708,6 +820,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" win32: dependency: transitive description: @@ -740,6 +868,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.8.0 <4.0.0" flutter: ">=3.27.0"