Teams CRUD
This commit is contained in:
parent
7fb465f6c9
commit
3892a45dba
|
|
@ -1,21 +1,24 @@
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'supabase_provider.dart';
|
||||||
import '../models/team.dart';
|
import '../models/team.dart';
|
||||||
import '../models/team_member.dart';
|
import '../models/team_member.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
||||||
|
|
||||||
final teamsProvider = FutureProvider<List<Team>>((ref) async {
|
/// Real-time stream of teams (keeps UI in sync with DB changes).
|
||||||
final data = await Supabase.instance.client
|
final teamsProvider = StreamProvider<List<Team>>((ref) {
|
||||||
|
final client = ref.watch(supabaseClientProvider);
|
||||||
|
return client
|
||||||
.from('teams')
|
.from('teams')
|
||||||
.select()
|
.stream(primaryKey: ['id'])
|
||||||
.order('name');
|
.order('name')
|
||||||
return (data as List<dynamic>? ?? [])
|
.map((rows) => rows.map((r) => Team.fromMap(r)).toList());
|
||||||
.map((e) => Team.fromMap(e as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final teamMembersProvider = FutureProvider<List<TeamMember>>((ref) async {
|
/// Real-time stream of team membership rows.
|
||||||
final data = await Supabase.instance.client.from('team_members').select();
|
final teamMembersProvider = StreamProvider<List<TeamMember>>((ref) {
|
||||||
return (data as List<dynamic>? ?? [])
|
final client = ref.watch(supabaseClientProvider);
|
||||||
.map((e) => TeamMember.fromMap(e as Map<String, dynamic>))
|
return client
|
||||||
.toList();
|
.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() {
|
void userTyping() {
|
||||||
if (_disposed || !mounted) {
|
if (_disposed || !mounted) {
|
||||||
if (kDebugMode)
|
if (kDebugMode) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'TypingIndicatorController.userTyping() ignored after dispose',
|
'TypingIndicatorController.userTyping() ignored after dispose',
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_client.auth.currentUser?.id == null) return;
|
if (_client.auth.currentUser?.id == null) return;
|
||||||
|
|
@ -144,10 +145,11 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
|
||||||
_typingTimer?.cancel();
|
_typingTimer?.cancel();
|
||||||
_typingTimer = Timer(const Duration(milliseconds: 150), () {
|
_typingTimer = Timer(const Duration(milliseconds: 150), () {
|
||||||
if (_disposed || !mounted) {
|
if (_disposed || !mounted) {
|
||||||
if (kDebugMode)
|
if (kDebugMode) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'TypingIndicatorController._typingTimer callback ignored after dispose',
|
'TypingIndicatorController._typingTimer callback ignored after dispose',
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_sendTypingEvent('stop');
|
_sendTypingEvent('stop');
|
||||||
|
|
@ -156,10 +158,11 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
|
||||||
|
|
||||||
void stopTyping() {
|
void stopTyping() {
|
||||||
if (_disposed || !mounted) {
|
if (_disposed || !mounted) {
|
||||||
if (kDebugMode)
|
if (kDebugMode) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'TypingIndicatorController.stopTyping() ignored after dispose',
|
'TypingIndicatorController.stopTyping() ignored after dispose',
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_typingTimer?.cancel();
|
_typingTimer?.cancel();
|
||||||
|
|
@ -182,10 +185,11 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
|
||||||
_remoteTimeouts[userId]?.cancel();
|
_remoteTimeouts[userId]?.cancel();
|
||||||
_remoteTimeouts[userId] = Timer(const Duration(milliseconds: 400), () {
|
_remoteTimeouts[userId] = Timer(const Duration(milliseconds: 400), () {
|
||||||
if (_disposed || !mounted) {
|
if (_disposed || !mounted) {
|
||||||
if (kDebugMode)
|
if (kDebugMode) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'TypingIndicatorController.remote timeout callback ignored after dispose for user: $userId',
|
'TypingIndicatorController.remote timeout callback ignored after dispose for user: $userId',
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_clearRemoteTyping(userId);
|
_clearRemoteTyping(userId);
|
||||||
|
|
@ -194,10 +198,11 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
|
||||||
|
|
||||||
void _clearRemoteTyping(String userId) {
|
void _clearRemoteTyping(String userId) {
|
||||||
if (_disposed || !mounted) {
|
if (_disposed || !mounted) {
|
||||||
if (kDebugMode)
|
if (kDebugMode) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'TypingIndicatorController._clearRemoteTyping ignored after dispose for user: $userId',
|
'TypingIndicatorController._clearRemoteTyping ignored after dispose for user: $userId',
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final updated = {...state.userIds}..remove(userId);
|
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 '../../models/team.dart';
|
||||||
import '../../providers/teams_provider.dart';
|
import '../../providers/teams_provider.dart';
|
||||||
import '../../providers/profile_provider.dart';
|
import '../../providers/profile_provider.dart';
|
||||||
|
import '../../providers/tickets_provider.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.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 '../../theme/app_surfaces.dart';
|
||||||
import '../../widgets/tasq_adaptive_list.dart';
|
import '../../widgets/tasq_adaptive_list.dart';
|
||||||
|
|
||||||
final officesProvider = FutureProvider<List<Office>>((ref) async {
|
// Note: `officesProvider` is provided globally in `tickets_provider.dart` so
|
||||||
final data = await Supabase.instance.client.from('offices').select();
|
// we reuse that StreamProvider here (avoids duplicate queries).
|
||||||
return (data as List<dynamic>? ?? [])
|
|
||||||
.map((e) => Office.fromMap(e as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
});
|
|
||||||
|
|
||||||
class TeamsScreen extends ConsumerWidget {
|
class TeamsScreen extends ConsumerStatefulWidget {
|
||||||
const TeamsScreen({super.key});
|
const TeamsScreen({super.key});
|
||||||
|
|
||||||
@override
|
@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 teamsAsync = ref.watch(teamsProvider);
|
||||||
final profilesAsync = ref.watch(profilesProvider);
|
final profilesAsync = ref.watch(profilesProvider);
|
||||||
final officesAsync = ref.watch(officesProvider);
|
final officesAsync = ref.watch(officesProvider);
|
||||||
|
|
@ -36,8 +54,98 @@ class TeamsScreen extends ConsumerWidget {
|
||||||
final profileById = {for (var p in profiles) p.id: p};
|
final profileById = {for (var p in profiles) p.id: p};
|
||||||
final officeById = {for (var o in offices) o.id: o};
|
final officeById = {for (var o in offices) o.id: o};
|
||||||
|
|
||||||
return TasQAdaptiveList<Team>(
|
// filters
|
||||||
items: teams,
|
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: [
|
columns: [
|
||||||
TasQColumn<Team>(
|
TasQColumn<Team>(
|
||||||
header: 'Name',
|
header: 'Name',
|
||||||
|
|
@ -93,41 +201,67 @@ class TeamsScreen extends ConsumerWidget {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: actions,
|
children: actions,
|
||||||
),
|
),
|
||||||
onTap: () => _showTeamDialog(context, ref, team: team),
|
onTap: () => _showTeamDialog(context, team: team),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
rowActions: (team) => [
|
rowActions: (team) => [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.edit),
|
icon: const Icon(Icons.edit),
|
||||||
tooltip: 'Edit',
|
tooltip: 'Edit',
|
||||||
onPressed: () => _showTeamDialog(context, ref, team: team),
|
onPressed: () => _showTeamDialog(context, team: team),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete),
|
icon: const Icon(Icons.delete),
|
||||||
tooltip: '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()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: () => _showTeamDialog(context, ref),
|
onPressed: () => _showTeamDialog(context),
|
||||||
tooltip: 'Add Team',
|
tooltip: 'Add Team',
|
||||||
child: const Icon(Icons.add),
|
child: const Icon(Icons.add),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showTeamDialog(
|
void _showTeamDialog(BuildContext context, {Team? team}) async {
|
||||||
BuildContext context,
|
|
||||||
WidgetRef ref, {
|
|
||||||
Team? team,
|
|
||||||
}) async {
|
|
||||||
final profiles = ref.read(profilesProvider).valueOrNull ?? [];
|
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;
|
if (!context.mounted) return;
|
||||||
final itStaff = profiles.where((p) => p.role == 'it_staff').toList();
|
final itStaff = profiles.where((p) => p.role == 'it_staff').toList();
|
||||||
final nameController = TextEditingController(text: team?.name ?? '');
|
final nameController = TextEditingController(text: team?.name ?? '');
|
||||||
|
|
@ -145,15 +279,13 @@ class TeamsScreen extends ConsumerWidget {
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
await showDialog(
|
final bool isMobileDialog = MediaQuery.of(context).size.width <= 600;
|
||||||
context: context,
|
const double kDialogMaxWidth = 720;
|
||||||
builder: (context) {
|
|
||||||
return StatefulBuilder(
|
// Dialog content builder (captures outer local vars like leaderId,
|
||||||
builder: (context, setState) {
|
// selectedOffices, selectedMembers, nameController).
|
||||||
return AlertDialog(
|
Widget buildFormContent(StateSetter setState) {
|
||||||
shape: AppSurfaces.of(context).dialogShape,
|
return SingleChildScrollView(
|
||||||
title: Text(isEdit ? 'Edit Team' : 'Add Team'),
|
|
||||||
content: SingleChildScrollView(
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -168,51 +300,106 @@ class TeamsScreen extends ConsumerWidget {
|
||||||
for (final p in itStaff)
|
for (final p in itStaff)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: p.id,
|
value: p.id,
|
||||||
child: Text(
|
child: Text(p.fullName.isNotEmpty ? p.fullName : p.id),
|
||||||
p.fullName.isNotEmpty ? p.fullName : p.id,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onChanged: (v) => setState(() => leaderId = v),
|
onChanged: (v) => setState(() => leaderId = v),
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(labelText: 'Team Leader'),
|
||||||
labelText: 'Team Leader',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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',
|
label: 'Offices',
|
||||||
items: offices,
|
items: availableOffices,
|
||||||
selectedIds: selectedOffices,
|
selectedIds: selectedOffices,
|
||||||
getId: (o) => o.id,
|
getId: (o) => o.id,
|
||||||
getLabel: (o) => o.name,
|
getLabel: (o) => o.name,
|
||||||
onChanged: (ids) => setState(() => selectedOffices = ids),
|
onChanged: (ids) => setState(() => selectedOffices = ids),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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',
|
label: 'Team Members',
|
||||||
items: itStaff,
|
items: availableItStaff,
|
||||||
selectedIds: selectedMembers,
|
selectedIds: selectedMembers,
|
||||||
getId: (p) => p.id,
|
getId: (p) => p.id,
|
||||||
getLabel: (p) =>
|
getLabel: (p) => p.fullName.isNotEmpty ? p.fullName : p.id,
|
||||||
p.fullName.isNotEmpty ? p.fullName : p.id,
|
|
||||||
onChanged: (ids) => setState(() => selectedMembers = ids),
|
onChanged: (ids) => setState(() => selectedMembers = ids),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
actions: [
|
}
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
Future<void> onSave(StateSetter setState, NavigatorState navigator) async {
|
||||||
child: const Text('Cancel'),
|
final messenger = ScaffoldMessenger.of(navigator.context);
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () async {
|
|
||||||
final navigator = Navigator.of(context);
|
|
||||||
final name = nameController.text.trim();
|
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;
|
final client = Supabase.instance.client;
|
||||||
|
try {
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
await client
|
// update team row
|
||||||
|
final upRes = await client
|
||||||
.from('teams')
|
.from('teams')
|
||||||
.update({
|
.update({
|
||||||
'name': name,
|
'name': name,
|
||||||
|
|
@ -220,18 +407,58 @@ class TeamsScreen extends ConsumerWidget {
|
||||||
'office_ids': selectedOffices,
|
'office_ids': selectedOffices,
|
||||||
})
|
})
|
||||||
.eq('id', team.id);
|
.eq('id', team.id);
|
||||||
// Update team members
|
if (upRes['error'] != null) {
|
||||||
await client
|
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')
|
.from('team_members')
|
||||||
.delete()
|
.delete()
|
||||||
.eq('team_id', team.id);
|
.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) {
|
if (selectedMembers.isNotEmpty) {
|
||||||
await client.from('team_members').insert([
|
final rows = selectedMembers
|
||||||
for (final userId in selectedMembers)
|
.map((u) => {'team_id': team.id, 'user_id': u})
|
||||||
{'team_id': team.id, 'user_id': userId},
|
.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 {
|
} else {
|
||||||
|
// create team and insert members
|
||||||
final insertRes = await client
|
final insertRes = await client
|
||||||
.from('teams')
|
.from('teams')
|
||||||
.insert({
|
.insert({
|
||||||
|
|
@ -241,51 +468,223 @@ class TeamsScreen extends ConsumerWidget {
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
final newTeamId = insertRes['id'] as String?;
|
|
||||||
if (newTeamId != null && selectedMembers.isNotEmpty) {
|
// normalize inserted row to extract id reliably across client response shapes
|
||||||
await client.from('team_members').insert([
|
dynamic insertedRow;
|
||||||
for (final userId in selectedMembers)
|
final insertResValue = insertRes;
|
||||||
{'team_id': newTeamId, 'user_id': userId},
|
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(teamsProvider);
|
||||||
ref.invalidate(teamMembersProvider);
|
ref.invalidate(teamMembersProvider);
|
||||||
navigator.pop();
|
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'),
|
child: Text(isEdit ? 'Save' : 'Add'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _deleteTeam(BuildContext context, WidgetRef ref, String teamId) async {
|
void _deleteTeam(BuildContext context, String teamId) async {
|
||||||
final navigator = Navigator.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
final confirmed = await showDialog<bool>(
|
|
||||||
|
final confirmed = await showDialog<bool?>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
shape: AppSurfaces.of(context).dialogShape,
|
shape: AppSurfaces.of(dialogContext).dialogShape,
|
||||||
title: const Text('Delete Team'),
|
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: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => navigator.pop(false),
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => navigator.pop(true),
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
child: const Text('Delete'),
|
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(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