iOS PWA and IT Job Checklist for IT Staff view

This commit is contained in:
2026-04-11 07:40:12 +08:00
parent 5cb6561924
commit f223d1f958
18 changed files with 389 additions and 109 deletions
+3
View File
@@ -9,6 +9,7 @@ import 'providers/profile_provider.dart';
import 'services/background_location_service.dart';
import 'theme/app_theme.dart';
import 'utils/snackbar.dart';
import 'widgets/ios_install_prompt.dart';
class TasqApp extends ConsumerWidget {
const TasqApp({super.key});
@@ -47,6 +48,8 @@ class TasqApp extends ConsumerWidget {
GlobalCupertinoLocalizations.delegate,
FlutterQuillLocalizations.delegate,
],
builder: (context, child) =>
IosInstallPrompt(child: child ?? const SizedBox.shrink()),
);
}
}
+39 -3
View File
@@ -305,12 +305,48 @@ Future<void> main() async {
// listen for auth changes to register/unregister token accordingly
supaClient.auth.onAuthStateChange.listen((data) async {
final event = data.event;
// Web: register FCM token for iOS 16.4+ PWA push support.
// Requires the VAPID key from Firebase Console → Project Settings →
// Cloud Messaging → Web Push certificates → Key pair.
// Add VAPID_KEY=<your_key> to your .env file.
if (kIsWeb) {
debugPrint(
'auth state change $event on web: skipping FCM token handling',
);
final vapidKey = dotenv.env['VAPID_KEY'] ?? '';
if (vapidKey.isEmpty) {
debugPrint(
'Web FCM: VAPID_KEY not set in .env — skipping token registration.',
);
return;
}
if (event == AuthChangeEvent.signedIn) {
try {
final token = await FirebaseMessaging.instance.getToken(
vapidKey: vapidKey,
);
if (token != null) {
final ctrl = NotificationsController(supaClient);
await ctrl.registerFcmToken(token);
debugPrint('Web FCM token registered');
}
} catch (e) {
debugPrint('Web FCM token registration failed: $e');
}
} else if (event == AuthChangeEvent.signedOut) {
try {
final token = await FirebaseMessaging.instance.getToken(
vapidKey: vapidKey,
);
if (token != null) {
final ctrl = NotificationsController(supaClient);
await ctrl.unregisterFcmToken(token);
}
} catch (e) {
debugPrint('Web FCM token unregister failed: $e');
}
}
return;
}
String? token;
try {
token = await FirebaseMessaging.instance.getToken();
+108 -81
View File
@@ -16,12 +16,20 @@ import '../../widgets/m3_card.dart';
import '../../widgets/mono_text.dart';
import '../../widgets/profile_avatar.dart';
/// IT Job Checklist tab — visible only to admin/dispatcher.
/// IT Job Checklist tab — visible to admin/dispatcher and IT staff.
///
/// Shows all completed tasks with a checkbox for tracking printed IT Job
/// submission status, plus a per-task notification button with 60s cooldown.
/// Admin/dispatcher view: shows all completed tasks with a checkbox for
/// tracking printed IT Job submission status, plus a per-task notification
/// button with 60s cooldown.
///
/// IT staff view: shows only tasks assigned to the current user, read-only,
/// so they can track which IT Jobs still need a physical copy submitted.
class ItJobChecklistTab extends ConsumerStatefulWidget {
const ItJobChecklistTab({super.key});
const ItJobChecklistTab({super.key, this.isAdminView = true});
/// When true, shows admin controls (checkbox, bell, stats, team filter).
/// When false (IT staff), shows only the current user's tasks, read-only.
final bool isAdminView;
@override
ConsumerState<ItJobChecklistTab> createState() => _ItJobChecklistTabState();
@@ -53,10 +61,21 @@ class _ItJobChecklistTabState extends ConsumerState<ItJobChecklistTab> {
final allTasks = tasksAsync.valueOrNull ?? [];
final allAssignments = assignmentsAsync.valueOrNull ?? [];
final showSkeleton = !tasksAsync.hasValue && !tasksAsync.hasError;
final currentUserId = widget.isAdminView
? null
: ref.watch(currentUserIdProvider);
// All completed tasks
var filtered = allTasks.where((t) => t.status == 'completed').toList();
// IT staff: restrict to tasks assigned to the current user
if (!widget.isAdminView && currentUserId != null) {
filtered = filtered.where((t) {
return allAssignments
.any((a) => a.taskId == t.id && a.userId == currentUserId);
}).toList();
}
// Search by task # or subject
if (_searchQuery.isNotEmpty) {
final q = _searchQuery.toLowerCase();
@@ -66,8 +85,8 @@ class _ItJobChecklistTabState extends ConsumerState<ItJobChecklistTab> {
}).toList();
}
// Filter by team
if (_selectedTeamId != null) {
// Filter by team (admin/dispatcher only)
if (widget.isAdminView && _selectedTeamId != null) {
final memberIds = teamMembers
.where((m) => m.teamId == _selectedTeamId)
.map((m) => m.userId)
@@ -93,7 +112,7 @@ class _ItJobChecklistTabState extends ConsumerState<ItJobChecklistTab> {
return ta.compareTo(tb);
});
// Stats (always computed over all completed, regardless of filter)
// Stats (admin/dispatcher only — computed over all completed tasks)
final allCompleted = allTasks.where((t) => t.status == 'completed');
final submitted = allCompleted.where((t) => t.itJobPrinted).length;
final total = allCompleted.length;
@@ -104,7 +123,8 @@ class _ItJobChecklistTabState extends ConsumerState<ItJobChecklistTab> {
// the list itself (see below inside Expanded).
return Column(
children: [
// Stats card
// Stats card — admin/dispatcher only
if (widget.isAdminView)
Padding(
padding: const EdgeInsets.fromLTRB(0, 12, 0, 4),
child: M3Card.filled(
@@ -183,34 +203,35 @@ class _ItJobChecklistTabState extends ConsumerState<ItJobChecklistTab> {
setState(() => _searchQuery = v.trim()),
),
),
// Team filter
SizedBox(
width: 180,
child: DropdownButtonFormField<String>(
key: ValueKey(_selectedTeamId),
initialValue: _selectedTeamId,
decoration: const InputDecoration(
labelText: 'Team',
isDense: true,
contentPadding:
EdgeInsets.symmetric(horizontal: 12, vertical: 8),
border: OutlineInputBorder(),
),
isExpanded: true,
items: [
const DropdownMenuItem<String>(
value: null,
child: Text('All Teams'),
// Team filter — admin/dispatcher only
if (widget.isAdminView)
SizedBox(
width: 180,
child: DropdownButtonFormField<String>(
key: ValueKey(_selectedTeamId),
initialValue: _selectedTeamId,
decoration: const InputDecoration(
labelText: 'Team',
isDense: true,
contentPadding:
EdgeInsets.symmetric(horizontal: 12, vertical: 8),
border: OutlineInputBorder(),
),
...teams.map((t) => DropdownMenuItem<String>(
value: t.id,
child: Text(t.name,
overflow: TextOverflow.ellipsis),
)),
],
onChanged: (v) => setState(() => _selectedTeamId = v),
isExpanded: true,
items: [
const DropdownMenuItem<String>(
value: null,
child: Text('All Teams'),
),
...teams.map((t) => DropdownMenuItem<String>(
value: t.id,
child: Text(t.name,
overflow: TextOverflow.ellipsis),
)),
],
onChanged: (v) => setState(() => _selectedTeamId = v),
),
),
),
// Status filter
SizedBox(
width: 180,
@@ -238,7 +259,7 @@ class _ItJobChecklistTabState extends ConsumerState<ItJobChecklistTab> {
),
),
// Clear button
if (_selectedTeamId != null ||
if ((widget.isAdminView && _selectedTeamId != null) ||
_statusFilter != 'not_submitted' ||
_searchQuery.isNotEmpty)
TextButton.icon(
@@ -300,6 +321,7 @@ class _ItJobChecklistTabState extends ConsumerState<ItJobChecklistTab> {
task: task,
assignees: assignees,
profiles: profiles,
isAdminView: widget.isAdminView,
);
},
),
@@ -338,11 +360,13 @@ class _ItJobTile extends ConsumerStatefulWidget {
required this.task,
required this.assignees,
required this.profiles,
this.isAdminView = true,
});
final Task task;
final List<TaskAssignment> assignees;
final List profiles;
final bool isAdminView;
@override
ConsumerState<_ItJobTile> createState() => _ItJobTileState();
@@ -539,55 +563,58 @@ class _ItJobTileState extends ConsumerState<_ItJobTile> {
],
),
),
// Compact checkbox — shrinkWrap removes the default 48 px touch
// target padding so it doesn't push the Row wider on mobile.
Checkbox(
value: _isChecked,
onChanged: _toggleChecked,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
// Bell / cooldown button
SizedBox(
width: 40,
height: 40,
child: _inCooldown
? Tooltip(
message: '$_secondsRemaining s',
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
value: _secondsRemaining / _cooldownSeconds,
strokeWidth: 2.5,
color: cs.primary,
backgroundColor:
cs.primary.withValues(alpha: 0.15),
// Compact checkbox + bell — admin/dispatcher only
if (widget.isAdminView) ...[
// Compact checkbox — shrinkWrap removes the default 48 px touch
// target padding so it doesn't push the Row wider on mobile.
Checkbox(
value: _isChecked,
onChanged: _toggleChecked,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
// Bell / cooldown button
SizedBox(
width: 40,
height: 40,
child: _inCooldown
? Tooltip(
message: '$_secondsRemaining s',
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
value: _secondsRemaining / _cooldownSeconds,
strokeWidth: 2.5,
color: cs.primary,
backgroundColor:
cs.primary.withValues(alpha: 0.15),
),
),
),
Text(
'$_secondsRemaining',
style: tt.labelSmall?.copyWith(
color: cs.primary,
Text(
'$_secondsRemaining',
style: tt.labelSmall?.copyWith(
color: cs.primary,
),
),
),
],
],
),
)
: IconButton(
tooltip: 'Remind assigned staff',
padding: EdgeInsets.zero,
onPressed: _sendReminder,
icon: Icon(
Icons.notifications_active_outlined,
color: cs.primary,
size: 20,
),
),
)
: IconButton(
tooltip: 'Remind assigned staff',
padding: EdgeInsets.zero,
onPressed: _sendReminder,
icon: Icon(
Icons.notifications_active_outlined,
color: cs.primary,
size: 20,
),
),
),
),
],
],
),
),
+6 -3
View File
@@ -151,7 +151,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
orElse: () => false,
);
final role = profileAsync.valueOrNull?.role ?? '';
final shouldShowItJobTab = role == 'admin' || role == 'dispatcher';
final shouldShowItJobTab =
role == 'admin' || role == 'dispatcher' || role == 'it_staff';
final isAdminOrDispatcher = role == 'admin' || role == 'dispatcher';
if (!_tabInited || _showItJobTab != shouldShowItJobTab) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _ensureTabController(shouldShowItJobTab);
@@ -587,7 +589,8 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
)
: makeList(filteredTasks),
if (_showItJobTab)
const ItJobChecklistTab(),
ItJobChecklistTab(
isAdminView: isAdminOrDispatcher),
],
),
),
@@ -600,7 +603,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
),
),
),
if (_showItJobTab && _tabController.index == 2)
if (_showItJobTab && _tabController.index == 2 && isAdminOrDispatcher)
Positioned(
right: 16,
bottom: 16,
+4 -1
View File
@@ -47,7 +47,10 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
// recovery via StreamRecoveryWrapper. This no longer forces a global reconnect,
// which was the blocking behavior users complained about.
if (state == AppLifecycleState.resumed) {
// Future: Could trigger stream-specific recovery hints if needed.
// Invalidate on resume so that iOS PWA users (who may not receive push
// notifications when the app is in the background) see fresh notifications
// as soon as they bring the app to the foreground.
ref.invalidate(notificationsProvider);
}
}
+93
View File
@@ -0,0 +1,93 @@
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'ios_install_prompt_stub.dart'
if (dart.library.html) 'ios_install_prompt_web.dart';
/// Wraps the app and shows a persistent banner on iOS Safari when the PWA has
/// not been added to the Home Screen.
///
/// Adding TasQ to the Home Screen is required to:
/// - Receive push notifications on iOS 16.4+
/// - Run in standalone (full-screen) mode without Safari chrome
///
/// The banner is dismissed permanently once the user taps "Got it".
class IosInstallPrompt extends StatefulWidget {
const IosInstallPrompt({required this.child, super.key});
final Widget child;
@override
State<IosInstallPrompt> createState() => _IosInstallPromptState();
}
class _IosInstallPromptState extends State<IosInstallPrompt> {
bool _showBanner = false;
static const _prefKey = 'ios_a2hs_prompt_dismissed_v1';
@override
void initState() {
super.initState();
if (kIsWeb) _checkShouldShow();
}
Future<void> _checkShouldShow() async {
if (!detectIosSafariNotInstalled()) return;
final prefs = await SharedPreferences.getInstance();
final dismissed = prefs.getBool(_prefKey) ?? false;
if (!dismissed && mounted) {
setState(() => _showBanner = true);
}
}
Future<void> _dismiss() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKey, true);
if (mounted) setState(() => _showBanner = false);
}
@override
Widget build(BuildContext context) {
if (!_showBanner) return widget.child;
final cs = Theme.of(context).colorScheme;
return Column(
children: [
Material(
color: cs.primaryContainer,
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
Icon(Icons.ios_share, size: 20, color: cs.onPrimaryContainer),
const SizedBox(width: 12),
Expanded(
child: Text(
'Install TasQ: tap Share then "Add to Home Screen" '
'to enable push notifications.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: cs.onPrimaryContainer,
),
),
),
TextButton(
onPressed: _dismiss,
child: Text(
'Got it',
style: TextStyle(color: cs.onPrimaryContainer),
),
),
],
),
),
),
),
Expanded(child: widget.child),
],
);
}
}
+2
View File
@@ -0,0 +1,2 @@
/// Stub for non-web platforms. Always returns false — no install prompt needed.
bool detectIosSafariNotInstalled() => false;
+27
View File
@@ -0,0 +1,27 @@
import 'dart:js_interop';
@JS('navigator.userAgent')
external String get _navigatorUserAgent;
@JS('window.matchMedia')
external _MediaQueryList _matchMedia(String query);
extension type _MediaQueryList._(JSObject _) implements JSObject {
external bool get matches;
}
/// Returns true when the user is on iOS Safari and the app has NOT been added
/// to the Home Screen (i.e., not running in standalone mode).
///
/// When this returns true, we show the "Add to Home Screen" install banner so
/// that users can unlock iOS 16.4+ web push notifications.
bool detectIosSafariNotInstalled() {
final ua = _navigatorUserAgent.toLowerCase();
final isIos =
ua.contains('iphone') || ua.contains('ipad') || ua.contains('ipod');
if (!isIos) return false;
// The PWA is "installed" if the display-mode: standalone media query matches.
final isStandalone = _matchMedia('(display-mode: standalone)').matches;
return !isStandalone;
}