Files
tasq/lib/widgets/office_picker.dart
T
redz1029 30b301765b * Improved Task and Ticket Detail Screen
* Made Office assignment searchable in Task and Ticket Creation and Edit Screen
2026-05-10 17:54:03 +08:00

261 lines
8.0 KiB
Dart

import 'package:flutter/material.dart';
import '../models/office.dart';
import '../theme/m3_motion.dart';
import 'app_breakpoints.dart';
class _PickResult {
const _PickResult(this.officeId);
final String? officeId;
}
/// A tappable form field that opens a searchable office picker.
///
/// Replaces [DropdownButtonFormField] when the office list is large.
class OfficeSelectorField extends StatelessWidget {
const OfficeSelectorField({
super.key,
required this.offices,
required this.selectedOfficeId,
required this.onChanged,
this.allowUnassigned = false,
this.labelText = 'Office',
});
final List<Office> offices;
final String? selectedOfficeId;
/// Null disables the field (shows dimmed, no tap).
final ValueChanged<String?>? onChanged;
/// When true, an "Unassigned" tile appears at the top of the picker.
final bool allowUnassigned;
final String labelText;
String _displayText() {
if (selectedOfficeId == null) {
return allowUnassigned ? 'Unassigned' : 'Select office…';
}
try {
return offices.firstWhere((o) => o.id == selectedOfficeId).name;
} catch (_) {
return 'Select office…';
}
}
Future<void> _openPicker(BuildContext context) async {
final sorted = List<Office>.from(offices)
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
final result = await _showOfficePicker(
context,
offices: sorted,
initialOfficeId: selectedOfficeId,
allowUnassigned: allowUnassigned,
);
if (result != null) onChanged?.call(result.officeId);
}
@override
Widget build(BuildContext context) {
final isEnabled = onChanged != null;
final cs = Theme.of(context).colorScheme;
return InkWell(
onTap: isEnabled ? () => _openPicker(context) : null,
borderRadius: BorderRadius.circular(16),
child: InputDecorator(
decoration: InputDecoration(
labelText: labelText,
enabled: isEnabled,
suffixIcon: Icon(
Icons.search_rounded,
color: isEnabled
? cs.onSurfaceVariant
: cs.onSurface.withValues(alpha: 0.38),
),
),
child: Text(
_displayText(),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isEnabled
? cs.onSurface
: cs.onSurface.withValues(alpha: 0.38),
),
),
),
);
}
}
Future<_PickResult?> _showOfficePicker(
BuildContext context, {
required List<Office> offices,
String? initialOfficeId,
bool allowUnassigned = false,
}) {
final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet;
List<Widget> buildItems(
String? selected,
String search,
void Function(String?) onPick,
ColorScheme cs,
) {
final filtered = search.isEmpty
? offices
: offices
.where((o) => o.name.toLowerCase().contains(search.toLowerCase()))
.toList();
return [
if (allowUnassigned)
ListTile(
dense: true,
leading: Icon(Icons.block_rounded, color: cs.onSurfaceVariant),
title: const Text('Unassigned'),
selected: selected == null,
trailing: selected == null
? Icon(Icons.check_rounded, size: 18, color: cs.primary)
: null,
onTap: () => onPick(null),
),
for (final office in filtered)
ListTile(
dense: true,
leading: Icon(Icons.business_rounded, color: cs.onSurfaceVariant),
title: Text(office.name),
selected: selected == office.id,
trailing: selected == office.id
? Icon(Icons.check_rounded, size: 18, color: cs.primary)
: null,
onTap: () => onPick(office.id),
),
if (filtered.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'No offices match your search.',
textAlign: TextAlign.center,
style: TextStyle(color: cs.onSurfaceVariant),
),
),
];
}
if (isMobile) {
return m3ShowBottomSheet<_PickResult>(
context: context,
isScrollControlled: true,
builder: (sheetCtx) {
String search = '';
String? selected = initialOfficeId;
return StatefulBuilder(
builder: (ctx, setState) {
final cs = Theme.of(ctx).colorScheme;
return SafeArea(
child: Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 8,
bottom: MediaQuery.viewInsetsOf(ctx).bottom + 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Text(
'Select Office',
style: Theme.of(ctx).textTheme.titleLarge,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(ctx).pop(),
),
],
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
hintText: 'Search office…',
prefixIcon: Icon(Icons.search_rounded),
isDense: true,
),
onChanged: (v) => setState(() => search = v),
),
const SizedBox(height: 4),
SizedBox(
height: 320,
child: ListView(
children: buildItems(
selected,
search,
(id) => Navigator.of(ctx).pop(_PickResult(id)),
cs,
),
),
),
],
),
),
);
},
);
},
);
}
return m3ShowDialog<_PickResult>(
context: context,
builder: (dialogCtx) {
String search = '';
String? selected = initialOfficeId;
return StatefulBuilder(
builder: (ctx, setState) {
final cs = Theme.of(ctx).colorScheme;
return AlertDialog(
title: const Text('Select Office'),
contentPadding: const EdgeInsets.fromLTRB(0, 12, 0, 0),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: TextField(
decoration: const InputDecoration(
hintText: 'Search office…',
prefixIcon: Icon(Icons.search_rounded),
isDense: true,
),
onChanged: (v) => setState(() => search = v),
),
),
SizedBox(
height: 280,
width: double.maxFinite,
child: ListView(
children: buildItems(
selected,
search,
(id) => Navigator.of(ctx).pop(_PickResult(id)),
cs,
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Cancel'),
),
],
);
},
);
},
);
}