iOS PWA and IT Job Checklist for IT Staff view
This commit is contained in:
parent
5cb6561924
commit
f223d1f958
|
|
@ -9,6 +9,7 @@ import 'providers/profile_provider.dart';
|
||||||
import 'services/background_location_service.dart';
|
import 'services/background_location_service.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
import 'utils/snackbar.dart';
|
import 'utils/snackbar.dart';
|
||||||
|
import 'widgets/ios_install_prompt.dart';
|
||||||
|
|
||||||
class TasqApp extends ConsumerWidget {
|
class TasqApp extends ConsumerWidget {
|
||||||
const TasqApp({super.key});
|
const TasqApp({super.key});
|
||||||
|
|
@ -47,6 +48,8 @@ class TasqApp extends ConsumerWidget {
|
||||||
GlobalCupertinoLocalizations.delegate,
|
GlobalCupertinoLocalizations.delegate,
|
||||||
FlutterQuillLocalizations.delegate,
|
FlutterQuillLocalizations.delegate,
|
||||||
],
|
],
|
||||||
|
builder: (context, child) =>
|
||||||
|
IosInstallPrompt(child: child ?? const SizedBox.shrink()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -305,12 +305,48 @@ Future<void> main() async {
|
||||||
// listen for auth changes to register/unregister token accordingly
|
// listen for auth changes to register/unregister token accordingly
|
||||||
supaClient.auth.onAuthStateChange.listen((data) async {
|
supaClient.auth.onAuthStateChange.listen((data) async {
|
||||||
final event = data.event;
|
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) {
|
if (kIsWeb) {
|
||||||
debugPrint(
|
final vapidKey = dotenv.env['VAPID_KEY'] ?? '';
|
||||||
'auth state change $event on web: skipping FCM token handling',
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String? token;
|
String? token;
|
||||||
try {
|
try {
|
||||||
token = await FirebaseMessaging.instance.getToken();
|
token = await FirebaseMessaging.instance.getToken();
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,20 @@ import '../../widgets/m3_card.dart';
|
||||||
import '../../widgets/mono_text.dart';
|
import '../../widgets/mono_text.dart';
|
||||||
import '../../widgets/profile_avatar.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
|
/// Admin/dispatcher view: shows all completed tasks with a checkbox for
|
||||||
/// submission status, plus a per-task notification button with 60s cooldown.
|
/// 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 {
|
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
|
@override
|
||||||
ConsumerState<ItJobChecklistTab> createState() => _ItJobChecklistTabState();
|
ConsumerState<ItJobChecklistTab> createState() => _ItJobChecklistTabState();
|
||||||
|
|
@ -53,10 +61,21 @@ class _ItJobChecklistTabState extends ConsumerState<ItJobChecklistTab> {
|
||||||
final allTasks = tasksAsync.valueOrNull ?? [];
|
final allTasks = tasksAsync.valueOrNull ?? [];
|
||||||
final allAssignments = assignmentsAsync.valueOrNull ?? [];
|
final allAssignments = assignmentsAsync.valueOrNull ?? [];
|
||||||
final showSkeleton = !tasksAsync.hasValue && !tasksAsync.hasError;
|
final showSkeleton = !tasksAsync.hasValue && !tasksAsync.hasError;
|
||||||
|
final currentUserId = widget.isAdminView
|
||||||
|
? null
|
||||||
|
: ref.watch(currentUserIdProvider);
|
||||||
|
|
||||||
// All completed tasks
|
// All completed tasks
|
||||||
var filtered = allTasks.where((t) => t.status == 'completed').toList();
|
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
|
// Search by task # or subject
|
||||||
if (_searchQuery.isNotEmpty) {
|
if (_searchQuery.isNotEmpty) {
|
||||||
final q = _searchQuery.toLowerCase();
|
final q = _searchQuery.toLowerCase();
|
||||||
|
|
@ -66,8 +85,8 @@ class _ItJobChecklistTabState extends ConsumerState<ItJobChecklistTab> {
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by team
|
// Filter by team (admin/dispatcher only)
|
||||||
if (_selectedTeamId != null) {
|
if (widget.isAdminView && _selectedTeamId != null) {
|
||||||
final memberIds = teamMembers
|
final memberIds = teamMembers
|
||||||
.where((m) => m.teamId == _selectedTeamId)
|
.where((m) => m.teamId == _selectedTeamId)
|
||||||
.map((m) => m.userId)
|
.map((m) => m.userId)
|
||||||
|
|
@ -93,7 +112,7 @@ class _ItJobChecklistTabState extends ConsumerState<ItJobChecklistTab> {
|
||||||
return ta.compareTo(tb);
|
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 allCompleted = allTasks.where((t) => t.status == 'completed');
|
||||||
final submitted = allCompleted.where((t) => t.itJobPrinted).length;
|
final submitted = allCompleted.where((t) => t.itJobPrinted).length;
|
||||||
final total = allCompleted.length;
|
final total = allCompleted.length;
|
||||||
|
|
@ -104,7 +123,8 @@ class _ItJobChecklistTabState extends ConsumerState<ItJobChecklistTab> {
|
||||||
// the list itself (see below inside Expanded).
|
// the list itself (see below inside Expanded).
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// Stats card
|
// Stats card — admin/dispatcher only
|
||||||
|
if (widget.isAdminView)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(0, 12, 0, 4),
|
padding: const EdgeInsets.fromLTRB(0, 12, 0, 4),
|
||||||
child: M3Card.filled(
|
child: M3Card.filled(
|
||||||
|
|
@ -183,34 +203,35 @@ class _ItJobChecklistTabState extends ConsumerState<ItJobChecklistTab> {
|
||||||
setState(() => _searchQuery = v.trim()),
|
setState(() => _searchQuery = v.trim()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Team filter
|
// Team filter — admin/dispatcher only
|
||||||
SizedBox(
|
if (widget.isAdminView)
|
||||||
width: 180,
|
SizedBox(
|
||||||
child: DropdownButtonFormField<String>(
|
width: 180,
|
||||||
key: ValueKey(_selectedTeamId),
|
child: DropdownButtonFormField<String>(
|
||||||
initialValue: _selectedTeamId,
|
key: ValueKey(_selectedTeamId),
|
||||||
decoration: const InputDecoration(
|
initialValue: _selectedTeamId,
|
||||||
labelText: 'Team',
|
decoration: const InputDecoration(
|
||||||
isDense: true,
|
labelText: 'Team',
|
||||||
contentPadding:
|
isDense: true,
|
||||||
EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
contentPadding:
|
||||||
border: OutlineInputBorder(),
|
EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
),
|
border: OutlineInputBorder(),
|
||||||
isExpanded: true,
|
|
||||||
items: [
|
|
||||||
const DropdownMenuItem<String>(
|
|
||||||
value: null,
|
|
||||||
child: Text('All Teams'),
|
|
||||||
),
|
),
|
||||||
...teams.map((t) => DropdownMenuItem<String>(
|
isExpanded: true,
|
||||||
value: t.id,
|
items: [
|
||||||
child: Text(t.name,
|
const DropdownMenuItem<String>(
|
||||||
overflow: TextOverflow.ellipsis),
|
value: null,
|
||||||
)),
|
child: Text('All Teams'),
|
||||||
],
|
),
|
||||||
onChanged: (v) => setState(() => _selectedTeamId = v),
|
...teams.map((t) => DropdownMenuItem<String>(
|
||||||
|
value: t.id,
|
||||||
|
child: Text(t.name,
|
||||||
|
overflow: TextOverflow.ellipsis),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
onChanged: (v) => setState(() => _selectedTeamId = v),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
// Status filter
|
// Status filter
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 180,
|
width: 180,
|
||||||
|
|
@ -238,7 +259,7 @@ class _ItJobChecklistTabState extends ConsumerState<ItJobChecklistTab> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Clear button
|
// Clear button
|
||||||
if (_selectedTeamId != null ||
|
if ((widget.isAdminView && _selectedTeamId != null) ||
|
||||||
_statusFilter != 'not_submitted' ||
|
_statusFilter != 'not_submitted' ||
|
||||||
_searchQuery.isNotEmpty)
|
_searchQuery.isNotEmpty)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
|
|
@ -300,6 +321,7 @@ class _ItJobChecklistTabState extends ConsumerState<ItJobChecklistTab> {
|
||||||
task: task,
|
task: task,
|
||||||
assignees: assignees,
|
assignees: assignees,
|
||||||
profiles: profiles,
|
profiles: profiles,
|
||||||
|
isAdminView: widget.isAdminView,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -338,11 +360,13 @@ class _ItJobTile extends ConsumerStatefulWidget {
|
||||||
required this.task,
|
required this.task,
|
||||||
required this.assignees,
|
required this.assignees,
|
||||||
required this.profiles,
|
required this.profiles,
|
||||||
|
this.isAdminView = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Task task;
|
final Task task;
|
||||||
final List<TaskAssignment> assignees;
|
final List<TaskAssignment> assignees;
|
||||||
final List profiles;
|
final List profiles;
|
||||||
|
final bool isAdminView;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<_ItJobTile> createState() => _ItJobTileState();
|
ConsumerState<_ItJobTile> createState() => _ItJobTileState();
|
||||||
|
|
@ -539,55 +563,58 @@ class _ItJobTileState extends ConsumerState<_ItJobTile> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Compact checkbox — shrinkWrap removes the default 48 px touch
|
// Compact checkbox + bell — admin/dispatcher only
|
||||||
// target padding so it doesn't push the Row wider on mobile.
|
if (widget.isAdminView) ...[
|
||||||
Checkbox(
|
// Compact checkbox — shrinkWrap removes the default 48 px touch
|
||||||
value: _isChecked,
|
// target padding so it doesn't push the Row wider on mobile.
|
||||||
onChanged: _toggleChecked,
|
Checkbox(
|
||||||
visualDensity: VisualDensity.compact,
|
value: _isChecked,
|
||||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
onChanged: _toggleChecked,
|
||||||
),
|
visualDensity: VisualDensity.compact,
|
||||||
// Bell / cooldown button
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
SizedBox(
|
),
|
||||||
width: 40,
|
// Bell / cooldown button
|
||||||
height: 40,
|
SizedBox(
|
||||||
child: _inCooldown
|
width: 40,
|
||||||
? Tooltip(
|
height: 40,
|
||||||
message: '$_secondsRemaining s',
|
child: _inCooldown
|
||||||
child: Stack(
|
? Tooltip(
|
||||||
alignment: Alignment.center,
|
message: '$_secondsRemaining s',
|
||||||
children: [
|
child: Stack(
|
||||||
SizedBox(
|
alignment: Alignment.center,
|
||||||
width: 28,
|
children: [
|
||||||
height: 28,
|
SizedBox(
|
||||||
child: CircularProgressIndicator(
|
width: 28,
|
||||||
value: _secondsRemaining / _cooldownSeconds,
|
height: 28,
|
||||||
strokeWidth: 2.5,
|
child: CircularProgressIndicator(
|
||||||
color: cs.primary,
|
value: _secondsRemaining / _cooldownSeconds,
|
||||||
backgroundColor:
|
strokeWidth: 2.5,
|
||||||
cs.primary.withValues(alpha: 0.15),
|
color: cs.primary,
|
||||||
|
backgroundColor:
|
||||||
|
cs.primary.withValues(alpha: 0.15),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
Text(
|
||||||
Text(
|
'$_secondsRemaining',
|
||||||
'$_secondsRemaining',
|
style: tt.labelSmall?.copyWith(
|
||||||
style: tt.labelSmall?.copyWith(
|
color: cs.primary,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||||
orElse: () => false,
|
orElse: () => false,
|
||||||
);
|
);
|
||||||
final role = profileAsync.valueOrNull?.role ?? '';
|
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) {
|
if (!_tabInited || _showItJobTab != shouldShowItJobTab) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) _ensureTabController(shouldShowItJobTab);
|
if (mounted) _ensureTabController(shouldShowItJobTab);
|
||||||
|
|
@ -587,7 +589,8 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||||
)
|
)
|
||||||
: makeList(filteredTasks),
|
: makeList(filteredTasks),
|
||||||
if (_showItJobTab)
|
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(
|
Positioned(
|
||||||
right: 16,
|
right: 16,
|
||||||
bottom: 16,
|
bottom: 16,
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,10 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
|
||||||
// recovery via StreamRecoveryWrapper. This no longer forces a global reconnect,
|
// recovery via StreamRecoveryWrapper. This no longer forces a global reconnect,
|
||||||
// which was the blocking behavior users complained about.
|
// which was the blocking behavior users complained about.
|
||||||
if (state == AppLifecycleState.resumed) {
|
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
lib/widgets/ios_install_prompt.dart
Normal file
93
lib/widgets/ios_install_prompt.dart
Normal 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
lib/widgets/ios_install_prompt_stub.dart
Normal file
2
lib/widgets/ios_install_prompt_stub.dart
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/// Stub for non-web platforms. Always returns false — no install prompt needed.
|
||||||
|
bool detectIosSafariNotInstalled() => false;
|
||||||
27
lib/widgets/ios_install_prompt_web.dart
Normal file
27
lib/widgets/ios_install_prompt_web.dart
Normal 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;
|
||||||
|
}
|
||||||
0
scheme.onSurfaceVariant
Normal file
0
scheme.onSurfaceVariant
Normal file
|
|
@ -9,19 +9,70 @@ importScripts('https://www.gstatic.com/firebasejs/9.22.2/firebase-messaging-comp
|
||||||
firebase.initializeApp({
|
firebase.initializeApp({
|
||||||
// values must match `firebase_options.dart` generated by `flutterfire`
|
// values must match `firebase_options.dart` generated by `flutterfire`
|
||||||
apiKey: 'AIzaSyBKGSaHYiqpZvbEgsvJJY45soiIkV6MV3M',
|
apiKey: 'AIzaSyBKGSaHYiqpZvbEgsvJJY45soiIkV6MV3M',
|
||||||
appId: '1:173359574734:web:f894a6b43a443e902baa9f',
|
appId: '1:173359574734:web:f894a6b43a443e902baa9f',
|
||||||
messagingSenderId: '173359574734',
|
messagingSenderId: '173359574734',
|
||||||
projectId: 'tasq-17fb3',
|
projectId: 'tasq-17fb3',
|
||||||
authDomain: 'tasq-17fb3.firebaseapp.com',
|
authDomain: 'tasq-17fb3.firebaseapp.com',
|
||||||
storageBucket: 'tasq-17fb3.firebasestorage.app',
|
storageBucket: 'tasq-17fb3.firebasestorage.app',
|
||||||
});
|
});
|
||||||
|
|
||||||
const messaging = firebase.messaging();
|
const messaging = firebase.messaging();
|
||||||
|
|
||||||
messaging.onBackgroundMessage(function(payload) {
|
messaging.onBackgroundMessage(function(payload) {
|
||||||
// display a notification using data in the payload
|
const notificationTitle = payload.notification?.title
|
||||||
self.registration.showNotification(payload.notification.title, {
|
|| payload.data?.title
|
||||||
body: payload.notification.body,
|
|| '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,
|
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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
BIN
web/icons/apple-touch-icon-152.png
Normal file
BIN
web/icons/apple-touch-icon-152.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
web/icons/apple-touch-icon-167.png
Normal file
BIN
web/icons/apple-touch-icon-167.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
web/icons/apple-touch-icon-180.png
Normal file
BIN
web/icons/apple-touch-icon-180.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
web/icons/splash-1179x2556.png
Normal file
BIN
web/icons/splash-1179x2556.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
BIN
web/icons/splash-1320x2868.png
Normal file
BIN
web/icons/splash-1320x2868.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
web/icons/splash-750x1334.png
Normal file
BIN
web/icons/splash-750x1334.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
|
|
@ -5,17 +5,43 @@
|
||||||
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||||
<meta name="description" content="A new Flutter project.">
|
<meta name="description" content="TasQ — Task and workforce management.">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
|
||||||
|
<!-- PWA / Android -->
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
<link rel="manifest" href="manifest.json">
|
||||||
<meta name="apple-mobile-web-app-title" content="tasq">
|
|
||||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
<!-- iOS PWA meta tags -->
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="TasQ">
|
||||||
|
|
||||||
|
<!-- Apple touch icons (iOS uses largest available that fits) -->
|
||||||
|
<link rel="apple-touch-icon" href="icons/apple-touch-icon-180.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="152x152" href="icons/apple-touch-icon-152.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="167x167" href="icons/apple-touch-icon-167.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="icons/apple-touch-icon-180.png">
|
||||||
|
|
||||||
|
<!-- iOS Splash Screens -->
|
||||||
|
<!-- iPhone 16 Pro Max (1320x2868 @3x) -->
|
||||||
|
<link rel="apple-touch-startup-image"
|
||||||
|
media="screen and (device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
href="icons/splash-1320x2868.png">
|
||||||
|
<!-- iPhone 14 / 15 / 16 (1179x2556 @3x) -->
|
||||||
|
<link rel="apple-touch-startup-image"
|
||||||
|
media="screen and (device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
href="icons/splash-1179x2556.png">
|
||||||
|
<!-- iPhone SE 3rd gen (750x1334 @2x) -->
|
||||||
|
<link rel="apple-touch-startup-image"
|
||||||
|
media="screen and (device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
|
href="icons/splash-750x1334.png">
|
||||||
|
|
||||||
|
<meta name="theme-color" content="#FFFFFF">
|
||||||
|
|
||||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||||
|
|
||||||
<title>tasq</title>
|
<title>TasQ</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var dartPdfJsVersion = "3.2.146";
|
var dartPdfJsVersion = "3.2.146";
|
||||||
|
|
@ -26,4 +52,4 @@
|
||||||
<body>
|
<body>
|
||||||
<script src="flutter_bootstrap.js" async></script>
|
<script src="flutter_bootstrap.js" async></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
{
|
{
|
||||||
"name": "tasq",
|
"id": "/",
|
||||||
"short_name": "tasq",
|
"name": "TasQ",
|
||||||
"start_url": ".",
|
"short_name": "TasQ",
|
||||||
|
"description": "Task and workforce management for your organisation.",
|
||||||
|
"start_url": "./",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
"display_override": ["standalone", "minimal-ui"],
|
||||||
"background_color": "#FFFFFF",
|
"background_color": "#FFFFFF",
|
||||||
"theme_color": "#FFFFFF",
|
"theme_color": "#FFFFFF",
|
||||||
"description": "A new Flutter project.",
|
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"prefer_related_applications": false,
|
"prefer_related_applications": false,
|
||||||
|
"categories": ["productivity", "business"],
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "icons/Icon-192.png",
|
"src": "icons/Icon-192.png",
|
||||||
|
|
@ -30,6 +33,12 @@
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/apple-touch-icon-180.png",
|
||||||
|
"sizes": "180x180",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user