import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:tatlock_ui/core/api/api_client.dart'; import 'package:tatlock_ui/routing/url_state.dart'; import 'package:tatlock_ui/shared/components/data_grid/adapters/core_api_source.dart'; import 'package:tatlock_ui/shared/components/data_grid/data_grid_exports.dart'; /// Simple group data class for display. class GroupData { GroupData({ required this.id, required this.name, required this.isSuperuser, required this.memberCount, this.parentName, }); factory GroupData.fromJson(Map json) => GroupData( id: json['id'] as String, name: json['name'] as String, isSuperuser: json['is_superuser'] as bool? ?? false, memberCount: json['member_count'] as int? ?? 0, parentName: json['parent_name'] as String?, ); final String id; final String name; final bool isSuperuser; final int memberCount; final String? parentName; } /// Groups list page using the shared DataGrid component. class GroupsListPage extends ConsumerStatefulWidget { const GroupsListPage({super.key, this.routerState}); /// Router state for URL deep-linking. final GoRouterState? routerState; @override ConsumerState createState() => _GroupsListPageState(); } class _GroupsListPageState extends ConsumerState { late final StateNotifierProvider, DataGridState> _gridProvider; late PageUrlState _urlState; Timer? _urlSyncTimer; bool _isSyncing = false; @override void initState() { super.initState(); // Parse URL state _urlState = PageUrlState.fromQueryParams( widget.routerState?.uri.queryParameters ?? {}, ); // Create provider in initState to ensure stable reference final dio = ref.read(coreApiClientProvider); final source = CoreApiDataSource( dio: dio, endpoint: '/auth/groups', fromJson: GroupData.fromJson, ); // Initialize grid with URL state _gridProvider = dataGridProvider( source: source, config: _buildConfig(), idSelector: (g) => g.id, initialSearch: _urlState.search, initialSortColumnIndex: _columnIndexForId(_urlState.sortColumn), initialSortDescending: _urlState.sortDescending, ); } @override void dispose() { _urlSyncTimer?.cancel(); super.dispose(); } /// Find column index by column ID. int? _columnIndexForId(String? columnId) { if (columnId == null) return null; final columns = _buildConfig().columns; for (var i = 0; i < columns.length; i++) { if (columns[i].id == columnId) return i; } return null; } /// Get column ID by index. String? _columnIdForIndex(int index) { final columns = _buildConfig().columns; if (index >= 0 && index < columns.length) { return columns[index].id; } return null; } /// Schedule URL sync with debounce. void _scheduleUrlSync() { _urlSyncTimer?.cancel(); _urlSyncTimer = Timer(const Duration(milliseconds: 500), _syncUrlParams); } /// Sync current state to URL. void _syncUrlParams() { final state = ref.read(_gridProvider); final params = PageUrlState( search: state.searchQuery.isEmpty ? null : state.searchQuery, sortColumn: state.sortColumnIndex != null ? _columnIdForIndex(state.sortColumnIndex!) : null, sortDescending: state.sortDescending, ).toQueryParams(); updateBrowserUrlParams(params); } Future _syncFromAuthentik() async { if (_isSyncing) return; setState(() => _isSyncing = true); try { final dio = ref.read(coreApiClientProvider); await dio.post('/auth/groups/sync-from-authentik'); if (!mounted) return; ref.read(_gridProvider.notifier).refresh(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text('Groups synced from Authentik'), behavior: SnackBarBehavior.floating, duration: const Duration(seconds: 2), ), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Sync failed: $e'), behavior: SnackBarBehavior.floating, duration: const Duration(seconds: 4), ), ); } finally { if (mounted) setState(() => _isSyncing = false); } } DataGridConfig _buildConfig() { return DataGridConfig( columns: [ DataGridColumn( id: 'name', header: 'Name', valueBuilder: (g) => g.name, sortable: true, searchable: true, width: const DataGridColumnWidth.flex(2), ), DataGridColumn( id: 'members', header: 'Members', valueBuilder: (g) => g.memberCount.toString(), width: const DataGridColumnWidth.fixed(100), alignment: DataGridColumnAlignment.end, ), DataGridColumn( id: 'type', header: 'Type', valueBuilder: (g) => g.isSuperuser ? 'Superuser' : 'Standard', cellBuilder: (context, g) => _GroupTypeBadge(isSuperuser: g.isSuperuser), width: const DataGridColumnWidth.fixed(120), ), DataGridColumn( id: 'parent', header: 'Parent', valueBuilder: (g) => g.parentName ?? '-', width: const DataGridColumnWidth.flex(1), ), ], enableSearch: true, searchHint: 'Search groups...', showHeader: true, showFooter: true, ); } @override Widget build(BuildContext context) { // Listen for grid state changes to sync URL ref.listen(_gridProvider, (previous, next) { if (previous?.searchQuery != next.searchQuery || previous?.sortColumnIndex != next.sortColumnIndex || previous?.sortDescending != next.sortDescending) { _scheduleUrlSync(); } }); return DataGrid( provider: _gridProvider, config: _buildConfig(), idSelector: (g) => g.id, toolbarActions: [ IconButton( icon: const Icon(Icons.refresh), tooltip: 'Refresh', onPressed: () => ref.read(_gridProvider.notifier).refresh(), ), const SizedBox(width: 8), FilledButton.icon( onPressed: _isSyncing ? null : _syncFromAuthentik, icon: _isSyncing ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.sync), label: const Text('Sync from Authentik'), ), ], ); } } class _GroupTypeBadge extends StatelessWidget { const _GroupTypeBadge({required this.isSuperuser}); final bool isSuperuser; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: isSuperuser ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: Text( isSuperuser ? 'Superuser' : 'Standard', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: isSuperuser ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, ), ), ); } }