* Improved Task and Ticket Detail Screen
* Made Office assignment searchable in Task and Ticket Creation and Edit Screen
This commit is contained in:
+222
-94
@@ -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',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user