From b2d3b32ba80aae3bfd3d9831cd243bed3a4de837 Mon Sep 17 00:00:00 2001 From: krille-chan Date: Sat, 28 Oct 2023 20:40:14 +0200 Subject: [PATCH] feat: Add experimental todo list for rooms --- assets/l10n/intl_en.arb | 8 +- lib/config/routes.dart | 12 + lib/pages/tasks/model/matrix_todo_list.dart | 59 +++++ lib/pages/tasks/tasks.dart | 234 +++++++++++++++++++ lib/pages/tasks/tasks_view.dart | 199 ++++++++++++++++ lib/utils/localized_exception_extension.dart | 4 + lib/widgets/chat_settings_popup_menu.dart | 13 ++ 7 files changed, 528 insertions(+), 1 deletion(-) create mode 100644 lib/pages/tasks/model/matrix_todo_list.dart create mode 100644 lib/pages/tasks/tasks.dart create mode 100644 lib/pages/tasks/tasks_view.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 7b30bc1c..60ffc39b 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2543,5 +2543,11 @@ "kickUserDescription": "The user is kicked out of the chat but not banned. In public chats, the user can rejoin at any time.", "makeAdminDescription": "Once you make this user admin, you may not be able to undo this as they will then have the same permissions as you.", "pushNotificationsNotAvailable": "Push notifications not available", - "learnMore": "Learn more" + "learnMore": "Learn more", + "todoLists": "(Beta) Todolists", + "newTodo": "New todo", + "noTodosYet": "No todos have been added to this chat yet. Create your first todo and start cooperating with others. 📝", + "editTodo": "Edit todo", + "pleaseAddATitle": "Please add a title", + "todoListChangedError": "Oops... The todo list has been changed while you edited it." } diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 2386fb3c..9d6b837e 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -31,6 +31,7 @@ import 'package:fluffychat/pages/settings_security/settings_security.dart'; import 'package:fluffychat/pages/settings_stories/settings_stories.dart'; import 'package:fluffychat/pages/settings_style/settings_style.dart'; import 'package:fluffychat/pages/story/story_page.dart'; +import 'package:fluffychat/pages/tasks/tasks.dart'; import 'package:fluffychat/widgets/layouts/empty_page.dart'; import 'package:fluffychat/widgets/layouts/two_column_layout.dart'; import 'package:fluffychat/widgets/log_view.dart'; @@ -391,6 +392,17 @@ abstract class AppRoutes { ], redirect: loggedOutRedirect, ), + GoRoute( + path: 'tasks', + pageBuilder: (context, state) => defaultPageBuilder( + context, + TasksPage( + room: Matrix.of(context) + .client + .getRoomById(state.pathParameters['roomid']!)!, + ), + ), + ), ], ), ], diff --git a/lib/pages/tasks/model/matrix_todo_list.dart b/lib/pages/tasks/model/matrix_todo_list.dart new file mode 100644 index 00000000..fdcb8897 --- /dev/null +++ b/lib/pages/tasks/model/matrix_todo_list.dart @@ -0,0 +1,59 @@ +import 'package:matrix/matrix.dart'; + +extension MatrixTodoExtension on Room { + static const String stateKey = 'im.fluffychat.matrix_todos'; + static const String contentKey = 'todos'; + + List? get matrixTodos => getState(stateKey) + ?.content + .tryGetList(contentKey) + ?.map((json) => MatrixTodo.fromJson(json)) + .toList(); + + Future updateMatrixTodos(List matrixTodos) => + client.setRoomStateWithKey( + id, + stateKey, + '', + {contentKey: matrixTodos.map((todo) => todo.toJson()).toList()}, + ); +} + +class MatrixTodo { + String title; + String? description; + DateTime? dueDate; + bool done; + List? subTasks; + + MatrixTodo({ + required this.title, + this.description, + this.dueDate, + this.done = false, + this.subTasks, + }); + + factory MatrixTodo.fromJson(Map json) => MatrixTodo( + title: json['title'] as String, + description: json['description'] as String?, + dueDate: json['due_date'] == null + ? null + : DateTime.fromMillisecondsSinceEpoch(json['due_date'] as int), + done: json['done'] as bool, + subTasks: json['sub_tasks'] == null + ? null + : (json['sub_tasks'] as List) + .map((json) => MatrixTodo.fromJson(json)) + .toList(), + ); + + Map toJson() => { + 'title': title, + if (description != null) 'description': description, + if (dueDate != null) 'due_date': dueDate?.millisecondsSinceEpoch, + 'done': done, + if (subTasks != null) + 'sub_tasks': subTasks?.map((t) => t.toJson()).toList(), + }; +} diff --git a/lib/pages/tasks/tasks.dart b/lib/pages/tasks/tasks.dart new file mode 100644 index 00000000..6874a950 --- /dev/null +++ b/lib/pages/tasks/tasks.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; + +import 'package:adaptive_dialog/adaptive_dialog.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pages/tasks/tasks_view.dart'; +import 'package:fluffychat/utils/localized_exception_extension.dart'; +import 'model/matrix_todo_list.dart'; + +class TasksPage extends StatefulWidget { + final Room room; + const TasksPage({required this.room, super.key}); + + @override + State createState() => TasksController(); +} + +class TasksController extends State { + bool isLoading = false; + DateTime? newTaskDateTime; + String? newTaskDescription; + + final FocusNode focusNode = FocusNode(); + final TextEditingController textEditingController = TextEditingController(); + + List? _tmpTodos; + + List get todos => _tmpTodos ?? widget.room.matrixTodos ?? []; + + Stream get onUpdate => widget.room.client.onSync.stream.where( + (syncUpdate) => + syncUpdate.rooms?.join?[widget.room.id]?.state + ?.any((event) => event.type == MatrixTodoExtension.stateKey) ?? + false, + ); + + void setNewTaskDateTime() async { + final now = DateTime.now(); + final date = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: now.subtract(const Duration(days: 365 * 100)), + lastDate: now.add(const Duration(days: 365 * 100)), + ); + if (date == null) return; + setState(() { + newTaskDateTime = date; + }); + } + + void setNewTaskDescription() async { + final text = await showTextInputDialog( + context: context, + title: L10n.of(context)!.addDescription, + textFields: [ + DialogTextField( + hintText: L10n.of(context)!.addDescription, + maxLength: 512, + minLines: 4, + maxLines: 8, + ), + ], + ); + if (text == null || text.single.isEmpty) return; + setState(() { + newTaskDescription = text.single; + }); + } + + void addTodo([_]) { + if (textEditingController.text.isEmpty) return; + updateTodos( + update: (todos) => [ + ...todos, + MatrixTodo( + title: textEditingController.text, + dueDate: newTaskDateTime, + description: newTaskDescription, + ), + ], + onSuccess: () { + newTaskDateTime = null; + newTaskDescription = null; + textEditingController.clear(); + focusNode.requestFocus(); + }, + ); + } + + void toggleDone(int i) => updateTodos( + update: (todos) { + todos[i].done = !todos[i].done; + return todos; + }, + ); + + void cleanUp() => updateTodos( + update: (todos) => todos..removeWhere((t) => t.done), + ); + + void onReorder(int oldindex, int newindex) => updateTodos( + update: (todos) { + if (newindex > oldindex) { + newindex -= 1; + } + final todo = todos.removeAt(oldindex); + todos.insert(newindex, todo); + + return todos; + }, + tmpTodo: true, + ); + + void updateTodos({ + required List Function(List) update, + void Function()? onSuccess, + bool tmpTodo = false, + }) async { + setState(() { + isLoading = true; + }); + try { + final newTodos = update(todos); + if (tmpTodo) { + setState(() { + _tmpTodos = newTodos; + }); + onUpdate.first.then((_) { + _tmpTodos = null; + }); + } + await widget.room.updateMatrixTodos(newTodos); + onSuccess?.call(); + } on MatrixException catch (e) { + if (e.error != MatrixError.M_LIMIT_EXCEEDED) rethrow; + Logs().w('Rate limit! Try again in ${e.raw['retry_after_ms']}ms'); + await Future.delayed( + Duration(milliseconds: e.raw['retry_after_ms'] as int), + ); + updateTodos(update: update, onSuccess: onSuccess); + } catch (e, s) { + Logs().w('Unable to toggle done', e, s); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(seconds: 20), + content: Row( + children: [ + Icon( + Icons.signal_wifi_connected_no_internet_4_outlined, + color: Theme.of(context).colorScheme.background, + ), + const SizedBox(width: 16), + Text(e.toLocalizedString(context)), + ], + ), + action: e is TodoListChangedException + ? null + : SnackBarAction( + label: 'Try again', + onPressed: () { + updateTodos(update: update, onSuccess: onSuccess); + }, + ), + ), + ); + } finally { + setState(() { + isLoading = false; + }); + } + } + + void editTodo(int i, MatrixTodo todo) async { + final texts = await showTextInputDialog( + context: context, + title: L10n.of(context)!.editTodo, + textFields: [ + DialogTextField( + hintText: L10n.of(context)!.newTodo, + initialText: todo.title, + maxLength: 64, + validator: (text) { + if (text == null) return L10n.of(context)!.pleaseAddATitle; + return null; + }, + ), + DialogTextField( + hintText: L10n.of(context)!.addDescription, + maxLength: 512, + minLines: 4, + maxLines: 8, + initialText: todo.description, + ), + ], + ); + if (texts == null) return; + updateTodos( + update: (todos) { + if (todos[i].toJson().toString() != todo.toJson().toString()) { + throw TodoListChangedException(); + } + todos[i].title = texts[0]; + todos[i].description = texts[1].isEmpty ? null : texts[1]; + return todos; + }, + ); + } + + void editTodoDueDate(int i, MatrixTodo todo) async { + final now = DateTime.now(); + final date = await showDatePicker( + context: context, + initialDate: todo.dueDate ?? DateTime.now(), + firstDate: now.subtract(const Duration(days: 365 * 100)), + lastDate: now.add(const Duration(days: 365 * 100)), + ); + if (date == null) return; + updateTodos( + update: (todos) { + if (todos[i].toJson().toString() != todo.toJson().toString()) { + throw TodoListChangedException(); + } + todos[i].dueDate = date; + return todos; + }, + ); + } + + @override + Widget build(BuildContext context) => TasksView(this); +} + +class TodoListChangedException implements Exception {} diff --git a/lib/pages/tasks/tasks_view.dart b/lib/pages/tasks/tasks_view.dart new file mode 100644 index 00000000..d60ac291 --- /dev/null +++ b/lib/pages/tasks/tasks_view.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:intl/intl.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pages/tasks/tasks.dart'; + +class TasksView extends StatelessWidget { + final TasksController controller; + const TasksView(this.controller, {super.key}); + + @override + Widget build(BuildContext context) { + final tag = Localizations.maybeLocaleOf(context)?.toLanguageTag(); + return Scaffold( + appBar: AppBar( + title: Text(L10n.of(context)!.todoLists), + actions: [ + AnimatedCrossFade( + duration: FluffyThemes.animationDuration, + firstChild: const SizedBox( + width: 32, + height: 32, + ), + secondChild: const Padding( + padding: EdgeInsets.all(8.0), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ), + ), + crossFadeState: controller.isLoading + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + ), + IconButton( + icon: const Icon(Icons.cleaning_services_outlined), + onPressed: controller.cleanUp, + ), + ], + ), + body: Column( + children: [ + Expanded( + child: Opacity( + opacity: controller.isLoading ? 0.66 : 1, + child: StreamBuilder( + stream: controller.widget.room.onUpdate.stream, + builder: (context, snapshot) { + final list = controller.todos; + if (list.isEmpty) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.task_alt, + size: 80, + color: Theme.of(context).colorScheme.secondary, + ), + const SizedBox(height: 16), + SizedBox( + width: 256, + child: Text( + L10n.of(context)!.noTodosYet, + textAlign: TextAlign.center, + ), + ), + ], + ); + } + return ReorderableListView.builder( + onReorder: controller.onReorder, + itemCount: list.length, + itemBuilder: (context, i) { + final todo = list[i]; + final description = todo.description; + final dueDate = todo.dueDate; + return ListTile( + key: Key(todo.toJson().toString()), + leading: Icon( + todo.done + ? Icons.check_circle + : Icons.circle_outlined, + ), + title: Text( + todo.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + decoration: + todo.done ? TextDecoration.lineThrough : null, + ), + ), + subtitle: description == null && dueDate == null + ? null + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (description != null) + Text( + description, + maxLines: 2, + ), + if (dueDate != null) + SizedBox( + height: 24, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 6, + ), + ), + icon: const Icon( + Icons.calendar_month, + size: 16, + ), + label: Text( + DateFormat.yMMMd(tag).format(dueDate), + style: const TextStyle(fontSize: 12), + ), + onPressed: () => + controller.editTodoDueDate( + i, + todo, + ), + ), + ), + ], + ), + onTap: controller.isLoading + ? null + : () => controller.toggleDone(i), + trailing: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: IconButton( + icon: const Icon(Icons.edit_outlined, size: 16), + onPressed: () => controller.editTodo(i, todo), + ), + ), + ); + }, + ); + }, + ), + ), + ), + Padding( + padding: const EdgeInsets.all(12.0), + child: TextField( + focusNode: controller.focusNode, + readOnly: controller.isLoading, + controller: controller.textEditingController, + onSubmitted: controller.addTodo, + maxLength: 64, + decoration: InputDecoration( + counterStyle: const TextStyle(height: double.minPositive), + counterText: '', + hintText: L10n.of(context)!.newTodo, + prefixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon( + controller.newTaskDateTime == null + ? Icons.calendar_month_outlined + : Icons.calendar_month, + color: controller.newTaskDateTime == null + ? null + : Theme.of(context).primaryColor, + ), + onPressed: controller.setNewTaskDateTime, + ), + IconButton( + icon: Icon( + Icons.text_fields, + color: controller.newTaskDescription == null + ? null + : Theme.of(context).primaryColor, + ), + onPressed: controller.setNewTaskDescription, + ), + ], + ), + suffixIcon: IconButton( + icon: const Icon(Icons.add_outlined), + onPressed: controller.isLoading ? null : controller.addTodo, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/utils/localized_exception_extension.dart b/lib/utils/localized_exception_extension.dart index b3d6093c..d665c7f1 100644 --- a/lib/utils/localized_exception_extension.dart +++ b/lib/utils/localized_exception_extension.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; +import 'package:fluffychat/pages/tasks/tasks.dart'; import 'uia_request_manager.dart'; extension LocalizedExceptionExtension on Object { @@ -19,6 +20,9 @@ extension LocalizedExceptionExtension on Object { return (this as MatrixException).errorMessage; } } + if (this is TodoListChangedException) { + return L10n.of(context)!.todoListChangedError; + } if (this is FileTooBigMatrixException) { return L10n.of(context)!.fileIsTooBigForServer; } diff --git a/lib/widgets/chat_settings_popup_menu.dart b/lib/widgets/chat_settings_popup_menu.dart index 54a2fbf0..0ccc55b6 100644 --- a/lib/widgets/chat_settings_popup_menu.dart +++ b/lib/widgets/chat_settings_popup_menu.dart @@ -63,6 +63,16 @@ class ChatSettingsPopupMenuState extends State { ], ), ), + PopupMenuItem( + value: 'todos', + child: Row( + children: [ + const Icon(Icons.task_alt_outlined), + const SizedBox(width: 12), + Text(L10n.of(context)!.todoLists), + ], + ), + ), PopupMenuItem( value: 'leave', child: Row( @@ -137,6 +147,9 @@ class ChatSettingsPopupMenuState extends State { widget.room.setPushRuleState(PushRuleState.notify), ); break; + case 'todos': + context.go('/rooms/${widget.room.id}/tasks'); + break; case 'details': _showChatDetails(); break;