Teams CRUD
This commit is contained in:
parent
7fb465f6c9
commit
3892a45dba
|
|
@ -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<List<Team>>((ref) async {
|
||||
final data = await Supabase.instance.client
|
||||
/// Real-time stream of teams (keeps UI in sync with DB changes).
|
||||
final teamsProvider = StreamProvider<List<Team>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return client
|
||||
.from('teams')
|
||||
.select()
|
||||
.order('name');
|
||||
return (data as List<dynamic>? ?? [])
|
||||
.map((e) => Team.fromMap(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
.stream(primaryKey: ['id'])
|
||||
.order('name')
|
||||
.map((rows) => rows.map((r) => Team.fromMap(r)).toList());
|
||||
});
|
||||
|
||||
final teamMembersProvider = FutureProvider<List<TeamMember>>((ref) async {
|
||||
final data = await Supabase.instance.client.from('team_members').select();
|
||||
return (data as List<dynamic>? ?? [])
|
||||
.map((e) => TeamMember.fromMap(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
/// Real-time stream of team membership rows.
|
||||
final teamMembersProvider = StreamProvider<List<TeamMember>>((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());
|
||||
});
|
||||
|
|
|
|||
|
|
@ -133,10 +133,11 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
|
|||
|
||||
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<TypingIndicatorState> {
|
|||
_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<TypingIndicatorState> {
|
|||
|
||||
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<TypingIndicatorState> {
|
|||
_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<TypingIndicatorState> {
|
|||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<T> 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<T> items;
|
||||
final List<String> selectedIds;
|
||||
final String Function(T) getId;
|
||||
final String Function(T) getLabel;
|
||||
final ValueChanged<List<String>> onChanged;
|
||||
|
||||
@override
|
||||
State<SearchableMultiSelectDropdown<T>> createState() =>
|
||||
SearchableMultiSelectDropdownState<T>();
|
||||
}
|
||||
|
||||
class SearchableMultiSelectDropdownState<T>
|
||||
extends State<SearchableMultiSelectDropdown<T>> {
|
||||
late List<String> _selectedIds;
|
||||
String _search = '';
|
||||
bool _selectAll = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedIds = List<String>.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<String>) {
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<List<Office>>((ref) async {
|
||||
final data = await Supabase.instance.client.from('offices').select();
|
||||
return (data as List<dynamic>? ?? [])
|
||||
.map((e) => Office.fromMap(e as Map<String, dynamic>))
|
||||
.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<TeamsScreen> createState() => _TeamsScreenState();
|
||||
}
|
||||
|
||||
class _TeamsScreenState extends ConsumerState<TeamsScreen> {
|
||||
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<Team>(
|
||||
items: teams,
|
||||
// filters
|
||||
final itStaff = profiles.where((p) => p.role == 'it_staff').toList();
|
||||
final officeOptions = <DropdownMenuItem<String?>>[
|
||||
const DropdownMenuItem<String?>(
|
||||
value: null,
|
||||
child: Text('All offices'),
|
||||
),
|
||||
...offices.map(
|
||||
(o) => DropdownMenuItem(value: o.id, child: Text(o.name)),
|
||||
),
|
||||
];
|
||||
final leaderOptions = <DropdownMenuItem<String?>>[
|
||||
const DropdownMenuItem<String?>(
|
||||
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<String?>(
|
||||
isExpanded: true,
|
||||
key: ValueKey(_selectedOfficeId),
|
||||
initialValue: _selectedOfficeId,
|
||||
items: officeOptions,
|
||||
onChanged: (v) => setState(() => _selectedOfficeId = v),
|
||||
decoration: const InputDecoration(labelText: 'Office'),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 220,
|
||||
child: DropdownButtonFormField<String?>(
|
||||
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<Team>(
|
||||
items: filteredTeams,
|
||||
filterHeader: filterHeader,
|
||||
columns: [
|
||||
TasQColumn<Team>(
|
||||
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<Office> 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,15 +279,13 @@ 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(
|
||||
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: [
|
||||
|
|
@ -168,51 +300,106 @@ class TeamsScreen extends ConsumerWidget {
|
|||
for (final p in itStaff)
|
||||
DropdownMenuItem(
|
||||
value: p.id,
|
||||
child: Text(
|
||||
p.fullName.isNotEmpty ? p.fullName : p.id,
|
||||
),
|
||||
child: Text(p.fullName.isNotEmpty ? p.fullName : p.id),
|
||||
),
|
||||
],
|
||||
onChanged: (v) => setState(() => leaderId = v),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Team Leader',
|
||||
),
|
||||
decoration: const InputDecoration(labelText: 'Team Leader'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SearchableMultiSelectDropdown<Office>(
|
||||
// Available offices: exclude offices already assigned to other teams
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final allTeams =
|
||||
ref.read(teamsProvider).valueOrNull ?? <Team>[];
|
||||
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<Office>(
|
||||
label: 'Offices',
|
||||
items: offices,
|
||||
items: availableOffices,
|
||||
selectedIds: selectedOffices,
|
||||
getId: (o) => o.id,
|
||||
getLabel: (o) => o.name,
|
||||
onChanged: (ids) => setState(() => selectedOffices = ids),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SearchableMultiSelectDropdown<Profile>(
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final members =
|
||||
ref.read(teamMembersProvider).valueOrNull ?? <dynamic>[];
|
||||
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<Profile>(
|
||||
label: 'Team Members',
|
||||
items: itStaff,
|
||||
items: availableItStaff,
|
||||
selectedIds: selectedMembers,
|
||||
getId: (p) => p.id,
|
||||
getLabel: (p) =>
|
||||
p.fullName.isNotEmpty ? p.fullName : 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);
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onSave(StateSetter setState, NavigatorState navigator) async {
|
||||
final messenger = ScaffoldMessenger.of(navigator.context);
|
||||
final name = nameController.text.trim();
|
||||
if (name.isEmpty || leaderId == null) return;
|
||||
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) {
|
||||
await client
|
||||
// update team row
|
||||
final upRes = await client
|
||||
.from('teams')
|
||||
.update({
|
||||
'name': name,
|
||||
|
|
@ -220,18 +407,58 @@ class TeamsScreen extends ConsumerWidget {
|
|||
'office_ids': selectedOffices,
|
||||
})
|
||||
.eq('id', team.id);
|
||||
// Update team members
|
||||
await client
|
||||
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) {
|
||||
await client.from('team_members').insert([
|
||||
for (final userId in selectedMembers)
|
||||
{'team_id': team.id, 'user_id': userId},
|
||||
]);
|
||||
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)
|
||||
: <dynamic>[];
|
||||
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({
|
||||
|
|
@ -241,51 +468,223 @@ class TeamsScreen extends ConsumerWidget {
|
|||
})
|
||||
.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},
|
||||
]);
|
||||
|
||||
// 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)
|
||||
: <dynamic>[];
|
||||
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<void>(
|
||||
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<void>(
|
||||
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<bool>(
|
||||
void _deleteTeam(BuildContext context, String teamId) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
final confirmed = await showDialog<bool?>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
314
lib/widgets/multi_select_picker.dart
Normal file
314
lib/widgets/multi_select_picker.dart
Normal file
|
|
@ -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<T> 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<T> items;
|
||||
final List<String> selectedIds;
|
||||
final String Function(T) getId;
|
||||
final String Function(T) getLabel;
|
||||
final ValueChanged<List<String>> onChanged;
|
||||
|
||||
@override
|
||||
State<MultiSelectPicker<T>> createState() => _MultiSelectPickerState<T>();
|
||||
}
|
||||
|
||||
class _MultiSelectPickerState<T> extends State<MultiSelectPicker<T>> {
|
||||
late List<String> _selectedIds;
|
||||
String _search = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedIds = List<String>.from(widget.selectedIds);
|
||||
}
|
||||
|
||||
Future<void> _openPicker() async {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isMobile = screenWidth <= 600;
|
||||
const double kPickerMaxWidth = 720;
|
||||
|
||||
List<String>? result;
|
||||
|
||||
if (isMobile) {
|
||||
result = await showModalBottomSheet<List<String>>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (sheetContext) {
|
||||
List<String> working = List<String>.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<List<String>>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
builder: (dialogContext) {
|
||||
List<String> working = List<String>.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<String>) {
|
||||
setState(() {
|
||||
_selectedIds = List<String>.from(result!);
|
||||
_search = '';
|
||||
});
|
||||
widget.onChanged(_selectedIds);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MultiSelectPicker<T> oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.selectedIds != widget.selectedIds) {
|
||||
_selectedIds = List<String>.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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user