Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
432 lines
13 KiB
Dart
432 lines
13 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:audioplayers/audioplayers.dart';
|
|
import 'package:firebase_core/firebase_core.dart';
|
|
import 'package:miler/helpers/http_overrides.dart';
|
|
import 'package:miler/providers/auth/auth_provider.dart';
|
|
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
|
import 'package:miler/views/helpers/constants/design_constants.dart';
|
|
|
|
// Top-level background handler required by Firebase Messaging
|
|
@pragma('vm:entry-point')
|
|
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
|
HttpOverrides.global = MyHttpOverrides();
|
|
try {
|
|
await Firebase.initializeApp();
|
|
} catch (_) {}
|
|
|
|
await NotificationServce.display(message);
|
|
}
|
|
|
|
class NotificationServce {
|
|
static final FirebaseMessaging _firebaseMessaging =
|
|
FirebaseMessaging.instance;
|
|
static final FlutterLocalNotificationsPlugin _notificationsPlugin =
|
|
FlutterLocalNotificationsPlugin();
|
|
static final AudioPlayer _player = AudioPlayer();
|
|
static String _channelId = 'Miler';
|
|
static bool _isPlaying = false;
|
|
|
|
static const AndroidNotificationChannel channel = AndroidNotificationChannel(
|
|
'Miler',
|
|
'Miler Notification',
|
|
description: 'Channel for Miler notifications',
|
|
importance: Importance.max,
|
|
playSound: true,
|
|
enableVibration: true,
|
|
showBadge: true,
|
|
);
|
|
|
|
static Future<void> initialize(BuildContext context) async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final alreadyInit = prefs.getBool('notifications_init_done') ?? false;
|
|
if (alreadyInit) {
|
|
return;
|
|
}
|
|
|
|
await FirebaseMessaging.instance.requestPermission(
|
|
alert: true,
|
|
badge: true,
|
|
sound: true,
|
|
);
|
|
|
|
final existing = (prefs.getString('order_alert_sound') ?? '').trim();
|
|
if (existing.isEmpty) {
|
|
await prefs.setString('order_alert_sound', 'assets/audio/alert-1.mp3');
|
|
}
|
|
await prefs.setBool('notifications_init_done', true);
|
|
} catch (_) {}
|
|
|
|
await _notificationsPlugin
|
|
.resolvePlatformSpecificImplementation<
|
|
AndroidFlutterLocalNotificationsPlugin
|
|
>()
|
|
?.createNotificationChannel(channel);
|
|
|
|
await _applyChannelSoundFromPrefs();
|
|
|
|
const InitializationSettings initializationSettings =
|
|
InitializationSettings(
|
|
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
|
iOS: DarwinInitializationSettings(
|
|
requestSoundPermission: true,
|
|
requestBadgePermission: true,
|
|
requestAlertPermission: true,
|
|
defaultPresentSound: true,
|
|
defaultPresentBadge: true,
|
|
defaultPresentBanner: true,
|
|
defaultPresentAlert: true,
|
|
defaultPresentList: true,
|
|
),
|
|
);
|
|
|
|
await _notificationsPlugin.initialize(
|
|
initializationSettings,
|
|
onDidReceiveNotificationResponse:
|
|
(NotificationResponse response) async {},
|
|
);
|
|
|
|
RemoteMessage? initialMessage = await _firebaseMessaging
|
|
.getInitialMessage();
|
|
if (initialMessage != null) {
|
|
await _handleInitialMessage(initialMessage);
|
|
}
|
|
|
|
FirebaseMessaging.onMessage.listen((RemoteMessage message) async {
|
|
await _handleMessage(message);
|
|
});
|
|
|
|
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) async {
|
|
await _handleMessageOpenedApp(message);
|
|
});
|
|
|
|
// FCM rotates tokens independently of our session. The cached token in
|
|
// prefs is what login registers with the backend, so without this listener
|
|
// a rotation silently stops all new-assignment pushes until the next
|
|
// reinstall. Re-cache and re-register on every rotation.
|
|
FirebaseMessaging.instance.onTokenRefresh.listen((String token) async {
|
|
if (token.isEmpty) return;
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString('fcmToken', token);
|
|
await AuthProvider().saveDeviceToken(token);
|
|
} catch (e) {
|
|
debugPrint('[FCM] token refresh handling failed: $e');
|
|
}
|
|
});
|
|
}
|
|
|
|
// ✅ Removed duplicated background handler (this was breaking your notifications)
|
|
|
|
static Future<void> _handleInitialMessage(RemoteMessage message) async {
|
|
if (message.notification != null) {
|
|
await display(message);
|
|
}
|
|
}
|
|
|
|
static Future<void> _handleMessage(RemoteMessage message) async {
|
|
if (message.notification != null) {
|
|
await display(message);
|
|
}
|
|
}
|
|
|
|
static Future<void> _handleMessageOpenedApp(RemoteMessage message) async {}
|
|
|
|
static Future<void> _applyChannelSoundFromPrefs() async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
String sel = (prefs.getString('order_alert_sound') ?? '').trim();
|
|
if (sel.isEmpty) {
|
|
sel = 'assets/audio/alert-1.mp3';
|
|
}
|
|
|
|
final fileName = sel.split('/').last;
|
|
final base = fileName.split('.').first;
|
|
final rawName = base.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_');
|
|
|
|
final androidImpl = _notificationsPlugin
|
|
.resolvePlatformSpecificImplementation<
|
|
AndroidFlutterLocalNotificationsPlugin
|
|
>();
|
|
|
|
if (androidImpl != null) {
|
|
_channelId = 'Miler_$rawName';
|
|
|
|
final custom = AndroidNotificationChannel(
|
|
_channelId,
|
|
'Miler Notification',
|
|
description: 'Channel for Miler notifications',
|
|
importance: Importance.max,
|
|
playSound: true,
|
|
sound: RawResourceAndroidNotificationSound(rawName),
|
|
enableVibration: true,
|
|
showBadge: true,
|
|
);
|
|
|
|
await androidImpl.createNotificationChannel(custom);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
static Future<String?> _downloadAndSaveImage(
|
|
String imageUrl,
|
|
String fileName,
|
|
) async {
|
|
try {
|
|
final directory = await getTemporaryDirectory();
|
|
final filePath = '${directory.path}/$fileName';
|
|
final response = await http.get(Uri.parse(imageUrl));
|
|
if (response.statusCode == 200) {
|
|
final file = File(filePath);
|
|
await file.writeAsBytes(response.bodyBytes);
|
|
return filePath;
|
|
}
|
|
return null;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static String? _extractImageUrl(RemoteMessage message) {
|
|
String? imageUrl = message.data['image'] as String?;
|
|
imageUrl ??= message.notification?.android?.imageUrl;
|
|
imageUrl ??= message.notification?.apple?.imageUrl;
|
|
return imageUrl;
|
|
}
|
|
|
|
static Future<void> _playSelectedSound({int times = 1}) async {
|
|
try {
|
|
if (_isPlaying) return;
|
|
|
|
_isPlaying = true;
|
|
|
|
final prefs = await SharedPreferences.getInstance();
|
|
String selected = (prefs.getString('order_alert_sound') ?? '').trim();
|
|
if (selected.isEmpty) {
|
|
selected = 'assets/audio/alert-1.mp3';
|
|
}
|
|
|
|
final rel = selected.startsWith('assets/')
|
|
? selected.replaceFirst('assets/', '')
|
|
: selected;
|
|
|
|
await _player.stop();
|
|
await _player.setReleaseMode(ReleaseMode.stop);
|
|
|
|
for (int i = 0; i < times; i++) {
|
|
await _player.play(AssetSource(rel));
|
|
try {
|
|
await _player.onPlayerComplete.first;
|
|
} catch (_) {}
|
|
if (i < times - 1) {
|
|
await Future.delayed(const Duration(milliseconds: 120));
|
|
}
|
|
}
|
|
} catch (_) {
|
|
} finally {
|
|
_isPlaying = false;
|
|
}
|
|
}
|
|
|
|
/// Lightweight local notification helper for in-app events (no FCM message).
|
|
static Future<void> showLocalNotification({
|
|
required String title,
|
|
required String body,
|
|
bool playSound = true,
|
|
String? payload,
|
|
}) async {
|
|
try {
|
|
final id = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
|
|
final notificationDetails = NotificationDetails(
|
|
android: AndroidNotificationDetails(
|
|
_channelId,
|
|
'Miler Notification',
|
|
importance: Importance.max,
|
|
priority: Priority.high,
|
|
icon: '@mipmap/ic_launcher',
|
|
playSound: playSound,
|
|
enableVibration: true,
|
|
channelShowBadge: true,
|
|
ongoing: false,
|
|
autoCancel: true,
|
|
),
|
|
iOS: DarwinNotificationDetails(
|
|
presentAlert: true,
|
|
presentBadge: true,
|
|
presentSound: playSound,
|
|
presentList: true,
|
|
presentBanner: true,
|
|
),
|
|
);
|
|
|
|
await _notificationsPlugin.show(
|
|
id,
|
|
title,
|
|
body,
|
|
notificationDetails,
|
|
payload: payload,
|
|
);
|
|
} catch (_) {}
|
|
}
|
|
|
|
static Future<void> display(RemoteMessage message) async {
|
|
final id = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
final payload = jsonEncode({'id': id.toString(), 'data': message.data});
|
|
|
|
final appState = WidgetsBinding.instance.lifecycleState;
|
|
final bool isForeground = appState == AppLifecycleState.resumed;
|
|
|
|
if (isForeground) {
|
|
await _playSelectedSound(times: 5);
|
|
}
|
|
|
|
NotificationDetails notificationDetails;
|
|
|
|
final imageUrl = _extractImageUrl(message);
|
|
String? persistedImageUrl = imageUrl;
|
|
String? persistedImagePath;
|
|
|
|
if (imageUrl != null && imageUrl.isNotEmpty) {
|
|
final imagePath = await _downloadAndSaveImage(
|
|
imageUrl,
|
|
'notification_image.jpg',
|
|
);
|
|
persistedImagePath = imagePath;
|
|
|
|
notificationDetails = NotificationDetails(
|
|
android: AndroidNotificationDetails(
|
|
_channelId,
|
|
'Miler Notification',
|
|
importance: Importance.max,
|
|
priority: Priority.high,
|
|
icon: '@mipmap/ic_launcher',
|
|
playSound: !isForeground,
|
|
enableVibration: true,
|
|
fullScreenIntent: true,
|
|
channelShowBadge: true,
|
|
ongoing: false,
|
|
autoCancel: true,
|
|
styleInformation: imagePath != null
|
|
? BigPictureStyleInformation(FilePathAndroidBitmap(imagePath))
|
|
: const DefaultStyleInformation(true, true),
|
|
),
|
|
iOS: const DarwinNotificationDetails(
|
|
presentAlert: true,
|
|
presentBadge: true,
|
|
presentSound: true,
|
|
presentList: true,
|
|
presentBanner: true,
|
|
),
|
|
);
|
|
} else {
|
|
notificationDetails = NotificationDetails(
|
|
android: AndroidNotificationDetails(
|
|
_channelId,
|
|
'Miler Notification',
|
|
importance: Importance.max,
|
|
priority: Priority.high,
|
|
icon: '@mipmap/ic_launcher',
|
|
playSound: !isForeground,
|
|
enableVibration: true,
|
|
fullScreenIntent: true,
|
|
channelShowBadge: true,
|
|
ongoing: false,
|
|
autoCancel: true,
|
|
),
|
|
iOS: const DarwinNotificationDetails(
|
|
presentAlert: true,
|
|
presentBadge: true,
|
|
presentSound: true,
|
|
presentList: true,
|
|
presentBanner: true,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (isForeground) {
|
|
final ctx = Get.context;
|
|
if (ctx != null) {
|
|
final title =
|
|
message.notification?.title ?? message.data['title'] ?? 'Miler';
|
|
final body = message.notification?.body ?? message.data['body'] ?? '';
|
|
|
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
|
SnackBar(
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
if (body.isNotEmpty) Text(body),
|
|
],
|
|
),
|
|
behavior: SnackBarBehavior.floating,
|
|
margin: const EdgeInsets.all(16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
|
),
|
|
duration: const Duration(seconds: 3),
|
|
),
|
|
);
|
|
}
|
|
} else {
|
|
await _notificationsPlugin.show(
|
|
id,
|
|
message.notification?.title ?? message.data['title'] ?? 'Miler',
|
|
message.notification?.body ?? message.data['body'] ?? 'Notification',
|
|
notificationDetails,
|
|
payload: payload,
|
|
);
|
|
}
|
|
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final nowIso = DateTime.now().toIso8601String();
|
|
|
|
final title =
|
|
message.notification?.title ?? message.data['title'] ?? 'Miler';
|
|
final body = message.notification?.body ?? message.data['body'] ?? '';
|
|
|
|
final entry = {
|
|
'id': id,
|
|
'title': title,
|
|
'body': body,
|
|
'time': nowIso,
|
|
'data': message.data,
|
|
if (persistedImageUrl != null) 'imageUrl': persistedImageUrl,
|
|
if (persistedImagePath != null) 'imagePath': persistedImagePath,
|
|
};
|
|
|
|
final existingRaw = prefs.getString('notifications_log');
|
|
List<dynamic> list = [];
|
|
|
|
if (existingRaw != null && existingRaw.isNotEmpty) {
|
|
try {
|
|
list = jsonDecode(existingRaw) as List<dynamic>;
|
|
} catch (_) {}
|
|
}
|
|
|
|
list.insert(0, entry);
|
|
|
|
if (list.length > 100) list = list.sublist(0, 100);
|
|
|
|
await prefs.setString('notifications_log', jsonEncode(list));
|
|
} catch (_) {}
|
|
}
|
|
}
|