tasq/lib/main.dart

763 lines
26 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'screens/update_check_screen.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'firebase_options.dart';
// removed unused imports
import 'app.dart';
import 'theme/app_theme.dart';
import 'providers/notifications_provider.dart';
import 'providers/notification_navigation_provider.dart';
import 'utils/app_time.dart';
import 'utils/notification_permission.dart';
import 'utils/location_permission.dart';
import 'services/notification_service.dart';
import 'services/notification_bridge.dart';
import 'services/background_location_service.dart';
import 'services/app_update_service.dart';
import 'models/app_version.dart';
import 'widgets/update_dialog.dart';
import 'utils/navigation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
// audio player not used at top-level; instantiate where needed
StreamSubscription<String?>? _fcmTokenRefreshSub;
late ProviderContainer _globalProviderContainer;
Map<String, String> _formatNotificationFromData(Map<String, dynamic> data) {
String actor = '';
if (data['actor_name'] != null) {
actor = data['actor_name'].toString();
} else if (data['mentioner_name'] != null) {
actor = data['mentioner_name'].toString();
} else if (data['user_name'] != null) {
actor = data['user_name'].toString();
} else if (data['from'] != null) {
actor = data['from'].toString();
} else if (data['actor'] != null) {
final a = data['actor'];
if (a is Map && a['name'] != null) {
actor = a['name'].toString();
} else if (a is String) {
try {
final parsed = jsonDecode(a);
if (parsed is Map && parsed['name'] != null) {
actor = parsed['name'].toString();
}
} catch (_) {
// ignore JSON parse errors
}
}
}
if (actor.isEmpty) {
actor = 'Someone';
}
final taskNumber =
(data['task_number'] ?? data['taskNumber'] ?? data['task_no'])
?.toString() ??
'';
final taskId =
(data['task_id'] ?? data['taskId'] ?? data['task'])?.toString() ?? '';
final ticketId =
data['ticket_id'] ?? data['ticketId'] ?? data['ticket'] ?? '';
final type = (data['type'] ?? '').toString().toLowerCase();
final taskLabel = taskNumber.isNotEmpty
? 'Task $taskNumber'
: (taskId.isNotEmpty ? 'Task #$taskId' : 'Task');
if ((taskId.isNotEmpty || taskNumber.isNotEmpty) &&
(type.contains('assign') ||
data['action'] == 'assign' ||
data['assigned'] == 'true')) {
return {
'title': 'Task assigned',
'body': '$actor has assigned you $taskLabel',
};
}
if ((taskId.isNotEmpty || taskNumber.isNotEmpty) &&
(type.contains('mention') ||
data['action'] == 'mention' ||
data['mentioned'] == 'true')) {
return {
'title': 'Mention',
'body': '$actor has mentioned you in $taskLabel',
};
}
if (ticketId.isNotEmpty &&
(type.contains('mention') || data['action'] == 'mention')) {
return {
'title': 'Mention',
'body': '$actor has mentioned you in Ticket #$ticketId',
};
}
// Fallback to supplied title/body or generic
final title = data['title']?.toString() ?? 'New Notification';
final body = data['body']?.toString() ?? 'You have a new update in TasQ.';
return {'title': title, 'body': body};
}
// Initialize the plugin
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
/// Handle messages received while the app is terminated or in background.
@pragma('vm:entry-point') // Required for background execution
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
// 1. Initialize the plugin inside the background isolate
final FlutterLocalNotificationsPlugin localNotifPlugin =
FlutterLocalNotificationsPlugin();
// 2. Extract and format title/body from the DATA payload (not message.notification)
final formatted = _formatNotificationFromData(message.data);
final String title = formatted['title']!;
final String body = formatted['body']!;
// Determine a stable ID for deduplication (prefer server-provided id)
String? stableId =
(message.data['notification_id'] as String?) ?? message.messageId;
if (stableId == null) {
final sb = StringBuffer();
final taskNumber =
(message.data['task_number'] ??
message.data['taskNumber'] ??
message.data['task_no'])
?.toString();
final taskId =
(message.data['task_id'] ??
message.data['taskId'] ??
message.data['task'])
?.toString();
final ticketId =
(message.data['ticket_id'] ??
message.data['ticketId'] ??
message.data['ticket'])
?.toString();
final type = (message.data['type'] ?? '').toString();
final actorId =
(message.data['actor_id'] ??
message.data['actorId'] ??
message.data['actor'])
?.toString();
if (taskNumber != null && taskNumber.isNotEmpty) {
sb.write('tasknum:$taskNumber');
} else if (taskId != null && taskId.isNotEmpty) {
sb.write('task:$taskId');
}
if (ticketId != null && ticketId.isNotEmpty) {
if (sb.isNotEmpty) sb.write('|');
sb.write('ticket:$ticketId');
}
if (type.isNotEmpty) {
if (sb.isNotEmpty) sb.write('|');
sb.write('type:$type');
}
if (actorId != null && actorId.isNotEmpty) {
if (sb.isNotEmpty) sb.write('|');
sb.write('actor:$actorId');
}
stableId = sb.isNotEmpty
? sb.toString()
: (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString();
}
// Dedupe: keep a short-lived cache of recent notification IDs to avoid duplicates
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString('recent_notifs') ?? '{}';
final Map<String, dynamic> recent = jsonDecode(raw);
final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
const int ttl = 60; // seconds
// prune old entries
recent.removeWhere(
(k, v) => (v is int ? v : int.parse(v.toString())) < now - ttl,
);
if (recent.containsKey(stableId)) {
// already shown recently — skip
return;
}
recent[stableId] = now;
await prefs.setString('recent_notifs', jsonEncode(recent));
} catch (e) {
// If prefs fail in background isolate, fall back to showing notification
}
// Create a unique ID for the notification display
final int id = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// Build payload string with ticket/task information for navigation
final payloadParts = <String>[];
final taskId =
(message.data['task_id'] ??
message.data['taskId'] ??
message.data['task'])
?.toString();
final ticketId =
message.data['ticket_id'] ??
message.data['ticketId'] ??
message.data['ticket']?.toString() ??
'';
if (taskId != null && taskId.isNotEmpty) {
payloadParts.add('task:$taskId');
}
if (ticketId.isNotEmpty) {
payloadParts.add('ticket:$ticketId');
}
final payload = payloadParts.join('|').isNotEmpty
? payloadParts.join('|')
: message.data['type']?.toString() ?? '';
// 3. Define the exact same channel specifics
const androidDetails = AndroidNotificationDetails(
'tasq_custom_sound_channel_3',
'High Importance Notifications',
importance: Importance.max,
priority: Priority.high,
playSound: true,
sound: RawResourceAndroidNotificationSound('tasq_notification'),
);
// 4. Show the notification manually
await localNotifPlugin.show(
id: id,
title: title,
body: body,
notificationDetails: const NotificationDetails(android: androidDetails),
payload: payload,
);
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// The flag optionally hides annoying WASM warnings in your Chrome dev console
pdfrxFlutterInitialize(dismissPdfiumWasmWarnings: true);
// initialize Firebase before anything that uses messaging
try {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
).timeout(const Duration(seconds: 15));
} catch (e) {
debugPrint('Firebase init failed or timed out: $e');
}
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
try {
await dotenv.load(fileName: '.env').timeout(const Duration(seconds: 5));
} catch (e) {
debugPrint('dotenv load failed or timed out: $e');
}
AppTime.initialize(location: 'Asia/Manila');
final supabaseUrl = dotenv.env['SUPABASE_URL'] ?? '';
final supabaseAnonKey = dotenv.env['SUPABASE_ANON_KEY'] ?? '';
if (supabaseUrl.isEmpty || supabaseAnonKey.isEmpty) {
runApp(const _MissingConfigApp());
return;
}
try {
await Supabase.initialize(
url: supabaseUrl,
anonKey: supabaseAnonKey,
).timeout(const Duration(seconds: 20));
} catch (e) {
debugPrint('Supabase init failed or timed out: $e');
runApp(const _MissingConfigApp());
return;
}
// Initialize background location service (flutter_background_service)
if (!kIsWeb) {
try {
await initBackgroundLocationService().timeout(
const Duration(seconds: 10),
);
} catch (e) {
debugPrint('Background location service init failed or timed out: $e');
}
}
// ensure token saved shortly after startup if already signed in.
// Run this after runApp so startup is not blocked by network/token ops.
final supaClient = Supabase.instance.client;
// listen for auth changes to register/unregister token accordingly
supaClient.auth.onAuthStateChange.listen((data) async {
final event = data.event;
if (kIsWeb) {
debugPrint(
'auth state change $event on web: skipping FCM token handling',
);
return;
}
String? token;
try {
token = await FirebaseMessaging.instance.getToken();
} catch (e) {
debugPrint('FCM getToken failed during auth change: $e');
token = null;
}
debugPrint('auth state change $event, token=$token');
if (token == null) return;
final ctrl = NotificationsController(supaClient);
if (event == AuthChangeEvent.signedIn) {
// register current token and ensure we listen for refreshes
await ctrl.registerFcmToken(token);
try {
// cancel any previous subscription
await _fcmTokenRefreshSub?.cancel();
} catch (_) {}
_fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen((
t,
) {
debugPrint('token refreshed (auth listener): $t');
ctrl.registerFcmToken(t);
});
} else if (event == AuthChangeEvent.signedOut) {
// cancel token refresh subscription and unregister
try {
await _fcmTokenRefreshSub?.cancel();
} catch (_) {}
_fcmTokenRefreshSub = null;
await ctrl.unregisterFcmToken(token);
}
});
if (!kIsWeb) {
// on Android 13+ we must request POST_NOTIFICATIONS at runtime; without it
// notifications are automatically denied and cannot be reenabled from the
// system settings. The helper uses `permission_handler`.
try {
final granted = await ensureNotificationPermission().timeout(
const Duration(seconds: 10),
);
if (!granted) {
// we don't block startup, but it's worth logging so developers notice.
// debugPrint('notification permission not granted');
}
} catch (e) {
debugPrint('Notification permission request failed or timed out: $e');
}
// Request location permission at launch (same pattern as notification)
try {
final locationGranted = await ensureLocationPermission().timeout(
const Duration(seconds: 10),
);
if (!locationGranted) {
// debugPrint('location permission not granted');
}
} catch (e) {
debugPrint('Location permission request failed or timed out: $e');
}
// request FCM permission (iOS/Android13+) and handle foreground messages
try {
await FirebaseMessaging.instance.requestPermission().timeout(
const Duration(seconds: 10),
);
} catch (e) {
debugPrint('FCM permission request failed or timed out: $e');
}
}
FirebaseMessaging.onMessage.listen((RemoteMessage message) async {
// Prefer the data payload and format friendly messages, with dedupe.
// If actor_name is not present but actor_id is, try to resolve the
// display name using the Supabase client (foreground only).
Map<String, dynamic> dataForFormatting = Map<String, dynamic>.from(
message.data,
);
try {
final hasActorName =
(dataForFormatting['actor_name'] ??
dataForFormatting['mentioner_name'] ??
dataForFormatting['user_name'] ??
dataForFormatting['from'] ??
dataForFormatting['actor']) !=
null;
final actorId =
dataForFormatting['actor_id'] ??
dataForFormatting['actorId'] ??
dataForFormatting['actor'];
if (!hasActorName && actorId is String && actorId.isNotEmpty) {
try {
final client = Supabase.instance.client;
final res = await client
.from('profiles')
.select('full_name,display_name,name')
.eq('id', actorId)
.maybeSingle();
if (res != null) {
String? name;
if (res['full_name'] != null) {
name = res['full_name'].toString();
} else if (res['display_name'] != null) {
name = res['display_name'].toString();
} else if (res['name'] != null) {
name = res['name'].toString();
}
if (name != null && name.isNotEmpty) {
dataForFormatting['actor_name'] = name;
}
}
} catch (_) {
// ignore lookup failures and fall back to data payload
}
}
} catch (_) {}
final formatted = _formatNotificationFromData(dataForFormatting);
String? stableId =
(message.data['notification_id'] as String?) ?? message.messageId;
if (stableId == null) {
final sb = StringBuffer();
final taskNumber =
(message.data['task_number'] ??
message.data['taskNumber'] ??
message.data['task_no'])
?.toString();
final taskId =
(message.data['task_id'] ??
message.data['taskId'] ??
message.data['task'])
?.toString();
final ticketId =
(message.data['ticket_id'] ??
message.data['ticketId'] ??
message.data['ticket'])
?.toString();
final type = (message.data['type'] ?? '').toString();
final actorId =
(message.data['actor_id'] ??
message.data['actorId'] ??
message.data['actor'])
?.toString();
if (taskNumber != null && taskNumber.isNotEmpty) {
sb.write('tasknum:$taskNumber');
} else if (taskId != null && taskId.isNotEmpty) {
sb.write('task:$taskId');
}
if (ticketId != null && ticketId.isNotEmpty) {
if (sb.isNotEmpty) sb.write('|');
sb.write('ticket:$ticketId');
}
if (type.isNotEmpty) {
if (sb.isNotEmpty) sb.write('|');
sb.write('type:$type');
}
if (actorId != null && actorId.isNotEmpty) {
if (sb.isNotEmpty) sb.write('|');
sb.write('actor:$actorId');
}
stableId = sb.isNotEmpty
? sb.toString()
: (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString();
}
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString('recent_notifs') ?? '{}';
final Map<String, dynamic> recent = jsonDecode(raw);
final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
const int ttl = 60;
recent.removeWhere(
(k, v) => (v is int ? v : int.parse(v.toString())) < now - ttl,
);
if (!recent.containsKey(stableId)) {
recent[stableId] = now;
await prefs.setString('recent_notifs', jsonEncode(recent));
// Build payload string with ticket/task information for navigation
final payloadParts = <String>[];
final taskId =
(message.data['task_id'] ??
message.data['taskId'] ??
message.data['task'])
?.toString();
final ticketId =
message.data['ticket_id'] ??
message.data['ticketId'] ??
message.data['ticket']?.toString() ??
'';
if (taskId != null && taskId.isNotEmpty) {
payloadParts.add('task:$taskId');
}
if (ticketId.isNotEmpty) {
payloadParts.add('ticket:$ticketId');
}
final payload = payloadParts.join('|').isNotEmpty
? payloadParts.join('|')
: message.data['payload']?.toString() ?? '';
NotificationService.show(
id: DateTime.now().millisecondsSinceEpoch ~/ 1000,
title: formatted['title']!,
body: formatted['body']!,
payload: payload,
);
}
} catch (e) {
// On failure, just show the notification to avoid dropping alerts
NotificationService.show(
id: DateTime.now().millisecondsSinceEpoch ~/ 1000,
title: formatted['title']!,
body: formatted['body']!,
payload: '',
);
}
});
// 1. Define the High Importance Channel (This MUST match your manifest exactly)
const AndroidNotificationChannel channel = AndroidNotificationChannel(
'tasq_custom_sound_channel', // id
'High Importance Notifications', // title visible to user in phone settings
description: 'This channel is used for important TasQ notifications.',
importance:
Importance.max, // THIS is what forces the sound and heads-up banner
playSound: true,
// 👇 Tell Android to use your specific file in the raw folder
sound: RawResourceAndroidNotificationSound('tasq_notification'),
);
// 2. Create the channel on the device
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(channel);
// Create the global provider container BEFORE initializing local
// notifications, because the tap callback needs to write to it and
// flutter_local_notifications may fire the callback synchronously if
// the app was launched by tapping a notification.
_globalProviderContainer = ProviderContainer(
observers: [NotificationSoundObserver()],
);
// initialize the local notifications plugin so we can post alerts later
await NotificationService.initialize(
onDidReceiveNotificationResponse: (response) {
// handle user tapping a notification; the payload format is "ticket:ID",
// "task:ID", "tasknum:NUMBER", or a combination separated by "|"
final payload = response.payload;
if (payload != null && payload.isNotEmpty) {
// Parse the payload to extract ticket and task information
final parts = payload.split('|');
String? ticketId;
String? taskId;
for (final part in parts) {
if (part.startsWith('ticket:')) {
ticketId = part.substring('ticket:'.length);
} else if (part.startsWith('task:')) {
taskId = part.substring('task:'.length);
}
}
// Update the pending navigation provider.
// Prefer task over ticket — assignment notifications include both
// IDs but the primary entity is the task.
if (taskId != null && taskId.isNotEmpty) {
_globalProviderContainer
.read(pendingNotificationNavigationProvider.notifier)
.state = (
type: 'task',
id: taskId,
);
} else if (ticketId != null && ticketId.isNotEmpty) {
_globalProviderContainer
.read(pendingNotificationNavigationProvider.notifier)
.state = (
type: 'ticket',
id: ticketId,
);
}
}
},
);
runApp(
UncontrolledProviderScope(
container: _globalProviderContainer,
child: const UpdateCheckWrapper(),
),
);
// Post-startup registration removed: token registration is handled
// centrally in the auth state change listener to avoid duplicate inserts.
}
/// Wrapper shown at app launch; performs update check and displays
/// [UpdateCheckingScreen] until complete.
class UpdateCheckWrapper extends StatefulWidget {
const UpdateCheckWrapper({super.key});
@override
State<UpdateCheckWrapper> createState() => _UpdateCheckWrapperState();
}
class _UpdateCheckWrapperState extends State<UpdateCheckWrapper> {
bool _done = false;
Future<AppUpdateInfo> _checkForUpdates() =>
AppUpdateService.instance.checkForUpdate();
Future<void> _handleUpdateComplete(AppUpdateInfo? info) async {
if (!mounted) return;
if (info?.isUpdateAvailable == true) {
// Keep the update-check screen visible while the dialog is shown.
// Use a safe context reference; fall back to the wrapper's own context.
final dialogContext = globalNavigatorKey.currentContext ?? context;
try {
await showDialog(
context: dialogContext,
barrierDismissible: !(info?.isForceUpdate ?? false),
builder: (_) => UpdateDialog(info: info!),
);
} catch (_) {
// If the dialog fails (rare), continue to the next screen.
}
}
if (!mounted) return;
setState(() {
_done = true;
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
themeMode: ThemeMode.system,
home: AnimatedSwitcher(
duration: const Duration(milliseconds: 420),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return FadeTransition(
opacity: curved,
child: ScaleTransition(
scale: Tween<double>(begin: 0.94, end: 1.0).animate(curved),
child: child,
),
);
},
child: kIsWeb
? const NotificationBridge(child: TasqApp())
: (_done
? const NotificationBridge(child: TasqApp())
: UpdateCheckingScreen(
checkForUpdates: _checkForUpdates,
onCompleted: _handleUpdateComplete,
)),
),
);
}
}
class NotificationSoundObserver extends ProviderObserver {
@override
void didUpdateProvider(
ProviderBase provider,
Object? previousValue,
Object? newValue,
ProviderContainer container,
) {
// play sound + show OS notification on unread-count increase
// if (provider == unreadNotificationsCountProvider) {
// final prev = previousValue as int?;
// final next = newValue as int?;
// if (prev != null && next != null && next > prev) {
// _maybeShowUnreadNotification(next);
// }
// }
// Profile changes no longer perform token registration here.
// Token registration is handled centrally in the auth state change listener
// to avoid duplicate DB rows and duplicate deliveries.
}
}
// void _maybeShowUnreadNotification(int next) async {
// try {
// final prefs = await SharedPreferences.getInstance();
// final raw = prefs.getString('recent_notifs') ?? '{}';
// final Map<String, dynamic> recent = jsonDecode(raw);
// final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// const int ttl = 60; // seconds
// // prune old entries
// recent.removeWhere(
// (k, v) => (v is int ? v : int.parse(v.toString())) < now - ttl,
// );
// // if there is any recent notification (from server or other), skip duplicating
// if (recent.isNotEmpty) return;
// // mark a synthetic unread-notif to prevent immediate duplicates
// recent['unread_summary'] = now;
// await prefs.setString('recent_notifs', jsonEncode(recent));
// _player.play(AssetSource('tasq_notification.wav'));
// NotificationService.show(
// id: DateTime.now().millisecondsSinceEpoch ~/ 1000,
// title: 'New notifications',
// body: 'You have $next unread notifications.',
// );
// } catch (e) {
// // fallback: show notification
// _player.play(AssetSource('tasq_notification.wav'));
// NotificationService.show(
// id: DateTime.now().millisecondsSinceEpoch ~/ 1000,
// title: 'New notifications',
// body: 'You have $next unread notifications.',
// );
// }
// }
class _MissingConfigApp extends StatelessWidget {
const _MissingConfigApp();
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(
body: Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Missing SUPABASE_URL or SUPABASE_ANON_KEY. '
'Provide them in the .env file.',
textAlign: TextAlign.center,
),
),
),
),
);
}
}