diff --git a/lib/providers/teams_provider.dart b/lib/providers/teams_provider.dart index 76817933..4b85c348 100644 --- a/lib/providers/teams_provider.dart +++ b/lib/providers/teams_provider.dart @@ -1,21 +1,24 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'supabase_provider.dart'; import '../models/team.dart'; import '../models/team_member.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; -final teamsProvider = FutureProvider>((ref) async { - final data = await Supabase.instance.client +/// Real-time stream of teams (keeps UI in sync with DB changes). +final teamsProvider = StreamProvider>((ref) { + final client = ref.watch(supabaseClientProvider); + return client .from('teams') - .select() - .order('name'); - return (data as List? ?? []) - .map((e) => Team.fromMap(e as Map)) - .toList(); + .stream(primaryKey: ['id']) + .order('name') + .map((rows) => rows.map((r) => Team.fromMap(r)).toList()); }); -final teamMembersProvider = FutureProvider>((ref) async { - final data = await Supabase.instance.client.from('team_members').select(); - return (data as List? ?? []) - .map((e) => TeamMember.fromMap(e as Map)) - .toList(); +/// Real-time stream of team membership rows. +final teamMembersProvider = StreamProvider>((ref) { + final client = ref.watch(supabaseClientProvider); + return client + .from('team_members') + .stream(primaryKey: ['team_id', 'user_id']) + .map((rows) => rows.map((r) => TeamMember.fromMap(r)).toList()); }); diff --git a/lib/providers/typing_provider.dart b/lib/providers/typing_provider.dart index 66579608..c292b3ec 100644 --- a/lib/providers/typing_provider.dart +++ b/lib/providers/typing_provider.dart @@ -133,10 +133,11 @@ class TypingIndicatorController extends StateNotifier { void userTyping() { if (_disposed || !mounted) { - if (kDebugMode) + if (kDebugMode) { debugPrint( 'TypingIndicatorController.userTyping() ignored after dispose', ); + } return; } if (_client.auth.currentUser?.id == null) return; @@ -144,10 +145,11 @@ class TypingIndicatorController extends StateNotifier { _typingTimer?.cancel(); _typingTimer = Timer(const Duration(milliseconds: 150), () { if (_disposed || !mounted) { - if (kDebugMode) + if (kDebugMode) { debugPrint( 'TypingIndicatorController._typingTimer callback ignored after dispose', ); + } return; } _sendTypingEvent('stop'); @@ -156,10 +158,11 @@ class TypingIndicatorController extends StateNotifier { void stopTyping() { if (_disposed || !mounted) { - if (kDebugMode) + if (kDebugMode) { debugPrint( 'TypingIndicatorController.stopTyping() ignored after dispose', ); + } return; } _typingTimer?.cancel(); @@ -182,10 +185,11 @@ class TypingIndicatorController extends StateNotifier { _remoteTimeouts[userId]?.cancel(); _remoteTimeouts[userId] = Timer(const Duration(milliseconds: 400), () { if (_disposed || !mounted) { - if (kDebugMode) + if (kDebugMode) { debugPrint( 'TypingIndicatorController.remote timeout callback ignored after dispose for user: $userId', ); + } return; } _clearRemoteTyping(userId); @@ -194,10 +198,11 @@ class TypingIndicatorController extends StateNotifier { void _clearRemoteTyping(String userId) { if (_disposed || !mounted) { - if (kDebugMode) + if (kDebugMode) { debugPrint( 'TypingIndicatorController._clearRemoteTyping ignored after dispose for user: $userId', ); + } return; } final updated = {...state.userIds}..remove(userId); diff --git a/lib/screens/searchable_multi_select_dropdown.dart b/lib/screens/searchable_multi_select_dropdown.dart deleted file mode 100644 index f4938994..00000000 --- a/lib/screens/searchable_multi_select_dropdown.dart +++ /dev/null @@ -1,180 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../theme/app_surfaces.dart'; - -/// Searchable multi-select dropdown with chips and 'Select All' option -class SearchableMultiSelectDropdown extends StatefulWidget { - const SearchableMultiSelectDropdown({ - super.key, - required this.label, - required this.items, - required this.selectedIds, - required this.getId, - required this.getLabel, - required this.onChanged, - }); - - final String label; - final List items; - final List selectedIds; - final String Function(T) getId; - final String Function(T) getLabel; - final ValueChanged> onChanged; - - @override - State> createState() => - SearchableMultiSelectDropdownState(); -} - -class SearchableMultiSelectDropdownState - extends State> { - late List _selectedIds; - String _search = ''; - bool _selectAll = false; - - @override - void initState() { - super.initState(); - _selectedIds = List.from(widget.selectedIds); - _selectAll = - _selectedIds.length == widget.items.length && widget.items.isNotEmpty; - } - - void _openDropdown() async { - await showDialog( - context: context, - builder: (context) { - return StatefulBuilder( - builder: (context, setState) { - final filtered = widget.items - .where( - (item) => widget - .getLabel(item) - .toLowerCase() - .contains(_search.toLowerCase()), - ) - .toList(); - return AlertDialog( - shape: AppSurfaces.of(context).dialogShape, - title: Text('Select ${widget.label}'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - decoration: const InputDecoration(hintText: 'Search...'), - onChanged: (v) => setState(() => _search = v), - ), - CheckboxListTile( - value: _selectAll, - title: const Text('Select All'), - onChanged: (checked) { - setState(() { - _selectAll = checked ?? false; - if (_selectAll) { - _selectedIds = widget.items - .map(widget.getId) - .toList(); - } else { - _selectedIds.clear(); - } - }); - }, - ), - SizedBox( - height: 200, - child: ListView( - children: [ - for (final item in filtered) - CheckboxListTile( - value: _selectedIds.contains(widget.getId(item)), - title: Text(widget.getLabel(item)), - onChanged: (checked) { - setState(() { - final id = widget.getId(item); - if (checked == true) { - _selectedIds.add(id); - } else { - _selectedIds.remove(id); - } - _selectAll = - _selectedIds.length == - widget.items.length && - widget.items.isNotEmpty; - }); - }, - ), - ], - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: () => Navigator.of(context).pop(_selectedIds), - child: const Text('Done'), - ), - ], - ); - }, - ); - }, - ).then((result) { - if (result is List) { - setState(() { - _selectedIds = result; - _selectAll = - _selectedIds.length == widget.items.length && - widget.items.isNotEmpty; - }); - widget.onChanged(_selectedIds); - } - }); - } - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - InputDecorator( - decoration: InputDecoration(labelText: widget.label), - child: Wrap( - spacing: 8, - runSpacing: 4, - children: [ - ..._selectedIds.map((id) { - T item; - try { - item = widget.items.firstWhere((e) => widget.getId(e) == id); - } catch (_) { - return const SizedBox.shrink(); - } - return Chip( - label: Text(widget.getLabel(item)), - onDeleted: () { - setState(() { - _selectedIds.remove(id); - _selectAll = - _selectedIds.length == widget.items.length && - widget.items.isNotEmpty; - }); - widget.onChanged(_selectedIds); - }, - ); - }), - ActionChip( - label: Text('Select'), - avatar: const Icon(Icons.arrow_drop_down), - onPressed: _openDropdown, - ), - ], - ), - ), - ], - ); - } -} diff --git a/lib/screens/teams/teams_screen.dart b/lib/screens/teams/teams_screen.dart index 14746f28..a7c86ea2 100644 --- a/lib/screens/teams/teams_screen.dart +++ b/lib/screens/teams/teams_screen.dart @@ -5,23 +5,41 @@ import '../../models/profile.dart'; import '../../models/team.dart'; import '../../providers/teams_provider.dart'; import '../../providers/profile_provider.dart'; +import '../../providers/tickets_provider.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -import 'package:tasq/screens/searchable_multi_select_dropdown.dart'; +import 'package:tasq/widgets/multi_select_picker.dart'; import '../../theme/app_surfaces.dart'; import '../../widgets/tasq_adaptive_list.dart'; -final officesProvider = FutureProvider>((ref) async { - final data = await Supabase.instance.client.from('offices').select(); - return (data as List? ?? []) - .map((e) => Office.fromMap(e as Map)) - .toList(); -}); +// Note: `officesProvider` is provided globally in `tickets_provider.dart` so +// we reuse that StreamProvider here (avoids duplicate queries). -class TeamsScreen extends ConsumerWidget { +class TeamsScreen extends ConsumerStatefulWidget { const TeamsScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _TeamsScreenState(); +} + +class _TeamsScreenState extends ConsumerState { + final TextEditingController _searchController = TextEditingController(); + String? _selectedOfficeId; + String? _selectedLeaderId; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + bool get _hasFilters { + return _searchController.text.trim().isNotEmpty || + _selectedOfficeId != null || + _selectedLeaderId != null; + } + + @override + Widget build(BuildContext context) { final teamsAsync = ref.watch(teamsProvider); final profilesAsync = ref.watch(profilesProvider); final officesAsync = ref.watch(officesProvider); @@ -36,8 +54,98 @@ class TeamsScreen extends ConsumerWidget { final profileById = {for (var p in profiles) p.id: p}; final officeById = {for (var o in offices) o.id: o}; - return TasQAdaptiveList( - items: teams, + // filters + final itStaff = profiles.where((p) => p.role == 'it_staff').toList(); + final officeOptions = >[ + const DropdownMenuItem( + value: null, + child: Text('All offices'), + ), + ...offices.map( + (o) => DropdownMenuItem(value: o.id, child: Text(o.name)), + ), + ]; + final leaderOptions = >[ + const DropdownMenuItem( + value: null, + child: Text('All leaders'), + ), + ...itStaff.map( + (p) => DropdownMenuItem( + value: p.id, + child: Text(p.fullName.isNotEmpty ? p.fullName : p.id), + ), + ), + ]; + + final filteredTeams = teams.where((t) { + final matchesName = + _searchController.text.trim().isEmpty || + t.name.toLowerCase().contains( + _searchController.text.trim().toLowerCase(), + ); + final matchesOffice = + _selectedOfficeId == null || + t.officeIds.contains(_selectedOfficeId); + final matchesLeader = + _selectedLeaderId == null || t.leaderId == _selectedLeaderId; + return matchesName && matchesOffice && matchesLeader; + }).toList(); + + final filterHeader = Wrap( + spacing: 12, + runSpacing: 12, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + SizedBox( + width: 280, + child: TextField( + controller: _searchController, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'Search team name', + prefixIcon: Icon(Icons.search), + ), + ), + ), + SizedBox( + width: 200, + child: DropdownButtonFormField( + isExpanded: true, + key: ValueKey(_selectedOfficeId), + initialValue: _selectedOfficeId, + items: officeOptions, + onChanged: (v) => setState(() => _selectedOfficeId = v), + decoration: const InputDecoration(labelText: 'Office'), + ), + ), + SizedBox( + width: 220, + child: DropdownButtonFormField( + isExpanded: true, + key: ValueKey(_selectedLeaderId), + initialValue: _selectedLeaderId, + items: leaderOptions, + onChanged: (v) => setState(() => _selectedLeaderId = v), + decoration: const InputDecoration(labelText: 'Team leader'), + ), + ), + if (_hasFilters) + TextButton.icon( + onPressed: () => setState(() { + _searchController.clear(); + _selectedOfficeId = null; + _selectedLeaderId = null; + }), + icon: const Icon(Icons.close), + label: const Text('Clear'), + ), + ], + ); + + final listBody = TasQAdaptiveList( + items: filteredTeams, + filterHeader: filterHeader, columns: [ TasQColumn( header: 'Name', @@ -93,41 +201,67 @@ class TeamsScreen extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: actions, ), - onTap: () => _showTeamDialog(context, ref, team: team), + onTap: () => _showTeamDialog(context, team: team), ); }, rowActions: (team) => [ IconButton( icon: const Icon(Icons.edit), tooltip: 'Edit', - onPressed: () => _showTeamDialog(context, ref, team: team), + onPressed: () => _showTeamDialog(context, team: team), ), IconButton( icon: const Icon(Icons.delete), tooltip: 'Delete', - onPressed: () => _deleteTeam(context, ref, team.id), + onPressed: () => _deleteTeam(context, team.id), ), ], ); + + return Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(top: 16, bottom: 8), + child: Stack( + alignment: Alignment.center, + children: [ + Align( + alignment: Alignment.center, + child: Text( + 'Team Management', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + Expanded(child: listBody), + ], + ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (err, stack) => Center(child: Text('Error: $err')), ), floatingActionButton: FloatingActionButton( - onPressed: () => _showTeamDialog(context, ref), + onPressed: () => _showTeamDialog(context), tooltip: 'Add Team', child: const Icon(Icons.add), ), ); } - void _showTeamDialog( - BuildContext context, - WidgetRef ref, { - Team? team, - }) async { + void _showTeamDialog(BuildContext context, {Team? team}) async { final profiles = ref.read(profilesProvider).valueOrNull ?? []; - final offices = await ref.read(officesProvider.future); + // officesProvider is a StreamProvider (global) — try to read the latest + // synchronous value first, otherwise await the first stream event. + final officesSync = ref.read(officesProvider).valueOrNull; + final List offices = + officesSync ?? await ref.read(officesProvider.future); if (!context.mounted) return; final itStaff = profiles.where((p) => p.role == 'it_staff').toList(); final nameController = TextEditingController(text: team?.name ?? ''); @@ -145,147 +279,412 @@ class TeamsScreen extends ConsumerWidget { .toList(); } - await showDialog( - context: context, - builder: (context) { - return StatefulBuilder( - builder: (context, setState) { - return AlertDialog( - shape: AppSurfaces.of(context).dialogShape, - title: Text(isEdit ? 'Edit Team' : 'Add Team'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: nameController, - decoration: const InputDecoration(labelText: 'Team Name'), - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: leaderId, - items: [ - for (final p in itStaff) - DropdownMenuItem( - value: p.id, - child: Text( - p.fullName.isNotEmpty ? p.fullName : p.id, - ), - ), - ], - onChanged: (v) => setState(() => leaderId = v), - decoration: const InputDecoration( - labelText: 'Team Leader', - ), - ), - const SizedBox(height: 12), - SearchableMultiSelectDropdown( - label: 'Offices', - items: offices, - selectedIds: selectedOffices, - getId: (o) => o.id, - getLabel: (o) => o.name, - onChanged: (ids) => setState(() => selectedOffices = ids), - ), - const SizedBox(height: 12), - SearchableMultiSelectDropdown( - label: 'Team Members', - items: itStaff, - selectedIds: selectedMembers, - getId: (p) => p.id, - getLabel: (p) => - p.fullName.isNotEmpty ? p.fullName : p.id, - onChanged: (ids) => setState(() => selectedMembers = ids), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: () async { - final navigator = Navigator.of(context); - final name = nameController.text.trim(); - if (name.isEmpty || leaderId == null) return; - final client = Supabase.instance.client; - if (isEdit) { - await client - .from('teams') - .update({ - 'name': name, - 'leader_id': leaderId, - 'office_ids': selectedOffices, - }) - .eq('id', team.id); - // Update team members - await client - .from('team_members') - .delete() - .eq('team_id', team.id); - if (selectedMembers.isNotEmpty) { - await client.from('team_members').insert([ - for (final userId in selectedMembers) - {'team_id': team.id, 'user_id': userId}, - ]); - } - } else { - final insertRes = await client - .from('teams') - .insert({ - 'name': name, - 'leader_id': leaderId, - 'office_ids': selectedOffices, - }) - .select() - .single(); - final newTeamId = insertRes['id'] as String?; - if (newTeamId != null && selectedMembers.isNotEmpty) { - await client.from('team_members').insert([ - for (final userId in selectedMembers) - {'team_id': newTeamId, 'user_id': userId}, - ]); - } - } - ref.invalidate(teamsProvider); - ref.invalidate(teamMembersProvider); - navigator.pop(); - }, - child: Text(isEdit ? 'Save' : 'Add'), - ), + final bool isMobileDialog = MediaQuery.of(context).size.width <= 600; + const double kDialogMaxWidth = 720; + + // Dialog content builder (captures outer local vars like leaderId, + // selectedOffices, selectedMembers, nameController). + Widget buildFormContent(StateSetter setState) { + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration(labelText: 'Team Name'), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: leaderId, + items: [ + for (final p in itStaff) + DropdownMenuItem( + value: p.id, + child: Text(p.fullName.isNotEmpty ? p.fullName : p.id), + ), ], - ); - }, + onChanged: (v) => setState(() => leaderId = v), + decoration: const InputDecoration(labelText: 'Team Leader'), + ), + const SizedBox(height: 12), + // Available offices: exclude offices already assigned to other teams + Builder( + builder: (context) { + final allTeams = + ref.read(teamsProvider).valueOrNull ?? []; + final assignedOfficeIds = allTeams + .where((t) => t.id != team?.id) + .expand((t) => t.officeIds) + .toSet(); + final availableOffices = offices + .where( + (o) => + !assignedOfficeIds.contains(o.id) || + selectedOffices.contains(o.id), + ) + .toList(); + + return MultiSelectPicker( + label: 'Offices', + items: availableOffices, + selectedIds: selectedOffices, + getId: (o) => o.id, + getLabel: (o) => o.name, + onChanged: (ids) => setState(() => selectedOffices = ids), + ); + }, + ), + const SizedBox(height: 12), + Builder( + builder: (context) { + final members = + ref.read(teamMembersProvider).valueOrNull ?? []; + final assignedUserIds = members + .where((m) => m.teamId != team?.id) + .map((m) => m.userId) + .toSet(); + final availableItStaff = itStaff + .where( + (p) => + !assignedUserIds.contains(p.id) || + selectedMembers.contains(p.id), + ) + .toList(); + + return MultiSelectPicker( + label: 'Team Members', + items: availableItStaff, + selectedIds: selectedMembers, + getId: (p) => p.id, + getLabel: (p) => p.fullName.isNotEmpty ? p.fullName : p.id, + onChanged: (ids) => setState(() => selectedMembers = ids), + ); + }, + ), + ], + ), + ); + } + + Future onSave(StateSetter setState, NavigatorState navigator) async { + final messenger = ScaffoldMessenger.of(navigator.context); + final name = nameController.text.trim(); + if (name.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Team name is required'))); + return; + } + if (leaderId == null) { + messenger.showSnackBar( + const SnackBar(content: Text('Please select a team leader')), ); - }, - ); + return; + } + if (selectedOffices.isEmpty) { + messenger.showSnackBar( + const SnackBar( + content: Text('Assign at least one office to the team'), + ), + ); + return; + } + + // ensure leader included + if (leaderId != null && !selectedMembers.contains(leaderId)) { + selectedMembers = [leaderId!, ...selectedMembers]; + } + + final client = Supabase.instance.client; + try { + if (isEdit) { + // update team row + final upRes = await client + .from('teams') + .update({ + 'name': name, + 'leader_id': leaderId, + 'office_ids': selectedOffices, + }) + .eq('id', team.id); + if (upRes['error'] != null) { + final err = upRes['error']; + throw Exception( + err is Map ? (err['message'] ?? err.toString()) : err.toString(), + ); + } + + // replace members for the team + final delRes = await client + .from('team_members') + .delete() + .eq('team_id', team.id); + if (delRes['error'] != null) { + final err = delRes['error']; + throw Exception( + err is Map ? (err['message'] ?? err.toString()) : err.toString(), + ); + } + + if (selectedMembers.isNotEmpty) { + final rows = selectedMembers + .map((u) => {'team_id': team.id, 'user_id': u}) + .toList(); + final memRes = await client.from('team_members').insert(rows); + if (memRes is Map && memRes['error'] != null) { + final err = memRes['error']; + throw Exception( + err is Map + ? (err['message'] ?? err.toString()) + : err.toString(), + ); + } + + // verify members persisted (handle Map or List response) + final dynamic checkRes = await client + .from('team_members') + .select() + .eq('team_id', team.id); + if (checkRes is Map && checkRes['error'] != null) { + throw Exception('Failed to verify team members'); + } + final List persisted = checkRes is List + ? List.from(checkRes) + : (checkRes is Map && checkRes['data'] is List) + ? List.from(checkRes['data'] as List) + : []; + if (persisted.length != selectedMembers.length) { + throw Exception('Member count mismatch after save'); + } + } + } else { + // create team and insert members + final insertRes = await client + .from('teams') + .insert({ + 'name': name, + 'leader_id': leaderId, + 'office_ids': selectedOffices, + }) + .select() + .single(); + + // normalize inserted row to extract id reliably across client response shapes + dynamic insertedRow; + final insertResValue = insertRes; + if (insertResValue is List && insertResValue.isNotEmpty) { + insertedRow = (insertResValue as List).first; + } else { + if (insertResValue['error'] != null) { + final err = insertResValue['error']; + throw Exception( + err is Map + ? (err['message'] ?? err.toString()) + : err.toString(), + ); + } + final dataField = insertResValue['data']; + if (dataField is List && dataField.isNotEmpty) { + insertedRow = dataField.first; + } else if (dataField is Map) { + insertedRow = dataField; + } else if (insertResValue['id'] != null) { + insertedRow = insertResValue; + } + } + + if (insertedRow == null || insertedRow['id'] == null) { + throw Exception('Unable to determine created team id'); + } + final teamId = insertedRow['id'] as String; + + if (selectedMembers.isNotEmpty) { + final rows = selectedMembers + .map((u) => {'team_id': teamId, 'user_id': u}) + .toList(); + final memRes = await client.from('team_members').insert(rows); + if (memRes is Map && memRes['error'] != null) { + final err = memRes['error']; + throw Exception( + err is Map + ? (err['message'] ?? err.toString()) + : err.toString(), + ); + } + + // verify members persisted (handle Map or List response) + final dynamic checkRes = await client + .from('team_members') + .select() + .eq('team_id', teamId); + if (checkRes is Map && checkRes['error'] != null) { + throw Exception('Failed to verify team members'); + } + final List persisted = checkRes is List + ? List.from(checkRes) + : (checkRes is Map && checkRes['data'] is List) + ? List.from(checkRes['data'] as List) + : []; + if (persisted.length != selectedMembers.length) { + throw Exception('Member count mismatch after save'); + } + } + } + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text('Failed to save team: $e')), + ); + return; + } + + ref.invalidate(teamsProvider); + ref.invalidate(teamMembersProvider); + navigator.pop(); + } + + if (isMobileDialog) { + // Mobile: bottom sheet presentation + await showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: AppSurfaces.of(context).dialogShape, + builder: (sheetContext) { + return StatefulBuilder( + builder: (sheetContext, setState) { + final navigator = Navigator.of(sheetContext); + return SafeArea( + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 12, + bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 12, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isEdit ? 'Edit Team' : 'Add Team', + style: Theme.of(sheetContext).textTheme.titleLarge, + ), + const SizedBox(height: 8), + buildFormContent(setState), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => navigator.pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () => onSave(setState, navigator), + child: Text(isEdit ? 'Save' : 'Add'), + ), + ], + ), + ], + ), + ), + ); + }, + ); + }, + ); + } else { + // Desktop / Tablet: centered fixed-width AlertDialog + await showDialog( + context: context, + builder: (dialogContext) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: kDialogMaxWidth), + child: StatefulBuilder( + builder: (dialogContext, setState) { + final navigator = Navigator.of(dialogContext); + return AlertDialog( + shape: AppSurfaces.of(dialogContext).dialogShape, + title: Text(isEdit ? 'Edit Team' : 'Add Team'), + content: buildFormContent(setState), + actions: [ + TextButton( + onPressed: () => navigator.pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => onSave(setState, navigator), + child: Text(isEdit ? 'Save' : 'Add'), + ), + ], + ); + }, + ), + ), + ); + }, + ); + } } - void _deleteTeam(BuildContext context, WidgetRef ref, String teamId) async { - final navigator = Navigator.of(context); - final confirmed = await showDialog( + void _deleteTeam(BuildContext context, String teamId) async { + final messenger = ScaffoldMessenger.of(context); + + final confirmed = await showDialog( context: context, - builder: (context) => AlertDialog( - shape: AppSurfaces.of(context).dialogShape, + builder: (dialogContext) => AlertDialog( + shape: AppSurfaces.of(dialogContext).dialogShape, title: const Text('Delete Team'), - content: const Text('Are you sure you want to delete this team?'), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: const Text('Are you sure you want to delete this team?'), + ), actions: [ TextButton( - onPressed: () => navigator.pop(false), + onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel'), ), ElevatedButton( - onPressed: () => navigator.pop(true), + onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('Delete'), ), ], ), ); - if (confirmed == true) { - await Supabase.instance.client.from('teams').delete().eq('id', teamId); + + if (confirmed != true) return; + + try { + final delMembersRes = await Supabase.instance.client + .from('team_members') + .delete() + .eq('team_id', teamId); + if (delMembersRes is Map && delMembersRes['error'] != null) { + final err = delMembersRes['error']; + throw Exception( + err is Map ? (err['message'] ?? err.toString()) : err.toString(), + ); + } + + final delTeamRes = await Supabase.instance.client + .from('teams') + .delete() + .eq('id', teamId); + if (delTeamRes is Map && delTeamRes['error'] != null) { + final err = delTeamRes['error']; + throw Exception( + err is Map ? (err['message'] ?? err.toString()) : err.toString(), + ); + } + ref.invalidate(teamsProvider); + ref.invalidate(teamMembersProvider); + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text('Failed to delete team: $e')), + ); + return; } } } diff --git a/lib/widgets/multi_select_picker.dart b/lib/widgets/multi_select_picker.dart new file mode 100644 index 00000000..24f25c47 --- /dev/null +++ b/lib/widgets/multi_select_picker.dart @@ -0,0 +1,314 @@ +import 'package:flutter/material.dart'; + +/// Lightweight, bounds-safe multi-select picker used in dialogs. +/// - Renders chips for selected items and a `Select` ActionChip. +/// - Opens a root dialog with search + select-all + checkbox list. +class MultiSelectPicker extends StatefulWidget { + const MultiSelectPicker({ + super.key, + required this.label, + required this.items, + required this.selectedIds, + required this.getId, + required this.getLabel, + required this.onChanged, + }); + + final String label; + final List items; + final List selectedIds; + final String Function(T) getId; + final String Function(T) getLabel; + final ValueChanged> onChanged; + + @override + State> createState() => _MultiSelectPickerState(); +} + +class _MultiSelectPickerState extends State> { + late List _selectedIds; + String _search = ''; + + @override + void initState() { + super.initState(); + _selectedIds = List.from(widget.selectedIds); + } + + Future _openPicker() async { + final screenWidth = MediaQuery.of(context).size.width; + final isMobile = screenWidth <= 600; + const double kPickerMaxWidth = 720; + + List? result; + + if (isMobile) { + result = await showModalBottomSheet>( + context: context, + isScrollControlled: true, + builder: (sheetContext) { + List working = List.from(_selectedIds); + bool workingSelectAll = + working.length == widget.items.length && widget.items.isNotEmpty; + String localSearch = _search; + + return StatefulBuilder( + builder: (sheetContext, setState) { + final filtered = widget.items + .where( + (item) => widget + .getLabel(item) + .toLowerCase() + .contains(localSearch.toLowerCase()), + ) + .toList(); + + return SafeArea( + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 12, + bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 12, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Select ${widget.label}', + style: Theme.of(sheetContext).textTheme.titleLarge, + ), + const SizedBox(height: 8), + TextField( + decoration: const InputDecoration( + hintText: 'Search...', + ), + onChanged: (v) => setState(() => localSearch = v), + ), + CheckboxListTile( + value: workingSelectAll, + title: const Text('Select All'), + onChanged: (checked) { + setState(() { + workingSelectAll = checked ?? false; + if (workingSelectAll) { + working = widget.items.map(widget.getId).toList(); + } else { + working = []; + } + }); + }, + ), + SizedBox( + height: 320, + child: ListView( + children: [ + for (final item in filtered) + CheckboxListTile( + value: working.contains(widget.getId(item)), + title: Text(widget.getLabel(item)), + onChanged: (checked) { + setState(() { + final id = widget.getId(item); + if (checked == true) { + working.add(id); + } else { + working.remove(id); + } + workingSelectAll = + working.length == widget.items.length && + widget.items.isNotEmpty; + }); + }, + ), + ], + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(sheetContext).pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () => + Navigator.of(sheetContext).pop(working), + child: const Text('Done'), + ), + ], + ), + ], + ), + ), + ); + }, + ); + }, + ); + } else { + result = await showDialog>( + context: context, + useRootNavigator: true, + builder: (dialogContext) { + List working = List.from(_selectedIds); + bool workingSelectAll = + working.length == widget.items.length && widget.items.isNotEmpty; + String localSearch = _search; + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: kPickerMaxWidth), + child: StatefulBuilder( + builder: (dialogContext, setState) { + final filtered = widget.items + .where( + (item) => widget + .getLabel(item) + .toLowerCase() + .contains(localSearch.toLowerCase()), + ) + .toList(); + + return AlertDialog( + title: Text('Select ${widget.label}'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + decoration: const InputDecoration( + hintText: 'Search...', + ), + onChanged: (v) => setState(() => localSearch = v), + ), + CheckboxListTile( + value: workingSelectAll, + title: const Text('Select All'), + onChanged: (checked) { + setState(() { + workingSelectAll = checked ?? false; + if (workingSelectAll) { + working = widget.items + .map(widget.getId) + .toList(); + } else { + working = []; + } + }); + }, + ), + SizedBox( + height: 220, + width: double.maxFinite, + child: ListView( + children: [ + for (final item in filtered) + CheckboxListTile( + value: working.contains(widget.getId(item)), + title: Text(widget.getLabel(item)), + onChanged: (checked) { + setState(() { + final id = widget.getId(item); + if (checked == true) { + working.add(id); + } else { + working.remove(id); + } + workingSelectAll = + working.length == + widget.items.length && + widget.items.isNotEmpty; + }); + }, + ), + ], + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => + Navigator.of(dialogContext).pop(working), + child: const Text('Done'), + ), + ], + ); + }, + ), + ), + ); + }, + ); + } + + if (result is List) { + setState(() { + _selectedIds = List.from(result!); + _search = ''; + }); + widget.onChanged(_selectedIds); + } + } + + @override + void didUpdateWidget(covariant MultiSelectPicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.selectedIds != widget.selectedIds) { + _selectedIds = List.from(widget.selectedIds); + } + } + + @override + Widget build(BuildContext context) { + // Avoid using LayoutBuilder inside InputDecorator because AlertDialog (and + // other parents) may request intrinsic dimensions which causes LayoutBuilder + // to throw. Use a MediaQuery-based maxWidth instead — it is intrinsic-safe. + final double screenMaxWidth = MediaQuery.of(context).size.width - 48.0; + + return InputDecorator( + decoration: InputDecoration(labelText: widget.label), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: screenMaxWidth), + child: Align( + alignment: Alignment.centerLeft, + child: Wrap( + spacing: 8, + runSpacing: 4, + children: [ + for (final id in _selectedIds) + Builder( + builder: (context) { + final item = widget.items.firstWhere( + (e) => widget.getId(e) == id, + orElse: () => null as T, + ); + if (item == null) return const SizedBox.shrink(); + return Chip( + label: Text(widget.getLabel(item)), + onDeleted: () { + setState(() { + _selectedIds.remove(id); + }); + widget.onChanged(_selectedIds); + }, + ); + }, + ), + ActionChip( + label: const Text('Select'), + avatar: const Icon(Icons.arrow_drop_down), + onPressed: _openPicker, + ), + ], + ), + ), + ), + ); + } +}