diff --git a/README.md b/README.md index 2bf261c..d8182c8 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ but publish independently. **Depend on a widget and you get its dependencies; no | [codifyiq_ai_progress_indicator](packages/codifyiq_ai_progress_indicator) | [![pub](https://img.shields.io/pub/v/codifyiq_ai_progress_indicator.svg)](https://pub.dev/packages/codifyiq_ai_progress_indicator) | ✔ | ✔ | ✔ | ✔ | ✔ | | [codifyiq_audio_message](packages/codifyiq_audio_message) | [![pub](https://img.shields.io/pub/v/codifyiq_audio_message.svg)](https://pub.dev/packages/codifyiq_audio_message) | ✔ | ✔ | ✔ | ✔ | ✔ | | [codifyiq_brightness_button](packages/codifyiq_brightness_button) | [![pub](https://img.shields.io/pub/v/codifyiq_brightness_button.svg)](https://pub.dev/packages/codifyiq_brightness_button) | ✔ | ✔ | ✔ | ✔ | ✔ | +| [codifyiq_group_manager](packages/codifyiq_group_manager) | [![pub](https://img.shields.io/pub/v/codifyiq_group_manager.svg)](https://pub.dev/packages/codifyiq_group_manager) | ✔ | ✔ | ✔ | ✔ | ✔ | | [codifyiq_image_viewer](packages/codifyiq_image_viewer) | [![pub](https://img.shields.io/pub/v/codifyiq_image_viewer.svg)](https://pub.dev/packages/codifyiq_image_viewer) | ✔ | ✔ | ✔ ¹ | ✔ | ✔ | | [codifyiq_notification_center](packages/codifyiq_notification_center) | [![pub](https://img.shields.io/pub/v/codifyiq_notification_center.svg)](https://pub.dev/packages/codifyiq_notification_center) | ✔ | ✔ | ✔ | ✔ | ✔ | | [codifyiq_pdf_viewer](packages/codifyiq_pdf_viewer) | [![pub](https://img.shields.io/pub/v/codifyiq_pdf_viewer.svg)](https://pub.dev/packages/codifyiq_pdf_viewer) | ✔ | ✔ | ✔ ² | ✔ | ✔ | diff --git a/claude.md b/claude.md index adbe05a..86c07fb 100644 --- a/claude.md +++ b/claude.md @@ -109,6 +109,7 @@ class MyWidget extends StatelessWidget { | `codifyiq_ai_progress_indicator` | Shimmer progress indicator for AI actions | `shimmer` | | `codifyiq_audio_message` | Chat-style audio player (pluggable backend) | `just_audio` | | `codifyiq_brightness_button` | Light/Dark/System theme toggle | `adaptive_theme` | +| `codifyiq_group_manager` | Authorization group catalog + per-user assignment | — | | `codifyiq_image_viewer` | Full-screen image viewer (swipe, pinch-zoom) | `photo_view` | | `codifyiq_notification_center` | Play Store-style notification bell + center | — | | `codifyiq_pdf_viewer` | PDF viewer with zoom + search | `pdfrx` | diff --git a/example/lib/group_manager_example.dart b/example/lib/group_manager_example.dart new file mode 100644 index 0000000..aef59b7 --- /dev/null +++ b/example/lib/group_manager_example.dart @@ -0,0 +1,310 @@ +import 'package:codifyiq_group_manager/codifyiq_group_manager.dart'; +import 'package:flutter/material.dart'; + +/// Demo for [GroupManagerController], [GroupManagerView], and +/// [GroupAssignmentField]. +/// +/// The first tab manages the group catalog (create / edit / delete). The +/// second assigns one or more of those groups to a handful of demo users via +/// removable chips and a searchable picker. The third shows the same field +/// attaching groups to a *non-user* target — folders — where the choices are +/// scoped to only the groups the signed-in user belongs to. All three tabs are +/// driven by a single UI-only controller — edits on one are reflected on the +/// others. +class GroupManagerExample extends StatefulWidget { + /// Creates a [GroupManagerExample]. + const GroupManagerExample({super.key}); + + @override + State createState() => _GroupManagerExampleState(); +} + +class _GroupManagerExampleState extends State { + final GroupManagerController _controller = GroupManagerController( + groups: const [ + Group( + id: 'admins', + name: 'Administrators', + description: 'Full access to every setting.', + color: GroupColor.primary, + icon: Icons.admin_panel_settings, + ), + Group( + id: 'editors', + name: 'Editors', + description: 'Can create and edit content.', + color: GroupColor.secondary, + icon: Icons.edit_note, + ), + Group( + id: 'viewers', + name: 'Viewers', + description: 'Read-only access.', + color: GroupColor.tertiary, + icon: Icons.visibility, + ), + Group( + id: 'billing', + name: 'Billing', + description: 'Manage invoices and subscriptions.', + color: GroupColor.neutral, + icon: Icons.receipt_long, + ), + ], + assignments: { + 'ada': {'admins', 'editors'}, + 'grace': {'editors'}, + }, + ); + + static const List<({String id, String name})> _users = [ + (id: 'ada', name: 'Ada Lovelace'), + (id: 'grace', name: 'Grace Hopper'), + (id: 'linus', name: 'Linus Torvalds'), + ]; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + title: const Text('Group Manager Example'), + bottom: const TabBar( + tabs: [ + Tab(text: 'Groups'), + Tab(text: 'Members'), + Tab(text: 'Apply groups'), + ], + ), + ), + body: TabBarView( + children: [ + GroupManagerView(controller: _controller), + _MembersTab(controller: _controller, users: _users), + _FoldersTab(controller: _controller, currentUserId: 'ada'), + ], + ), + ), + ); + } +} + +class _MembersTab extends StatefulWidget { + const _MembersTab({required this.controller, required this.users}); + + final GroupManagerController controller; + final List<({String id, String name})> users; + + @override + State<_MembersTab> createState() => _MembersTabState(); +} + +class _MembersTabState extends State<_MembersTab> { + final TextEditingController _search = TextEditingController(); + String _query = ''; + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final q = _query.trim().toLowerCase(); + final users = q.isEmpty + ? widget.users + : widget.users.where((u) => u.name.toLowerCase().contains(q)).toList(); + + return ListenableBuilder( + listenable: widget.controller, + builder: (context, _) { + // Cap the content to a comfortable measure and center it on wide panes + // (M3 large-screen guidance), matching GroupManagerView's default. + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 840), + child: Column( + // Let the search bar fill its pane (M3: scale with the layout, + // stay close to the content it filters) — matching the Groups tab. + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: SearchBar( + controller: _search, + hintText: 'Search members', + leading: const Icon(Icons.search), + elevation: const WidgetStatePropertyAll(0), + trailing: [ + if (_query.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear), + tooltip: 'Clear search', + onPressed: () { + _search.clear(); + setState(() => _query = ''); + }, + ), + ], + onChanged: (value) => setState(() => _query = value), + ), + ), + Expanded( + child: users.isEmpty + ? Center( + child: Text( + 'No members match "${_query.trim()}"', + style: Theme.of(context).textTheme.titleMedium, + ), + ) + : ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: users.length, + separatorBuilder: (_, _) => + const SizedBox(height: 12), + itemBuilder: (context, index) { + final user = users[index]; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: GroupAssignmentField( + label: user.name, + groups: widget.controller.groups, + selected: widget.controller.groupsFor( + user.id, + ), + onChanged: (ids) => widget.controller + .setAssignments(user.id, ids), + // Editing a person's memberships reads better + // with a "manage user" glyph than a pencil. + editIcon: Icons.manage_accounts_outlined, + editLabel: 'Edit groups', + pickerTitle: 'Assign groups to ${user.name}', + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +/// Attaches groups to a non-user target — folders. +/// +/// Demonstrates the reusable [GroupAssignmentField] pointed at an arbitrary +/// object: each folder is keyed by `'folder:'` in the same controller, and +/// the offered groups are scoped to only those the signed-in user belongs to — +/// you can share a folder with your own groups, but not ones you lack. +class _FoldersTab extends StatelessWidget { + const _FoldersTab({required this.controller, required this.currentUserId}); + + final GroupManagerController controller; + final String currentUserId; + + static const List<({String id, String name, IconData icon})> _folders = [ + (id: 'reports', name: 'Quarterly Reports', icon: Icons.folder_outlined), + (id: 'designs', name: 'Product Designs', icon: Icons.folder_outlined), + (id: 'contracts', name: 'Contracts', icon: Icons.folder_outlined), + ]; + + Widget _folderCard( + BuildContext context, + ({String id, String name, IconData icon}) folder, + List grantable, + ) { + final shared = controller.groupsFor('folder:${folder.id}'); + final grantableIds = grantable.map((g) => g.id).toSet(); + // Offer what the signer can grant, plus anything already shared with this + // folder, so an existing share stays visible and removable even if they + // later leave that group. Without this the assignment would orphan: gone + // from both the chips and the picker, yet still in the data. + final offered = [ + for (final group in controller.groups) + if (grantableIds.contains(group.id) || shared.contains(group.id)) group, + ]; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: GroupAssignmentField( + label: folder.name, + groups: offered, + selected: shared, + onChanged: (ids) => + controller.setAssignments('folder:${folder.id}', ids), + // Granting a folder access — the default group-add glyph fits, but + // relabel the action to read as access rather than membership. + editLabel: 'Manage access', + pickerTitle: 'Share "${folder.name}" with your groups', + emptyHint: 'Not shared with any of your groups', + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + // The choices are scoped to the signed-in user's own groups — what they + // are allowed to grant — not the whole catalog. + final grantable = controller.resolvedGroupsFor(currentUserId); + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 840), + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'An example of applying existing groups to objects in your ' + 'application. Here, the signed-in user shares folders with the ' + 'groups they belong to.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + Card( + color: theme.colorScheme.surfaceContainerHigh, + child: ListTile( + leading: const Icon(Icons.account_circle_outlined), + title: const Text('Signed in as Ada Lovelace'), + subtitle: Text( + grantable.isEmpty + ? 'You belong to no groups, so there is nothing to share.' + : 'You can share folders with the groups you belong to: ' + '${grantable.map((g) => g.name).join(', ')}.', + ), + ), + ), + const SizedBox(height: 12), + for (final folder in _folders) ...[ + _folderCard(context, folder, grantable), + const SizedBox(height: 12), + ], + ], + ), + ), + ); + }, + ); + } +} diff --git a/example/lib/router.dart b/example/lib/router.dart index 2da5310..77ab3de 100644 --- a/example/lib/router.dart +++ b/example/lib/router.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'ai_progress_indicator_example.dart'; import 'audio_message_example.dart'; +import 'group_manager_example.dart'; import 'notification_center_example.dart'; import 'pdf_viewer_widget_example.dart'; import 'image_viewer_widget_example.dart'; @@ -83,6 +84,12 @@ ShellRoute _getMainApplicationShellRoute() { return const AudioMessageExample(); }, ), + GoRoute( + path: "/group-manager", + builder: (BuildContext context, GoRouterState state) { + return const GroupManagerExample(); + }, + ), ], ); } diff --git a/example/lib/widget_catalog.dart b/example/lib/widget_catalog.dart index 2a21360..3540861 100644 --- a/example/lib/widget_catalog.dart +++ b/example/lib/widget_catalog.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'ai_progress_indicator_example.dart'; import 'audio_message_example.dart'; +import 'group_manager_example.dart'; import 'image_viewer_widget_example.dart'; import 'notification_center_example.dart'; import 'pdf_viewer_widget_example.dart'; @@ -59,6 +60,12 @@ class WidgetCatalog extends StatelessWidget { 'Chat-style audio message player with play/pause, progress, and duration', 'route': AudioMessageExample(), }, + { + 'name': 'Group Manager', + 'description': + 'Manage authorization groups and assign one or more to each user', + 'route': GroupManagerExample(), + }, ]; @override diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 7455040..6f03499 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: codifyiq_ai_progress_indicator: ^1.0.0 codifyiq_audio_message: ^1.0.0 codifyiq_brightness_button: ^1.0.0 + codifyiq_group_manager: ^1.0.0 codifyiq_image_viewer: ^1.0.0 codifyiq_notification_center: ^1.0.0 codifyiq_pdf_viewer: ^1.0.0 diff --git a/packages/codifyiq_group_manager/CHANGELOG.md b/packages/codifyiq_group_manager/CHANGELOG.md new file mode 100644 index 0000000..82ba1ad --- /dev/null +++ b/packages/codifyiq_group_manager/CHANGELOG.md @@ -0,0 +1,9 @@ +## 1.0.0 + +* Initial release. +* `GroupManagerController` + `GroupManagerScope` — a UI-only state container for a flat catalog of authorization groups and their assignment to principals (no nested groups, no separate roles). +* `GroupManagerView` and `GroupListView` — drop-in Material 3 surfaces for creating, editing, and deleting groups, with cascade-unassign on delete and built-in search to filter long catalogs. `GroupManagerView` caps its content to a comfortable measure and centers it on large screens (configurable via `maxContentWidth`). Intercept persistence with `onCreate` / `onEdit` / `onDelete` to wire create/edit/delete to a backend before (or instead of) mutating the controller. +* `GroupColor` — group accents are theme-derived roles (`primary`/`secondary`/`tertiary`/`neutral`) resolved against the active `ColorScheme`, so they adapt to light/dark; a stable role is auto-assigned when none is set. +* `GroupAssignmentField` — assign one or more groups to a target via removable chips and a stationary edit button that opens a searchable picker (which both adds and removes membership). Customize the trigger's tooltip and glyph via `editLabel` / `editIcon` — e.g. an authorization icon when granting access rather than editing membership. +* `GroupPicker` — a searchable, multi-select group picker that adapts between a bottom sheet (compact widths) and a dialog (large screens). +* `GroupEditorDialog`, `GroupChip`, and `GroupAvatar` — composable building blocks for custom group-management screens. diff --git a/packages/codifyiq_group_manager/LICENSE b/packages/codifyiq_group_manager/LICENSE new file mode 100644 index 0000000..e4043c3 --- /dev/null +++ b/packages/codifyiq_group_manager/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 CodifyIQ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/codifyiq_group_manager/README.md b/packages/codifyiq_group_manager/README.md new file mode 100644 index 0000000..6bbf550 --- /dev/null +++ b/packages/codifyiq_group_manager/README.md @@ -0,0 +1,113 @@ +# codifyiq_group_manager + +[![pub package](https://img.shields.io/pub/v/codifyiq_group_manager.svg)](https://pub.dev/packages/codifyiq_group_manager) + +Material 3 widgets for managing **flat authorization groups** and assigning one or more of them +to users. Groups are intentionally simple — no nesting and no separate roles. A principal (a user, +service account, or any subject you authorize) is just assigned one or more groups, and your app +derives whatever permissions it likes from that membership. + +This package is **UI-only**: it never talks to a backend. You drive the controller and wire its +mutations to your own persistence layer. + +## Installation + +```yaml +dependencies: + codifyiq_group_manager: ^1.0.0 +``` + +## Concepts + +| Piece | Role | +|---|---| +| `Group` | Immutable group model (id, name, optional description/color/icon). | +| `GroupColor` | Theme-derived accent role (resolves against `ColorScheme`). | +| `GroupManagerController` | UI-only state container for the catalog and assignments. | +| `GroupManagerScope` | Inherited notifier exposing the controller to a subtree. | +| `GroupManagerView` | Drop-in catalog screen (create / edit / delete + search). | +| `GroupListView` | The catalog list, controller-driven, with edit/delete + filter. | +| `GroupAssignmentField` | Assign groups to any target (user, folder, …) — chips + searchable add picker. | +| `GroupPicker` | Searchable multi-select picker — adaptive bottom sheet / dialog. | +| `GroupEditorDialog` / `GroupChip` / `GroupAvatar` | Composable building blocks. | + +## Usage + +### Manage the catalog + +```dart +final controller = GroupManagerController( + groups: const [ + Group(id: 'admins', name: 'Administrators', icon: Icons.admin_panel_settings), + Group(id: 'editors', name: 'Editors'), + ], +); + +// Drop the full management surface into a Scaffold body: +Scaffold(body: GroupManagerView(controller: controller)); +``` + +`GroupManagerView` (and `GroupListView`) handle create, edit, and delete against the controller, +and `GroupManagerView` includes a search field that filters the catalog once it has groups. +Deleting a group cascades — it is also unassigned from every member. + +### Group colors follow the theme + +A group's accent is a `GroupColor` role (`primary`, `secondary`, `tertiary`, `neutral`) resolved +against the active `ColorScheme` at render time — so it harmonizes with your app and adapts to +light/dark automatically. Leave `Group.color` `null` to auto-assign a stable role per group: + +```dart +const Group(id: 'admins', name: 'Administrators', color: GroupColor.primary); +const Group(id: 'editors', name: 'Editors'); // auto-derived, stable per id +``` + +### Assign groups to a user + +`GroupAssignmentField` is value-driven, so wire it to the controller from your form: + +```dart +ListenableBuilder( + listenable: controller, + builder: (context, _) => GroupAssignmentField( + label: 'Groups', + groups: controller.groups, + selected: controller.groupsFor(userId), + onChanged: (ids) => controller.setAssignments(userId, ids), + ), +); +``` + +### Assign groups to any object (folder, document, project, …) + +The field is target-agnostic, and assignments are keyed by any id you choose — so +the same field attaches groups to a folder just as well as to a user. Key the +assignment by the object's id, and offer a **scoped subset** of groups to limit +choices — for example, only the groups the signed-in user belongs to (what they +are allowed to grant): + +```dart +GroupAssignmentField( + label: folder.name, + groups: controller.resolvedGroupsFor(currentUserId), // only what I can grant + selected: controller.groupsFor('folder:${folder.id}'), + onChanged: (ids) => controller.setAssignments('folder:${folder.id}', ids), + pickerTitle: 'Share "${folder.name}" with your groups', +); +``` + +### Ambient access via scope + +```dart +GroupManagerScope( + controller: controller, + child: MyApp(), +); + +// Anywhere below: +GroupManagerScope.of(context).assign(userId, 'admins'); +``` + +--- + +Part of the [CodifyIQ component family](https://github.com/CodifyIQ/codifyiq-core-components) · [pub.dev/publishers/codifyiq.com](https://pub.dev/publishers/codifyiq.com) diff --git a/packages/codifyiq_group_manager/example/README.md b/packages/codifyiq_group_manager/example/README.md new file mode 100644 index 0000000..b98cd92 --- /dev/null +++ b/packages/codifyiq_group_manager/example/README.md @@ -0,0 +1,30 @@ +# codifyiq_group_manager example + +```dart +import 'package:codifyiq_group_manager/codifyiq_group_manager.dart'; +import 'package:flutter/material.dart'; + +final controller = GroupManagerController( + groups: const [ + Group(id: 'admins', name: 'Administrators', icon: Icons.admin_panel_settings), + Group(id: 'editors', name: 'Editors'), + ], +); + +// Catalog management: +Widget buildCatalog() => GroupManagerView(controller: controller); + +// Assign groups to a user: +Widget buildAssignment(String userId) => ListenableBuilder( + listenable: controller, + builder: (context, _) => GroupAssignmentField( + label: 'Groups', + groups: controller.groups, + selected: controller.groupsFor(userId), + onChanged: (ids) => controller.setAssignments(userId, ids), + ), +); +``` + +A runnable catalog demonstrating every CodifyIQ component lives in the +[`example/` app at the repository root](https://github.com/CodifyIQ/codifyiq-core-components/tree/dev/example). diff --git a/packages/codifyiq_group_manager/lib/codifyiq_group_manager.dart b/packages/codifyiq_group_manager/lib/codifyiq_group_manager.dart new file mode 100644 index 0000000..e346725 --- /dev/null +++ b/packages/codifyiq_group_manager/lib/codifyiq_group_manager.dart @@ -0,0 +1,14 @@ +/// Material 3 widgets for managing flat authorization groups and assigning +/// them to principals. +library; + +export 'src/group.dart'; +export 'src/group_assignment_field.dart'; +export 'src/group_avatar.dart'; +export 'src/group_chip.dart'; +export 'src/group_color.dart'; +export 'src/group_editor_dialog.dart'; +export 'src/group_list_view.dart'; +export 'src/group_manager_controller.dart'; +export 'src/group_manager_view.dart'; +export 'src/group_picker.dart'; diff --git a/packages/codifyiq_group_manager/lib/src/group.dart b/packages/codifyiq_group_manager/lib/src/group.dart new file mode 100644 index 0000000..2174697 --- /dev/null +++ b/packages/codifyiq_group_manager/lib/src/group.dart @@ -0,0 +1,89 @@ +import 'package:flutter/widgets.dart'; + +import 'group_color.dart'; + +/// An authorization group that one or more principals (users) can belong to. +/// +/// Groups are flat — there is no nesting and no separate notion of roles. A +/// principal is simply assigned one or more groups, and the host application +/// derives whatever permissions it likes from that membership. +/// +/// Groups are immutable; produce modified copies with [copyWith]. Equality is +/// by value across every field so that list widgets rebuild only when a group +/// actually changes. +@immutable +class Group { + /// Creates a [Group]. + /// + /// [id] must be stable and unique within a catalog — it is what the + /// controller and assignment APIs key on. [name] is the human-readable + /// label shown in the UI. + const Group({ + required this.id, + required this.name, + this.description, + this.color, + this.icon, + }); + + /// Stable, unique identifier used to reference the group in assignments. + final String id; + + /// Human-readable label shown in lists, chips, and pickers. + final String name; + + /// Optional secondary line describing the group's purpose. + final String? description; + + /// Optional theme-derived accent for the group's avatar and chip. + /// + /// References a Material 3 container role rather than a fixed color, so the + /// accent always harmonizes with the app theme and adapts to light/dark. + /// When `null`, a stable role is auto-derived from [id] (see + /// [GroupColor.auto]). + final GroupColor? color; + + /// Optional glyph for the group's avatar and chip. When `null`, widgets + /// fall back to the first letter of [name]. + final IconData? icon; + + /// Returns a copy with the given fields replaced. + /// + /// Pass [clearDescription], [clearColor], or [clearIcon] to explicitly reset + /// those nullable fields back to `null`. + Group copyWith({ + String? id, + String? name, + String? description, + GroupColor? color, + IconData? icon, + bool clearDescription = false, + bool clearColor = false, + bool clearIcon = false, + }) { + return Group( + id: id ?? this.id, + name: name ?? this.name, + description: clearDescription ? null : (description ?? this.description), + color: clearColor ? null : (color ?? this.color), + icon: clearIcon ? null : (icon ?? this.icon), + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Group && + runtimeType == other.runtimeType && + id == other.id && + name == other.name && + description == other.description && + color == other.color && + icon == other.icon; + + @override + int get hashCode => Object.hash(id, name, description, color, icon); + + @override + String toString() => 'Group(id: $id, name: $name)'; +} diff --git a/packages/codifyiq_group_manager/lib/src/group_assignment_field.dart b/packages/codifyiq_group_manager/lib/src/group_assignment_field.dart new file mode 100644 index 0000000..bc804ae --- /dev/null +++ b/packages/codifyiq_group_manager/lib/src/group_assignment_field.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; + +import 'group.dart'; +import 'group_chip.dart'; +import 'group_picker.dart'; + +/// A form field for assigning one or more groups to a target. +/// +/// The target is anything your app authorizes — a user, but equally a folder, +/// document, project, or any other object. The widget is target-agnostic: it +/// only deals in the [groups] it may offer and the [selected] ids; the caller +/// decides what those groups are being attached to. +/// +/// Renders the currently selected groups as removable [GroupChip]s, with a +/// stationary "Edit" button in the header that opens a searchable [GroupPicker] +/// of the offered [groups]. The picker both adds and removes membership, so the +/// affordance reads "Edit" rather than "Add", and it stays put in the header so +/// it doesn't drift as chips are added or removed. Removing a chip or confirming +/// the picker reports the new selection through [onChanged]. +/// +/// Only [selected] ids that are present in [groups] are rendered — the widget +/// has no [Group] data for ids outside the offered set, so it can neither show +/// nor remove them. When you scope [groups] to a subset (e.g. only the signed-in +/// user's own groups), keep it a superset of [selected], or reconcile the +/// selection when the offered set shrinks; otherwise a previously-assigned group +/// that drops out of [groups] becomes an invisible, unremovable assignment. +/// +/// This widget is value-driven and stateless with respect to membership — the +/// caller owns [selected] and applies updates in [onChanged]. Wire it to a +/// [GroupManagerController] from the caller, for example to assign groups to a +/// user: +/// +/// ```dart +/// ListenableBuilder( +/// listenable: controller, +/// builder: (context, _) => GroupAssignmentField( +/// groups: controller.groups, +/// selected: controller.groupsFor(userId), +/// onChanged: (ids) => controller.setAssignments(userId, ids), +/// ), +/// ); +/// ``` +/// +/// To attach groups to some other object — say, share a folder with only the +/// groups the signed-in user belongs to — offer that scoped subset and key the +/// assignment by the object's id: +/// +/// ```dart +/// GroupAssignmentField( +/// groups: controller.resolvedGroupsFor(currentUserId), // only what I can grant +/// selected: controller.groupsFor('folder:$folderId'), +/// onChanged: (ids) => controller.setAssignments('folder:$folderId', ids), +/// ); +/// ``` +class GroupAssignmentField extends StatelessWidget { + /// Creates a [GroupAssignmentField]. + const GroupAssignmentField({ + super.key, + required this.groups, + required this.selected, + required this.onChanged, + this.label, + this.enabled = true, + this.editLabel = 'Edit groups', + this.editIcon = Icons.group_add_outlined, + this.pickerTitle = 'Assign groups', + this.emptyHint = 'No groups assigned', + }); + + /// The groups that may be assigned to the target. Pass the full catalog, or a + /// scoped subset (e.g. only the signed-in user's own groups) to limit choices. + final List groups; + + /// Ids of the groups currently assigned to the target. + final Set selected; + + /// Called with the updated id set whenever the assignment changes. + final ValueChanged> onChanged; + + /// Optional label rendered above the chips. + final String? label; + + /// Whether the field is interactive. When `false`, chips are read-only and + /// the "Edit" affordance is hidden. + final bool enabled; + + /// Tooltip for the header button that opens the picker. The picker both adds + /// and removes membership, so this defaults to "Edit groups" rather than + /// "Add" — pairing with the group-add [editIcon] without implying add-only. + final String editLabel; + + /// Icon for the header button that opens the picker. Defaults to a group-add + /// glyph; pass a more specific domain icon — e.g. a "manage user" glyph when + /// assigning to a person, or an authorization glyph when granting access. + final IconData editIcon; + + /// Title shown on the picker sheet. + final String pickerTitle; + + /// Hint shown in place of the chips when nothing is assigned. When [enabled], + /// the header's edit button remains available to add the first group. + final String emptyHint; + + List get _selectedGroups => [ + for (final group in groups) + if (selected.contains(group.id)) group, + ]; + + Future _openPicker(BuildContext context) async { + final result = await GroupPicker.show( + context, + groups: groups, + initiallySelected: selected, + title: pickerTitle, + ); + if (result != null) onChanged(result); + } + + void _remove(String id) { + onChanged({...selected}..remove(id)); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final selectedGroups = _selectedGroups; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // The label and edit trigger share a fixed header row, so the trigger + // stays anchored instead of drifting to the end of the chip flow as + // membership changes. + if (label != null || enabled) ...[ + Row( + children: [ + if (label != null) + Expanded( + child: Text( + label!, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + else + const Spacer(), + if (enabled) + IconButton( + icon: Icon(editIcon), + tooltip: editLabel, + onPressed: () => _openPicker(context), + ), + ], + ), + const SizedBox(height: 8), + ], + if (selectedGroups.isEmpty) + Text( + emptyHint, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ) + else + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (final group in selectedGroups) + GroupChip( + group: group, + onDeleted: enabled ? () => _remove(group.id) : null, + ), + ], + ), + ], + ); + } +} diff --git a/packages/codifyiq_group_manager/lib/src/group_avatar.dart b/packages/codifyiq_group_manager/lib/src/group_avatar.dart new file mode 100644 index 0000000..0834da1 --- /dev/null +++ b/packages/codifyiq_group_manager/lib/src/group_avatar.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +import 'group.dart'; +import 'group_color.dart'; + +/// A small circular badge representing a [Group]. +/// +/// Renders the group's [Group.icon] when present, otherwise the first letter of +/// its [Group.name]. The circle is tinted with the group's [GroupColor] role +/// resolved against the current theme — or, when the group has no explicit +/// role, a stable one auto-derived from its id. Foreground contrast comes from +/// the role's matching `on*` token, so it is always correct. +class GroupAvatar extends StatelessWidget { + /// Creates a [GroupAvatar] for [group]. + const GroupAvatar({super.key, required this.group, this.radius = 20.0}); + + /// The group to represent. + final Group group; + + /// Radius of the circle, in logical pixels. + final double radius; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final role = group.color ?? GroupColor.auto(group.id); + final (:background, :foreground) = role.resolve(scheme); + + final icon = group.icon; + final initial = group.name.trim().isEmpty + ? '?' + : group.name.trim().characters.first.toUpperCase(); + + return CircleAvatar( + radius: radius, + backgroundColor: background, + child: icon != null + ? Icon(icon, size: radius, color: foreground) + : Text( + initial, + style: TextStyle( + fontSize: radius * 0.9, + fontWeight: FontWeight.bold, + color: foreground, + ), + ), + ); + } +} diff --git a/packages/codifyiq_group_manager/lib/src/group_chip.dart b/packages/codifyiq_group_manager/lib/src/group_chip.dart new file mode 100644 index 0000000..18fa048 --- /dev/null +++ b/packages/codifyiq_group_manager/lib/src/group_chip.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +import 'group.dart'; +import 'group_avatar.dart'; + +/// A Material 3 chip representing a single [Group]. +/// +/// When [onDeleted] is provided the chip renders as a removable +/// [InputChip] (with a trailing delete affordance) — the shape used inside +/// [GroupAssignmentField] for an assigned group. Otherwise it renders as a +/// static, read-only chip suitable for compact membership summaries. +class GroupChip extends StatelessWidget { + /// Creates a [GroupChip] for [group]. + const GroupChip({ + super.key, + required this.group, + this.onDeleted, + this.onPressed, + }); + + /// The group to display. + final Group group; + + /// Called when the user removes the chip. When non-null the chip shows a + /// trailing delete icon. + final VoidCallback? onDeleted; + + /// Called when the user taps the chip body. + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final avatar = GroupAvatar(group: group, radius: 12); + final label = Text(group.name); + + if (onDeleted != null) { + return InputChip( + avatar: avatar, + label: label, + onPressed: onPressed, + onDeleted: onDeleted, + deleteButtonTooltipMessage: 'Remove ${group.name}', + ); + } + + return onPressed != null + ? ActionChip(avatar: avatar, label: label, onPressed: onPressed) + : Chip(avatar: avatar, label: label); + } +} diff --git a/packages/codifyiq_group_manager/lib/src/group_color.dart b/packages/codifyiq_group_manager/lib/src/group_color.dart new file mode 100644 index 0000000..822fa7a --- /dev/null +++ b/packages/codifyiq_group_manager/lib/src/group_color.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +/// A theme-derived accent for a [Group]. +/// +/// Rather than storing a concrete [Color] — which can't follow light/dark or a +/// rebranded [ColorScheme] — a group references one of Material 3's container +/// roles. The actual color is resolved against the ambient theme at render +/// time via [resolve], so a group's accent always harmonizes with the app and +/// adapts automatically when the theme changes. +enum GroupColor { + /// The primary container role. + primary, + + /// The secondary container role. + secondary, + + /// The tertiary container role. + tertiary, + + /// A neutral, low-emphasis surface role. + neutral; + + /// The roles eligible for automatic per-group assignment. Excludes + /// [neutral], so an auto-colored group always gets a distinct accent; + /// [neutral] remains available as an explicit, deliberately muted choice. + static const List _autoRoles = [ + primary, + secondary, + tertiary, + ]; + + /// Deterministically derives a stable accent for the group with [id]. + /// + /// The same id always maps to the same role (within and across runs), so a + /// group's auto color doesn't shuffle on rebuild. Used when a group has no + /// explicit [GroupColor]. + static GroupColor auto(String id) { + var hash = 0; + for (final unit in id.codeUnits) { + hash = (hash * 31 + unit) & 0x7fffffff; + } + return _autoRoles[hash % _autoRoles.length]; + } + + /// Resolves this role to a background/foreground pair from [scheme]. + /// + /// The foreground is the role's matching `on*` token, so contrast is correct + /// in every theme without any luminance guessing. + ({Color background, Color foreground}) resolve(ColorScheme scheme) { + return switch (this) { + GroupColor.primary => ( + background: scheme.primaryContainer, + foreground: scheme.onPrimaryContainer, + ), + GroupColor.secondary => ( + background: scheme.secondaryContainer, + foreground: scheme.onSecondaryContainer, + ), + GroupColor.tertiary => ( + background: scheme.tertiaryContainer, + foreground: scheme.onTertiaryContainer, + ), + GroupColor.neutral => ( + background: scheme.surfaceContainerHighest, + foreground: scheme.onSurfaceVariant, + ), + }; + } +} diff --git a/packages/codifyiq_group_manager/lib/src/group_editor_dialog.dart b/packages/codifyiq_group_manager/lib/src/group_editor_dialog.dart new file mode 100644 index 0000000..08d2db2 --- /dev/null +++ b/packages/codifyiq_group_manager/lib/src/group_editor_dialog.dart @@ -0,0 +1,273 @@ +import 'package:flutter/material.dart'; + +import 'group.dart'; +import 'group_color.dart'; + +/// A Material 3 dialog for creating or editing a [Group]. +/// +/// Collects a required name, an optional description, an optional theme-derived +/// accent ([GroupColor]) and an optional icon chosen from a small preset +/// palette. The dialog performs no persistence — it returns the assembled +/// [Group] to the caller, which is responsible for adding or updating it (e.g. +/// via a [GroupManagerController]). +/// +/// Use [show] to present it; the future completes with the saved [Group], or +/// `null` if the user cancels. +class GroupEditorDialog extends StatefulWidget { + /// Creates a [GroupEditorDialog]. + /// + /// When [initial] is supplied the dialog opens in edit mode, pre-filled with + /// that group's values and preserving its id on save. When omitted, a new id + /// is generated from the current time on save. + const GroupEditorDialog({super.key, this.initial}); + + /// The group being edited, or `null` when creating a new group. + final Group? initial; + + /// Preset icons offered in the editor. + static const List iconPresets = [ + Icons.group, + Icons.admin_panel_settings, + Icons.shield, + Icons.engineering, + Icons.support_agent, + Icons.school, + Icons.account_balance, + Icons.science, + ]; + + /// Shows the dialog and resolves with the saved [Group], or `null` if the + /// user cancels. + static Future show(BuildContext context, {Group? initial}) { + return showDialog( + context: context, + builder: (_) => GroupEditorDialog(initial: initial), + ); + } + + @override + State createState() => _GroupEditorDialogState(); +} + +class _GroupEditorDialogState extends State { + final _formKey = GlobalKey(); + late final TextEditingController _name; + late final TextEditingController _description; + GroupColor? _color; + IconData? _icon; + + @override + void initState() { + super.initState(); + _name = TextEditingController(text: widget.initial?.name ?? ''); + _description = TextEditingController( + text: widget.initial?.description ?? '', + ); + _color = widget.initial?.color; + _icon = widget.initial?.icon; + } + + @override + void dispose() { + _name.dispose(); + _description.dispose(); + super.dispose(); + } + + void _save() { + if (!_formKey.currentState!.validate()) return; + final description = _description.text.trim(); + final result = Group( + id: + widget.initial?.id ?? + 'group-${DateTime.now().microsecondsSinceEpoch}', + name: _name.text.trim(), + description: description.isEmpty ? null : description, + color: _color, + icon: _icon, + ); + Navigator.of(context).pop(result); + } + + @override + Widget build(BuildContext context) { + final isEditing = widget.initial != null; + return AlertDialog( + // Scroll the content so a short viewport (or the validation message + // expanding the form) never overflows. + scrollable: true, + title: Text(isEditing ? 'Edit group' : 'New group'), + content: SizedBox( + width: 380, + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _name, + autofocus: true, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + labelText: 'Name', + hintText: 'e.g. Administrators', + ), + validator: (value) => (value == null || value.trim().isEmpty) + ? 'Name is required' + : null, + onFieldSubmitted: (_) => _save(), + ), + const SizedBox(height: 16), + TextFormField( + controller: _description, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'Description (optional)', + ), + maxLines: 2, + ), + const SizedBox(height: 20), + _SectionLabel('Color'), + const SizedBox(height: 8), + _ColorPalette( + selected: _color, + onSelected: (c) => setState(() => _color = c), + ), + const SizedBox(height: 20), + _SectionLabel('Icon'), + const SizedBox(height: 8), + _IconPalette( + selected: _icon, + onSelected: (i) => setState(() => _icon = i), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _save, + child: Text(isEditing ? 'Save' : 'Create'), + ), + ], + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Text( + text, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ); + } +} + +class _ColorPalette extends StatelessWidget { + const _ColorPalette({required this.selected, required this.onSelected}); + + final GroupColor? selected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + // "Auto" — derive a stable role from the group id. + InkResponse( + radius: 24, + onTap: () => onSelected(null), + child: CircleAvatar( + radius: 16, + backgroundColor: scheme.surfaceContainerHighest, + child: Icon( + selected == null ? Icons.check : Icons.auto_awesome, + size: 16, + color: scheme.onSurfaceVariant, + ), + ), + ), + for (final role in GroupColor.values) + _Swatch( + role: role, + selected: selected == role, + onTap: () => onSelected(role), + ), + ], + ); + } +} + +class _Swatch extends StatelessWidget { + const _Swatch({ + required this.role, + required this.selected, + required this.onTap, + }); + + final GroupColor role; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final (:background, :foreground) = role.resolve( + Theme.of(context).colorScheme, + ); + return InkResponse( + radius: 24, + onTap: onTap, + child: CircleAvatar( + radius: 16, + backgroundColor: background, + child: selected ? Icon(Icons.check, size: 16, color: foreground) : null, + ), + ); + } +} + +class _IconPalette extends StatelessWidget { + const _IconPalette({required this.selected, required this.onSelected}); + + final IconData? selected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final icon in GroupEditorDialog.iconPresets) + IconButton.filledTonal( + isSelected: selected == icon, + onPressed: () => onSelected(selected == icon ? null : icon), + icon: Icon(icon), + style: selected == icon + ? IconButton.styleFrom( + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + ) + : null, + ), + ], + ); + } +} diff --git a/packages/codifyiq_group_manager/lib/src/group_list_view.dart b/packages/codifyiq_group_manager/lib/src/group_list_view.dart new file mode 100644 index 0000000..c93074a --- /dev/null +++ b/packages/codifyiq_group_manager/lib/src/group_list_view.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; + +import 'group.dart'; +import 'group_avatar.dart'; +import 'group_editor_dialog.dart'; +import 'group_manager_controller.dart'; + +/// A scrollable catalog of the groups in a [GroupManagerController], with +/// built-in edit and delete affordances. +/// +/// The list is controller-driven: it rebuilds when the catalog changes, and +/// edits and deletes apply to the controller directly. Supply an explicit +/// [controller], or omit it to resolve the nearest [GroupManagerScope]. +/// +/// The built-in editor dialog and delete confirmation always run; the +/// controller is updated optimistically, then [onEdit] / [onDelete] fire with +/// the resulting group so you can persist the change to a backend (rolling back +/// via the controller on failure). Provide [onTap] to make rows selectable — +/// e.g. to reveal a group's members elsewhere in your UI. +class GroupListView extends StatelessWidget { + /// Creates a [GroupListView]. + const GroupListView({ + super.key, + this.controller, + this.query = '', + this.onTap, + this.onEdit, + this.onDelete, + this.showActions = true, + this.padding = const EdgeInsets.symmetric(vertical: 8), + this.shrinkWrap = false, + this.physics, + this.emptyState, + }); + + /// The controller to render. When `null`, the nearest [GroupManagerScope] is + /// used. + final GroupManagerController? controller; + + /// Case-insensitive filter applied to each group's name and description. When + /// empty (the default), every group is shown. Drive this from a search field + /// — [GroupManagerView] does so out of the box. + final String query; + + /// Called when a row is tapped. When `null`, rows are not tappable. + final ValueChanged? onTap; + + /// Called after a row is edited through the built-in [GroupEditorDialog] and + /// the change is applied via [GroupManagerController.updateGroup], with the + /// updated group. Use it to persist the edit to a backend. + final ValueChanged? onEdit; + + /// Called after a group is deleted through the built-in confirmation dialog + /// and removed via [GroupManagerController.removeGroup] (cascade-unassigning + /// it from every member), with the deleted group. Use it to persist the + /// deletion to a backend. + final ValueChanged? onDelete; + + /// Whether to show the per-row edit/delete menu. + final bool showActions; + + /// Padding around the list. + final EdgeInsetsGeometry padding; + + /// Whether the list should size itself to its content. + final bool shrinkWrap; + + /// Scroll physics forwarded to the underlying [ListView]. + final ScrollPhysics? physics; + + /// Widget shown when the catalog is empty. Defaults to a centered hint. + final Widget? emptyState; + + GroupManagerController _resolve(BuildContext context) => + controller ?? GroupManagerScope.of(context, listen: false); + + List _applyQuery(List groups) { + final q = query.trim().toLowerCase(); + if (q.isEmpty) return groups; + return groups + .where( + (g) => + g.name.toLowerCase().contains(q) || + (g.description?.toLowerCase().contains(q) ?? false), + ) + .toList(); + } + + Future _handleEdit(BuildContext context, Group group) async { + final ctrl = _resolve(context); + final edited = await GroupEditorDialog.show(context, initial: group); + if (edited == null) return; + ctrl.updateGroup(edited); + onEdit?.call(edited); + } + + Future _handleDelete(BuildContext context, Group group) async { + final ctrl = _resolve(context); + final confirmed = await showDialog( + context: context, + // Use the dialog's own context to pop — popping via the outer context + // resolves to a nested navigator and dismisses the page route instead. + builder: (dialogContext) => AlertDialog( + title: Text('Delete "${group.name}"?'), + content: const Text( + 'This removes the group and unassigns it from every member.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Theme.of(dialogContext).colorScheme.error, + foregroundColor: Theme.of(dialogContext).colorScheme.onError, + ), + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + if (!(confirmed ?? false)) return; + ctrl.removeGroup(group.id); + onDelete?.call(group); + } + + @override + Widget build(BuildContext context) { + // listen: false — the ListenableBuilder below already drives rebuilds, so + // an inherited dependency here would just double the rebuild path. + final ctrl = controller ?? GroupManagerScope.of(context, listen: false); + return ListenableBuilder( + listenable: ctrl, + builder: (context, _) { + if (ctrl.isEmpty) { + return emptyState ?? const _EmptyCatalog(); + } + final groups = _applyQuery(ctrl.groups); + if (groups.isEmpty) { + return _NoMatches(query: query.trim()); + } + return ListView.builder( + padding: padding, + shrinkWrap: shrinkWrap, + physics: physics, + itemCount: groups.length, + itemBuilder: (context, index) { + final group = groups[index]; + return ListTile( + leading: GroupAvatar(group: group), + title: Text(group.name), + subtitle: group.description == null + ? null + : Text( + group.description!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: onTap == null ? null : () => onTap!(group), + trailing: showActions + ? _RowMenu( + onEdit: () => _handleEdit(context, group), + onDelete: () => _handleDelete(context, group), + ) + : null, + ); + }, + ); + }, + ); + } +} + +class _RowMenu extends StatelessWidget { + const _RowMenu({required this.onEdit, required this.onDelete}); + + final VoidCallback onEdit; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + return MenuAnchor( + builder: (context, controller, child) => IconButton( + icon: const Icon(Icons.more_vert), + tooltip: 'Group actions', + onPressed: () => + controller.isOpen ? controller.close() : controller.open(), + ), + // Per MD3, error coloring is reserved for the actual irreversible action + // (the Delete button in the confirmation dialog), not the menu trigger + // that merely opens that dialog. + menuChildren: [ + MenuItemButton( + leadingIcon: const Icon(Icons.edit_outlined), + onPressed: onEdit, + child: const Text('Edit'), + ), + MenuItemButton( + leadingIcon: const Icon(Icons.delete_outline), + onPressed: onDelete, + child: const Text('Delete'), + ), + ], + ); + } +} + +class _EmptyCatalog extends StatelessWidget { + const _EmptyCatalog(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.groups_outlined, + size: 48, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'No groups yet', + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + 'Create a group to start assigning members.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} + +class _NoMatches extends StatelessWidget { + const _NoMatches({required this.query}); + + final String query; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.search_off, + size: 48, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'No groups match "$query"', + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} diff --git a/packages/codifyiq_group_manager/lib/src/group_manager_controller.dart b/packages/codifyiq_group_manager/lib/src/group_manager_controller.dart new file mode 100644 index 0000000..d830307 --- /dev/null +++ b/packages/codifyiq_group_manager/lib/src/group_manager_controller.dart @@ -0,0 +1,239 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'group.dart'; + +/// State container for authorization groups and their assignment to principals. +/// +/// Holds two things: +/// +/// 1. A **catalog** of [Group]s, in insertion order. +/// 2. A set of **assignments** mapping each principal id (a user, service +/// account, or any other subject the host app authorizes) to the ids of the +/// groups it belongs to. +/// +/// Typical lifecycle: +/// +/// 1. Seed the catalog via the constructor or [addGroup]. +/// 2. Manage the catalog with [addGroup], [updateGroup], and [removeGroup]. +/// Removing a group cascades — it is also stripped from every principal's +/// assignments. +/// 3. Assign membership with [assign], [unassign], or [setAssignments], and +/// read it back with [groupsFor] / [resolvedGroupsFor]. +/// +/// This controller is **UI-only**: it never talks to a backend. Consumers wire +/// its mutations to their own persistence layer (REST, GraphQL, Firestore, +/// etc.) — typically by calling the controller optimistically and then +/// reconciling, or by mutating it from within a successful response handler. +class GroupManagerController extends ChangeNotifier { + /// Creates a controller seeded with an optional [groups] catalog and + /// [assignments]. + /// + /// [assignments] maps a principal id to the set of group ids it belongs to; + /// entries referencing unknown groups are tolerated and simply ignored by + /// [resolvedGroupsFor]. + GroupManagerController({ + List groups = const [], + Map> assignments = const >{}, + }) { + for (final group in groups) { + _groups[group.id] = group; + } + assignments.forEach((principalId, groupIds) { + _assignments[principalId] = {...groupIds}; + }); + } + + // Insertion-ordered map preserves catalog order for stable list rendering. + final Map _groups = {}; + final Map> _assignments = >{}; + + /// Unmodifiable view of every group in the catalog, in insertion order. + List get groups => List.unmodifiable(_groups.values); + + /// Whether the catalog currently has no groups. + bool get isEmpty => _groups.isEmpty; + + /// Number of groups in the catalog. + int get length => _groups.length; + + /// Looks up a group by [id], or returns `null` if absent. + Group? groupById(String id) => _groups[id]; + + /// Adds [group] to the catalog. + /// + /// Throws an [ArgumentError] if a group with the same id already exists — use + /// [updateGroup] to modify an existing group. + void addGroup(Group group) { + if (_groups.containsKey(group.id)) { + throw ArgumentError.value( + group.id, + 'group.id', + 'A group with this id already exists', + ); + } + _groups[group.id] = group; + notifyListeners(); + } + + /// Replaces the catalog group sharing [group]'s id. + /// + /// Throws an [ArgumentError] if no such group exists — use [addGroup] to + /// create one. Assignments are preserved, since membership keys on the id, + /// which is unchanged. + void updateGroup(Group group) { + if (!_groups.containsKey(group.id)) { + throw ArgumentError.value( + group.id, + 'group.id', + 'No group with this id exists', + ); + } + _groups[group.id] = group; + notifyListeners(); + } + + /// Removes the group with [id] from the catalog and from every principal's + /// assignments. + /// + /// Does nothing if no such group exists. + void removeGroup(String id) { + if (_groups.remove(id) == null) return; + // Strip the group from every principal, dropping any principal left with + // no memberships so empty entries don't accumulate. + _assignments.removeWhere((_, memberships) { + memberships.remove(id); + return memberships.isEmpty; + }); + notifyListeners(); + } + + /// Returns the ids of the groups assigned to [principalId]. + /// + /// The returned set is an unmodifiable snapshot; mutate membership through + /// [assign], [unassign], or [setAssignments]. + Set groupsFor(String principalId) => + Set.unmodifiable(_assignments[principalId] ?? const {}); + + /// Returns the [Group]s assigned to [principalId], in catalog order. + /// + /// Assignment ids that no longer resolve to a catalog group are skipped. + List resolvedGroupsFor(String principalId) { + final ids = _assignments[principalId]; + if (ids == null || ids.isEmpty) return const []; + return [ + for (final group in _groups.values) + if (ids.contains(group.id)) group, + ]; + } + + /// Whether [principalId] is currently a member of the group [groupId]. + bool isAssigned(String principalId, String groupId) => + _assignments[principalId]?.contains(groupId) ?? false; + + /// Adds [principalId] to the group [groupId]. + /// + /// Does nothing if the membership already exists. Throws an [ArgumentError] if + /// [groupId] is not in the catalog, mirroring [addGroup] / [updateGroup] — a + /// single deliberate assignment to a non-existent group is a programming + /// error, not a silent no-op. For bulk reconciliation that tolerates unknown + /// ids, use [setAssignments]. + void assign(String principalId, String groupId) { + if (!_groups.containsKey(groupId)) { + throw ArgumentError.value( + groupId, + 'groupId', + 'No group with this id exists', + ); + } + final memberships = _assignments.putIfAbsent(principalId, () => {}); + if (memberships.add(groupId)) notifyListeners(); + } + + /// Removes [principalId] from the group [groupId]. + /// + /// Does nothing if the membership does not exist. + void unassign(String principalId, String groupId) { + final memberships = _assignments[principalId]; + if (memberships == null) return; + if (!memberships.remove(groupId)) return; + // Drop the principal entirely once its last membership is gone, matching + // setAssignments — no lingering empty entries. + if (memberships.isEmpty) _assignments.remove(principalId); + notifyListeners(); + } + + /// Replaces [principalId]'s entire membership with [groupIds]. + /// + /// Ids that are not present in the catalog are ignored. Passing an empty set + /// clears the principal's membership. + void setAssignments(String principalId, Set groupIds) { + final next = { + for (final id in groupIds) + if (_groups.containsKey(id)) id, + }; + final current = _assignments[principalId] ?? const {}; + if (setEquals(current, next)) return; + if (next.isEmpty) { + _assignments.remove(principalId); + } else { + _assignments[principalId] = next; + } + notifyListeners(); + } +} + +/// Provides an ambient [GroupManagerController] to descendants. +/// +/// Wrap a subtree so any widget below can reach the controller without +/// prop-drilling: +/// +/// ```dart +/// GroupManagerScope( +/// controller: myController, +/// child: MyApp(), +/// ); +/// +/// // Anywhere below: +/// GroupManagerScope.of(context).assign(userId, groupId); +/// ``` +class GroupManagerScope extends InheritedNotifier { + /// Creates a scope hosting [controller]. + const GroupManagerScope({ + super.key, + required GroupManagerController controller, + required super.child, + }) : super(notifier: controller); + + /// Returns the nearest enclosing controller. + /// + /// By default the calling element is registered as a dependency and rebuilds + /// whenever the controller fires. Pass `listen: false` to look it up without + /// subscribing — useful when the caller already wraps its build in a + /// [ListenableBuilder]. + /// + /// Throws a [FlutterError] when no scope is found. + static GroupManagerController of(BuildContext context, {bool listen = true}) { + final controller = maybeOf(context, listen: listen); + assert( + controller != null, + 'No GroupManagerScope found in the widget tree.', + ); + return controller!; + } + + /// Like [of] but returns `null` when no scope is present. + static GroupManagerController? maybeOf( + BuildContext context, { + bool listen = true, + }) { + if (listen) { + return context + .dependOnInheritedWidgetOfExactType() + ?.notifier; + } + final element = context + .getElementForInheritedWidgetOfExactType(); + return (element?.widget as GroupManagerScope?)?.notifier; + } +} diff --git a/packages/codifyiq_group_manager/lib/src/group_manager_view.dart b/packages/codifyiq_group_manager/lib/src/group_manager_view.dart new file mode 100644 index 0000000..9e46811 --- /dev/null +++ b/packages/codifyiq_group_manager/lib/src/group_manager_view.dart @@ -0,0 +1,213 @@ +import 'package:flutter/material.dart'; + +import 'group.dart'; +import 'group_editor_dialog.dart'; +import 'group_list_view.dart'; +import 'group_manager_controller.dart'; + +/// A complete, drop-in catalog-management surface. +/// +/// Combines a "New group" action, a search field, and a [GroupListView], wired +/// to a [GroupManagerController] for the full create / edit / delete lifecycle. +/// Designed to be dropped straight into a [Scaffold] body. Supply an explicit +/// [controller] or omit it to resolve the nearest [GroupManagerScope]. +/// +/// The search field filters the catalog by name and description; it appears +/// once the catalog is non-empty (set [searchable] to `false` to hide it). For +/// finer control, compose [GroupListView] and [GroupEditorDialog] directly +/// instead. +/// +/// ## Wiring to a repository +/// +/// By default, create / edit / delete apply directly to the controller — +/// perfect for local-only state. To persist to a backend, supply [onCreate], +/// [onEdit], and [onDelete]: the built-in create / edit dialogs and delete +/// confirmation still run and the controller is updated optimistically, then +/// the callback fires with the resulting group so you can persist it and roll +/// back via the controller on failure. Because the controller's listeners only +/// drive UI rebuilds, mutating it never re-triggers these callbacks, so there +/// is no feedback loop. +/// +/// ```dart +/// GroupManagerView( +/// controller: controller, +/// // The group is already added locally; persist it and undo on failure. +/// onCreate: (group) async { +/// try { +/// await repository.create(group); +/// } catch (e) { +/// controller.removeGroup(group.id); // roll back the optimistic add +/// // ...and surface the error to the user +/// } +/// }, +/// // The group is already removed locally; persist the deletion. +/// onDelete: (group) => repository.delete(group.id), +/// ); +/// ``` +class GroupManagerView extends StatefulWidget { + /// Creates a [GroupManagerView]. + const GroupManagerView({ + super.key, + this.controller, + this.onTap, + this.onCreate, + this.onEdit, + this.onDelete, + this.searchable = true, + this.padding = const EdgeInsets.all(16), + this.maxContentWidth = 840, + this.createButtonLabel = 'New group', + }); + + /// The controller to manage. When `null`, the nearest [GroupManagerScope] is + /// used. + final GroupManagerController? controller; + + /// Called when a group row is tapped — e.g. to drill into its members. + final ValueChanged? onTap; + + /// Called after a new group is created through the built-in dialog and added + /// via [GroupManagerController.addGroup], with the created group. Use it to + /// persist the creation to a backend. + final ValueChanged? onCreate; + + /// Called after a row is edited and applied to the controller, forwarded to + /// [GroupListView.onEdit]. Use it to persist the edit to a backend. + final ValueChanged? onEdit; + + /// Called after a group is deleted and removed from the controller, forwarded + /// to [GroupListView.onDelete]. Use it to persist the deletion to a backend. + final ValueChanged? onDelete; + + /// Whether to show the search field once the catalog has groups. + final bool searchable; + + /// Padding around the surface. + final EdgeInsetsGeometry padding; + + /// Maximum content width, in logical pixels. + /// + /// Follows M3's large-screen guidance: the content (search bar, list, create + /// action) fills narrow panes but is capped at this width and centered on + /// wider ones, so it doesn't stretch to an uncomfortable measure. Defaults to + /// 840 (the medium/expanded window boundary). Pass `null` to fill the pane + /// edge-to-edge. + final double? maxContentWidth; + + /// Label for the create action. + final String createButtonLabel; + + @override + State createState() => _GroupManagerViewState(); +} + +class _GroupManagerViewState extends State { + final TextEditingController _search = TextEditingController(); + String _query = ''; + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + GroupManagerController get _controller => + widget.controller ?? GroupManagerScope.of(context, listen: false); + + Future _create() async { + final created = await GroupEditorDialog.show(context); + if (created == null) return; + _controller.addGroup(created); + widget.onCreate?.call(created); + } + + void _onQueryChanged(String value) => setState(() => _query = value); + + @override + Widget build(BuildContext context) { + // listen: false — the ListenableBuilder below already drives rebuilds. + final ctrl = widget.controller ?? GroupManagerScope.of(context, listen: false); + final maxWidth = widget.maxContentWidth; + final content = Padding( + padding: widget.padding, + child: ListenableBuilder( + listenable: ctrl, + builder: (context, _) { + final showSearch = widget.searchable && !ctrl.isEmpty; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showSearch) + Row( + children: [ + Expanded( + child: SearchBar( + controller: _search, + hintText: 'Search groups', + leading: const Icon(Icons.search), + // M3 search has no shadow by default. + elevation: const WidgetStatePropertyAll(0), + trailing: [ + if (_query.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear), + tooltip: 'Clear search', + onPressed: () { + _search.clear(); + _onQueryChanged(''); + }, + ), + ], + onChanged: _onQueryChanged, + ), + ), + const SizedBox(width: 8), + // Create sits outside the search bar so it reads as its own + // action rather than a search affordance. Outlined (MD3 + // medium emphasis) gives it definition against the surface + // without competing with the search bar for prominence. + IconButton.outlined( + icon: const Icon(Icons.add), + tooltip: widget.createButtonLabel, + onPressed: _create, + ), + ], + ) + else + // No search bar to host the action when the catalog is empty — + // offer a standalone create button instead. + Align( + alignment: Alignment.centerRight, + child: FilledButton.icon( + icon: const Icon(Icons.add), + label: Text(widget.createButtonLabel), + onPressed: _create, + ), + ), + const SizedBox(height: 12), + Expanded( + child: GroupListView( + controller: ctrl, + query: showSearch ? _query : '', + onTap: widget.onTap, + onEdit: widget.onEdit, + onDelete: widget.onDelete, + ), + ), + ], + ); + }, + ), + ); + + if (maxWidth == null) return content; + // Cap and center on wide panes; fills narrow ones (the constraint is looser + // than the available width there, so it's a no-op). + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: content, + ), + ); + } +} diff --git a/packages/codifyiq_group_manager/lib/src/group_picker.dart b/packages/codifyiq_group_manager/lib/src/group_picker.dart new file mode 100644 index 0000000..964fc42 --- /dev/null +++ b/packages/codifyiq_group_manager/lib/src/group_picker.dart @@ -0,0 +1,224 @@ +import 'package:flutter/material.dart'; + +import 'group.dart'; +import 'group_avatar.dart'; + +/// A searchable, multi-select picker for choosing groups. +/// +/// Presentational and value-driven: it takes the full [groups] catalog and the +/// [initiallySelected] ids, and resolves with the updated selection when the +/// user confirms — or `null` if they dismiss it. It performs no persistence. +/// +/// [show] presents the picker **adaptively**, following Material 3's +/// large-screen guidance: a modal bottom sheet on compact widths (the M3 mobile +/// pattern for a long, icon-and-description list) and a centered dialog at +/// `600dp` and wider, where a full-width bottom sheet anchored far from its +/// trigger reads awkwardly. The widget body is identical in both — only the +/// surrounding surface differs. +/// +/// Used by [GroupAssignmentField] for its "Add" affordance, but also usable on +/// its own. +class GroupPicker extends StatefulWidget { + /// Creates a [GroupPicker]. + const GroupPicker({ + super.key, + required this.groups, + this.initiallySelected = const {}, + this.title = 'Select groups', + }); + + /// Every group the user may choose from. + final List groups; + + /// Ids selected when the picker opens. + final Set initiallySelected; + + /// Heading shown at the top of the picker. + final String title; + + /// M3 compact/medium breakpoint. Below this the picker is a bottom sheet; at + /// or above it, a dialog. + static const double _dialogBreakpoint = 600; + + /// Shows the picker adaptively and resolves with the chosen ids, or `null` if + /// dismissed. + /// + /// Presents a modal bottom sheet on compact widths and a dialog at + /// [_dialogBreakpoint] and wider. + static Future?> show( + BuildContext context, { + required List groups, + Set initiallySelected = const {}, + String title = 'Select groups', + }) { + final media = MediaQuery.of(context); + // Host the picker on surfaceContainerLow so the surfaceContainerHigh search + // bar inside it stays two tonal steps clear and never blends (M3 search: + // keep container roles more than one step apart). + final surface = Theme.of(context).colorScheme.surfaceContainerLow; + final picker = GroupPicker( + groups: groups, + initiallySelected: initiallySelected, + title: title, + ); + + if (media.size.width < _dialogBreakpoint) { + return showModalBottomSheet>( + context: context, + isScrollControlled: true, + showDragHandle: true, + backgroundColor: surface, + builder: (_) => SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: media.size.height * 0.75), + child: picker, + ), + ), + ); + } + + return showDialog>( + context: context, + builder: (_) => Dialog( + clipBehavior: Clip.antiAlias, + backgroundColor: surface, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: media.size.height * 0.8), + child: SizedBox(width: 420, child: picker), + ), + ), + ); + } + + @override + State createState() => _GroupPickerState(); +} + +class _GroupPickerState extends State { + late final Set _selected = {...widget.initiallySelected}; + final TextEditingController _search = TextEditingController(); + String _query = ''; + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + List get _filtered { + final q = _query.trim().toLowerCase(); + if (q.isEmpty) return widget.groups; + return widget.groups + .where( + (g) => + g.name.toLowerCase().contains(q) || + (g.description?.toLowerCase().contains(q) ?? false), + ) + .toList(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final filtered = _filtered; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Chrome (title, search, actions) is inset 16dp; the list is full-bleed + // because CheckboxListTile applies its own 16dp, so rows line up with + // the title rather than indenting an extra 16dp. + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Text(widget.title, style: theme.textTheme.titleLarge), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: SearchBar( + controller: _search, + hintText: 'Search groups', + leading: const Icon(Icons.search), + // M3 search: no shadow by default — the filled container, not + // elevation, separates it. surfaceContainerHigh is the search + // container role; it reads here because the surface behind it is + // surfaceContainerLow (two steps clear). + elevation: const WidgetStatePropertyAll(0), + backgroundColor: WidgetStatePropertyAll( + theme.colorScheme.surfaceContainerHigh, + ), + trailing: [ + if (_query.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear), + tooltip: 'Clear search', + onPressed: () { + _search.clear(); + setState(() => _query = ''); + }, + ), + ], + onChanged: (value) => setState(() => _query = value), + ), + ), + Flexible( + child: filtered.isEmpty + ? Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 24), + child: Text( + 'No groups match "${_query.trim()}".', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + : ListView.builder( + padding: EdgeInsets.zero, + itemCount: filtered.length, + itemBuilder: (context, index) { + final group = filtered[index]; + final checked = _selected.contains(group.id); + return CheckboxListTile( + value: checked, + secondary: GroupAvatar(group: group), + title: Text(group.name), + subtitle: group.description == null + ? null + : Text( + group.description!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onChanged: (value) => setState(() { + if (value ?? false) { + _selected.add(group.id); + } else { + _selected.remove(group.id); + } + }), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () => Navigator.of(context).pop(_selected), + child: Text('Done (${_selected.length})'), + ), + ], + ), + ), + ], + ); + } +} diff --git a/packages/codifyiq_group_manager/pubspec.yaml b/packages/codifyiq_group_manager/pubspec.yaml new file mode 100644 index 0000000..c4b92f1 --- /dev/null +++ b/packages/codifyiq_group_manager/pubspec.yaml @@ -0,0 +1,29 @@ +name: codifyiq_group_manager +description: Material 3 widgets for managing flat authorization groups and assigning one or more of them to users. +version: 1.0.0 +homepage: https://github.com/CodifyIQ/codifyiq-core-components/tree/dev/packages/codifyiq_group_manager +repository: https://github.com/CodifyIQ/codifyiq-core-components/tree/dev/packages/codifyiq_group_manager +issue_tracker: https://github.com/CodifyIQ/codifyiq-core-components/issues +topics: + - codifyiq + - ui + - widgets + - authorization + +resolution: workspace + +environment: + sdk: ^3.10.0 + flutter: ">=3.41.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true diff --git a/packages/codifyiq_group_manager/test/group_manager_controller_test.dart b/packages/codifyiq_group_manager/test/group_manager_controller_test.dart new file mode 100644 index 0000000..231c72e --- /dev/null +++ b/packages/codifyiq_group_manager/test/group_manager_controller_test.dart @@ -0,0 +1,552 @@ +import 'package:codifyiq_group_manager/codifyiq_group_manager.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('GroupManagerController catalog', () { + test('seeds groups in insertion order', () { + final controller = GroupManagerController( + groups: const [ + Group(id: 'a', name: 'Alpha'), + Group(id: 'b', name: 'Beta'), + ], + ); + expect(controller.groups.map((g) => g.id), ['a', 'b']); + expect(controller.length, 2); + expect(controller.groupById('b')?.name, 'Beta'); + }); + + test('addGroup appends and notifies', () { + final controller = GroupManagerController(); + var notifications = 0; + controller.addListener(() => notifications++); + + controller.addGroup(const Group(id: 'a', name: 'Alpha')); + + expect(controller.groups.single.id, 'a'); + expect(notifications, 1); + }); + + test('addGroup rejects duplicate ids', () { + final controller = GroupManagerController( + groups: const [Group(id: 'a', name: 'Alpha')], + ); + expect( + () => controller.addGroup(const Group(id: 'a', name: 'Again')), + throwsArgumentError, + ); + }); + + test('updateGroup replaces in place and requires existence', () { + final controller = GroupManagerController( + groups: const [Group(id: 'a', name: 'Alpha')], + ); + controller.updateGroup(const Group(id: 'a', name: 'Alpha Prime')); + expect(controller.groupById('a')?.name, 'Alpha Prime'); + + expect( + () => controller.updateGroup(const Group(id: 'z', name: 'Nope')), + throwsArgumentError, + ); + }); + + test('removeGroup cascades to assignments', () { + final controller = GroupManagerController( + groups: const [ + Group(id: 'a', name: 'Alpha'), + Group(id: 'b', name: 'Beta'), + ], + ); + controller.assign('user1', 'a'); + controller.assign('user1', 'b'); + + controller.removeGroup('a'); + + expect(controller.groupById('a'), isNull); + expect(controller.groupsFor('user1'), {'b'}); + }); + + test('removeGroup is a no-op for unknown id', () { + final controller = GroupManagerController( + groups: const [Group(id: 'a', name: 'Alpha')], + ); + var notifications = 0; + controller.addListener(() => notifications++); + controller.removeGroup('missing'); + expect(notifications, 0); + }); + }); + + group('GroupManagerController assignments', () { + test('assign / isAssigned / unassign', () { + final controller = GroupManagerController( + groups: const [Group(id: 'a', name: 'Alpha')], + ); + expect(controller.isAssigned('u', 'a'), isFalse); + + controller.assign('u', 'a'); + expect(controller.isAssigned('u', 'a'), isTrue); + expect(controller.groupsFor('u'), {'a'}); + + controller.unassign('u', 'a'); + expect(controller.isAssigned('u', 'a'), isFalse); + expect(controller.groupsFor('u'), isEmpty); + }); + + test('assign throws on unknown groups', () { + final controller = GroupManagerController(); + var notifications = 0; + controller.addListener(() => notifications++); + expect(() => controller.assign('u', 'ghost'), throwsArgumentError); + expect(controller.groupsFor('u'), isEmpty); + expect(notifications, 0); + }); + + test('duplicate assign does not notify twice', () { + final controller = GroupManagerController( + groups: const [Group(id: 'a', name: 'Alpha')], + ); + var notifications = 0; + controller.addListener(() => notifications++); + controller.assign('u', 'a'); + controller.assign('u', 'a'); + expect(notifications, 1); + }); + + test('setAssignments filters unknown ids and clears when empty', () { + final controller = GroupManagerController( + groups: const [ + Group(id: 'a', name: 'Alpha'), + Group(id: 'b', name: 'Beta'), + ], + ); + controller.setAssignments('u', {'a', 'b', 'ghost'}); + expect(controller.groupsFor('u'), {'a', 'b'}); + + controller.setAssignments('u', {}); + expect(controller.groupsFor('u'), isEmpty); + }); + + test('setAssignments is a no-op when unchanged', () { + final controller = GroupManagerController( + groups: const [Group(id: 'a', name: 'Alpha')], + ); + controller.assign('u', 'a'); + var notifications = 0; + controller.addListener(() => notifications++); + controller.setAssignments('u', {'a'}); + expect(notifications, 0); + }); + + test('resolvedGroupsFor returns groups in catalog order', () { + final controller = GroupManagerController( + groups: const [ + Group(id: 'a', name: 'Alpha'), + Group(id: 'b', name: 'Beta'), + Group(id: 'c', name: 'Gamma'), + ], + ); + // Assigned out of catalog order… + controller.setAssignments('u', {'c', 'a'}); + // …but resolved back in catalog order. + expect(controller.resolvedGroupsFor('u').map((g) => g.id), ['a', 'c']); + }); + + test('groupsFor returns an unmodifiable snapshot', () { + final controller = GroupManagerController( + groups: const [Group(id: 'a', name: 'Alpha')], + ); + controller.assign('u', 'a'); + expect(() => controller.groupsFor('u').add('b'), throwsUnsupportedError); + }); + }); + + group('Group model', () { + test('copyWith replaces and clears fields', () { + const group = Group(id: 'a', name: 'Alpha', description: 'desc'); + expect(group.copyWith(name: 'Beta').name, 'Beta'); + expect(group.copyWith(name: 'Beta').description, 'desc'); + expect(group.copyWith(clearDescription: true).description, isNull); + }); + + test('equality is by value', () { + const a = Group(id: 'a', name: 'Alpha'); + const b = Group(id: 'a', name: 'Alpha'); + const c = Group(id: 'a', name: 'Different'); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + + test('copyWith updates and clears the color role', () { + const group = Group(id: 'a', name: 'Alpha', color: GroupColor.primary); + expect( + group.copyWith(color: GroupColor.tertiary).color, + GroupColor.tertiary, + ); + expect(group.copyWith(clearColor: true).color, isNull); + }); + }); + + group('GroupColor', () { + test('auto is deterministic and never neutral', () { + // Stable for a given id across calls… + expect(GroupColor.auto('admins'), GroupColor.auto('admins')); + // …and only ever a colorful role, never the muted neutral. + for (final id in ['admins', 'editors', 'viewers', 'x', 'team-42']) { + expect(GroupColor.auto(id), isNot(GroupColor.neutral)); + } + }); + + test('resolve returns the matching on-color for contrast', () { + final scheme = ColorScheme.fromSeed(seedColor: const Color(0xFF033B53)); + final primary = GroupColor.primary.resolve(scheme); + expect(primary.background, scheme.primaryContainer); + expect(primary.foreground, scheme.onPrimaryContainer); + + final neutral = GroupColor.neutral.resolve(scheme); + expect(neutral.background, scheme.surfaceContainerHighest); + expect(neutral.foreground, scheme.onSurfaceVariant); + }); + }); + + group('GroupListView', () { + testWidgets('row menu opens and deletes through the confirm dialog', ( + tester, + ) async { + final controller = GroupManagerController( + groups: const [Group(id: 'a', name: 'Alpha')], + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: GroupListView(controller: controller)), + ), + ); + + // Opening the overflow menu must not throw — a ListTile inside a + // PopupMenuItem crashes under IntrinsicWidth; MenuAnchor does not. + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + expect(find.text('Edit'), findsOneWidget); + expect(find.text('Delete'), findsOneWidget); + + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + + // Confirm dialog → commit the deletion. + expect(find.text('Delete "Alpha"?'), findsOneWidget); + await tester.tap(find.widgetWithText(FilledButton, 'Delete')); + await tester.pumpAndSettle(); + + expect(controller.isEmpty, isTrue); + }); + + testWidgets('onDelete fires with the group after it is removed', ( + tester, + ) async { + final controller = GroupManagerController( + groups: const [Group(id: 'a', name: 'Alpha')], + ); + addTearDown(controller.dispose); + Group? deleted; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GroupListView( + controller: controller, + onDelete: (g) => deleted = g, + ), + ), + ), + ); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Delete')); + await tester.pumpAndSettle(); + + // The confirmation still ran, the controller applied the removal, and the + // callback fired with the deleted group. + expect(controller.isEmpty, isTrue); + expect(deleted?.id, 'a'); + }); + + testWidgets('onEdit fires with the updated group after it is applied', ( + tester, + ) async { + final controller = GroupManagerController( + groups: const [Group(id: 'a', name: 'Alpha')], + ); + addTearDown(controller.dispose); + Group? edited; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GroupListView( + controller: controller, + onEdit: (g) => edited = g, + ), + ), + ), + ); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextFormField).first, 'Alpha Prime'); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + expect(controller.groupById('a')?.name, 'Alpha Prime'); + expect(edited?.name, 'Alpha Prime'); + }); + }); + + group('GroupEditorDialog', () { + testWidgets('blocks save on an empty name, then returns a new group', ( + tester, + ) async { + Group? result; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () async => + result = await GroupEditorDialog.show(context), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + // Empty name fails validation and keeps the dialog open. + await tester.tap(find.widgetWithText(FilledButton, 'Create')); + await tester.pumpAndSettle(); + expect(find.text('Name is required'), findsOneWidget); + expect(find.byType(GroupEditorDialog), findsOneWidget); + + await tester.enterText(find.byType(TextFormField).first, 'Admins'); + await tester.tap(find.widgetWithText(FilledButton, 'Create')); + await tester.pumpAndSettle(); + + expect(result, isNotNull); + expect(result!.name, 'Admins'); + expect(result!.id, startsWith('group-')); + }); + + testWidgets('edit mode pre-fills and preserves the id', (tester) async { + Group? result; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () async => result = await GroupEditorDialog.show( + context, + initial: const Group(id: 'admins', name: 'Administrators'), + ), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(find.text('Edit group'), findsOneWidget); + + await tester.enterText(find.byType(TextFormField).first, 'Admins Renamed'); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + expect(result!.id, 'admins'); + expect(result!.name, 'Admins Renamed'); + }); + }); + + group('GroupPicker', () { + testWidgets('searches description and returns the selected ids', ( + tester, + ) async { + Set? result; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () async => result = await GroupPicker.show( + context, + groups: const [ + Group(id: 'a', name: 'Alpha', description: 'finance team'), + Group(id: 'b', name: 'Beta', description: 'design team'), + ], + ), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + // A term only present in a description still finds the group. + await tester.enterText(find.byType(SearchBar), 'finance'); + await tester.pumpAndSettle(); + expect(find.text('Alpha'), findsOneWidget); + expect(find.text('Beta'), findsNothing); + + await tester.tap(find.text('Alpha')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Done (1)')); + await tester.pumpAndSettle(); + + expect(result, {'a'}); + }); + }); + + group('GroupAssignmentField', () { + testWidgets('removes a chip and reports the new selection', (tester) async { + Set? changed; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GroupAssignmentField( + groups: const [ + Group(id: 'a', name: 'Alpha'), + Group(id: 'b', name: 'Beta'), + ], + selected: const {'a', 'b'}, + onChanged: (ids) => changed = ids, + ), + ), + ), + ); + expect(find.byType(InputChip), findsNWidgets(2)); + + await tester.tap(find.byTooltip('Remove Alpha')); + await tester.pumpAndSettle(); + expect(changed, {'b'}); + }); + + testWidgets('disabled field hides the edit button and shows the hint', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: GroupAssignmentField( + groups: [Group(id: 'a', name: 'Alpha')], + selected: {}, + onChanged: _noop, + enabled: false, + emptyHint: 'Nothing here', + ), + ), + ), + ); + expect(find.text('Nothing here'), findsOneWidget); + expect(find.byTooltip('Edit groups'), findsNothing); + }); + + testWidgets('header button opens the picker and reports the selection', ( + tester, + ) async { + Set? changed; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GroupAssignmentField( + groups: const [ + Group(id: 'a', name: 'Alpha'), + Group(id: 'b', name: 'Beta'), + ], + selected: const {}, + onChanged: (ids) => changed = ids, + editLabel: 'Edit groups', + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Edit groups')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Beta')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Done (1)')); + await tester.pumpAndSettle(); + + expect(changed, {'b'}); + }); + }); + + group('GroupManagerView', () { + testWidgets('swaps the create button for a search bar once populated', ( + tester, + ) async { + final controller = GroupManagerController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: GroupManagerView(controller: controller)), + ), + ); + + // Empty catalog: standalone create button, no search bar. + expect(find.byType(SearchBar), findsNothing); + expect(find.widgetWithText(FilledButton, 'New group'), findsOneWidget); + + controller.addGroup(const Group(id: 'a', name: 'Alpha')); + await tester.pumpAndSettle(); + + // Populated: the search bar appears and hosts the create action. + expect(find.byType(SearchBar), findsOneWidget); + expect(find.text('Alpha'), findsOneWidget); + }); + + testWidgets('onCreate fires with the group after it is added', ( + tester, + ) async { + final controller = GroupManagerController(); + addTearDown(controller.dispose); + Group? created; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GroupManagerView( + controller: controller, + onCreate: (g) => created = g, + ), + ), + ), + ); + + await tester.tap(find.widgetWithText(FilledButton, 'New group')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField).first, 'Admins'); + await tester.tap(find.widgetWithText(FilledButton, 'Create')); + await tester.pumpAndSettle(); + + // The dialog ran, the controller has the new group, and the callback + // fired with it. + expect(controller.groups.single.name, 'Admins'); + expect(created?.name, 'Admins'); + }); + }); +} + +void _noop(Set _) {} diff --git a/pubspec.yaml b/pubspec.yaml index 3995d35..b61c24d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,6 +10,7 @@ workspace: - packages/codifyiq_ai_progress_indicator - packages/codifyiq_audio_message - packages/codifyiq_brightness_button + - packages/codifyiq_group_manager - packages/codifyiq_image_viewer - packages/codifyiq_notification_center - packages/codifyiq_pdf_viewer