Repository: verse-pbc/plur
Pull request #128: Added admin panel
Author: login: bryanmontz
Created: 2025-03-26T13:06:20Z | Merged: 2025-03-28T13:33:03Z

## Issues covered
https://github.com/verse-pbc/issues/issues/263

## Description
A description of the changes proposed in the pull request. Explain what it does to give the reviewer some context for what they're reviewing.

## How to test
1. Navigate to a group you are an admin of.
2. Tap the ellipsis button on the Group Info view.
3. Tap the Admin Panel item.

## Screenshots/Video

Picking an image:  
![image picking](https://github.com/user-attachments/assets/3fcf4575-3fc3-4202-876f-5a979b9895b3)

@Chardot Please review these UI changes to be sure they match the design.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
	- Introduced an Admin Panel that lets administrators manage group settings such as editing group details, updating images, and modifying descriptions.
	- Added a new admin option in the group menu for quick access.
	- Enhanced file upload options with more flexible selection modes.
	- Updated UI text labels to improve clarity for community and administrative actions.
	- Added a new route for accessing the group admin screen.
	- Implemented dynamic navigation to the group admin screen based on group ID.
	- Added localization support for admin functionalities and community features.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7a74ec384..8f732e8e4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### Release Notes
 - Fixed issue where new notes toast was not getting displayed in groups. [#250](https://github.com/verse-pbc/issues/issues/250)
 - Updated the confirm button disabled color. [#279](https://github.com/verse-pbc/issues/issues/279)
+- Added Admin Panel. [#263](https://github.com/verse-pbc/issues/issues/263)
 
 ### Internal Changes
 
diff --git a/lib/consts/router_path.dart b/lib/consts/router_path.dart
index 75b848089..c4dc9dded 100644
--- a/lib/consts/router_path.dart
+++ b/lib/consts/router_path.dart
@@ -46,6 +46,7 @@ class RouterPath {
   static const String FOLLOW_SET_DETAIL = "/followSetDetail";
   static const String FOLLOW_SET_FEED = "/followSetFeed";
   static const String NWC_SETTING = "/nwcSetting";
+  static const String GROUP_ADMIN = "/groupAdmin";
   static const String GROUP_LIST = "/groupList";
   static const String GROUP_DETAIL = "/groupDetail";
   static const String GROUP_EDIT = "/groupEdit";
diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart
index f4ceacced..4b3289b2f 100644
--- a/lib/generated/intl/messages_en.dart
+++ b/lib/generated/intl/messages_en.dart
@@ -52,6 +52,7 @@ class MessageLookup extends MessageLookupByLibrary {
         "Address_can_t_be_null":
             MessageLookupByLibrary.simpleMessage("Address can\'t be null."),
         "Admin": MessageLookupByLibrary.simpleMessage("Admin"),
+        "Admin_Panel": MessageLookupByLibrary.simpleMessage("Admin Panel"),
         "Admins": MessageLookupByLibrary.simpleMessage("Admins"),
         "All_media_public": MessageLookupByLibrary.simpleMessage(
             "All media is publicly accessible to anyone with the URL."),
@@ -93,6 +94,8 @@ class MessageLookup extends MessageLookupByLibrary {
         "Closed": MessageLookupByLibrary.simpleMessage("Closed"),
         "Closed_group": MessageLookupByLibrary.simpleMessage("Closed group"),
         "Communities": MessageLookupByLibrary.simpleMessage("Communities"),
+        "Community_Name":
+            MessageLookupByLibrary.simpleMessage("Community Name"),
         "Confirm": MessageLookupByLibrary.simpleMessage("Confirm"),
         "Contact": MessageLookupByLibrary.simpleMessage("Contact"),
         "Content": MessageLookupByLibrary.simpleMessage("Content"),
@@ -136,6 +139,7 @@ class MessageLookup extends MessageLookupByLibrary {
             MessageLookupByLibrary.simpleMessage("Delete Account"),
         "Delete_Account_Tips": MessageLookupByLibrary.simpleMessage(
             "We will try to delete you infomation. When you login with this Key again, you will lose your data."),
+        "Description": MessageLookupByLibrary.simpleMessage("Description"),
         "Detail": MessageLookupByLibrary.simpleMessage("Detail"),
         "Development": MessageLookupByLibrary.simpleMessage("Development"),
         "Dirtywords": MessageLookupByLibrary.simpleMessage("Dirtywords"),
@@ -149,6 +153,10 @@ class MessageLookup extends MessageLookupByLibrary {
         "Emoji": MessageLookupByLibrary.simpleMessage("Emoji"),
         "Empty_text_may_be_ban_by_relays": MessageLookupByLibrary.simpleMessage(
             "Empty text may be ban by relays."),
+        "Enter_Community_Description": MessageLookupByLibrary.simpleMessage(
+            "Enter a description of your community"),
+        "Enter_Community_Name": MessageLookupByLibrary.simpleMessage(
+            "Enter a name for your community"),
         "Event_Sign_Check":
             MessageLookupByLibrary.simpleMessage("Event Sign Check"),
         "Face": MessageLookupByLibrary.simpleMessage("Face"),
@@ -211,6 +219,8 @@ class MessageLookup extends MessageLookupByLibrary {
         "Image_service": MessageLookupByLibrary.simpleMessage("Image service"),
         "Image_service_path":
             MessageLookupByLibrary.simpleMessage("Image service path"),
+        "Image_upload_failed":
+            MessageLookupByLibrary.simpleMessage("Image upload failed"),
         "Import_notes": MessageLookupByLibrary.simpleMessage("Import notes"),
         "Input": MessageLookupByLibrary.simpleMessage("Input"),
         "Input_Comment": MessageLookupByLibrary.simpleMessage("Input Comment"),
@@ -374,6 +384,7 @@ class MessageLookup extends MessageLookupByLibrary {
             MessageLookupByLibrary.simpleMessage("Remove from public bookmark"),
         "Replying": MessageLookupByLibrary.simpleMessage("Replying"),
         "Request": MessageLookupByLibrary.simpleMessage("Request"),
+        "Save": MessageLookupByLibrary.simpleMessage("Save"),
         "Search": MessageLookupByLibrary.simpleMessage("Search"),
         "Search_User_from_cache":
             MessageLookupByLibrary.simpleMessage("Search User from cache"),
@@ -436,6 +447,7 @@ class MessageLookup extends MessageLookupByLibrary {
             MessageLookupByLibrary.simpleMessage("Translate Source Language"),
         "Translate_Target_Language":
             MessageLookupByLibrary.simpleMessage("Translate Target Language"),
+        "Update_Image": MessageLookupByLibrary.simpleMessage("Update Image"),
         "Upload_fail": MessageLookupByLibrary.simpleMessage("Upload fail."),
         "Upload_num": MessageLookupByLibrary.simpleMessage("Upload num"),
         "Url": MessageLookupByLibrary.simpleMessage("Url"),
diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart
index e1367b0b6..24670b3e8 100644
--- a/lib/generated/l10n.dart
+++ b/lib/generated/l10n.dart
@@ -3300,6 +3300,86 @@ class S {
     );
   }
 
+  /// `Admin Panel`
+  String get Admin_Panel {
+    return Intl.message(
+      'Admin Panel',
+      name: 'Admin_Panel',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Update Image`
+  String get Update_Image {
+    return Intl.message(
+      'Update Image',
+      name: 'Update_Image',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Community Name`
+  String get Community_Name {
+    return Intl.message(
+      'Community Name',
+      name: 'Community_Name',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Enter a name for your community`
+  String get Enter_Community_Name {
+    return Intl.message(
+      'Enter a name for your community',
+      name: 'Enter_Community_Name',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Enter a description of your community`
+  String get Enter_Community_Description {
+    return Intl.message(
+      'Enter a description of your community',
+      name: 'Enter_Community_Description',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Description`
+  String get Description {
+    return Intl.message(
+      'Description',
+      name: 'Description',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Save`
+  String get Save {
+    return Intl.message(
+      'Save',
+      name: 'Save',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Image upload failed`
+  String get Image_upload_failed {
+    return Intl.message(
+      'Image upload failed',
+      name: 'Image_upload_failed',
+      desc: '',
+      args: [],
+    );
+  }
+
   /// `All media is publicly accessible to anyone with the URL.`
   String get All_media_public {
     return Intl.message(
diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb
index eff4c4a92..f6dac9048 100644
--- a/lib/l10n/intl_en.arb
+++ b/lib/l10n/intl_en.arb
@@ -324,6 +324,14 @@
     "Group_Info": "Group Info",
     "Your_Groups": "Your Groups",
     "Admin": "Admin",
+    "Admin_Panel": "Admin Panel",
+    "Update_Image": "Update Image",
+    "Community_Name": "Community Name",
+    "Enter_Community_Name": "Enter a name for your community",
+    "Enter_Community_Description": "Enter a description of your community",
+    "Description": "Description",
+    "Save": "Save",
+    "Image_upload_failed": "Image upload failed",
     "All_media_public": "All media is publicly accessible to anyone with the URL.",
     "Development": "Development",
     "Closed_group": "Closed group",
diff --git a/lib/main.dart b/lib/main.dart
index 6fa36a4cc..536fc9c19 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -28,6 +28,7 @@ import 'package:nostrmo/provider/group_provider.dart';
 import 'package:nostrmo/provider/mention_me_new_provider.dart';
 import 'package:nostrmo/provider/music_provider.dart';
 import 'package:nostrmo/provider/nwc_provider.dart';
+import 'package:nostrmo/router/group/group_admin/group_admin_screen.dart';
 import 'package:nostrmo/router/group/group_detail_widget.dart';
 import 'package:nostrmo/router/group/group_edit_widget.dart';
 import 'package:nostrmo/router/group/communities_widget.dart';
@@ -579,6 +580,23 @@ class _MyApp extends State<MyApp> {
           darkTheme: defaultDarkTheme,
           initialRoute: RouterPath.INDEX,
           routes: routes,
+          onGenerateRoute: (settings) {
+            switch (settings.name) {
+              case RouterPath.GROUP_ADMIN:
+                final groupId = settings.arguments as GroupIdentifier?;
+                if (groupId == null) {
+                  return null;
+                }
+                return MaterialPageRoute(
+                  builder: (context) => Provider<GroupIdentifier>.value(
+                    value: groupId,
+                    child: const GroupAdminScreen(),
+                  ),
+                );
+              default:
+                return null;
+            }
+          },
         ),
       ),
     );
diff --git a/lib/provider/group_provider.dart b/lib/provider/group_provider.dart
index a58cd65fd..5344470eb 100644
--- a/lib/provider/group_provider.dart
+++ b/lib/provider/group_provider.dart
@@ -228,9 +228,11 @@ class GroupProvider extends ChangeNotifier with LaterFunction {
     return updated;
   }
 
-  void udpateMetadata(
-      GroupIdentifier groupIdentifier, GroupMetadata groupMetadata) async {
-    var relays = [groupIdentifier.host];
+  Future<void> updateMetadata(
+    GroupIdentifier groupIdentifier,
+    GroupMetadata groupMetadata,
+  ) async {
+    final relays = [groupIdentifier.host];
 
     var tags = [];
     tags.add(["h", groupIdentifier.groupId]);
@@ -244,12 +246,15 @@ class GroupProvider extends ChangeNotifier with LaterFunction {
       tags.add(["about", groupMetadata.about!]);
     }
 
-    var e = Event(nostr!.publicKey, EventKind.GROUP_EDIT_METADATA, tags, "");
-    var result =
+    final e = Event(nostr!.publicKey, EventKind.GROUP_EDIT_METADATA, tags, "");
+    final result =
         await nostr!.sendEvent(e, tempRelays: relays, targetRelays: relays);
     if (result != null) {
       handleEvent(
-          groupMetadatas, groupIdentifier, GroupMetadata.loadFromEvent(e));
+        groupMetadatas,
+        groupIdentifier,
+        GroupMetadata.loadFromEvent(e),
+      );
     }
   }
 }
diff --git a/lib/provider/list_provider.dart b/lib/provider/list_provider.dart
index f74303c02..f29012fef 100644
--- a/lib/provider/list_provider.dart
+++ b/lib/provider/list_provider.dart
@@ -555,7 +555,7 @@ class ListProvider extends ChangeNotifier {
       0,
       name: groupName,
     );
-    groupProvider.udpateMetadata(group, groupMetadata);
+    groupProvider.updateMetadata(group, groupMetadata);
   }
 
   String createInviteLink(GroupIdentifier group, String inviteCode,
diff --git a/lib/provider/uploader.dart b/lib/provider/uploader.dart
index 16a1dd1a7..bf0ed3470 100644
--- a/lib/provider/uploader.dart
+++ b/lib/provider/uploader.dart
@@ -91,13 +91,19 @@ class Uploader {
     return null;
   }
 
-  /// Shows UI to allow the user to pick multiple files, and returns the paths.
-  static Future<List<String>> pickFiles(BuildContext context) async {
+  /// Shows UI to allow the user to pick one or more files and returns the paths.
+  static Future<List<String>> pickFiles(
+    BuildContext context, {
+    FileType type = FileType.any,
+    bool allowMultiple = true,
+  }) async {
     List<String> resultFiles = [];
 
     if (PlatformUtil.isPC() || PlatformUtil.isWeb()) {
-      FilePickerResult? result =
-          await FilePicker.platform.pickFiles(allowMultiple: true);
+      FilePickerResult? result = await FilePicker.platform.pickFiles(
+        type: type,
+        allowMultiple: allowMultiple,
+      );
 
       if (result != null) {
         for (var file in result.files) {
@@ -120,9 +126,14 @@ class Uploader {
       return resultFiles;
     }
 
-    var assets = await AssetPicker.pickAssets(
+    final assetRequestType =
+        type == FileType.image ? RequestType.image : RequestType.common;
+    final assets = await AssetPicker.pickAssets(
       context,
-      pickerConfig: const AssetPickerConfig(maxAssets: 20),
+      pickerConfig: AssetPickerConfig(
+        maxAssets: allowMultiple ? 20 : 1,
+        requestType: assetRequestType,
+      ),
       useRootNavigator: false,
     );
 
diff --git a/lib/router/group/group_admin/group_admin_screen.dart b/lib/router/group/group_admin/group_admin_screen.dart
new file mode 100644
index 000000000..2ed6276d6
--- /dev/null
+++ b/lib/router/group/group_admin/group_admin_screen.dart
@@ -0,0 +1,318 @@
+import 'package:bot_toast/bot_toast.dart';
+import 'package:file_picker/file_picker.dart';
+import 'package:flutter/material.dart';
+import 'package:nostr_sdk/nip29/group_identifier.dart';
+import 'package:nostr_sdk/nip29/group_metadata.dart';
+import 'package:nostrmo/util/theme_util.dart';
+import 'package:provider/provider.dart';
+
+import '../../../component/appbar_back_btn_widget.dart';
+import '../../../component/group/group_avatar_widget.dart';
+import '../../../consts/router_path.dart';
+import '../../../generated/l10n.dart';
+import '../../../provider/group_provider.dart';
+import '../../../provider/uploader.dart';
+import '../../../util/router_util.dart';
+
+class GroupAdminScreen extends StatefulWidget {
+  const GroupAdminScreen({super.key});
+
+  @override
+  State<GroupAdminScreen> createState() => _GroupAdminScreenState();
+}
+
+class _GroupAdminScreenState extends State<GroupAdminScreen> {
+  final _nameController = TextEditingController();
+  final _descriptionController = TextEditingController();
+  String? _pictureUrl;
+  var _hasChanges = false;
+  var _isSaving = false;
+  var _isUploading = false;
+
+  @override
+  void initState() {
+    _nameController.addListener(_checkForChanges);
+    _descriptionController.addListener(_checkForChanges);
+
+    super.initState();
+  }
+
+  @override
+  void dispose() {
+    _nameController.removeListener(_checkForChanges);
+    _descriptionController.removeListener(_checkForChanges);
+    _nameController.dispose();
+    _descriptionController.dispose();
+
+    super.dispose();
+  }
+
+  @override
+  void didChangeDependencies() {
+    super.didChangeDependencies();
+
+    final groupId = context.read<GroupIdentifier>();
+    final groupProvider = Provider.of<GroupProvider>(context, listen: false);
+    final metadata = groupProvider.getMetadata(groupId);
+
+    _nameController.text = metadata?.name ?? "";
+    _descriptionController.text = metadata?.about ?? "";
+    _pictureUrl = metadata?.picture;
+    setState(() => _hasChanges = false);
+  }
+
+  void _checkForChanges() {
+    final groupId = context.read<GroupIdentifier>();
+    final groupProvider = Provider.of<GroupProvider>(context, listen: false);
+    final metadata = groupProvider.getMetadata(groupId);
+
+    final newHasChanges = _nameController.text != metadata?.name ||
+        _descriptionController.text != metadata?.about ||
+        _pictureUrl != metadata?.picture;
+
+    if (_hasChanges != newHasChanges) {
+      setState(() => _hasChanges = newHasChanges);
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final themeData = Theme.of(context);
+    final localization = S.of(context);
+
+    const double cornerRadius = 8;
+    const borderRadius = BorderRadius.all(Radius.circular(cornerRadius));
+    final inputDecoration = InputDecoration(
+      border: OutlineInputBorder(
+        borderRadius: borderRadius,
+        borderSide:
+        BorderSide(color: themeData.customColors.secondaryForegroundColor),
+      ),
+      enabledBorder: OutlineInputBorder(
+        borderRadius: borderRadius,
+        borderSide:
+        BorderSide(color: themeData.customColors.secondaryForegroundColor),
+      ),
+      focusedBorder: OutlineInputBorder(
+        borderRadius: borderRadius,
+        borderSide: BorderSide(
+            color: themeData.customColors.secondaryForegroundColor, width: 2),
+      ),
+      labelStyle:
+          TextStyle(color: themeData.customColors.primaryForegroundColor),
+    );
+
+    return Scaffold(
+      appBar: AppBar(
+        leading: const AppbarBackBtnWidget(),
+        title: Text(
+          localization.Admin_Panel,
+          style: TextStyle(
+            color: themeData.customColors.primaryForegroundColor,
+            fontSize: 16,
+            fontWeight: FontWeight.bold,
+          ),
+        ),
+        actions: [
+          if (_isSaving)
+            Padding(
+              padding: const EdgeInsets.only(right: 16.0),
+              child: SizedBox(
+                width: 24,
+                height: 24,
+                child: CircularProgressIndicator(
+                  strokeWidth: 2,
+                  valueColor: AlwaysStoppedAnimation<Color>(
+                    themeData.customColors.accentColor,
+                  ),
+                ),
+              ),
+            )
+          else
+            TextButton(
+              onPressed: _hasChanges ? _save : null,
+              style: const ButtonStyle(),
+              child: Text(
+                localization.Save,
+                style: TextStyle(
+                  color: _hasChanges
+                      ? themeData.customColors.accentColor
+                      : Colors.grey,
+                  fontSize: 18,
+                ),
+              ),
+            ),
+        ],
+      ),
+      body: SingleChildScrollView(
+        child: Column(
+          mainAxisAlignment: MainAxisAlignment.center,
+          children: [
+            const SizedBox(height: 26),
+            Stack(
+              alignment: Alignment.center,
+              children: [
+                GroupAvatar(imageUrl: _pictureUrl),
+                if (_isUploading)
+                  const Center(child: CircularProgressIndicator()),
+              ],
+            ),
+            TextButton(
+              onPressed: _updateImage,
+              style: TextButton.styleFrom(
+                textStyle: const TextStyle(
+                  fontSize: 16.0,
+                  fontWeight: FontWeight.w500,
+                ),
+                foregroundColor: themeData.customColors.accentColor,
+              ),
+              child: Text(localization.Update_Image),
+            ),
+            const SizedBox(height: 44),
+            Padding(
+              padding: const EdgeInsets.symmetric(horizontal: 16),
+              child: TextFormField(
+                controller: _nameController,
+                decoration: inputDecoration.copyWith(
+                  labelText: localization.Community_Name,
+                  hintText: localization.Enter_Community_Name,
+                ),
+              ),
+            ),
+            const SizedBox(height: 32),
+            Padding(
+              padding: const EdgeInsets.symmetric(horizontal: 16),
+              child: TextFormField(
+                controller: _descriptionController,
+                decoration: inputDecoration.copyWith(
+                  labelText: localization.Description,
+                  hintText: localization.Enter_Community_Description,
+                  alignLabelWithHint: true,
+                ),
+                maxLines: 5,
+                minLines: 5,
+                keyboardType: TextInputType.multiline,
+                textAlignVertical: TextAlignVertical.top,
+              ),
+            ),
+            const SizedBox(height: 32),
+            Container(
+              margin: const EdgeInsets.symmetric(horizontal: 16),
+              decoration: BoxDecoration(
+                borderRadius: BorderRadius.circular(cornerRadius),
+              ),
+              clipBehavior: Clip.antiAlias,
+              // Ensures the InkWell respects rounded corners
+              child: Column(
+                children: [
+                  _NavigationRow(
+                    title: localization.Members,
+                    onTap: () {
+                      final groupId = context.read<GroupIdentifier>();
+                      RouterUtil.router(
+                          context, RouterPath.GROUP_MEMBERS, groupId);
+                    },
+                  ),
+                ],
+              ),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+
+  Future<void> _updateImage() async {
+    try {
+      final files = await Uploader.pickFiles(
+        context,
+        type: FileType.image,
+        allowMultiple: false,
+      );
+
+      final firstFile = files.firstOrNull;
+      if (firstFile == null) return;
+
+      setState(() => _isUploading = true);
+      final remoteUrl = await Uploader.upload(firstFile);
+      if (!mounted) return;
+
+      if (remoteUrl == null) {
+        final errorMessage = S.of(context).Image_upload_failed;
+        throw Exception(errorMessage);
+      }
+      setState(() => _pictureUrl = remoteUrl);
+      _checkForChanges();
+    } catch (e) {
+      BotToast.showText(text: e.toString());
+    } finally {
+      setState(() => _isUploading = false);
+    }
+  }
+
+  void _save() async {
+    setState(() => _isSaving = true);
+
+    final groupIdentifier = context.read<GroupIdentifier>();
+    GroupMetadata groupMetadata = GroupMetadata(
+      groupIdentifier.groupId,
+      0,
+      name: _nameController.text,
+      picture: _pictureUrl,
+      about: _descriptionController.text,
+    );
+
+    try {
+      final groupProvider = Provider.of<GroupProvider>(context, listen: false);
+      await groupProvider.updateMetadata(groupIdentifier, groupMetadata);
+
+      if (!mounted) return;
+      RouterUtil.back(context);
+    } catch (e) {
+      BotToast.showText(text: e.toString());
+    } finally {
+      setState(() => _isSaving = false);
+    }
+  }
+}
+
+class _NavigationRow extends StatelessWidget {
+  final String title;
+  final VoidCallback onTap;
+
+  const _NavigationRow({
+    Key? key,
+    required this.title,
+    required this.onTap,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    final themeData = Theme.of(context);
+    return InkWell(
+      onTap: onTap,
+      child: Container(
+        padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
+        color: themeData.customColors.feedBgColor,
+        child: Row(
+          mainAxisAlignment: MainAxisAlignment.spaceBetween,
+          children: [
+            Text(
+              title,
+              style: TextStyle(
+                color: themeData.customColors.primaryForegroundColor,
+                fontSize: 18,
+                fontWeight: FontWeight.w500,
+              ),
+            ),
+            Icon(
+              Icons.chevron_right,
+              color: themeData.customColors.secondaryForegroundColor,
+              size: 24,
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}
diff --git a/lib/router/group/group_edit_widget.dart b/lib/router/group/group_edit_widget.dart
index cf9899810..d783fa2e2 100644
--- a/lib/router/group/group_edit_widget.dart
+++ b/lib/router/group/group_edit_widget.dart
@@ -295,7 +295,7 @@ class _GroupEditWidgetState extends State<GroupEditWidget> {
       picture: pictureController.text,
       about: aboutController.text,
     );
-    groupProvider.udpateMetadata(groupIdentifier!, groupMetadata);
+    groupProvider.updateMetadata(groupIdentifier!, groupMetadata);
 
     if (oldGroupMetadata != null) {
       bool updateStatus = false;
diff --git a/lib/router/group/group_info/group_info_popupmenu_widget.dart b/lib/router/group/group_info/group_info_popupmenu_widget.dart
index 8d31e6278..760658f5f 100644
--- a/lib/router/group/group_info/group_info_popupmenu_widget.dart
+++ b/lib/router/group/group_info/group_info_popupmenu_widget.dart
@@ -4,6 +4,10 @@ import 'package:nostr_sdk/nostr_sdk.dart';
 import 'package:nostrmo/util/router_util.dart';
 import 'package:nostrmo/consts/router_path.dart';
 import 'package:nostrmo/component/styled_popup_menu.dart';
+import 'package:provider/provider.dart';
+
+import '../../../main.dart';
+import '../../../provider/group_provider.dart';
 
 /// Displays a popup menu at the top right of the group info screen.
 class GroupInfoPopupMenuWidget extends StatelessWidget {
@@ -18,8 +22,18 @@ class GroupInfoPopupMenuWidget extends StatelessWidget {
   Widget build(BuildContext context) {
     final localization = S.of(context);
 
+    final groupProvider = Provider.of<GroupProvider>(context);
+    final groupAdmins = groupProvider.getAdmins(groupId);
+    final isAdmin = groupAdmins?.containsUser(nostr!.publicKey) ?? false;
+
     return StyledPopupMenu(
       items: [
+        if (isAdmin)
+          StyledPopupItem(
+            value: "admin",
+            text: localization.Admin_Panel,
+            icon: Icons.admin_panel_settings,
+          ),
         StyledPopupItem(
           value: "edit",
           text: localization.Edit,
@@ -28,8 +42,11 @@ class GroupInfoPopupMenuWidget extends StatelessWidget {
       ],
       onSelected: (value) {
         switch (value) {
+          case "admin":
+            RouterUtil.router(context, RouterPath.GROUP_ADMIN, groupId);
           case "edit":
             RouterUtil.router(context, RouterPath.GROUP_EDIT, groupId);
+
         }
       },
     );


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (2)</summary><blockquote>

<details>
<summary>lib/router/group/group_admin/group_admin_screen.dart (2)</summary><blockquote>

`50-62`: **Initialization of form fields needs enhancement.**

The code correctly initializes the form fields, but there's a potential null reference issue if metadata is null. Consider using null-aware operators or providing default values more explicitly.

```diff
-    _nameController.text = metadata?.name ?? "";
-    _descriptionController.text = metadata?.about ?? "";
-    _pictureUrl = metadata?.picture;
+    _nameController.text = metadata?.name ?? "";
+    _descriptionController.text = metadata?.about ?? "";
+    _pictureUrl = metadata?.picture;
+    // If metadata is completely null, you might want to handle it differently
+    // For example, showing a message or disabling editing
```

---

`85-85`: **Unused variable detected.**

The `metadata` variable is declared but not used in the `build` method. It's used for getting the content in other methods but not directly in the build method.

You can remove this variable if it's not used in the build method or document why it's needed.

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 GitHub Actions: Analyze Project</summary>

[warning] 85-85: The value of the local variable 'metadata' isn't used.

</details>

</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between c66bc83c4518ad4b497ba9df9365a32e6dca18e2 and 4587171f3153d065d46349108b0750ca300714f4.

</details>

<details>
<summary>⛔ Files ignored due to path filters (2)</summary>

* `lib/generated/intl/messages_en.dart` is excluded by `!**/generated/**`
* `lib/generated/l10n.dart` is excluded by `!**/generated/**`

</details>

<details>
<summary>📒 Files selected for processing (10)</summary>

* `CHANGELOG.md` (1 hunks)
* `lib/consts/router_path.dart` (1 hunks)
* `lib/l10n/intl_en.arb` (1 hunks)
* `lib/main.dart` (2 hunks)
* `lib/provider/group_provider.dart` (2 hunks)
* `lib/provider/list_provider.dart` (1 hunks)
* `lib/provider/uploader.dart` (2 hunks)
* `lib/router/group/group_admin/group_admin_screen.dart` (1 hunks)
* `lib/router/group/group_edit_widget.dart` (1 hunks)
* `lib/router/group/group_info/group_info_popupmenu_widget.dart` (3 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🪛 GitHub Actions: Analyze Project</summary>

<details>
<summary>lib/consts/router_path.dart</summary>

[warning] 49-54: The constant name 'GROUP_ADMIN' isn't a lowerCamelCase identifier.

</details>
<details>
<summary>lib/router/group/group_admin/group_admin_screen.dart</summary>

[warning] 85-85: The value of the local variable 'metadata' isn't used.

</details>

</details>

</details>

<details>
<summary>🔇 Additional comments (22)</summary><blockquote>

<details>
<summary>lib/consts/router_path.dart (1)</summary>

`49-49`: **New router path for the admin panel looks good**

The addition of the GROUP_ADMIN constant for routing to the admin panel is well-placed among other group-related routes.


While the pipeline flagged a linting warning about using UPPER_SNAKE_CASE instead of lowerCamelCase, I notice that all other route constants in this file follow the same UPPER_SNAKE_CASE convention, so this is consistent with the established pattern.

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 GitHub Actions: Analyze Project</summary>

[warning] 49-54: The constant name 'GROUP_ADMIN' isn't a lowerCamelCase identifier.

</details>

</details>

</details>
<details>
<summary>lib/router/group/group_edit_widget.dart (1)</summary>

`298-298`: **Method name spelling correction**

Fixed the typo in the method name from `udpateMetadata` to `updateMetadata`, improving code consistency.

</details>
<details>
<summary>lib/provider/list_provider.dart (1)</summary>

`561-561`: **Method name spelling correction**

Fixed the typo in the method name from `udpateMetadata` to `updateMetadata`, improving code consistency across the codebase.

</details>
<details>
<summary>CHANGELOG.md (1)</summary>

`25-25`: **Well-documented feature addition in changelog**

The addition of the Admin Panel feature is properly documented in the Release Notes section, following the project's changelog format with a reference to the relevant issue.

</details>
<details>
<summary>lib/router/group/group_info/group_info_popupmenu_widget.dart (4)</summary>

`7-10`: **New imports added for the Admin Panel functionality.**

These imports support the new feature by providing access to Provider for state management and the necessary components for admin functionality.

---

`25-27`: **Good implementation of admin status check.**

The code properly checks if the current user is an admin by retrieving admin information from the GroupProvider and checking if the user's public key is in the list.

---

`31-36`: **Admin Panel menu item conditionally displayed.**

Good use of conditional rendering with `if (isAdmin)` to show the Admin Panel option only to users with admin privileges. The implementation follows Flutter's declarative UI pattern and leverages localization for the menu item text.

---

`45-47`: **Admin panel navigation handler implemented correctly.**

The switch case handles navigation to the admin panel when the "admin" menu item is selected, using the RouterUtil utility with the correct path constant and group ID.

</details>
<details>
<summary>lib/main.dart (2)</summary>

`31-31`: **New import for the Admin Panel screen.**

This import brings in the necessary screen component for the admin functionality.

---

`583-599`: **Well-implemented route generation for the Admin Panel.**

The onGenerateRoute implementation follows best practices by:
1. Checking for the specific route name
2. Safely extracting and validating the group ID
3. Returning null for unhandled routes
4. Properly using Provider to pass the group ID to the screen

The code is clean, follows separation of concerns, and handles potential null cases.

</details>
<details>
<summary>lib/provider/group_provider.dart (3)</summary>

`231-234`: **Method signature improved with async return type.**

The method has been updated to return `Future<void>` to properly reflect its asynchronous nature, which is a good practice for methods that perform async operations.

---

`235-236`: **Use of final for immutable variables.**

Good practice using `final` for variables that shouldn't be reassigned after initialization.

---

`249-257`: **Improved code formatting and variable immutability.**

The code now:
1. Uses `final` for event variable to prevent accidental reassignment
2. Has better formatting for the handleEvent method call, improving readability
3. Properly awaits the async sendEvent call

These changes make the code more maintainable and less prone to errors.

</details>
<details>
<summary>lib/l10n/intl_en.arb (1)</summary>

`327-333`: **Complete localization added for Admin Panel feature.**

All necessary localization strings have been added to support the Admin Panel functionality, including:
1. Admin panel title
2. Image update action
3. Community name and description fields
4. Save action

This ensures the feature will be properly localized for English-speaking users and provides a template for translations to other languages.

</details>
<details>
<summary>lib/provider/uploader.dart (3)</summary>

`94-99`: **Method signature enhancement improves file picking flexibility.**

The updated method signature with optional parameters for file type and multiple selection provides better control for specific use cases like image selection in the admin panel.

---

`103-106`: **Properly implemented FilePicker parameters.**

The parameters passed to `FilePicker.platform.pickFiles()` correctly utilize the new method parameters, allowing for customized file picking behavior.

---

`129-136`: **Good enhancement to AssetPicker configuration.**

The implementation correctly sets the request type based on the file type and limits the number of assets based on the `allowMultiple` parameter. This provides consistent behavior across different platforms.

</details>
<details>
<summary>lib/router/group/group_admin/group_admin_screen.dart (5)</summary>

`24-31`: **Well-structured state management for the admin panel.**

The state variables and controllers are properly defined for tracking changes, loading states, and form inputs.

---

`32-48`: **Good lifecycle management.**

The `initState` and `dispose` methods correctly handle listener management for text controllers, which prevents memory leaks.

---

`225-245`: **Good implementation of image uploading with proper error handling.**

The `_updateImage` method correctly uses the enhanced `Uploader.pickFiles` method with appropriate parameters. The implementation includes proper state management and error handling.

---

`247-270`: **Well-implemented save functionality with proper error handling.**

The `_save` method correctly constructs a GroupMetadata object and updates it through the provider. The implementation includes proper state management, loading indicators, and error handling.

---

`273-312`: **Well-designed navigation component.**

The `_NavigationRow` widget is well-structured and reusable. It provides a consistent UI for navigation items in the admin panel.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T12:53:15Z
user: login: mplorentz
body: I know this wasn't specified anywhere but personally I'd like to go ahead and add a confirmation before navigating away from the admin panel if you have changed things but haven't saved them. Something to run by @joshuatbrown and doesn't need to be done in this PR. I had one other question here but nothing to block a merge. Nice work! 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T16:02:20Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (3)</summary><blockquote>

<details>
<summary>lib/router/group/group_admin/group_admin_screen.dart (3)</summary><blockquote>

`161-170`: **Check font size for Update Image text.**

Based on PR comments, the font size for the "Update Image" text appears larger than expected in the simulator. Consider adjusting the font size to match the design specifications.

```diff
 textStyle: const TextStyle(
-  fontSize: 16.0,
+  fontSize: 14.0,
   fontWeight: FontWeight.w500,
 ),
```

---

`225-250`: **Refactor error handling in _updateImage method.**

Consider separating error generation from presentation by using a dedicated error type or message constant instead of directly using the localized string in the throw statement.

```diff
 if (remoteUrl == null) {
-  throw S.of(context).Image_upload_failed;
+  final errorMessage = S.of(context).Image_upload_failed;
+  throw Exception(errorMessage);
 }
```

---

`290-316`: **Use theme colors instead of hardcoded values.**

The `_NavigationRow` widget uses hardcoded color values (`Colors.white` and `Colors.white54`) which might not adapt well to different theme modes. Consider using theme colors for better dark/light mode support.

```diff
 Text(
   title,
   style: const TextStyle(
-    color: Colors.white,
+    color: themeData.customColors.primaryForegroundColor,
     fontSize: 18,
     fontWeight: FontWeight.w500,
   ),
 ),
 const Icon(
   Icons.chevron_right,
-  color: Colors.white54,
+  color: themeData.customColors.secondaryForegroundColor,
   size: 24,
 ),
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 4587171f3153d065d46349108b0750ca300714f4 and 5ae6fd041c0c15a16060512015642bb4df09c0de.

</details>

<details>
<summary>⛔ Files ignored due to path filters (2)</summary>

* `lib/generated/intl/messages_en.dart` is excluded by `!**/generated/**`
* `lib/generated/l10n.dart` is excluded by `!**/generated/**`

</details>

<details>
<summary>📒 Files selected for processing (4)</summary>

* `CHANGELOG.md` (1 hunks)
* `lib/l10n/intl_en.arb` (1 hunks)
* `lib/provider/list_provider.dart` (1 hunks)
* `lib/router/group/group_admin/group_admin_screen.dart` (1 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (2)</summary>

* lib/provider/list_provider.dart
* CHANGELOG.md

</details>

<details>
<summary>🔇 Additional comments (8)</summary><blockquote>

<details>
<summary>lib/router/group/group_admin/group_admin_screen.dart (7)</summary>

`1-16`: **LGTM! Clean import organization.**

The imports are well-organized and contain all necessary dependencies for the admin panel functionality.

---

`17-22`: **LGTM! Clean widget declaration.**

The `GroupAdminScreen` widget follows the standard StatefulWidget pattern with appropriate constructor parameters.

---

`24-31`: **LGTM! Good state management setup.**

The state variables are well-defined with clear purposes for tracking form state, image upload progress, and save status.

---

`32-48`: **LGTM! Proper lifecycle management.**

The `initState` and `dispose` methods correctly handle controller listeners to prevent memory leaks.

---

`50-62`: **LGTM! Good initialization in didChangeDependencies.**

The method properly initializes form fields with existing group metadata and resets the change tracking state.

---

`64-76`: **LGTM! Clean change detection logic.**

The `_checkForChanges` method efficiently compares current values with original metadata to determine if there are unsaved changes.

---

`252-275`: <details>
<summary>✅ Verification successful</summary>

**Verify the timestamp value in GroupMetadata constructor.**

The second parameter in the GroupMetadata constructor is set to 0, which may represent a timestamp. Verify if this should be a dynamic value like `DateTime.now().millisecondsSinceEpoch`.

---

🏁 Script executed:

```shell
#!/bin/bash
# Search for other occurrences of GroupMetadata construction to verify timestamp usage
rg "GroupMetadata\(" -A 3
```

Length of output: 2245

---



</details>

**Timestamp Value in GroupMetadata Confirmed as Intended**

Your investigation shows that passing `0` as the timestamp in the `GroupMetadata` constructor is consistent across the codebase (e.g., in `lib/provider/list_provider.dart`, `lib/router/group/group_edit_widget.dart`, and here in `lib/router/group/group_admin/group_admin_screen.dart`). If the design intention is to have the timestamp set later or interpreted as a default value, then no change is needed. However, if a dynamic timestamp is required at this point, consider using `DateTime.now().millisecondsSinceEpoch`.

</details>
<details>
<summary>lib/l10n/intl_en.arb (1)</summary>

`327-334`: **LGTM! Comprehensive localization additions.**

The added localization keys provide complete support for the new admin panel feature, following the naming conventions of the existing keys.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T13:05:25Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (6)</summary><blockquote>

<details>
<summary>lib/router/group/group_admin/group_admin_screen.dart (6)</summary><blockquote>

`32-38`: **Call `super.initState()` first in the initialization method.**

The `super.initState()` call should be the first statement in `initState()` as per Flutter best practices to ensure proper initialization order.


```diff
@override
void initState() {
+  super.initState();
+
  _nameController.addListener(_checkForChanges);
  _descriptionController.addListener(_checkForChanges);
-
-  super.initState();
}
```

---

`134-135`: **Remove unnecessary empty style property.**

The `ButtonStyle()` constructor without parameters doesn't modify the button appearance, making this line unnecessary.


```diff
  TextButton(
    onPressed: _hasChanges ? _save : null,
-   style: const ButtonStyle(),
    child: Text(
```

---

`172-181`: **Add form validation for the group name field.**

The group name appears to be a required field, but there's no validation to ensure it's not empty before saving.


```diff
child: TextFormField(
  controller: _nameController,
+ validator: (value) {
+   if (value == null || value.isEmpty) {
+     return localization.Name_Required;
+   }
+   return null;
+ },
  decoration: inputDecoration.copyWith(
    labelText: localization.Community_Name,
    hintText: localization.Enter_Community_Name,
  ),
),
```

---

`225-251`: **Consider adding image size limits or compression.**

Large images can impact performance and consume bandwidth. Consider implementing size validation or compression before uploading.


The image uploading functionality could benefit from:
1. Checking file size before uploading
2. Compressing images when they exceed a certain threshold
3. Providing user feedback about file size limits

You could add size validation after line 234:
```dart
if (firstFile.size > 5 * 1024 * 1024) { // 5MB limit
  BotToast.showText(text: S.of(context).Image_Size_Too_Large);
  return;
}
```

---

`253-276`: **Consider adding a confirmation dialog when navigating away with unsaved changes.**

As mentioned in PR comments, it would be valuable to add a confirmation dialog when the user attempts to navigate away from the admin panel with unsaved changes.


You could implement this by:

1. Adding a WillPopScope widget around the Scaffold
2. Showing a confirmation dialog when _hasChanges is true

```dart
@override
Widget build(BuildContext context) {
  // ...existing code...
  
  return WillPopScope(
    onWillPop: () async {
      if (_hasChanges) {
        final shouldPop = await showDialog<bool>(
          context: context,
          builder: (context) => AlertDialog(
            title: Text(localization.Unsaved_Changes),
            content: Text(localization.Discard_Changes_Question),
            actions: [
              TextButton(
                onPressed: () => Navigator.of(context).pop(false),
                child: Text(localization.Cancel),
              ),
              TextButton(
                onPressed: () => Navigator.of(context).pop(true),
                child: Text(localization.Discard),
              ),
            ],
          ),
        );
        return shouldPop ?? false;
      }
      return true;
    },
    child: Scaffold(
      // ...existing Scaffold code...
    ),
  );
}
```

---

`105-146`: **Show feedback when changes are successfully saved.**

Currently, the app navigates back on successful save without providing direct feedback to the user. Consider showing a success message.


Add a success toast before navigation in the `_save` method:

```diff
try {
  final groupProvider = Provider.of<GroupProvider>(context, listen: false);
  await groupProvider.updateMetadata(groupIdentifier, groupMetadata);

  if (!mounted) return;
+ BotToast.showText(text: S.of(context).Changes_Saved_Successfully);
  RouterUtil.back(context);
} catch (e) {
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 5ae6fd041c0c15a16060512015642bb4df09c0de and a8f8c5d3bfb85a5da8b9c876db0e46457d4988b8.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `lib/router/group/group_admin/group_admin_screen.dart` (1 hunks)

</details>

<details>
<summary>🔇 Additional comments (3)</summary><blockquote>

<details>
<summary>lib/router/group/group_admin/group_admin_screen.dart (3)</summary>

`1-6`: **The imports are well-organized and appropriate for the functionality.**

The file imports necessary packages for UI components, file picking, toast notifications, and group data management.

---

`279-318`: **Well-designed navigation component with good reusability.**

The `_NavigationRow` component is well-structured, follows Flutter UI patterns, and provides a consistent appearance for navigation items.

---

`257-263`: <details>
<summary>❓ Verification inconclusive</summary>

**Clarify the purpose of the hardcoded '0' value in GroupMetadata constructor.**

The second parameter in the GroupMetadata constructor (set to 0) seems to be a timestamp or version number. Using a hardcoded value might cause issues.

---

🏁 Script executed:

```shell
#!/bin/bash
# Find the definition of the GroupMetadata class to understand the meaning of the second parameter
rg -A 10 "class GroupMetadata" --type dart
```

Length of output: 712

---

Based on the results, please review if the second parameter (the hardcoded `0`) is indeed meant to represent a timestamp, a version number, or something else, so we can clarify its purpose in the review comment.

---

🏁 Script executed:

```shell
#!/bin/bash
# Search for the GroupMetadata constructor in the file to get more details on its parameters
rg -n "GroupMetadata\s*\(" packages/nostr_sdk/lib/nip29/group_metadata.dart
```

Length of output: 126

---



</details>

**Action Required: Clarify the use of the hardcoded '0' in GroupMetadata**

- In **lib/router/group/group_admin/group_admin_screen.dart** (lines 257–263), the `GroupMetadata` constructor is invoked with a `0` as its second argument.
- The current context (from `packages/nostr_sdk/lib/nip29/group_metadata.dart`) does not clarify whether this `0` represents an initial version, a default timestamp, or another placeholder.
- Please add inline documentation or refactor using a named constant (e.g., `INITIAL_VERSION`) if this value is meant to indicate a specific state.
- If it’s intended as a temporary value, consider dynamically generating the appropriate value to prevent potential issues.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T13:14:29Z
user: login: bryanmontz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T13:26:44Z
user: login: mplorentz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T15:50:35Z

Review comments:
diff_hunk: @@ -579,6 +580,23 @@ class _MyApp extends State<MyApp> {
           darkTheme: defaultDarkTheme,
           initialRoute: RouterPath.INDEX,
           routes: routes,
+          onGenerateRoute: (settings) {
path: lib/main.dart
user: login: mplorentz
body: This is a different pattern for routing than I have seen elsewhere in Nostrmo, which has been a call to `RouterUtil.router(...)` when a button is tapped. I'm curious why you went with a generated route as opposed to that?
created_at: 2025-03-27T15:56:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -228,9 +228,11 @@ class GroupProvider extends ChangeNotifier with LaterFunction {
     return updated;
   }
 
-  void udpateMetadata(
path: lib/provider/group_provider.dart
user: login: mplorentz
body: udpate is more fun to say in my head, but fine
created_at: 2025-03-27T15:56:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -579,6 +580,23 @@ class _MyApp extends State<MyApp> {
           darkTheme: defaultDarkTheme,
           initialRoute: RouterPath.INDEX,
           routes: routes,
+          onGenerateRoute: (settings) {
path: lib/main.dart
user: login: bryanmontz
body: Yes, good question! It's not really generated, though the argument name says "generate". I did this because the existing pattern nostrmo had for extracting parameters from router arguments is highly discouraged.

Here's the pattern I'm referring to, in various widgets in the codebase:
```dart
Widget build(BuildContext context) {
    var arg = RouterUtil.routerArgs(context);
    if (arg == null || arg is! GroupIdentifier) {
      RouterUtil.back(context);
      return Container();
    }
    groupIdentifier = arg;
    ...
```

This has some problems. For one, every time we render the widget, we're extracting the same arguments again, which is inefficient. Second, we've got a side-effect action of going back if we arrive here and don't have the expected argument.

With the pattern I applied here instead, we just don't show the widget if the expected argument doesn't exist, and this is a much cleaner and more efficient way to get what we need in the build method:
```dart
final groupId = context.read<GroupIdentifier>();
```

Even still, it could be argued that we should keep the existing pattern and change them all at once if/when we decide on a new pattern. I leaned towards changing it because this is more similar to how it will look with Riverpod implemented, and we've already decided to move in that direction.
created_at: 2025-03-28T13:26:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -579,6 +580,23 @@ class _MyApp extends State<MyApp> {
           darkTheme: defaultDarkTheme,
           initialRoute: RouterPath.INDEX,
           routes: routes,
+          onGenerateRoute: (settings) {
path: lib/main.dart
user: login: mplorentz
body: Works for me! Thanks for explaining it.
created_at: 2025-03-28T15:50:35Z
author_association: CONTRIBUTOR

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-03-26T13:06:28Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough
This pull request introduces an "Admin Panel" feature across the codebase. It updates the changelog, adds a new route constant, and supplements localization keys for admin and community functions. The routing mechanism in `main.dart` is enhanced to navigate to the new `GroupAdminScreen` when provided a valid `groupId`. Additionally, several method name typos are corrected, asynchronous handling is improved, and updates to file picking functionality and conditional UI elements based on user status are implemented.

## Changes

| File(s) | Change Summary |
|---------|----------------|
| `CHANGELOG.md`, `lib/consts/.../router_path.dart`, `lib/l10n/intl_en.arb` | Added new Admin Panel entry in the changelog, defined a `GROUP_ADMIN` route constant, and introduced localization keys for admin and community actions. |
| `lib/main.dart` | Integrated `onGenerateRoute` logic to handle the `GROUP_ADMIN` route, constructing a route with `GroupAdminScreen` when a valid `groupId` is provided. |
| `lib/provider/group_provider.dart`, `lib/provider/list_provider.dart` | Corrected method naming from `udpateMetadata` to `updateMetadata` and updated the method signature to return `Future<void>` for asynchronous operations. |
| `lib/provider/uploader.dart` | Expanded the `pickFiles` method with optional parameters (`FileType` and `allowMultiple`) and adjusted the file picker configuration accordingly. |
| `lib/router/.../group_admin_screen.dart` | Introduced a new stateful widget, `GroupAdminScreen`, for managing group administration with functionalities to edit group details and update images. |
| `lib/router/.../group_edit_widget.dart`, `lib/router/.../group_info_popupmenu_widget.dart` | Corrected the method call to update group metadata and added logic to conditionally display an admin menu item, routing to the Admin Panel. |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant U as User
    participant R as Router/Main App
    participant GAS as GroupAdminScreen
    participant GP as GroupProvider
    U->>R: Select "Admin Panel" from menu
    R->>R: onGenerateRoute checks for "/groupAdmin"
    R->>GAS: Create GroupAdminScreen with groupId
    GAS->>GP: updateMetadata(groupId, groupMetadata)
    GP-->>GAS: Confirm metadata update
```

## Possibly related PRs
- verse-pbc/plur#126: The changes in the main PR, which introduce an "Admin Panel" feature and related routing, are related to the retrieved PR that refactors the logic for checking admin status, as both involve functionalities that pertain to group administration.

## Suggested labels
`Skip-Changelog`

## Suggested reviewers
- joshuatbrown
- mplorentz
- pelumy
- martindsq

## Poem
> I'm a bunny hopping through new code lanes,  
> Adding an admin panel as bright as spring rains.  
> Routes and keys now sing their precise tune,  
> Bugs fixed and features blooming soon.  
> With carrots of code and joyful delight,  
> I celebrate these changes deep into the night!

<!-- walkthrough_end -->


---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between a8f8c5d3bfb85a5da8b9c876db0e46457d4988b8 and e1a3c1505ac840a65f1bd8e67aad833279a3c090.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `CHANGELOG.md` (1 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* CHANGELOG.md

</details>

</details>
<!-- internal state start -->


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

<!-- internal state end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=verse-pbc/plur&utm_content=128):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai plan` to trigger planning for file edits and PR creation.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: CONTRIBUTOR
user: login: mplorentz
created_at: 2025-03-27T15:44:48Z
body: 👀 
author_association: CONTRIBUTOR
user: login: mplorentz
created_at: 2025-03-27T16:03:22Z
body: Oh yeah and the Update Image text looked bigger to me in the simulator than in the designs, could you double-check that you have the right font size there?
author_association: CONTRIBUTOR
user: login: bryanmontz
created_at: 2025-03-28T12:03:56Z
body: > Oh yeah and the Update Image text looked bigger to me in the simulator than in the designs, could you double-check that you have the right font size there?

You were right: it was a bit too big. Fixed!
author_association: CONTRIBUTOR
user: login: bryanmontz
created_at: 2025-03-28T13:03:42Z
body: > I know this wasn't specified anywhere but personally I'd like to go ahead and add a confirmation before navigating away from the admin panel if you have changed things but haven't saved them. Something to run by @joshuatbrown and doesn't need to be done in this PR. I had one other question here but nothing to block a merge. Nice work!

Agree. [Here's](https://github.com/verse-pbc/issues/issues/299) a ticket for that. @joshuatbrown could you confirm this and add preferred copy to it?
author_association: CONTRIBUTOR
user: login: bryanmontz
created_at: 2025-03-28T13:32:56Z
body: FYI: I'm merging this bypassing the lint changes job failure because the only thing it mentions is not something I changed in this PR. I did add `GROUP_ADMIN`, following the existing pattern, but the linter wants camel case. However, that should be changed outside of this PR.
author_association: CONTRIBUTOR