* Improved Task and Ticket Detail Screen

* Made Office assignment searchable in Task and Ticket Creation and Edit Screen
This commit is contained in:
2026-05-10 17:54:03 +08:00
parent 4d0d4d5ab3
commit 30b301765b
7 changed files with 1404 additions and 524 deletions
+222 -94
View File
@@ -58,69 +58,29 @@ class AppScaffold extends ConsumerWidget {
return Scaffold(
appBar: AppBar(
title: Row(
children: [
Image.asset('assets/tasq_ico.png', width: 28, height: 28),
const SizedBox(width: 8),
Text('TasQ'),
],
title: _AppBarTitle(
location: location,
showRail: showRail,
isExtended: isExtended,
),
actions: [
if (isStandard)
PopupMenuButton<int>(
tooltip: 'Account',
onSelected: (value) {
if (value == 0) {
context.go('/profile');
} else if (value == 1) {
ref.read(authControllerProvider).signOut();
}
},
itemBuilder: (context) => const [
PopupMenuItem(value: 0, child: Text('My profile')),
PopupMenuItem(value: 1, child: Text('Sign out')),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
ProfileAvatar(
fullName: displayName,
avatarUrl: avatarUrl,
radius: 16,
heroTag: 'profile-avatar',
),
const SizedBox(width: 8),
Text(displayName),
const SizedBox(width: 4),
const Icon(Icons.expand_more),
],
),
),
)
else
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Profile',
onPressed: () => context.go('/profile'),
icon: ProfileAvatar(
fullName: displayName,
avatarUrl: avatarUrl,
radius: 16,
heroTag: 'profile-avatar',
),
),
IconButton(
tooltip: 'Sign out',
onPressed: () => ref.read(authControllerProvider).signOut(),
icon: const Icon(Icons.logout),
),
],
),
_ProfileMenuAnchor(
isStandard: isStandard,
displayName: displayName,
avatarUrl: avatarUrl,
onProfile: () => context.go('/profile'),
onSignOut: () => ref.read(authControllerProvider).signOut(),
),
const _NotificationBell(),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: Divider(
height: 1,
thickness: 1,
color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
),
bottomNavigationBar: showRail
? null
@@ -279,43 +239,35 @@ class _NotificationBell extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final unreadCount = ref.watch(unreadNotificationsCountProvider);
final cs = Theme.of(context).colorScheme;
return IconButton(
tooltip: 'Notifications',
onPressed: () => context.go('/notifications'),
icon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.notifications_outlined),
Positioned(
right: -3,
top: -3,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) => ScaleTransition(
scale: animation,
child: child,
),
child: unreadCount > 0
? Container(
key: const ValueKey('badge'),
width: 10,
height: 10,
decoration: BoxDecoration(
color: cs.error,
shape: BoxShape.circle,
border: Border.all(
color: cs.surface,
width: 1.5,
),
),
)
: const SizedBox.shrink(key: ValueKey('no-badge')),
final String? badgeLabel = unreadCount == 0
? null
: unreadCount > 99
? '99+'
: unreadCount.toString();
return Padding(
padding: const EdgeInsets.only(right: 4),
child: IconButton(
key: notificationBellKey,
tooltip: 'Notifications',
onPressed: () => context.go('/notifications'),
icon: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) =>
ScaleTransition(scale: animation, child: child),
child: Badge(
key: ValueKey(badgeLabel),
isLabelVisible: unreadCount > 0,
label: badgeLabel != null ? Text(badgeLabel) : null,
child: Icon(
unreadCount > 0
? Icons.notifications
: Icons.notifications_outlined,
),
),
],
),
),
);
}
@@ -649,3 +601,179 @@ int _currentIndex(String location, List<NavItem> items) {
final overflowIndex = items.indexWhere((item) => item.isOverflow);
return overflowIndex == -1 ? 0 : overflowIndex;
}
String _routeToTitle(String location) {
final path = location.split('?').first;
return switch (path) {
'/dashboard' => 'Dashboard',
'/attendance' => 'Attendance',
'/tickets' => 'Tickets',
'/tasks' => 'Tasks',
'/it-service-requests' => 'IT Service Requests',
'/notifications' => 'Notifications',
'/whereabouts' => 'Whereabouts',
'/workforce' => 'Workforce',
'/reports' => 'Reports',
'/announcements' => 'Announcements',
'/profile' => 'My Profile',
'/settings/users' => 'Users',
'/settings/offices' => 'Offices',
'/settings/teams' => 'IT Teams',
_ => '',
};
}
// ── Adaptive AppBar title ────────────────────────────────────────────────────
class _AppBarTitle extends StatelessWidget {
const _AppBarTitle({
required this.location,
required this.showRail,
required this.isExtended,
});
final String location;
final bool showRail;
final bool isExtended;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
final pageTitle = _routeToTitle(location);
// Shared page-name suffix: divider line + title text
List<Widget> pageSuffix = pageTitle.isEmpty
? []
: [
const SizedBox(width: 12),
Container(width: 1, height: 28, color: cs.outlineVariant),
const SizedBox(width: 12),
Text(
pageTitle,
style: tt.titleMedium?.copyWith(
color: cs.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
];
// Mobile: compact logo + brand name, no page title
if (!showRail) {
return Row(mainAxisSize: MainAxisSize.min, children: [
Image.asset('assets/tasq_ico.png', width: 28, height: 28),
const SizedBox(width: 8),
const Text('TasQ'),
]);
}
// Desktop (extended rail already shows brand): logo + page name only
if (isExtended) {
return Row(mainAxisSize: MainAxisSize.min, children: [
Image.asset('assets/tasq_ico.png', width: 28, height: 28),
...pageSuffix,
]);
}
// Tablet (collapsed rail): logo + brand + page name
return Row(mainAxisSize: MainAxisSize.min, children: [
Image.asset('assets/tasq_ico.png', width: 28, height: 28),
const SizedBox(width: 8),
const Text('TasQ'),
...pageSuffix,
]);
}
}
// ── M3 profile menu anchor ───────────────────────────────────────────────────
class _ProfileMenuAnchor extends StatelessWidget {
const _ProfileMenuAnchor({
required this.isStandard,
required this.displayName,
required this.avatarUrl,
required this.onProfile,
required this.onSignOut,
});
final bool isStandard;
final String displayName;
final String? avatarUrl;
final VoidCallback onProfile;
final VoidCallback onSignOut;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
return MenuAnchor(
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(Icons.person_outline),
onPressed: onProfile,
child: const Text('My Profile'),
),
MenuItemButton(
leadingIcon: const Icon(Icons.logout),
onPressed: onSignOut,
child: const Text('Sign out'),
),
],
builder: (context, controller, _) {
void toggle() =>
controller.isOpen ? controller.close() : controller.open();
if (isStandard) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: InkWell(
onTap: toggle,
borderRadius: BorderRadius.circular(24),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: cs.secondaryContainer,
borderRadius: BorderRadius.circular(24),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
ProfileAvatar(
fullName: displayName,
avatarUrl: avatarUrl,
radius: 14,
heroTag: 'profile-avatar',
),
const SizedBox(width: 8),
Text(
displayName,
style: tt.labelLarge?.copyWith(
color: cs.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 4),
Icon(Icons.expand_more, size: 18, color: cs.onSecondaryContainer),
]),
),
),
);
}
// Admin / non-standard: circular avatar icon button
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: IconButton(
tooltip: 'Account',
onPressed: toggle,
icon: ProfileAvatar(
fullName: displayName,
avatarUrl: avatarUrl,
radius: 16,
heroTag: 'profile-avatar',
),
),
);
},
);
}
}
+260
View File
@@ -0,0 +1,260 @@
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'),
),
],
);
},
);
},
);
}