Add group management screen with group creation, renaming, and deleting

codemagic-setup
Hank Grabowski 2023-04-18 19:39:52 -04:00
rodzic 9e93faad2e
commit db7945ea77
12 zmienionych plików z 443 dodań i 29 usunięć

Wyświetl plik

@ -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',

Wyświetl plik

@ -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<GroupData> groups) {
throw UnimplementedError();
}
abstract class IGroupsRepo {
void addAllGroups(List<GroupData> groups);
void clearMyGroups() {
throw UnimplementedError();
}
void clearMyGroups();
List<GroupData> getMyGroups() {
throw UnimplementedError();
}
void upsertGroup(GroupData group);
Result<List<GroupData>, ExecError> getGroupsForUser(String id) {
throw UnimplementedError();
}
void deleteGroup(GroupData group);
bool updateConnectionGroupData(String id, List<GroupData> currentGroups) {
throw UnimplementedError();
}
List<GroupData> getMyGroups();
Result<List<GroupData>, ExecError> getGroupsForUser(String id);
bool updateConnectionGroupData(String id, List<GroupData> currentGroups);
}

Wyświetl plik

@ -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);
}
}

Wyświetl plik

@ -167,6 +167,47 @@ class GroupsClient extends FriendicaClient {
: ExecError(type: ErrorType.localError, message: error.toString()));
}
FutureResult<GroupData, ExecError> 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<GroupData, ExecError> 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<bool, ExecError> 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<List<GroupData>, ExecError> getMemberGroupsForConnection(
String connectionId) async {
_logger.finest(() =>

Wyświetl plik

@ -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;
}

Wyświetl plik

@ -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,

Wyświetl plik

@ -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<GroupCreateScreen> createState() => _GroupCreateScreenState();
}
class _GroupCreateScreenState extends State<GroupCreateScreen> {
final groupTextController = TextEditingController();
Future<void> 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<ActiveProfileSelector<ConnectionsManager>>()
.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'),
),
],
),
),
);
}
}

Wyświetl plik

@ -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<GroupEditorScreen> createState() => _GroupEditorScreenState();
}
class _GroupEditorScreenState extends State<GroupEditorScreen> {
final groupTextController = TextEditingController();
var processingUpdate = false;
var allowNameEditing = false;
late GroupData groupData;
Future<void> 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<void> 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<ActiveProfileSelector<ConnectionsManager>>().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<ActiveProfileSelector<ConnectionsManager>>()
.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,
),
),
],
),
),
),
));
}
}

Wyświetl plik

@ -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<ActiveProfileSelector<ConnectionsManager>>()
.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,
),
),
),
));
}
}

Wyświetl plik

@ -194,6 +194,41 @@ class ConnectionsManager extends ChangeNotifier {
return myGroups;
}
FutureResult<GroupData, ExecError> createGroup(String newName) async {
final result = await GroupsClient(getIt<AccountsService>().currentProfile)
.createGroup(newName)
.withResultAsync((newGroup) async {
groupsRepo.upsertGroup(newGroup);
notifyListeners();
});
return result.execErrorCast();
}
FutureResult<GroupData, ExecError> renameGroup(
String id, String newName) async {
final result = await GroupsClient(getIt<AccountsService>().currentProfile)
.renameGroup(id, newName)
.withResultAsync((renamedGroup) async {
groupsRepo.upsertGroup(renamedGroup);
notifyListeners();
});
return result.execErrorCast();
}
FutureResult<bool, ExecError> deleteGroup(GroupData groupData) async {
final result = await GroupsClient(getIt<AccountsService>().currentProfile)
.deleteGroup(groupData)
.withResultAsync((_) async {
groupsRepo.deleteGroup(groupData);
notifyListeners();
});
return result.execErrorCast();
}
void refreshGroups() {
_updateMyGroups(true);
}
Result<List<GroupData>, ExecError> getGroupsForUser(String id) {
final result = groupsRepo.getGroupsForUser(id);
if (result.isSuccess) {

Wyświetl plik

@ -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:

Wyświetl plik

@ -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