Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | ✔ | ✔ | ✔ ² | ✔ | ✔ |
Expand Down
1 change: 1 addition & 0 deletions claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
310 changes: 310 additions & 0 deletions example/lib/group_manager_example.dart

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q/S: There is a bug in this example, and I'm having trouble thinking through whether there's some real-world case it aligns to that needs to be accounted for.

The "bug" happens when you assign a group to the signed in user (Ada), assign that group to a folder, then remove Ada from that group. The Folders tab then does not show that group in the UI for the associated folder, but does retain the assignment in the data. I.e. if you re-add Ada to the group it reappears. There is no way to remove it while Ada is not in the group.

Maybe the answer is that the implementer should have cleared that group from Ada's folders, and this is a non-issue?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reconciliation is the host app's policy call. Two things done: (1) documented the contract on GroupAssignmentField (keep groups a superset of selected, or reconcile when the offered set shrinks); (2) fixed the example to offer grantable ∪ already-shared, so an existing share stays visible and removable even after the signer leaves that group — you just can't add new groups you don't belong to.

Original file line number Diff line number Diff line change
@@ -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<GroupManagerExample> createState() => _GroupManagerExampleState();
}

class _GroupManagerExampleState extends State<GroupManagerExample> {
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:<id>'` 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<Group> 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 = <Group>[
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),
],
],
),
),
);
},
);
}
}
7 changes: 7 additions & 0 deletions example/lib/router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -83,6 +84,12 @@ ShellRoute _getMainApplicationShellRoute() {
return const AudioMessageExample();
},
),
GoRoute(
path: "/group-manager",
builder: (BuildContext context, GoRouterState state) {
return const GroupManagerExample();
},
),
],
);
}
7 changes: 7 additions & 0 deletions example/lib/widget_catalog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions packages/codifyiq_group_manager/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Loading