* Task/Ticket desktop dialogs — widened from 520 → 600dp, and tasks now uses SizedBox(width: 600) instead of ConstrainedBox(maxWidth:) which was the root cause of the "still small" issue (max-only never forced expansion)

* Pass Slip + Leave — both dialogs gained an isSheet flag; callers now branch on AppBreakpoints.tablet: bottom sheet on mobile, dialog on desktop
This commit is contained in:
2026-04-30 06:45:51 +08:00
parent 48cd3bcd60
commit 783c5eb0be
19 changed files with 1224 additions and 887 deletions
@@ -261,9 +261,23 @@ class _AnnouncementCard extends ConsumerStatefulWidget {
class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
static const _cooldownSeconds = 60;
DateTime? _sentAt;
Timer? _cooldownTimer;
void _startCooldownTimer() {
_cooldownTimer?.cancel();
_cooldownTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
if (!mounted) {
_cooldownTimer?.cancel();
return;
}
setState(() {});
if (!_inCooldown) _cooldownTimer?.cancel();
});
}
@override
void dispose() {
_cooldownTimer?.cancel();
super.dispose();
}
@@ -289,9 +303,15 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
await ref
.read(announcementsControllerProvider)
.resendAnnouncementNotification(widget.announcement);
if (mounted) setState(() => _sentAt = DateTime.now());
if (mounted) {
setState(() => _sentAt = DateTime.now());
_startCooldownTimer();
}
} on AnnouncementNotificationException {
if (mounted) setState(() => _sentAt = DateTime.now());
if (mounted) {
setState(() => _sentAt = DateTime.now());
_startCooldownTimer();
}
} catch (e) {
if (mounted) showErrorSnackBar(context, 'Failed to send: $e');
}
@@ -326,13 +346,6 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
final isAnnouncementPending =
pendingNew || pendingUpdates.containsKey(widget.announcement.id);
// Rebuild UI when cooldown is active.
if (_inCooldown) {
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) setState(() {});
});
}
final hasBanner = widget.announcement.bannerEnabled;
return Padding(
@@ -456,7 +469,14 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 0),
child: Text(widget.announcement.body, style: tt.bodyMedium),
child: Text(
widget.announcement.body,
style: tt.bodyMedium,
maxLines: widget.isExpanded ? null : 8,
overflow: widget.isExpanded
? TextOverflow.clip
: TextOverflow.ellipsis,
),
),
// Visible roles chips
Padding(
+416 -392
View File
@@ -39,6 +39,7 @@ import '../../utils/snackbar.dart';
import '../../widgets/gemini_animated_text_field.dart';
import '../../widgets/gemini_button.dart';
import '../../widgets/multi_select_picker.dart';
import '../../widgets/app_breakpoints.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/responsive_body.dart';
import '../../widgets/sync_pending_badge.dart';
@@ -150,65 +151,74 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
);
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Leave option
if (canFileLeave) ...[
return SafeArea(
bottom: true,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Leave option
if (canFileLeave) ...[
_FabMenuItem(
heroTag: 'fab_leave',
label: 'File Leave',
icon: Icons.event_busy,
color: colors.tertiaryContainer,
onColor: colors.onTertiaryContainer,
onTap: () {
setState(() => _fabMenuOpen = false);
_showLeaveDialog(context, isAdmin);
},
),
const SizedBox(height: 12),
],
// Pass Slip option
_FabMenuItem(
heroTag: 'fab_leave',
label: 'File Leave',
icon: Icons.event_busy,
color: colors.tertiaryContainer,
onColor: colors.onTertiaryContainer,
heroTag: 'fab_slip',
label: 'Request Slip',
icon: Icons.receipt_long,
color: colors.secondaryContainer,
onColor: colors.onSecondaryContainer,
onTap: () {
setState(() => _fabMenuOpen = false);
_showLeaveDialog(context, isAdmin);
_showPassSlipDialog(context, profile);
},
),
const SizedBox(height: 12),
// Close button
M3Fab(
heroTag: 'fab_close',
onPressed: () => setState(() => _fabMenuOpen = false),
icon: const Icon(Icons.close),
),
],
// Pass Slip option
_FabMenuItem(
heroTag: 'fab_slip',
label: 'Request Slip',
icon: Icons.receipt_long,
color: colors.secondaryContainer,
onColor: colors.onSecondaryContainer,
onTap: () {
setState(() => _fabMenuOpen = false);
_showPassSlipDialog(context, profile);
},
),
const SizedBox(height: 12),
// Close button
M3Fab(
heroTag: 'fab_close',
onPressed: () => setState(() => _fabMenuOpen = false),
icon: const Icon(Icons.close),
),
],
),
);
}
void _showLeaveDialog(BuildContext context, bool isAdmin) {
m3ShowDialog(
context: context,
builder: (ctx) => _FileLeaveDialog(
isAdmin: isAdmin,
onSubmitted: () {
if (mounted) {
showSuccessSnackBar(
context,
isAdmin
? 'Leave filed and auto-approved.'
: 'Leave application submitted for approval.',
);
}
},
),
);
final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet;
void onSubmitted() {
if (mounted) {
showSuccessSnackBar(
context,
isAdmin ? 'Leave filed and auto-approved.' : 'Leave application submitted for approval.',
);
}
}
if (isMobile) {
m3ShowBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => _FileLeaveDialog(isAdmin: isAdmin, onSubmitted: onSubmitted, isSheet: true),
);
} else {
m3ShowDialog(
context: context,
builder: (_) => _FileLeaveDialog(isAdmin: isAdmin, onSubmitted: onSubmitted),
);
}
}
void _showPassSlipDialog(BuildContext context, Profile profile) {
@@ -243,17 +253,24 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
return;
}
m3ShowDialog(
context: context,
builder: (ctx) => _PassSlipDialog(
scheduleId: todaySchedule.first.id,
onSubmitted: () {
if (mounted) {
showSuccessSnackBar(context, 'Pass slip requested.');
}
},
),
);
void onSubmitted() {
if (mounted) showSuccessSnackBar(context, 'Pass slip requested.');
}
final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet;
if (isMobile) {
m3ShowBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => _PassSlipDialog(scheduleId: todaySchedule.first.id, onSubmitted: onSubmitted, isSheet: true),
);
} else {
m3ShowDialog(
context: context,
builder: (_) => _PassSlipDialog(scheduleId: todaySchedule.first.id, onSubmitted: onSubmitted),
);
}
}
}
@@ -2195,17 +2212,21 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> {
}) {
final groupedByDate = _groupByDate(entries);
final groups = groupedByDate.entries.toList();
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: groupedByDate.entries.map((group) {
final shiftGroups = _groupByShift(group.value);
return _DateGroupTile(
dateLabel: group.key,
shiftGroups: shiftGroups,
currentUserId: currentUserId,
onReverify: onReverify,
);
}).toList(),
children: [
for (int i = 0; i < groups.length; i++)
M3FadeSlideIn(
delay: Duration(milliseconds: i.clamp(0, 6) * 60),
child: _DateGroupTile(
dateLabel: groups[i].key,
shiftGroups: _groupByShift(groups[i].value),
currentUserId: currentUserId,
onReverify: onReverify,
),
),
],
);
}
@@ -2463,10 +2484,9 @@ class _DateGroupTile extends StatelessWidget {
final isMobile = MediaQuery.sizeOf(context).width < 700;
if (isMobile) {
showModalBottomSheet<void>(
m3ShowBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
useSafeArea: true,
builder: (ctx) => DraggableScrollableSheet(
initialChildSize: 0.9,
@@ -3558,7 +3578,9 @@ class _MyScheduleTabState extends ConsumerState<_MyScheduleTab> {
s.requesterId == currentUserId &&
s.status == 'pending');
return Padding(
return M3FadeSlideIn(
delay: Duration(milliseconds: index.clamp(0, 6) * 60),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Card(
child: Padding(
@@ -3663,6 +3685,7 @@ class _MyScheduleTabState extends ConsumerState<_MyScheduleTab> {
),
),
),
),
);
},
),
@@ -4111,7 +4134,7 @@ class _MyScheduleTabState extends ConsumerState<_MyScheduleTab> {
) {
final isMobile = MediaQuery.sizeOf(context).width < 600;
if (isMobile) {
showModalBottomSheet(
m3ShowBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
@@ -4904,6 +4927,8 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
return slipsAsync.when(
data: (slips) {
final pendingSlipList = slips.where((s) => s.status == 'pending').toList();
final historySlipList = slips.where((s) => s.status != 'pending' || !isAdmin).take(50).toList();
return ListView(
padding: const EdgeInsets.all(16),
children: [
@@ -4997,18 +5022,18 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
),
),
const SizedBox(height: 8),
...slips
.where((s) => s.status == 'pending')
.map(
(slip) => _buildSlipCard(
context,
slip,
profileById,
showActions: true,
isPending: pendingSlips.any((p) => p.id == slip.id),
),
for (int i = 0; i < pendingSlipList.length; i++)
M3FadeSlideIn(
delay: Duration(milliseconds: i.clamp(0, 6) * 60),
child: _buildSlipCard(
context,
pendingSlipList[i],
profileById,
showActions: true,
isPending: pendingSlips.any((p) => p.id == pendingSlipList[i].id),
),
if (slips.where((s) => s.status == 'pending').isEmpty)
),
if (pendingSlipList.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
@@ -5029,18 +5054,17 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
),
),
const SizedBox(height: 8),
...slips
.where((s) => s.status != 'pending' || !isAdmin)
.take(50)
.map(
(slip) => _buildSlipCard(
context,
slip,
profileById,
showActions: false,
isPending: pendingSlips.any((p) => p.id == slip.id),
),
for (int i = 0; i < historySlipList.length; i++)
M3FadeSlideIn(
delay: Duration(milliseconds: i.clamp(0, 6) * 60),
child: _buildSlipCard(
context,
historySlipList[i],
profileById,
showActions: false,
isPending: pendingSlips.any((p) => p.id == historySlipList[i].id),
),
),
if (slips.isEmpty)
Center(
child: Padding(
@@ -5271,6 +5295,11 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
.toList()
: <LeaveOfAbsence>[];
final myLeavesList = myLeaves.take(50).toList();
final allLeaveHistory = leaves
.where((l) => l.status != 'pending' && l.userId != profile.id)
.take(50)
.toList();
return ListView(
padding: const EdgeInsets.all(16),
children: [
@@ -5293,15 +5322,17 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
),
),
),
...pendingApprovals.map(
(leave) => _buildLeaveCard(
context,
leave,
profileById,
showApproval: true,
isPending: pendingLeavesList.any((l) => l.id == leave.id),
for (int i = 0; i < pendingApprovals.length; i++)
M3FadeSlideIn(
delay: Duration(milliseconds: i.clamp(0, 6) * 60),
child: _buildLeaveCard(
context,
pendingApprovals[i],
profileById,
showApproval: true,
isPending: pendingLeavesList.any((l) => l.id == pendingApprovals[i].id),
),
),
),
const SizedBox(height: 24),
],
@@ -5325,17 +5356,17 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
),
),
),
...myLeaves
.take(50)
.map(
(leave) => _buildLeaveCard(
context,
leave,
profileById,
showApproval: false,
isPending: pendingLeavesList.any((l) => l.id == leave.id),
),
for (int i = 0; i < myLeavesList.length; i++)
M3FadeSlideIn(
delay: Duration(milliseconds: i.clamp(0, 6) * 60),
child: _buildLeaveCard(
context,
myLeavesList[i],
profileById,
showApproval: false,
isPending: pendingLeavesList.any((l) => l.id == myLeavesList[i].id),
),
),
// ── All Leave History (admin only) ──
if (isAdmin) ...[
@@ -5347,21 +5378,18 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
),
),
const SizedBox(height: 8),
...leaves
.where((l) => l.status != 'pending' && l.userId != profile.id)
.take(50)
.map(
(leave) => _buildLeaveCard(
context,
leave,
profileById,
showApproval: false,
isPending: pendingLeavesList.any((l) => l.id == leave.id),
),
for (int i = 0; i < allLeaveHistory.length; i++)
M3FadeSlideIn(
delay: Duration(milliseconds: i.clamp(0, 6) * 60),
child: _buildLeaveCard(
context,
allLeaveHistory[i],
profileById,
showApproval: false,
isPending: pendingLeavesList.any((l) => l.id == allLeaveHistory[i].id),
),
if (leaves
.where((l) => l.status != 'pending' && l.userId != profile.id)
.isEmpty)
),
if (allLeaveHistory.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
@@ -5629,9 +5657,10 @@ class _FabMenuItem extends StatelessWidget {
// ─── Pass Slip Dialog (with Gemini) ─────────────────────────────
class _PassSlipDialog extends ConsumerStatefulWidget {
const _PassSlipDialog({required this.scheduleId, required this.onSubmitted});
const _PassSlipDialog({required this.scheduleId, required this.onSubmitted, this.isSheet = false});
final String scheduleId;
final VoidCallback onSubmitted;
final bool isSheet;
@override
ConsumerState<_PassSlipDialog> createState() => _PassSlipDialogState();
@@ -5695,121 +5724,127 @@ class _PassSlipDialogState extends ConsumerState<_PassSlipDialog> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final content = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Request Pass Slip', style: theme.textTheme.headlineSmall),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: GeminiAnimatedTextField(
controller: _reasonController,
labelText: 'Reason',
maxLines: 3,
enabled: !_submitting,
isProcessing: _isGeminiProcessing,
),
),
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: GeminiButton(
textController: _reasonController,
onTextUpdated: (text) {
setState(() => _reasonController.text = text);
},
onProcessingStateChanged: (processing) {
setState(() => _isGeminiProcessing = processing);
},
tooltip: 'Translate/Enhance with AI',
promptBuilder: (_) =>
'Translate this sentence to clear professional English '
'if needed, and enhance grammar/clarity while preserving '
'the original meaning. Return ONLY the improved text, '
'with no explanations, no recommendations, and no extra context.',
),
),
],
),
const SizedBox(height: 16),
InkWell(
borderRadius: BorderRadius.circular(12),
onTap: _submitting
? null
: () async {
final picked = await showTimePicker(
context: context,
initialTime: _requestedStartTime ?? TimeOfDay.now(),
);
if (picked != null && mounted) {
setState(() => _requestedStartTime = picked);
}
},
child: InputDecorator(
decoration: InputDecoration(
labelText: 'Preferred start time (optional)',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
prefixIcon: const Icon(Icons.schedule),
suffixIcon: _requestedStartTime != null
? IconButton(
icon: const Icon(Icons.clear),
onPressed: _submitting
? null
: () => setState(() => _requestedStartTime = null),
tooltip: 'Clear',
)
: null,
),
child: Text(
_requestedStartTime != null
? _requestedStartTime!.format(context)
: 'Immediately upon approval',
style: theme.textTheme.bodyLarge?.copyWith(
color: _requestedStartTime != null
? null
: theme.colorScheme.onSurfaceVariant,
),
),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _submitting ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Submit'),
),
],
),
],
);
if (widget.isSheet) {
return Padding(
padding: EdgeInsets.only(
left: 24,
right: 24,
top: 8,
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
),
child: SingleChildScrollView(child: content),
);
}
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Request Pass Slip', style: theme.textTheme.headlineSmall),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: GeminiAnimatedTextField(
controller: _reasonController,
labelText: 'Reason',
maxLines: 3,
enabled: !_submitting,
isProcessing: _isGeminiProcessing,
),
),
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: GeminiButton(
textController: _reasonController,
onTextUpdated: (text) {
setState(() => _reasonController.text = text);
},
onProcessingStateChanged: (processing) {
setState(() => _isGeminiProcessing = processing);
},
tooltip: 'Translate/Enhance with AI',
promptBuilder: (_) =>
'Translate this sentence to clear professional English '
'if needed, and enhance grammar/clarity while preserving '
'the original meaning. Return ONLY the improved text, '
'with no explanations, no recommendations, and no extra context.',
),
),
],
),
const SizedBox(height: 16),
// Optional start time picker
InkWell(
borderRadius: BorderRadius.circular(12),
onTap: _submitting
? null
: () async {
final picked = await showTimePicker(
context: context,
initialTime:
_requestedStartTime ?? TimeOfDay.now(),
);
if (picked != null && mounted) {
setState(() => _requestedStartTime = picked);
}
},
child: InputDecorator(
decoration: InputDecoration(
labelText: 'Preferred start time (optional)',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
prefixIcon: const Icon(Icons.schedule),
suffixIcon: _requestedStartTime != null
? IconButton(
icon: const Icon(Icons.clear),
onPressed: _submitting
? null
: () => setState(
() => _requestedStartTime = null),
tooltip: 'Clear',
)
: null,
),
child: Text(
_requestedStartTime != null
? _requestedStartTime!.format(context)
: 'Immediately upon approval',
style: theme.textTheme.bodyLarge?.copyWith(
color: _requestedStartTime != null
? null
: theme.colorScheme.onSurfaceVariant,
),
),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _submitting
? null
: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Submit'),
),
],
),
],
),
),
child: Padding(padding: const EdgeInsets.all(24), child: content),
),
);
}
@@ -5817,9 +5852,10 @@ class _PassSlipDialogState extends ConsumerState<_PassSlipDialog> {
// ─── File Leave Dialog ──────────────────────────────────────────
class _FileLeaveDialog extends ConsumerStatefulWidget {
const _FileLeaveDialog({required this.isAdmin, required this.onSubmitted});
const _FileLeaveDialog({required this.isAdmin, required this.onSubmitted, this.isSheet = false});
final bool isAdmin;
final VoidCallback onSubmitted;
final bool isSheet;
@override
ConsumerState<_FileLeaveDialog> createState() => _FileLeaveDialogState();
@@ -5978,165 +6014,153 @@ class _FileLeaveDialogState extends ConsumerState<_FileLeaveDialog> {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final content = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('File Leave of Absence', style: theme.textTheme.headlineSmall),
const SizedBox(height: 16),
// Leave type
DropdownButtonFormField<String>(
// ignore: deprecated_member_use
value: _leaveType,
decoration: const InputDecoration(labelText: 'Leave Type'),
items: _leaveTypes.entries
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
.toList(),
onChanged: (v) {
if (v != null) setState(() => _leaveType = v);
},
),
const SizedBox(height: 12),
// Date picker
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.calendar_today),
title: Text(
_startDate == null ? 'Select Date' : AppTime.formatDate(_startDate!),
),
subtitle: const Text('Current or future dates only'),
onTap: _pickDate,
),
// Time range
Row(
children: [
Expanded(
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.access_time),
title: Text(_startTime == null ? 'Start Time' : _startTime!.format(context)),
onTap: _pickStartTime,
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Icon(Icons.arrow_forward),
),
Expanded(
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.access_time),
title: Text(_endTime == null ? 'End Time' : _endTime!.format(context)),
subtitle: const Text('From shift schedule'),
onTap: _pickEndTime,
),
),
],
),
const SizedBox(height: 12),
// Justification with AI
Row(
children: [
Expanded(
child: GeminiAnimatedTextField(
controller: _justificationController,
labelText: 'Justification',
maxLines: 3,
enabled: !_submitting,
isProcessing: _isGeminiProcessing,
),
),
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: GeminiButton(
textController: _justificationController,
onTextUpdated: (text) {
setState(() => _justificationController.text = text);
},
onProcessingStateChanged: (processing) {
setState(() => _isGeminiProcessing = processing);
},
tooltip: 'Translate/Enhance with AI',
promptBuilder: (_) =>
'Translate this sentence to clear professional English '
'if needed, and enhance grammar/clarity while preserving '
'the original meaning. Return ONLY the improved text, '
'with no explanations, no recommendations, and no extra context.',
),
),
],
),
const SizedBox(height: 16),
if (widget.isAdmin)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'As admin, your leave will be auto-approved.',
style: theme.textTheme.bodySmall?.copyWith(color: colors.primary),
),
),
// Actions
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _submitting ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _submitting ? null : _submit,
icon: _submitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.event_busy),
label: const Text('File Leave'),
),
],
),
],
);
if (widget.isSheet) {
return Padding(
padding: EdgeInsets.only(
left: 24,
right: 24,
top: 8,
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
),
child: SingleChildScrollView(child: content),
);
}
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Padding(
padding: const EdgeInsets.all(24),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'File Leave of Absence',
style: theme.textTheme.headlineSmall,
),
const SizedBox(height: 16),
// Leave type
DropdownButtonFormField<String>(
// ignore: deprecated_member_use
value: _leaveType,
decoration: const InputDecoration(labelText: 'Leave Type'),
items: _leaveTypes.entries
.map(
(e) => DropdownMenuItem(
value: e.key,
child: Text(e.value),
),
)
.toList(),
onChanged: (v) {
if (v != null) setState(() => _leaveType = v);
},
),
const SizedBox(height: 12),
// Date picker
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.calendar_today),
title: Text(
_startDate == null
? 'Select Date'
: AppTime.formatDate(_startDate!),
),
subtitle: const Text('Current or future dates only'),
onTap: _pickDate,
),
// Time range
Row(
children: [
Expanded(
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.access_time),
title: Text(
_startTime == null
? 'Start Time'
: _startTime!.format(context),
),
onTap: _pickStartTime,
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Icon(Icons.arrow_forward),
),
Expanded(
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.access_time),
title: Text(
_endTime == null
? 'End Time'
: _endTime!.format(context),
),
subtitle: const Text('From shift schedule'),
onTap: _pickEndTime,
),
),
],
),
const SizedBox(height: 12),
// Justification with AI
Row(
children: [
Expanded(
child: GeminiAnimatedTextField(
controller: _justificationController,
labelText: 'Justification',
maxLines: 3,
enabled: !_submitting,
isProcessing: _isGeminiProcessing,
),
),
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: GeminiButton(
textController: _justificationController,
onTextUpdated: (text) {
setState(() {
_justificationController.text = text;
});
},
onProcessingStateChanged: (processing) {
setState(() => _isGeminiProcessing = processing);
},
tooltip: 'Translate/Enhance with AI',
promptBuilder: (_) =>
'Translate this sentence to clear professional English '
'if needed, and enhance grammar/clarity while preserving '
'the original meaning. Return ONLY the improved text, '
'with no explanations, no recommendations, and no extra context.',
),
),
],
),
const SizedBox(height: 16),
if (widget.isAdmin)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'As admin, your leave will be auto-approved.',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.primary,
),
),
),
// Actions
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _submitting
? null
: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _submitting ? null : _submit,
icon: _submitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.event_busy),
label: const Text('File Leave'),
),
],
),
],
),
),
child: SingleChildScrollView(child: content),
),
),
);
+5 -1
View File
@@ -740,7 +740,11 @@ class _DashboardScreenState extends State<DashboardScreen> {
subtitle: 'Live metrics and team activity',
),
const _DashboardStatusBanner(),
...sections,
for (int i = 0; i < sections.length; i++)
M3FadeSlideIn(
delay: Duration(milliseconds: i.clamp(0, 5) * 40),
child: sections[i],
),
],
);
@@ -2,6 +2,7 @@
import 'dart:convert';
import 'dart:async';
import 'dart:typed_data';
import '../../theme/m3_motion.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
@@ -458,10 +459,15 @@ class _ItServiceRequestDetailScreenState
),
body: Skeletonizer(
enabled: showSkeleton,
child: request == null && !showSkeleton
? const Center(child: Text('Request not found'))
: ResponsiveBody(
maxWidth: double.infinity,
child: M3AnimatedSwitcher(
child: request == null && !showSkeleton
? const Center(
key: ValueKey('isr_not_found'),
child: Text('Request not found'),
)
: ResponsiveBody(
key: const ValueKey('isr_content'),
maxWidth: double.infinity,
child: Column(
children: [
AnimatedSwitcher(
@@ -516,6 +522,7 @@ class _ItServiceRequestDetailScreenState
],
),
),
),
),
);
}
+121 -129
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../theme/m3_motion.dart';
import '../../providers/reports_provider.dart';
import '../../widgets/app_page_header.dart';
import 'report_date_filter.dart';
@@ -162,138 +163,129 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
builder: (context, constraints) {
final isWide = constraints.maxWidth >= 600;
final chartSections = <Widget>[
if (_anyEnabled(enabled, [
ReportWidgetType.ticketsByStatus,
ReportWidgetType.tasksByStatus,
]))
_responsiveRow(
isWide: isWide,
children: [
if (enabled.contains(ReportWidgetType.ticketsByStatus))
TicketsByStatusChart(
repaintKey: _keys[ReportWidgetType.ticketsByStatus],
),
if (enabled.contains(ReportWidgetType.tasksByStatus))
TasksByStatusChart(
repaintKey: _keys[ReportWidgetType.tasksByStatus],
),
],
),
if (enabled.contains(ReportWidgetType.conversionRate))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: ConversionRateCard(
repaintKey: _keys[ReportWidgetType.conversionRate],
),
),
if (_anyEnabled(enabled, [
ReportWidgetType.requestType,
ReportWidgetType.requestCategory,
]))
_responsiveRow(
isWide: isWide,
children: [
if (enabled.contains(ReportWidgetType.requestType))
RequestTypeChart(
repaintKey: _keys[ReportWidgetType.requestType],
),
if (enabled.contains(ReportWidgetType.requestCategory))
RequestCategoryChart(
repaintKey: _keys[ReportWidgetType.requestCategory],
),
],
),
if (enabled.contains(ReportWidgetType.monthlyOverview))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: MonthlyOverviewChart(
repaintKey: _keys[ReportWidgetType.monthlyOverview],
),
),
if (_anyEnabled(enabled, [
ReportWidgetType.tasksByHour,
ReportWidgetType.ticketsByHour,
]))
_responsiveRow(
isWide: isWide,
children: [
if (enabled.contains(ReportWidgetType.tasksByHour))
TasksByHourChart(
repaintKey: _keys[ReportWidgetType.tasksByHour],
),
if (enabled.contains(ReportWidgetType.ticketsByHour))
TicketsByHourChart(
repaintKey: _keys[ReportWidgetType.ticketsByHour],
),
],
),
if (_anyEnabled(enabled, [
ReportWidgetType.topOfficesTickets,
ReportWidgetType.topTicketSubjects,
]))
_responsiveRow(
isWide: isWide,
children: [
if (enabled.contains(ReportWidgetType.topOfficesTickets))
TopOfficesTicketsChart(
repaintKey: _keys[ReportWidgetType.topOfficesTickets],
),
if (enabled.contains(ReportWidgetType.topTicketSubjects))
TopTicketSubjectsChart(
repaintKey: _keys[ReportWidgetType.topTicketSubjects],
),
],
),
if (_anyEnabled(enabled, [
ReportWidgetType.topOfficesTasks,
ReportWidgetType.topTaskSubjects,
]))
_responsiveRow(
isWide: isWide,
children: [
if (enabled.contains(ReportWidgetType.topOfficesTasks))
TopOfficesTasksChart(
repaintKey: _keys[ReportWidgetType.topOfficesTasks],
),
if (enabled.contains(ReportWidgetType.topTaskSubjects))
TopTaskSubjectsChart(
repaintKey: _keys[ReportWidgetType.topTaskSubjects],
),
],
),
if (enabled.contains(ReportWidgetType.avgResolution))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: AvgResolutionChart(
repaintKey: _keys[ReportWidgetType.avgResolution],
),
),
if (enabled.contains(ReportWidgetType.staffWorkload))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: StaffWorkloadChart(
repaintKey: _keys[ReportWidgetType.staffWorkload],
),
),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── Status donuts (paired on desktop) ──
if (_anyEnabled(enabled, [
ReportWidgetType.ticketsByStatus,
ReportWidgetType.tasksByStatus,
]))
_responsiveRow(
isWide: isWide,
children: [
if (enabled.contains(ReportWidgetType.ticketsByStatus))
TicketsByStatusChart(
repaintKey: _keys[ReportWidgetType.ticketsByStatus],
),
if (enabled.contains(ReportWidgetType.tasksByStatus))
TasksByStatusChart(
repaintKey: _keys[ReportWidgetType.tasksByStatus],
),
],
),
// ── Conversion rate KPI ──
if (enabled.contains(ReportWidgetType.conversionRate))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: ConversionRateCard(
repaintKey: _keys[ReportWidgetType.conversionRate],
),
),
// ── Request type + category donuts ──
if (_anyEnabled(enabled, [
ReportWidgetType.requestType,
ReportWidgetType.requestCategory,
]))
_responsiveRow(
isWide: isWide,
children: [
if (enabled.contains(ReportWidgetType.requestType))
RequestTypeChart(
repaintKey: _keys[ReportWidgetType.requestType],
),
if (enabled.contains(ReportWidgetType.requestCategory))
RequestCategoryChart(
repaintKey: _keys[ReportWidgetType.requestCategory],
),
],
),
// ── Monthly overview (full width) ──
if (enabled.contains(ReportWidgetType.monthlyOverview))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: MonthlyOverviewChart(
repaintKey: _keys[ReportWidgetType.monthlyOverview],
),
),
// ── Hourly charts (paired) ──
if (_anyEnabled(enabled, [
ReportWidgetType.tasksByHour,
ReportWidgetType.ticketsByHour,
]))
_responsiveRow(
isWide: isWide,
children: [
if (enabled.contains(ReportWidgetType.tasksByHour))
TasksByHourChart(
repaintKey: _keys[ReportWidgetType.tasksByHour],
),
if (enabled.contains(ReportWidgetType.ticketsByHour))
TicketsByHourChart(
repaintKey: _keys[ReportWidgetType.ticketsByHour],
),
],
),
// ── Top offices + subjects for Tickets (paired) ──
if (_anyEnabled(enabled, [
ReportWidgetType.topOfficesTickets,
ReportWidgetType.topTicketSubjects,
]))
_responsiveRow(
isWide: isWide,
children: [
if (enabled.contains(ReportWidgetType.topOfficesTickets))
TopOfficesTicketsChart(
repaintKey: _keys[ReportWidgetType.topOfficesTickets],
),
if (enabled.contains(ReportWidgetType.topTicketSubjects))
TopTicketSubjectsChart(
repaintKey: _keys[ReportWidgetType.topTicketSubjects],
),
],
),
// ── Top offices + subjects for Tasks (paired) ──
if (_anyEnabled(enabled, [
ReportWidgetType.topOfficesTasks,
ReportWidgetType.topTaskSubjects,
]))
_responsiveRow(
isWide: isWide,
children: [
if (enabled.contains(ReportWidgetType.topOfficesTasks))
TopOfficesTasksChart(
repaintKey: _keys[ReportWidgetType.topOfficesTasks],
),
if (enabled.contains(ReportWidgetType.topTaskSubjects))
TopTaskSubjectsChart(
repaintKey: _keys[ReportWidgetType.topTaskSubjects],
),
],
),
// ── Avg resolution (full width) ──
if (enabled.contains(ReportWidgetType.avgResolution))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: AvgResolutionChart(
repaintKey: _keys[ReportWidgetType.avgResolution],
),
),
// ── Staff workload (full width) ──
if (enabled.contains(ReportWidgetType.staffWorkload))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: StaffWorkloadChart(
repaintKey: _keys[ReportWidgetType.staffWorkload],
),
for (int i = 0; i < chartSections.length; i++)
M3FadeSlideIn(
delay: Duration(milliseconds: i.clamp(0, 4) * 80),
child: chartSections[i],
),
],
);
+239 -184
View File
@@ -28,6 +28,7 @@ import '../../widgets/tasq_adaptive_list.dart';
import '../../widgets/typing_dots.dart';
import '../../theme/app_surfaces.dart';
import '../../utils/snackbar.dart';
import '../../widgets/app_breakpoints.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
import '../../utils/subject_suggestions.dart';
@@ -324,106 +325,114 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
latestAssigneeByTaskId: latestAssigneeByTaskId,
);
final filterHeader = Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SizedBox(
width: 220,
child: TextField(
controller: _subjectController,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
labelText: 'Subject',
prefixIcon: Icon(Icons.search),
),
),
),
SizedBox(
width: 200,
child: DropdownButtonFormField<String?>(
isExpanded: true,
key: ValueKey(_selectedOfficeId),
initialValue: _selectedOfficeId,
items: officeOptions,
onChanged: (value) =>
setState(() => _selectedOfficeId = value),
decoration: const InputDecoration(labelText: 'Office'),
),
),
SizedBox(
width: 160,
child: TextField(
controller: _taskNumberController,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
labelText: 'Task #',
prefixIcon: Icon(Icons.filter_alt),
),
),
),
if (_tabController.index == 1)
SizedBox(
width: 220,
child: DropdownButtonFormField<String?>(
isExpanded: true,
key: ValueKey(_selectedAssigneeId),
initialValue: _selectedAssigneeId,
items: staffOptions,
onChanged: (value) =>
setState(() => _selectedAssigneeId = value),
decoration: const InputDecoration(
labelText: 'Assigned staff',
final filterHeader = LayoutBuilder(
builder: (context, constraints) {
final isMobile = constraints.maxWidth < 600;
final halfWidth = (constraints.maxWidth - 12) / 2;
return Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SizedBox(
width: isMobile ? constraints.maxWidth : 220,
child: TextField(
controller: _subjectController,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
labelText: 'Subject',
prefixIcon: Icon(Icons.search),
),
),
),
),
SizedBox(
width: 180,
child: DropdownButtonFormField<String?>(
isExpanded: true,
key: ValueKey(_selectedStatus),
initialValue: _selectedStatus,
items: statusOptions,
onChanged: (value) =>
setState(() => _selectedStatus = value),
decoration: const InputDecoration(labelText: 'Status'),
),
),
OutlinedButton.icon(
onPressed: () async {
final next = await showDateRangePicker(
context: context,
firstDate: DateTime(2020),
lastDate: AppTime.now().add(
const Duration(days: 365),
SizedBox(
width: isMobile ? halfWidth : 200,
child: DropdownButtonFormField<String?>(
isExpanded: true,
key: ValueKey(_selectedOfficeId),
initialValue: _selectedOfficeId,
items: officeOptions,
onChanged: (value) =>
setState(() => _selectedOfficeId = value),
decoration:
const InputDecoration(labelText: 'Office'),
),
currentDate: AppTime.now(),
initialDateRange: _selectedDateRange,
);
if (!mounted) return;
setState(() => _selectedDateRange = next);
},
icon: const Icon(Icons.date_range),
label: Text(
_selectedDateRange == null
? 'Date range'
: AppTime.formatDateRange(_selectedDateRange!),
),
),
if (_hasTaskFilters)
TextButton.icon(
onPressed: () => setState(() {
_subjectController.clear();
_selectedOfficeId = null;
_selectedStatus = null;
_selectedAssigneeId = null;
_selectedDateRange = null;
}),
icon: const Icon(Icons.close),
label: const Text('Clear'),
),
],
),
SizedBox(
width: isMobile ? halfWidth : 160,
child: TextField(
controller: _taskNumberController,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
labelText: 'Task #',
prefixIcon: Icon(Icons.filter_alt),
),
),
),
if (_tabController.index == 1)
SizedBox(
width: isMobile ? halfWidth : 220,
child: DropdownButtonFormField<String?>(
isExpanded: true,
key: ValueKey(_selectedAssigneeId),
initialValue: _selectedAssigneeId,
items: staffOptions,
onChanged: (value) =>
setState(() => _selectedAssigneeId = value),
decoration: const InputDecoration(
labelText: 'Assigned staff',
),
),
),
SizedBox(
width: isMobile ? halfWidth : 180,
child: DropdownButtonFormField<String?>(
isExpanded: true,
key: ValueKey(_selectedStatus),
initialValue: _selectedStatus,
items: statusOptions,
onChanged: (value) =>
setState(() => _selectedStatus = value),
decoration:
const InputDecoration(labelText: 'Status'),
),
),
OutlinedButton.icon(
onPressed: () async {
final next = await showDateRangePicker(
context: context,
firstDate: DateTime(2020),
lastDate: AppTime.now().add(
const Duration(days: 365),
),
currentDate: AppTime.now(),
initialDateRange: _selectedDateRange,
);
if (!mounted) return;
setState(() => _selectedDateRange = next);
},
icon: const Icon(Icons.date_range),
label: Text(
_selectedDateRange == null
? 'Date range'
: AppTime.formatDateRange(_selectedDateRange!),
),
),
if (_hasTaskFilters)
TextButton.icon(
onPressed: () => setState(() {
_subjectController.clear();
_selectedOfficeId = null;
_selectedStatus = null;
_selectedAssigneeId = null;
_selectedDateRange = null;
}),
icon: const Icon(Icons.close),
label: const Text('Clear'),
),
],
);
},
);
// reusable helper for rendering a list given a subset of tasks
@@ -803,25 +812,22 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
var showTitleGemini = false;
Timer? titleTypingTimer;
await m3ShowDialog<void>(
context: context,
builder: (dialogContext) {
bool saving = false;
bool titleProcessing = false;
bool descProcessing = false;
bool titleDeepSeek = false;
bool descDeepSeek = false;
final officesAsync = ref.watch(officesProvider);
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
shape: AppSurfaces.of(context).dialogShape,
title: const Text('Create Task'),
content: SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet;
// Lifted so both mobile/desktop branches share one set of form state vars.
var saving = false;
var titleProcessing = false;
var descProcessing = false;
var titleDeepSeek = false;
var descDeepSeek = false;
Column buildFormColumn(
StateSetter setState,
AsyncValue<List<Office>> officesAsync,
) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
@@ -1038,77 +1044,126 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
error: (error, _) =>
Text('Failed to load offices: $error'),
),
],
);
}
List<Widget> buildActions(BuildContext closeCtx, StateSetter setState) {
return [
TextButton(
onPressed: saving ? null : () => Navigator.of(closeCtx).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: saving
? null
: () async {
final title = SubjectSuggestionEngine.normalizeDisplay(
titleController.text.trim(),
);
final description = descriptionController.text.trim();
final officeId = selectedOfficeId;
if (title.isEmpty || officeId == null) return;
setState(() => saving = true);
try {
await ref.read(tasksControllerProvider).createTask(
title: title,
description: description,
officeId: officeId,
requestType: selectedRequestType,
requestTypeOther: requestTypeOther,
requestCategory: selectedRequestCategory,
);
if (closeCtx.mounted) {
Navigator.of(closeCtx).pop();
showSuccessSnackBarGlobal(
'Task "$title" has been created successfully.',
);
}
} catch (e) {
if (!closeCtx.mounted) return;
if (isOfflineSaveError(e)) {
Navigator.of(closeCtx).pop();
showSuccessSnackBarGlobal(
'Task "$title" saved offline — will sync when connected.',
);
} else {
showErrorSnackBarGlobal('Failed to create task: $e');
}
} finally {
if (closeCtx.mounted) setState(() => saving = false);
}
},
child: saving
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Create'),
),
];
}
if (isMobile) {
await m3ShowBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (ctx) {
final officesAsync = ref.watch(officesProvider);
return StatefulBuilder(
builder: (context, setState) => Padding(
padding: EdgeInsets.only(
left: 24,
right: 24,
top: 8,
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Create Task',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
buildFormColumn(setState, officesAsync),
const SizedBox(height: 16),
OverflowBar(
alignment: MainAxisAlignment.end,
children: buildActions(ctx, setState),
),
const SizedBox(height: 8),
],
),
),
actions: [
TextButton(
onPressed: saving
? null
: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: saving
? null
: () async {
final title =
SubjectSuggestionEngine.normalizeDisplay(
titleController.text.trim(),
);
final description = descriptionController.text.trim();
final officeId = selectedOfficeId;
if (title.isEmpty || officeId == null) {
return;
}
setState(() => saving = true);
try {
await ref
.read(tasksControllerProvider)
.createTask(
title: title,
description: description,
officeId: officeId,
requestType: selectedRequestType,
requestTypeOther: requestTypeOther,
requestCategory: selectedRequestCategory,
);
if (context.mounted) {
Navigator.of(dialogContext).pop();
showSuccessSnackBarGlobal(
'Task "$title" has been created successfully.',
);
}
} catch (e) {
if (!dialogContext.mounted) return;
if (isOfflineSaveError(e)) {
Navigator.of(dialogContext).pop();
showSuccessSnackBarGlobal(
'Task "$title" saved offline — will sync when connected.',
);
} else {
showErrorSnackBarGlobal('Failed to create task: $e');
}
} finally {
if (dialogContext.mounted) {
setState(() => saving = false);
}
}
},
child: saving
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Create'),
),
],
);
},
);
},
);
),
);
},
);
} else {
await m3ShowDialog<void>(
context: context,
builder: (dialogContext) {
final officesAsync = ref.watch(officesProvider);
return StatefulBuilder(
builder: (context, setState) => AlertDialog(
scrollable: true,
shape: AppSurfaces.of(context).dialogShape,
title: const Text('Create Task'),
content: SizedBox(
width: 600,
child: buildFormColumn(setState, officesAsync),
),
actions: buildActions(dialogContext, setState),
),
);
},
);
}
}
bool _hasTaskMention(
+155 -143
View File
@@ -21,6 +21,7 @@ import '../../widgets/tasq_adaptive_list.dart';
import '../../widgets/typing_dots.dart';
import '../../theme/app_surfaces.dart';
import '../../utils/snackbar.dart';
import '../../widgets/app_breakpoints.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
import '../../widgets/sync_pending_badge.dart';
@@ -441,152 +442,163 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
final descriptionController = TextEditingController();
Office? selectedOffice;
await m3ShowDialog<void>(
context: context,
builder: (dialogContext) {
bool saving = false;
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
shape: AppSurfaces.of(context).dialogShape,
title: const Text('Create Ticket'),
content: Consumer(
builder: (context, ref, child) {
final officesAsync = ref.watch(officesProvider);
return SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: subjectController,
decoration: const InputDecoration(
labelText: 'Subject',
),
enabled: !saving,
),
const SizedBox(height: 12),
TextField(
controller: descriptionController,
decoration: const InputDecoration(
labelText: 'Description',
),
maxLines: 3,
enabled: !saving,
),
const SizedBox(height: 12),
officesAsync.when(
data: (offices) {
if (offices.isEmpty) {
return const Text('No offices assigned.');
}
final officesSorted = List<Office>.from(offices)
..sort(
(a, b) => a.name.toLowerCase().compareTo(
b.name.toLowerCase(),
),
);
selectedOffice ??= officesSorted.first;
return DropdownButtonFormField<Office>(
key: ValueKey(selectedOffice?.id),
initialValue: selectedOffice,
items: officesSorted
.map(
(office) => DropdownMenuItem(
value: office,
child: Text(office.name),
),
)
.toList(),
onChanged: saving
? null
: (value) =>
setState(() => selectedOffice = value),
decoration: const InputDecoration(
labelText: 'Office',
),
);
},
loading: () => const LinearProgressIndicator(),
error: (error, _) =>
Text('Failed to load offices: $error'),
),
],
),
final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet;
var saving = false;
Widget buildFormContent(StateSetter setState) {
return Consumer(
builder: (context, ref, _) {
final officesAsync = ref.watch(officesProvider);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: subjectController,
decoration: const InputDecoration(labelText: 'Subject'),
enabled: !saving,
),
const SizedBox(height: 12),
TextField(
controller: descriptionController,
decoration: const InputDecoration(labelText: 'Description'),
maxLines: 3,
enabled: !saving,
),
const SizedBox(height: 12),
officesAsync.when(
data: (offices) {
if (offices.isEmpty) return const Text('No offices assigned.');
final officesSorted = List<Office>.from(offices)
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
selectedOffice ??= officesSorted.first;
return DropdownButtonFormField<Office>(
key: ValueKey(selectedOffice?.id),
initialValue: selectedOffice,
items: officesSorted
.map((o) => DropdownMenuItem(value: o, child: Text(o.name)))
.toList(),
onChanged: saving ? null : (v) => setState(() => selectedOffice = v),
decoration: const InputDecoration(labelText: 'Office'),
);
},
loading: () => const LinearProgressIndicator(),
error: (e, _) => Text('Failed to load offices: $e'),
),
actions: [
TextButton(
onPressed: saving
? null
: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: saving
? null
: () async {
final subject = subjectController.text.trim();
final description = descriptionController.text.trim();
if (subject.isEmpty ||
description.isEmpty ||
selectedOffice == null) {
showWarningSnackBar(
context,
'Fill out all fields.',
);
return;
}
setState(() => saving = true);
try {
final pendingTicket = await ref
.read(ticketsControllerProvider)
.createTicket(
subject: subject,
description: description,
officeId: selectedOffice!.id,
);
if (pendingTicket != null) {
// Offline: show immediately in the list until sync.
final current = ref.read(offlinePendingTicketsProvider);
ref.read(offlinePendingTicketsProvider.notifier).state = [
pendingTicket,
...current,
];
}
if (context.mounted) {
Navigator.of(dialogContext).pop();
showSuccessSnackBar(
context,
pendingTicket != null
? 'Ticket "$subject" saved offline — will sync when connected.'
: 'Ticket "$subject" has been created successfully.',
);
}
} catch (e) {
if (!dialogContext.mounted) return;
showErrorSnackBarGlobal('Failed to create ticket: $e');
} finally {
if (dialogContext.mounted) {
setState(() => saving = false);
}
}
},
child: saving
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Create'),
),
],
);
},
);
},
);
],
);
},
);
}
List<Widget> buildActions(BuildContext closeCtx, StateSetter setState) {
return [
TextButton(
onPressed: saving ? null : () => Navigator.of(closeCtx).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: saving
? null
: () async {
final subject = subjectController.text.trim();
final description = descriptionController.text.trim();
if (subject.isEmpty || description.isEmpty || selectedOffice == null) {
showWarningSnackBar(closeCtx, 'Fill out all fields.');
return;
}
setState(() => saving = true);
try {
final pendingTicket = await ref
.read(ticketsControllerProvider)
.createTicket(
subject: subject,
description: description,
officeId: selectedOffice!.id,
);
if (pendingTicket != null) {
final current = ref.read(offlinePendingTicketsProvider);
ref.read(offlinePendingTicketsProvider.notifier).state = [
pendingTicket,
...current,
];
}
if (closeCtx.mounted) {
Navigator.of(closeCtx).pop();
showSuccessSnackBar(
closeCtx,
pendingTicket != null
? 'Ticket "$subject" saved offline — will sync when connected.'
: 'Ticket "$subject" has been created successfully.',
);
}
} catch (e) {
if (!closeCtx.mounted) return;
showErrorSnackBarGlobal('Failed to create ticket: $e');
} finally {
if (closeCtx.mounted) setState(() => saving = false);
}
},
child: saving
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Create'),
),
];
}
if (isMobile) {
await m3ShowBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (ctx) => StatefulBuilder(
builder: (context, setState) => Padding(
padding: EdgeInsets.only(
left: 24,
right: 24,
top: 8,
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Create Ticket', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
buildFormContent(setState),
const SizedBox(height: 16),
OverflowBar(
alignment: MainAxisAlignment.end,
children: buildActions(ctx, setState),
),
const SizedBox(height: 8),
],
),
),
),
),
);
} else {
await m3ShowDialog<void>(
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
shape: AppSurfaces.of(context).dialogShape,
title: const Text('Create Ticket'),
content: SizedBox(
width: 600,
child: buildFormContent(setState),
),
actions: buildActions(dialogContext, setState),
),
),
);
}
}
Map<String, bool> _unreadByTicketId(