diff --git a/lib/app.dart b/lib/app.dart index 774cadf7..96f16c85 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -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()), ); } } diff --git a/lib/main.dart b/lib/main.dart index 742fac77..08c7bb54 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -305,12 +305,48 @@ Future 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= 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(); diff --git a/lib/screens/tasks/it_job_checklist_tab.dart b/lib/screens/tasks/it_job_checklist_tab.dart index 6c5d9520..0dc0a53d 100644 --- a/lib/screens/tasks/it_job_checklist_tab.dart +++ b/lib/screens/tasks/it_job_checklist_tab.dart @@ -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 createState() => _ItJobChecklistTabState(); @@ -53,10 +61,21 @@ class _ItJobChecklistTabState extends ConsumerState { 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 { }).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 { 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 { // 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 { setState(() => _searchQuery = v.trim()), ), ), - // Team filter - SizedBox( - width: 180, - child: DropdownButtonFormField( - 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( - value: null, - child: Text('All Teams'), + // Team filter — admin/dispatcher only + if (widget.isAdminView) + SizedBox( + width: 180, + child: DropdownButtonFormField( + 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( - value: t.id, - child: Text(t.name, - overflow: TextOverflow.ellipsis), - )), - ], - onChanged: (v) => setState(() => _selectedTeamId = v), + isExpanded: true, + items: [ + const DropdownMenuItem( + value: null, + child: Text('All Teams'), + ), + ...teams.map((t) => DropdownMenuItem( + 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 { ), ), // 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 { 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 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, - ), - ), - ), + ), + ], ], ), ), diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index 1dbe24d6..d1e59d46 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -151,7 +151,9 @@ class _TasksListScreenState extends ConsumerState 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 ) : makeList(filteredTasks), if (_showItJobTab) - const ItJobChecklistTab(), + ItJobChecklistTab( + isAdminView: isAdminOrDispatcher), ], ), ), @@ -600,7 +603,7 @@ class _TasksListScreenState extends ConsumerState ), ), ), - if (_showItJobTab && _tabController.index == 2) + if (_showItJobTab && _tabController.index == 2 && isAdminOrDispatcher) Positioned( right: 16, bottom: 16, diff --git a/lib/services/notification_bridge.dart b/lib/services/notification_bridge.dart index 3ca655ad..90ed2a53 100644 --- a/lib/services/notification_bridge.dart +++ b/lib/services/notification_bridge.dart @@ -47,7 +47,10 @@ class _NotificationBridgeState extends ConsumerState // 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); } } diff --git a/lib/widgets/ios_install_prompt.dart b/lib/widgets/ios_install_prompt.dart new file mode 100644 index 00000000..9d0c6d01 --- /dev/null +++ b/lib/widgets/ios_install_prompt.dart @@ -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 createState() => _IosInstallPromptState(); +} + +class _IosInstallPromptState extends State { + bool _showBanner = false; + static const _prefKey = 'ios_a2hs_prompt_dismissed_v1'; + + @override + void initState() { + super.initState(); + if (kIsWeb) _checkShouldShow(); + } + + Future _checkShouldShow() async { + if (!detectIosSafariNotInstalled()) return; + final prefs = await SharedPreferences.getInstance(); + final dismissed = prefs.getBool(_prefKey) ?? false; + if (!dismissed && mounted) { + setState(() => _showBanner = true); + } + } + + Future _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), + ], + ); + } +} diff --git a/lib/widgets/ios_install_prompt_stub.dart b/lib/widgets/ios_install_prompt_stub.dart new file mode 100644 index 00000000..8a4a434e --- /dev/null +++ b/lib/widgets/ios_install_prompt_stub.dart @@ -0,0 +1,2 @@ +/// Stub for non-web platforms. Always returns false — no install prompt needed. +bool detectIosSafariNotInstalled() => false; diff --git a/lib/widgets/ios_install_prompt_web.dart b/lib/widgets/ios_install_prompt_web.dart new file mode 100644 index 00000000..27684a14 --- /dev/null +++ b/lib/widgets/ios_install_prompt_web.dart @@ -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; +} diff --git a/scheme.onSurfaceVariant b/scheme.onSurfaceVariant new file mode 100644 index 00000000..e69de29b diff --git a/web/firebase-messaging-sw.js b/web/firebase-messaging-sw.js index 268e16a5..2d0caa62 100644 --- a/web/firebase-messaging-sw.js +++ b/web/firebase-messaging-sw.js @@ -9,19 +9,70 @@ importScripts('https://www.gstatic.com/firebasejs/9.22.2/firebase-messaging-comp firebase.initializeApp({ // values must match `firebase_options.dart` generated by `flutterfire` apiKey: 'AIzaSyBKGSaHYiqpZvbEgsvJJY45soiIkV6MV3M', - appId: '1:173359574734:web:f894a6b43a443e902baa9f', - messagingSenderId: '173359574734', - projectId: 'tasq-17fb3', - authDomain: 'tasq-17fb3.firebaseapp.com', - storageBucket: 'tasq-17fb3.firebasestorage.app', + appId: '1:173359574734:web:f894a6b43a443e902baa9f', + messagingSenderId: '173359574734', + projectId: 'tasq-17fb3', + authDomain: 'tasq-17fb3.firebaseapp.com', + storageBucket: 'tasq-17fb3.firebasestorage.app', }); const messaging = firebase.messaging(); messaging.onBackgroundMessage(function(payload) { - // display a notification using data in the payload - self.registration.showNotification(payload.notification.title, { - body: payload.notification.body, + const notificationTitle = payload.notification?.title + || payload.data?.title + || 'TasQ Notification'; + const notificationBody = payload.notification?.body + || payload.data?.body + || ''; + + const notificationOptions = { + body: notificationBody, + icon: '/icons/Icon-192.png', + badge: '/icons/Icon-192.png', data: payload.data, - }); + // tag deduplicates: same notification_id won't stack + tag: payload.data?.notification_id || payload.messageId || 'tasq', + renotify: true, + }; + + return self.registration.showNotification(notificationTitle, notificationOptions); +}); + +// Handle notification click — focus the PWA window and navigate to the +// relevant task / ticket route based on the data payload. +self.addEventListener('notificationclick', function(event) { + event.notification.close(); + + const data = event.notification.data || {}; + const navigateTo = data.navigate_to || data.route; + const taskId = data.task_id || data.taskId; + const ticketId = data.ticket_id || data.ticketId; + + let targetPath = '/'; + if (navigateTo && navigateTo !== '/') { + targetPath = navigateTo; + } else if (taskId) { + targetPath = '/tasks/' + taskId; + } else if (ticketId) { + targetPath = '/tickets/' + ticketId; + } + + event.waitUntil( + clients + .matchAll({ type: 'window', includeUncontrolled: true }) + .then(function(clientList) { + // If the app is already open, navigate it and bring it to front + for (const client of clientList) { + if ('navigate' in client) { + client.navigate(targetPath); + return client.focus(); + } + } + // Otherwise open a new window + if (clients.openWindow) { + return clients.openWindow(targetPath); + } + }) + ); }); diff --git a/web/icons/apple-touch-icon-152.png b/web/icons/apple-touch-icon-152.png new file mode 100644 index 00000000..c77b842b Binary files /dev/null and b/web/icons/apple-touch-icon-152.png differ diff --git a/web/icons/apple-touch-icon-167.png b/web/icons/apple-touch-icon-167.png new file mode 100644 index 00000000..0131370a Binary files /dev/null and b/web/icons/apple-touch-icon-167.png differ diff --git a/web/icons/apple-touch-icon-180.png b/web/icons/apple-touch-icon-180.png new file mode 100644 index 00000000..dd8dbc9d Binary files /dev/null and b/web/icons/apple-touch-icon-180.png differ diff --git a/web/icons/splash-1179x2556.png b/web/icons/splash-1179x2556.png new file mode 100644 index 00000000..4dcdc33f Binary files /dev/null and b/web/icons/splash-1179x2556.png differ diff --git a/web/icons/splash-1320x2868.png b/web/icons/splash-1320x2868.png new file mode 100644 index 00000000..df1e3b11 Binary files /dev/null and b/web/icons/splash-1320x2868.png differ diff --git a/web/icons/splash-750x1334.png b/web/icons/splash-750x1334.png new file mode 100644 index 00000000..f09d2796 Binary files /dev/null and b/web/icons/splash-750x1334.png differ diff --git a/web/index.html b/web/index.html index b16c5641..a179cc43 100644 --- a/web/index.html +++ b/web/index.html @@ -5,17 +5,43 @@ - + + + - - - + + + + + + + + + + + + + + + + + + + + + + - tasq - + TasQ - \ No newline at end of file + diff --git a/web/manifest.json b/web/manifest.json index 85c4b394..7ead7221 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -1,13 +1,16 @@ { - "name": "tasq", - "short_name": "tasq", - "start_url": ".", + "id": "/", + "name": "TasQ", + "short_name": "TasQ", + "description": "Task and workforce management for your organisation.", + "start_url": "./", "display": "standalone", + "display_override": ["standalone", "minimal-ui"], "background_color": "#FFFFFF", "theme_color": "#FFFFFF", - "description": "A new Flutter project.", "orientation": "portrait-primary", "prefer_related_applications": false, + "categories": ["productivity", "business"], "icons": [ { "src": "icons/Icon-192.png", @@ -30,6 +33,12 @@ "sizes": "512x512", "type": "image/png", "purpose": "maskable" + }, + { + "src": "icons/apple-touch-icon-180.png", + "sizes": "180x180", + "type": "image/png", + "purpose": "any" } ] -} \ No newline at end of file +}