241 lines
8.0 KiB
Dart
241 lines
8.0 KiB
Dart
import 'dart:async';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||
import 'package:audioplayers/audioplayers.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.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';
|
||
import 'providers/profile_provider.dart';
|
||
import 'models/profile.dart';
|
||
|
||
import 'app.dart';
|
||
|
||
import 'providers/notifications_provider.dart';
|
||
import 'utils/app_time.dart';
|
||
import 'utils/notification_permission.dart';
|
||
import 'services/notification_service.dart';
|
||
import 'services/notification_bridge.dart';
|
||
|
||
/// Handle messages received while the app is terminated or in background.
|
||
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||
// initialize plugin in background isolate
|
||
await NotificationService.initialize();
|
||
final notification = message.notification;
|
||
if (notification != null) {
|
||
NotificationService.show(
|
||
id: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||
title: notification.title ?? 'Notification',
|
||
body: notification.body ?? '',
|
||
payload: message.data['payload'],
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<void> main() async {
|
||
WidgetsFlutterBinding.ensureInitialized();
|
||
|
||
// initialize Firebase before anything that uses messaging
|
||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
|
||
|
||
await dotenv.load(fileName: '.env');
|
||
|
||
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;
|
||
}
|
||
|
||
await Supabase.initialize(url: supabaseUrl, anonKey: supabaseAnonKey);
|
||
|
||
// ensure token saved right away if already signed in
|
||
final supaClient = Supabase.instance.client;
|
||
String? initialToken;
|
||
if (!kIsWeb) {
|
||
try {
|
||
initialToken = await FirebaseMessaging.instance.getToken();
|
||
} catch (e) {
|
||
debugPrint('FCM getToken failed: $e');
|
||
initialToken = null;
|
||
}
|
||
if (initialToken != null && supaClient.auth.currentUser != null) {
|
||
debugPrint('initial FCM token for signed-in user: $initialToken');
|
||
final ctrl = NotificationsController(supaClient);
|
||
await ctrl.registerFcmToken(initialToken);
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
await ctrl.registerFcmToken(token);
|
||
} else if (event == AuthChangeEvent.signedOut) {
|
||
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`.
|
||
final granted = await ensureNotificationPermission();
|
||
if (!granted) {
|
||
// we don’t block startup, but it’s worth logging so developers notice.
|
||
// A real app might show a dialog pointing the user to settings.
|
||
// debugPrint('notification permission not granted');
|
||
}
|
||
|
||
// request FCM permission (iOS/Android13+) and handle foreground messages
|
||
await FirebaseMessaging.instance.requestPermission();
|
||
}
|
||
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
|
||
final notification = message.notification;
|
||
if (notification != null) {
|
||
NotificationService.show(
|
||
id: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||
title: notification.title ?? 'Notification',
|
||
body: notification.body ?? '',
|
||
payload: message.data['payload'],
|
||
);
|
||
}
|
||
});
|
||
|
||
// 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 up to us
|
||
final payload = response.payload;
|
||
if (payload != null && payload.startsWith('ticket:')) {
|
||
// ignore if context not mounted; we might use a navigator key in real
|
||
// app, but keep this simple for now
|
||
// TODO: navigate to ticket/task as appropriate
|
||
}
|
||
},
|
||
);
|
||
|
||
// global navigator key used for snackbars/navigation from notification
|
||
final navigatorKey = GlobalKey<NavigatorState>();
|
||
|
||
runApp(
|
||
ProviderScope(
|
||
observers: [NotificationSoundObserver()],
|
||
child: NotificationBridge(
|
||
navigatorKey: navigatorKey,
|
||
child: const TasqApp(),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
class NotificationSoundObserver extends ProviderObserver {
|
||
static final AudioPlayer _player = AudioPlayer();
|
||
StreamSubscription<String?>? _tokenSub;
|
||
|
||
@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) {
|
||
_player.play(AssetSource('tasq_notification.wav'));
|
||
NotificationService.show(
|
||
id: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||
title: 'New notifications',
|
||
body: 'You have $next unread notifications.',
|
||
);
|
||
}
|
||
}
|
||
|
||
// when profile changes, register or unregister tokens
|
||
if (provider == currentProfileProvider) {
|
||
final profile = newValue as Profile?;
|
||
final controller = container.read(notificationsControllerProvider);
|
||
|
||
if (profile != null) {
|
||
// signed in: save current token and keep listening for refreshes
|
||
if (!kIsWeb) {
|
||
FirebaseMessaging.instance
|
||
.getToken()
|
||
.then((token) {
|
||
if (token != null) {
|
||
debugPrint('profile observer registering token: $token');
|
||
controller.registerFcmToken(token);
|
||
}
|
||
})
|
||
.catchError((e) => debugPrint('getToken error: $e'));
|
||
_tokenSub = FirebaseMessaging.instance.onTokenRefresh.listen((token) {
|
||
debugPrint('token refreshed: $token');
|
||
controller.registerFcmToken(token);
|
||
});
|
||
}
|
||
} else {
|
||
// signed out: unregister whatever token we currently have
|
||
_tokenSub?.cancel();
|
||
if (!kIsWeb) {
|
||
FirebaseMessaging.instance
|
||
.getToken()
|
||
.then((token) {
|
||
if (token != null) {
|
||
debugPrint('profile observer unregistering token: $token');
|
||
controller.unregisterFcmToken(token);
|
||
}
|
||
})
|
||
.catchError((e) => debugPrint('getToken error: $e'));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|