From db7945ea77492edea8451f8814be80f907315477 Mon Sep 17 00:00:00 2001 From: Hank Grabowski Date: Tue, 18 Apr 2023 19:39:52 -0400 Subject: [PATCH] Add group management screen with group creation, renaming, and deleting --- lib/controls/standard_app_drawer.dart | 6 + lib/data/interfaces/groups_repo.intf.dart | 26 ++- lib/data/memory/memory_groups_repo.dart | 12 ++ lib/friendica_client/friendica_client.dart | 41 +++++ lib/models/group_data.dart | 18 +-- lib/routes.dart | 21 +++ lib/screens/group_create_screen.dart | 75 +++++++++ lib/screens/group_editor_screen.dart | 174 +++++++++++++++++++++ lib/screens/group_management_screen.dart | 58 +++++++ lib/services/connections_manager.dart | 35 +++++ pubspec.lock | 4 +- pubspec.yaml | 2 +- 12 files changed, 443 insertions(+), 29 deletions(-) create mode 100644 lib/screens/group_create_screen.dart create mode 100644 lib/screens/group_editor_screen.dart create mode 100644 lib/screens/group_management_screen.dart diff --git a/lib/controls/standard_app_drawer.dart b/lib/controls/standard_app_drawer.dart index 1d6a152..9d2d44b 100644 --- a/lib/controls/standard_app_drawer.dart +++ b/lib/controls/standard_app_drawer.dart @@ -73,6 +73,12 @@ class StandardAppDrawer extends StatelessWidget { 'Direct Messages', () => context.pushNamed(ScreenPaths.messages), ), + const Divider(), + buildMenuButton( + context, + 'Groups Management', + () => context.pushNamed(ScreenPaths.groupManagement), + ), buildMenuButton( context, 'Settings', diff --git a/lib/data/interfaces/groups_repo.intf.dart b/lib/data/interfaces/groups_repo.intf.dart index 55e66fa..cd09886 100644 --- a/lib/data/interfaces/groups_repo.intf.dart +++ b/lib/data/interfaces/groups_repo.intf.dart @@ -3,24 +3,18 @@ import 'package:result_monad/result_monad.dart'; import '../../models/exec_error.dart'; import '../../models/group_data.dart'; -class IGroupsRepo { - void addAllGroups(List groups) { - throw UnimplementedError(); - } +abstract class IGroupsRepo { + void addAllGroups(List groups); - void clearMyGroups() { - throw UnimplementedError(); - } + void clearMyGroups(); - List getMyGroups() { - throw UnimplementedError(); - } + void upsertGroup(GroupData group); - Result, ExecError> getGroupsForUser(String id) { - throw UnimplementedError(); - } + void deleteGroup(GroupData group); - bool updateConnectionGroupData(String id, List currentGroups) { - throw UnimplementedError(); - } + List getMyGroups(); + + Result, ExecError> getGroupsForUser(String id); + + bool updateConnectionGroupData(String id, List currentGroups); } diff --git a/lib/data/memory/memory_groups_repo.dart b/lib/data/memory/memory_groups_repo.dart index c44a0a0..a359703 100644 --- a/lib/data/memory/memory_groups_repo.dart +++ b/lib/data/memory/memory_groups_repo.dart @@ -40,4 +40,16 @@ class MemoryGroupsRepo implements IGroupsRepo { _groupsForConnection[id] = currentGroups; return true; } + + @override + void upsertGroup(GroupData group) { + _myGroups.remove(group); + _myGroups.add(group); + } + + @override + void deleteGroup(GroupData group) { + _groupsForConnection.remove(group.id); + _myGroups.remove(group); + } } diff --git a/lib/friendica_client/friendica_client.dart b/lib/friendica_client/friendica_client.dart index bfe0607..b724913 100644 --- a/lib/friendica_client/friendica_client.dart +++ b/lib/friendica_client/friendica_client.dart @@ -167,6 +167,47 @@ class GroupsClient extends FriendicaClient { : ExecError(type: ErrorType.localError, message: error.toString())); } + FutureResult createGroup(String title) async { + _logger.finest(() => 'Creating group (Mastodon List) of name $title'); + final url = 'https://$serverName/api/v1/lists'; + final body = { + 'title': title, + }; + final result = await postUrl( + Uri.parse(url), + body, + headers: _headers, + ).andThenSuccessAsync( + (data) async => GroupDataMastodonExtensions.fromJson(jsonDecode(data))); + return result.execErrorCast(); + } + + FutureResult renameGroup( + String id, String title) async { + _logger.finest(() => 'Reanming group (Mastodon List) to name $title'); + final url = 'https://$serverName/api/v1/lists/$id'; + final body = { + 'title': title, + }; + final result = await putUrl( + Uri.parse(url), + body, + headers: _headers, + ).andThenSuccessAsync((data) async { + final json = jsonDecode(data); + return GroupDataMastodonExtensions.fromJson(json); + }); + return result.execErrorCast(); + } + + FutureResult deleteGroup(GroupData groupData) async { + _logger.finest( + () => 'Reanming group (Mastodon List) to name ${groupData.name}'); + final url = 'https://$serverName/api/v1/lists/${groupData.id}'; + final result = await deleteUrl(Uri.parse(url), {}, headers: _headers); + return result.mapValue((_) => true).execErrorCast(); + } + FutureResult, ExecError> getMemberGroupsForConnection( String connectionId) async { _logger.finest(() => diff --git a/lib/models/group_data.dart b/lib/models/group_data.dart index 6c2c6a7..bd2c411 100644 --- a/lib/models/group_data.dart +++ b/lib/models/group_data.dart @@ -3,16 +3,6 @@ class GroupData { final String id; - @override - bool operator ==(Object other) => - identical(this, other) || - other is GroupData && - runtimeType == other.runtimeType && - id == other.id && - name == other.name; - - @override - int get hashCode => id.hashCode ^ name.hashCode; final String name; GroupData(this.id, this.name); @@ -21,4 +11,12 @@ class GroupData { String toString() { return 'GroupData{id: $id, name: $name}'; } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GroupData && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; } diff --git a/lib/routes.dart b/lib/routes.dart index 6c0b115..f982e11 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -7,6 +7,9 @@ import 'screens/editor.dart'; import 'screens/follow_request_adjudication_screen.dart'; import 'screens/gallery_browsers_screen.dart'; import 'screens/gallery_screen.dart'; +import 'screens/group_create_screen.dart'; +import 'screens/group_editor_screen.dart'; +import 'screens/group_management_screen.dart'; import 'screens/home.dart'; import 'screens/interactions_viewer_screen.dart'; import 'screens/message_thread_screen.dart'; @@ -34,6 +37,7 @@ class ScreenPaths { static String notifications = '/notifications'; static String signin = '/signin'; static String manageProfiles = '/switchProfiles'; + static String groupManagement = '/group_management'; static String signup = '/signup'; static String userProfile = '/user_profile'; static String userPosts = '/user_posts'; @@ -120,6 +124,23 @@ final appRouter = GoRouter( builder: (context, state) => MessageThreadScreen(parentThreadId: state.queryParams['uri']!), ), + GoRoute( + name: ScreenPaths.groupManagement, + path: ScreenPaths.groupManagement, + builder: (context, state) => const GroupManagementScreen(), + routes: [ + GoRoute( + path: 'show/:id', + builder: (context, state) => GroupEditorScreen( + groupId: state.params['id']!, + ), + ), + GoRoute( + path: 'new', + builder: (context, state) => GroupCreateScreen(), + ), + ], + ), GoRoute( path: ScreenPaths.settings, name: ScreenPaths.settings, diff --git a/lib/screens/group_create_screen.dart b/lib/screens/group_create_screen.dart new file mode 100644 index 0000000..c5c522c --- /dev/null +++ b/lib/screens/group_create_screen.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +import '../controls/padding.dart'; +import '../controls/standard_appbar.dart'; +import '../services/connections_manager.dart'; +import '../utils/active_profile_selector.dart'; +import '../utils/snackbar_builder.dart'; + +class GroupCreateScreen extends StatefulWidget { + GroupCreateScreen({super.key}); + + @override + State createState() => _GroupCreateScreenState(); +} + +class _GroupCreateScreenState extends State { + final groupTextController = TextEditingController(); + + Future createGroup(ConnectionsManager manager) async { + if (groupTextController.text.isEmpty) { + buildSnackbar(context, "Group name can't be empty"); + return; + } + + final result = await manager.createGroup(groupTextController.text); + if (context.mounted) { + result.match( + onSuccess: (_) => context.canPop() ? context.pop() : null, + onError: (error) => + buildSnackbar(context, 'Error trying to create new group: $error'), + ); + } + } + + @override + Widget build(BuildContext context) { + final manager = context + .watch>() + .activeEntry + .value; + + return Scaffold( + appBar: StandardAppBar.build( + context, + 'New group', + withHome: false, + ), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + TextFormField( + controller: groupTextController, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + labelText: 'Group Name', + border: OutlineInputBorder( + borderSide: const BorderSide(), + borderRadius: BorderRadius.circular(5.0), + ), + ), + ), + const VerticalPadding(), + ElevatedButton( + onPressed: () => createGroup(manager), + child: const Text('Create'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/group_editor_screen.dart b/lib/screens/group_editor_screen.dart new file mode 100644 index 0000000..f8dd128 --- /dev/null +++ b/lib/screens/group_editor_screen.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import 'package:result_monad/result_monad.dart'; + +import '../controls/padding.dart'; +import '../controls/responsive_max_width.dart'; +import '../controls/standard_appbar.dart'; +import '../globals.dart'; +import '../models/group_data.dart'; +import '../services/connections_manager.dart'; +import '../utils/active_profile_selector.dart'; +import '../utils/snackbar_builder.dart'; + +class GroupEditorScreen extends StatefulWidget { + final String groupId; + + GroupEditorScreen({super.key, required this.groupId}); + + @override + State createState() => _GroupEditorScreenState(); +} + +class _GroupEditorScreenState extends State { + final groupTextController = TextEditingController(); + var processingUpdate = false; + var allowNameEditing = false; + late GroupData groupData; + + Future updateGroupName( + BuildContext context, ConnectionsManager manager) async { + processingUpdate = true; + final updated = groupTextController.text; + if (groupTextController.text != groupData.name) { + final confirm = await showYesNoDialog( + context, 'Change the group name from ${groupData.name} to $updated?'); + if (context.mounted && confirm == true) { + await manager.renameGroup(widget.groupId, updated).match( + onSuccess: (updatedGroupData) { + groupData = updatedGroupData; + setState(() { + allowNameEditing = false; + }); + }, onError: (error) { + if (mounted) { + buildSnackbar(context, 'Error renaming group: $error'); + } + }); + } else { + groupTextController.text = groupData.name; + } + } + processingUpdate = false; + } + + Future deleteGroup(ConnectionsManager manager) async { + final confirm = await showYesNoDialog(context, + "Permanently delete group ${groupData.name}? This can't be undone."); + if (context.mounted && confirm == true) { + await manager.deleteGroup(groupData).match( + onSuccess: (_) => context.canPop() ? context.pop() : null, + onError: (error) => + buildSnackbar(context, 'Error trying to delete group: $error'), + ); + } + } + + @override + void initState() { + super.initState(); + final manager = + getIt>().activeEntry.value; + groupData = manager + .getMyGroups() + .where( + (g) => g.id == widget.groupId, + ) + .first; + groupTextController.text = groupData.name; + } + + @override + Widget build(BuildContext context) { + final manager = context + .watch>() + .activeEntry + .value; + + return Scaffold( + appBar: StandardAppBar.build( + context, + 'Group Editor', + withHome: false, + actions: [ + IconButton( + onPressed: () => deleteGroup(manager), + icon: const Icon(Icons.delete), + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: RefreshIndicator( + onRefresh: () async { + manager.refreshGroups(); + }, + child: ResponsiveMaxWidth( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + Expanded( + child: TextFormField( + enabled: allowNameEditing, + readOnly: !allowNameEditing, + onEditingComplete: () async { + if (processingUpdate) { + return; + } + updateGroupName(context, manager); + }, + onTapOutside: (_) async { + if (processingUpdate) { + return; + } + updateGroupName(context, manager); + }, + controller: groupTextController, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + labelText: 'Group Name', + border: OutlineInputBorder( + borderSide: const BorderSide(), + borderRadius: BorderRadius.circular(5.0), + ), + ), + ), + ), + const HorizontalPadding(), + IconButton( + onPressed: () { + if (allowNameEditing) { + groupTextController.text = groupData.name; + } + setState(() { + allowNameEditing = !allowNameEditing; + }); + }, + icon: const Icon(Icons.edit), + ), + ], + ), + ), + Expanded( + child: ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + itemBuilder: (context, index) { + return ListTile( + title: Text("User"), + ); + }, + separatorBuilder: (_, __) => const Divider(), + itemCount: 1, + ), + ), + ], + ), + ), + ), + )); + } +} diff --git a/lib/screens/group_management_screen.dart b/lib/screens/group_management_screen.dart new file mode 100644 index 0000000..71fd9a0 --- /dev/null +++ b/lib/screens/group_management_screen.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +import '../controls/responsive_max_width.dart'; +import '../controls/standard_appbar.dart'; +import '../routes.dart'; +import '../services/connections_manager.dart'; +import '../utils/active_profile_selector.dart'; + +class GroupManagementScreen extends StatelessWidget { + const GroupManagementScreen({super.key}); + + @override + Widget build(BuildContext context) { + final manager = context + .watch>() + .activeEntry + .value; + final groups = manager.getMyGroups(); + return Scaffold( + appBar: StandardAppBar.build( + context, + 'Groups Management', + withHome: false, + actions: [ + IconButton( + onPressed: () => context.push( + '${ScreenPaths.groupManagement}/new', + ), + icon: const Icon(Icons.add), + ), + ], + ), + body: Center( + child: RefreshIndicator( + onRefresh: () async { + manager.refreshGroups(); + }, + child: ResponsiveMaxWidth( + child: ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + itemBuilder: (context, index) { + final group = groups[index]; + return ListTile( + title: Text(group.name), + onTap: () => context.push( + '${ScreenPaths.groupManagement}/show/${group.id}'), + ); + }, + separatorBuilder: (_, __) => const Divider(), + itemCount: groups.length, + ), + ), + ), + )); + } +} diff --git a/lib/services/connections_manager.dart b/lib/services/connections_manager.dart index 737eff2..5afc74f 100644 --- a/lib/services/connections_manager.dart +++ b/lib/services/connections_manager.dart @@ -194,6 +194,41 @@ class ConnectionsManager extends ChangeNotifier { return myGroups; } + FutureResult createGroup(String newName) async { + final result = await GroupsClient(getIt().currentProfile) + .createGroup(newName) + .withResultAsync((newGroup) async { + groupsRepo.upsertGroup(newGroup); + notifyListeners(); + }); + return result.execErrorCast(); + } + + FutureResult renameGroup( + String id, String newName) async { + final result = await GroupsClient(getIt().currentProfile) + .renameGroup(id, newName) + .withResultAsync((renamedGroup) async { + groupsRepo.upsertGroup(renamedGroup); + notifyListeners(); + }); + return result.execErrorCast(); + } + + FutureResult deleteGroup(GroupData groupData) async { + final result = await GroupsClient(getIt().currentProfile) + .deleteGroup(groupData) + .withResultAsync((_) async { + groupsRepo.deleteGroup(groupData); + notifyListeners(); + }); + return result.execErrorCast(); + } + + void refreshGroups() { + _updateMyGroups(true); + } + Result, ExecError> getGroupsForUser(String id) { final result = groupsRepo.getGroupsForUser(id); if (result.isSuccess) { diff --git a/pubspec.lock b/pubspec.lock index 11979e7..669ba60 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -948,10 +948,10 @@ packages: dependency: "direct main" description: name: result_monad - sha256: "8f7720b9d517dbb54d612e2f6c6c4f409d51374f0d9ff9749dfcb0e0c6ab2fd4" + sha256: "59e65e969f93c8aff18104f36233b0fd102a096d6501d3515e2a80cd67f3565a" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.0" rxdart: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c89d0c0..98dcc01 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,7 +38,7 @@ dependencies: network_to_file_image: ^4.0.1 path: ^1.8.2 provider: ^6.0.4 - result_monad: ^2.0.2 + result_monad: ^2.1.0 scrollable_positioned_list: ^0.3.5 shared_preferences: ^2.0.15 sqlite3: ^1.9.1