680 lines
23 KiB
Dart
680 lines
23 KiB
Dart
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 '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 '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 '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 (Workmanager)
|
||
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 re‑enabled 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 NotificationBridge(child: TasqApp()),
|
||
),
|
||
);
|
||
|
||
// Post-startup registration removed: token registration is handled
|
||
// centrally in the auth state change listener to avoid duplicate inserts.
|
||
}
|
||
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|