* Push Notification Setup and attempt
* Office Ordering * Allow editing of Task and Ticket Details after creation
This commit is contained in:
parent
cc6fda0e79
commit
5979a04254
|
|
@ -1,5 +1,8 @@
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
|
// START: FlutterFire Configuration
|
||||||
|
id("com.google.gms.google-services")
|
||||||
|
// END: FlutterFire Configuration
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
|
|
|
||||||
29
android/app/google-services.json
Normal file
29
android/app/google-services.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "173359574734",
|
||||||
|
"project_id": "tasq-17fb3",
|
||||||
|
"storage_bucket": "tasq-17fb3.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:173359574734:android:28f9a6792ea579ad2baa9f",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.example.tasq"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyDt0ZYxfjXXxF4PS8NKbzOHFSHJC2LFvU4"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,9 @@ pluginManagement {
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.11.1" apply false
|
id("com.android.application") version "8.11.1" apply false
|
||||||
|
// START: FlutterFire Configuration
|
||||||
|
id("com.google.gms.google-services") version("4.3.15") apply false
|
||||||
|
// END: FlutterFire Configuration
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
1
firebase.json
Normal file
1
firebase.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"flutter":{"platforms":{"android":{"default":{"projectId":"tasq-17fb3","appId":"1:173359574734:android:28f9a6792ea579ad2baa9f","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"tasq-17fb3","configurations":{"android":"1:173359574734:android:28f9a6792ea579ad2baa9f","ios":"1:173359574734:ios:2acd406a087240172baa9f","macos":"1:173359574734:ios:2acd406a087240172baa9f","web":"1:173359574734:web:f894a6b43a443e902baa9f","windows":"1:173359574734:web:c7603df3290c4a832baa9f"}}}}}}
|
||||||
86
lib/firebase_options.dart
Normal file
86
lib/firebase_options.dart
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
// File generated by FlutterFire CLI.
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||||
|
import 'package:flutter/foundation.dart'
|
||||||
|
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||||
|
|
||||||
|
/// Default [FirebaseOptions] for use with your Firebase apps.
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
/// ```dart
|
||||||
|
/// import 'firebase_options.dart';
|
||||||
|
/// // ...
|
||||||
|
/// await Firebase.initializeApp(
|
||||||
|
/// options: DefaultFirebaseOptions.currentPlatform,
|
||||||
|
/// );
|
||||||
|
/// ```
|
||||||
|
class DefaultFirebaseOptions {
|
||||||
|
static FirebaseOptions get currentPlatform {
|
||||||
|
if (kIsWeb) {
|
||||||
|
return web;
|
||||||
|
}
|
||||||
|
switch (defaultTargetPlatform) {
|
||||||
|
case TargetPlatform.android:
|
||||||
|
return android;
|
||||||
|
case TargetPlatform.iOS:
|
||||||
|
return ios;
|
||||||
|
case TargetPlatform.macOS:
|
||||||
|
return macos;
|
||||||
|
case TargetPlatform.windows:
|
||||||
|
return windows;
|
||||||
|
case TargetPlatform.linux:
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions have not been configured for linux - '
|
||||||
|
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions are not supported for this platform.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static const FirebaseOptions web = FirebaseOptions(
|
||||||
|
apiKey: 'AIzaSyBKGSaHYiqpZvbEgsvJJY45soiIkV6MV3M',
|
||||||
|
appId: '1:173359574734:web:f894a6b43a443e902baa9f',
|
||||||
|
messagingSenderId: '173359574734',
|
||||||
|
projectId: 'tasq-17fb3',
|
||||||
|
authDomain: 'tasq-17fb3.firebaseapp.com',
|
||||||
|
storageBucket: 'tasq-17fb3.firebasestorage.app',
|
||||||
|
);
|
||||||
|
|
||||||
|
static const FirebaseOptions android = FirebaseOptions(
|
||||||
|
apiKey: 'AIzaSyDt0ZYxfjXXxF4PS8NKbzOHFSHJC2LFvU4',
|
||||||
|
appId: '1:173359574734:android:28f9a6792ea579ad2baa9f',
|
||||||
|
messagingSenderId: '173359574734',
|
||||||
|
projectId: 'tasq-17fb3',
|
||||||
|
storageBucket: 'tasq-17fb3.firebasestorage.app',
|
||||||
|
);
|
||||||
|
|
||||||
|
static const FirebaseOptions ios = FirebaseOptions(
|
||||||
|
apiKey: 'AIzaSyCeOKj8Q_E45Vn2XrLmQekTPGaG3T-unS4',
|
||||||
|
appId: '1:173359574734:ios:2acd406a087240172baa9f',
|
||||||
|
messagingSenderId: '173359574734',
|
||||||
|
projectId: 'tasq-17fb3',
|
||||||
|
storageBucket: 'tasq-17fb3.firebasestorage.app',
|
||||||
|
iosBundleId: 'com.example.tasq',
|
||||||
|
);
|
||||||
|
|
||||||
|
static const FirebaseOptions macos = FirebaseOptions(
|
||||||
|
apiKey: 'AIzaSyCeOKj8Q_E45Vn2XrLmQekTPGaG3T-unS4',
|
||||||
|
appId: '1:173359574734:ios:2acd406a087240172baa9f',
|
||||||
|
messagingSenderId: '173359574734',
|
||||||
|
projectId: 'tasq-17fb3',
|
||||||
|
storageBucket: 'tasq-17fb3.firebasestorage.app',
|
||||||
|
iosBundleId: 'com.example.tasq',
|
||||||
|
);
|
||||||
|
|
||||||
|
static const FirebaseOptions windows = FirebaseOptions(
|
||||||
|
apiKey: 'AIzaSyBKGSaHYiqpZvbEgsvJJY45soiIkV6MV3M',
|
||||||
|
appId: '1:173359574734:web:c7603df3290c4a832baa9f',
|
||||||
|
messagingSenderId: '173359574734',
|
||||||
|
projectId: 'tasq-17fb3',
|
||||||
|
authDomain: 'tasq-17fb3.firebaseapp.com',
|
||||||
|
storageBucket: 'tasq-17fb3.firebasestorage.app',
|
||||||
|
);
|
||||||
|
}
|
||||||
102
lib/main.dart
102
lib/main.dart
|
|
@ -1,18 +1,45 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:audioplayers/audioplayers.dart';
|
import 'package:audioplayers/audioplayers.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.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 'app.dart';
|
||||||
|
|
||||||
import 'providers/notifications_provider.dart';
|
import 'providers/notifications_provider.dart';
|
||||||
import 'utils/app_time.dart';
|
import 'utils/app_time.dart';
|
||||||
import 'utils/notification_permission.dart';
|
import 'utils/notification_permission.dart';
|
||||||
import 'services/notification_service.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 {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// initialize Firebase before anything that uses messaging
|
||||||
|
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||||
|
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
|
||||||
|
|
||||||
await dotenv.load(fileName: '.env');
|
await dotenv.load(fileName: '.env');
|
||||||
|
|
||||||
AppTime.initialize(location: 'Asia/Manila');
|
AppTime.initialize(location: 'Asia/Manila');
|
||||||
|
|
@ -27,6 +54,29 @@ Future<void> main() async {
|
||||||
|
|
||||||
await Supabase.initialize(url: supabaseUrl, anonKey: supabaseAnonKey);
|
await Supabase.initialize(url: supabaseUrl, anonKey: supabaseAnonKey);
|
||||||
|
|
||||||
|
// ensure token saved right away if already signed in
|
||||||
|
final supaClient = Supabase.instance.client;
|
||||||
|
final initialToken = await FirebaseMessaging.instance.getToken();
|
||||||
|
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;
|
||||||
|
final token = await FirebaseMessaging.instance.getToken();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// on Android 13+ we must request POST_NOTIFICATIONS at runtime; without it
|
// on Android 13+ we must request POST_NOTIFICATIONS at runtime; without it
|
||||||
// notifications are automatically denied and cannot be re‑enabled from the
|
// notifications are automatically denied and cannot be re‑enabled from the
|
||||||
// system settings. The helper uses `permission_handler`.
|
// system settings. The helper uses `permission_handler`.
|
||||||
|
|
@ -37,6 +87,20 @@ Future<void> main() async {
|
||||||
// debugPrint('notification permission not granted');
|
// 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
|
// initialize the local notifications plugin so we can post alerts later
|
||||||
await NotificationService.initialize(
|
await NotificationService.initialize(
|
||||||
onDidReceiveNotificationResponse: (response) {
|
onDidReceiveNotificationResponse: (response) {
|
||||||
|
|
@ -50,16 +114,23 @@ Future<void> main() async {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// global navigator key used for snackbars/navigation from notification
|
||||||
|
final navigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
observers: [NotificationSoundObserver()],
|
observers: [NotificationSoundObserver()],
|
||||||
|
child: NotificationBridge(
|
||||||
|
navigatorKey: navigatorKey,
|
||||||
child: const TasqApp(),
|
child: const TasqApp(),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class NotificationSoundObserver extends ProviderObserver {
|
class NotificationSoundObserver extends ProviderObserver {
|
||||||
static final AudioPlayer _player = AudioPlayer();
|
static final AudioPlayer _player = AudioPlayer();
|
||||||
|
StreamSubscription<String?>? _tokenSub;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateProvider(
|
void didUpdateProvider(
|
||||||
|
|
@ -68,12 +139,12 @@ class NotificationSoundObserver extends ProviderObserver {
|
||||||
Object? newValue,
|
Object? newValue,
|
||||||
ProviderContainer container,
|
ProviderContainer container,
|
||||||
) {
|
) {
|
||||||
|
// play sound + show OS notification on unread-count increase
|
||||||
if (provider == unreadNotificationsCountProvider) {
|
if (provider == unreadNotificationsCountProvider) {
|
||||||
final prev = previousValue as int?;
|
final prev = previousValue as int?;
|
||||||
final next = newValue as int?;
|
final next = newValue as int?;
|
||||||
if (prev != null && next != null && next > prev) {
|
if (prev != null && next != null && next > prev) {
|
||||||
_player.play(AssetSource('tasq_notification.wav'));
|
_player.play(AssetSource('tasq_notification.wav'));
|
||||||
// also post a system notification so the user sees it outside the app
|
|
||||||
NotificationService.show(
|
NotificationService.show(
|
||||||
id: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
id: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
title: 'New notifications',
|
title: 'New notifications',
|
||||||
|
|
@ -81,6 +152,35 @@ class NotificationSoundObserver extends ProviderObserver {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
FirebaseMessaging.instance.getToken().then((token) {
|
||||||
|
if (token != null) {
|
||||||
|
debugPrint('profile observer registering token: $token');
|
||||||
|
controller.registerFcmToken(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_tokenSub = FirebaseMessaging.instance.onTokenRefresh.listen((token) {
|
||||||
|
debugPrint('token refreshed: $token');
|
||||||
|
controller.registerFcmToken(token);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// signed out: unregister whatever token we currently have
|
||||||
|
_tokenSub?.cancel();
|
||||||
|
FirebaseMessaging.instance.getToken().then((token) {
|
||||||
|
if (token != null) {
|
||||||
|
debugPrint('profile observer unregistering token: $token');
|
||||||
|
controller.unregisterFcmToken(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
||||||
|
|
@ -40,6 +41,72 @@ class NotificationsController {
|
||||||
|
|
||||||
final SupabaseClient _client;
|
final SupabaseClient _client;
|
||||||
|
|
||||||
|
/// Internal helper that inserts notification rows and sends pushes if
|
||||||
|
/// [targetUserIds] is provided.
|
||||||
|
/// Internal helper that inserts notification rows and optionally sends
|
||||||
|
/// FCM pushes. Callers should use [createNotification] instead.
|
||||||
|
Future<void> _createAndPush(
|
||||||
|
List<Map<String, dynamic>> rows, {
|
||||||
|
List<String>? targetUserIds,
|
||||||
|
String? pushTitle,
|
||||||
|
String? pushBody,
|
||||||
|
Map<String, dynamic>? pushData,
|
||||||
|
}) async {
|
||||||
|
if (rows.isEmpty) return;
|
||||||
|
debugPrint(
|
||||||
|
'notifications_provider: inserting ${rows.length} rows; pushTitle=$pushTitle',
|
||||||
|
);
|
||||||
|
await _client.from('notifications').insert(rows);
|
||||||
|
|
||||||
|
// push notifications are now handled by a database trigger that
|
||||||
|
// calls the `send_fcm` edge function. We keep the client-side
|
||||||
|
// `sendPush` method around for manual/integration use, but avoid
|
||||||
|
// invoking it here to prevent CORS issues on web.
|
||||||
|
if (targetUserIds == null || targetUserIds.isEmpty) return;
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'notification rows inserted; server trigger will perform pushes',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a typed notification in the database. This method handles
|
||||||
|
/// inserting the row(s); a PostgreSQL trigger will forward the new row to
|
||||||
|
/// the `send_fcm` edge function, so clients do **not** directly invoke it
|
||||||
|
/// (avoids CORS/auth problems on web). The [pushTitle]/[pushBody] values
|
||||||
|
/// are still stored for the trigger payload.
|
||||||
|
Future<void> createNotification({
|
||||||
|
required List<String> userIds,
|
||||||
|
required String type,
|
||||||
|
required String actorId,
|
||||||
|
Map<String, dynamic>? fields,
|
||||||
|
String? pushTitle,
|
||||||
|
String? pushBody,
|
||||||
|
Map<String, dynamic>? pushData,
|
||||||
|
}) async {
|
||||||
|
debugPrint(
|
||||||
|
'createNotification called type=$type users=${userIds.length} pushTitle=$pushTitle pushBody=$pushBody',
|
||||||
|
);
|
||||||
|
if (userIds.isEmpty) return;
|
||||||
|
|
||||||
|
final rows = userIds.map((userId) {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'user_id': userId,
|
||||||
|
'actor_id': actorId,
|
||||||
|
'type': type,
|
||||||
|
...?fields,
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
await _createAndPush(
|
||||||
|
rows,
|
||||||
|
targetUserIds: userIds,
|
||||||
|
pushTitle: pushTitle,
|
||||||
|
pushBody: pushBody,
|
||||||
|
pushData: pushData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience for mention-specific case; left for compatibility.
|
||||||
Future<void> createMentionNotifications({
|
Future<void> createMentionNotifications({
|
||||||
required List<String> userIds,
|
required List<String> userIds,
|
||||||
required String actorId,
|
required String actorId,
|
||||||
|
|
@ -47,24 +114,22 @@ class NotificationsController {
|
||||||
String? ticketId,
|
String? ticketId,
|
||||||
String? taskId,
|
String? taskId,
|
||||||
}) async {
|
}) async {
|
||||||
if (userIds.isEmpty) return;
|
return createNotification(
|
||||||
if ((ticketId == null || ticketId.isEmpty) &&
|
userIds: userIds,
|
||||||
(taskId == null || taskId.isEmpty)) {
|
type: 'mention',
|
||||||
return;
|
actorId: actorId,
|
||||||
}
|
fields: {
|
||||||
final rows = userIds
|
|
||||||
.map(
|
|
||||||
(userId) => {
|
|
||||||
'user_id': userId,
|
|
||||||
'actor_id': actorId,
|
|
||||||
'ticket_id': ticketId,
|
|
||||||
'task_id': taskId,
|
|
||||||
'message_id': messageId,
|
'message_id': messageId,
|
||||||
'type': 'mention',
|
if (ticketId != null) 'ticket_id': ticketId,
|
||||||
|
if (taskId != null) 'task_id': taskId,
|
||||||
},
|
},
|
||||||
)
|
pushTitle: 'New mention',
|
||||||
.toList();
|
pushBody: 'You were mentioned in a message',
|
||||||
await _client.from('notifications').insert(rows);
|
pushData: {
|
||||||
|
if (ticketId != null) 'ticket_id': ticketId,
|
||||||
|
if (taskId != null) 'task_id': taskId,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> markRead(String id) async {
|
Future<void> markRead(String id) async {
|
||||||
|
|
@ -95,4 +160,85 @@ class NotificationsController {
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.filter('read_at', 'is', null);
|
.filter('read_at', 'is', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Store or update an FCM token for the current user.
|
||||||
|
Future<void> registerFcmToken(String token) async {
|
||||||
|
final userId = _client.auth.currentUser?.id;
|
||||||
|
if (userId == null) return;
|
||||||
|
final res = await _client.from('fcm_tokens').insert({
|
||||||
|
'user_id': userId,
|
||||||
|
'token': token,
|
||||||
|
});
|
||||||
|
if (res == null) {
|
||||||
|
debugPrint(
|
||||||
|
'registerFcmToken: null response for user=$userId token=$token',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final dyn = res as dynamic;
|
||||||
|
if (dyn.error != null) {
|
||||||
|
// duplicate key or RLS issue - just log it
|
||||||
|
debugPrint('registerFcmToken error: ${dyn.error?.message ?? dyn.error}');
|
||||||
|
} else {
|
||||||
|
debugPrint('registerFcmToken success for user=$userId token=$token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove an FCM token (e.g. when the user logs out or uninstalls).
|
||||||
|
Future<void> unregisterFcmToken(String token) async {
|
||||||
|
final userId = _client.auth.currentUser?.id;
|
||||||
|
if (userId == null) return;
|
||||||
|
final res = await _client
|
||||||
|
.from('fcm_tokens')
|
||||||
|
.delete()
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.eq('token', token);
|
||||||
|
if (res == null) {
|
||||||
|
debugPrint(
|
||||||
|
'unregisterFcmToken: null response for user=$userId token=$token',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final uDyn = res as dynamic;
|
||||||
|
if (uDyn.error != null) {
|
||||||
|
debugPrint(
|
||||||
|
'unregisterFcmToken error: ${uDyn.error?.message ?? uDyn.error}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a push message via the `send_fcm` edge function.
|
||||||
|
Future<void> sendPush({
|
||||||
|
List<String>? tokens,
|
||||||
|
List<String>? userIds,
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
Map<String, dynamic>? data,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
if (tokens != null) {
|
||||||
|
debugPrint(
|
||||||
|
'invoking send_fcm with ${tokens.length} tokens, title="$title"',
|
||||||
|
);
|
||||||
|
} else if (userIds != null) {
|
||||||
|
debugPrint(
|
||||||
|
'invoking send_fcm with userIds=${userIds.length}, title="$title"',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
debugPrint('sendPush called with neither tokens nor userIds');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final bodyPayload = <String, dynamic>{
|
||||||
|
if (tokens != null) 'tokens': tokens,
|
||||||
|
if (userIds != null) 'user_ids': userIds,
|
||||||
|
'title': title,
|
||||||
|
'body': body,
|
||||||
|
'data': data ?? {},
|
||||||
|
};
|
||||||
|
await _client.functions.invoke('send_fcm', body: bodyPayload);
|
||||||
|
} catch (err) {
|
||||||
|
debugPrint('sendPush invocation error: $err');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -685,6 +685,35 @@ class TasksController {
|
||||||
await _client.from('tasks').update(payload).eq('id', taskId);
|
await _client.from('tasks').update(payload).eq('id', taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update editable task fields such as title, description, office or linked ticket.
|
||||||
|
Future<void> updateTaskFields({
|
||||||
|
required String taskId,
|
||||||
|
String? title,
|
||||||
|
String? description,
|
||||||
|
String? officeId,
|
||||||
|
String? ticketId,
|
||||||
|
}) async {
|
||||||
|
final payload = <String, dynamic>{};
|
||||||
|
if (title != null) payload['title'] = title;
|
||||||
|
if (description != null) payload['description'] = description;
|
||||||
|
if (officeId != null) payload['office_id'] = officeId;
|
||||||
|
if (ticketId != null) payload['ticket_id'] = ticketId;
|
||||||
|
if (payload.isEmpty) return;
|
||||||
|
|
||||||
|
await _client.from('tasks').update(payload).eq('id', taskId);
|
||||||
|
|
||||||
|
// record an activity log for edit operations (best-effort)
|
||||||
|
try {
|
||||||
|
final actorId = _client.auth.currentUser?.id;
|
||||||
|
await _insertActivityRows(_client, {
|
||||||
|
'task_id': taskId,
|
||||||
|
'actor_id': actorId,
|
||||||
|
'action_type': 'updated',
|
||||||
|
'meta': payload,
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-assignment logic executed once on creation.
|
// Auto-assignment logic executed once on creation.
|
||||||
Future<void> _autoAssignTask({
|
Future<void> _autoAssignTask({
|
||||||
required String taskId,
|
required String taskId,
|
||||||
|
|
|
||||||
|
|
@ -370,6 +370,32 @@ class TicketsController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update editable ticket fields such as subject, description, and office.
|
||||||
|
Future<void> updateTicket({
|
||||||
|
required String ticketId,
|
||||||
|
String? subject,
|
||||||
|
String? description,
|
||||||
|
String? officeId,
|
||||||
|
}) async {
|
||||||
|
final payload = <String, dynamic>{};
|
||||||
|
if (subject != null) payload['subject'] = subject;
|
||||||
|
if (description != null) payload['description'] = description;
|
||||||
|
if (officeId != null) payload['office_id'] = officeId;
|
||||||
|
if (payload.isEmpty) return;
|
||||||
|
|
||||||
|
await _client.from('tickets').update(payload).eq('id', ticketId);
|
||||||
|
|
||||||
|
// record an activity row for edit operations (best-effort)
|
||||||
|
try {
|
||||||
|
final actorId = _client.auth.currentUser?.id;
|
||||||
|
await _client.from('ticket_messages').insert({
|
||||||
|
'ticket_id': ticketId,
|
||||||
|
'sender_id': actorId,
|
||||||
|
'content': 'Ticket updated',
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class OfficesController {
|
class OfficesController {
|
||||||
|
|
|
||||||
|
|
@ -212,10 +212,21 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||||
if (_selectedOfficeIds.isEmpty)
|
if (_selectedOfficeIds.isEmpty)
|
||||||
const Text('No office selected.')
|
const Text('No office selected.')
|
||||||
else
|
else
|
||||||
Wrap(
|
Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final sortedIds =
|
||||||
|
List<String>.from(_selectedOfficeIds)..sort(
|
||||||
|
(a, b) => (officeNameById[a] ?? a)
|
||||||
|
.toLowerCase()
|
||||||
|
.compareTo(
|
||||||
|
(officeNameById[b] ?? b)
|
||||||
|
.toLowerCase(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: _selectedOfficeIds.map((id) {
|
children: sortedIds.map((id) {
|
||||||
final name = officeNameById[id] ?? id;
|
final name = officeNameById[id] ?? id;
|
||||||
return Chip(
|
return Chip(
|
||||||
label: Text(name),
|
label: Text(name),
|
||||||
|
|
@ -223,11 +234,14 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
setState(
|
setState(
|
||||||
() => _selectedOfficeIds.remove(id),
|
() =>
|
||||||
|
_selectedOfficeIds.remove(id),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import '../../models/task_assignment.dart';
|
||||||
import '../../models/task_activity_log.dart';
|
import '../../models/task_activity_log.dart';
|
||||||
import '../../models/ticket.dart';
|
import '../../models/ticket.dart';
|
||||||
import '../../models/ticket_message.dart';
|
import '../../models/ticket_message.dart';
|
||||||
|
import '../../models/office.dart';
|
||||||
import '../../providers/notifications_provider.dart';
|
import '../../providers/notifications_provider.dart';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
@ -22,6 +23,7 @@ import '../../providers/tasks_provider.dart';
|
||||||
import '../../providers/tickets_provider.dart';
|
import '../../providers/tickets_provider.dart';
|
||||||
import '../../providers/typing_provider.dart';
|
import '../../providers/typing_provider.dart';
|
||||||
import '../../utils/app_time.dart';
|
import '../../utils/app_time.dart';
|
||||||
|
import '../../utils/snackbar.dart';
|
||||||
import '../../widgets/app_breakpoints.dart';
|
import '../../widgets/app_breakpoints.dart';
|
||||||
import '../../widgets/mono_text.dart';
|
import '../../widgets/mono_text.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
|
|
@ -29,7 +31,6 @@ import '../../widgets/status_pill.dart';
|
||||||
import '../../theme/app_surfaces.dart';
|
import '../../theme/app_surfaces.dart';
|
||||||
import '../../widgets/task_assignment_section.dart';
|
import '../../widgets/task_assignment_section.dart';
|
||||||
import '../../widgets/typing_dots.dart';
|
import '../../widgets/typing_dots.dart';
|
||||||
import '../../utils/snackbar.dart';
|
|
||||||
|
|
||||||
// Simple image embed builder to support data-URI and network images
|
// Simple image embed builder to support data-URI and network images
|
||||||
class _ImageEmbedBuilder extends quill.EmbedBuilder {
|
class _ImageEmbedBuilder extends quill.EmbedBuilder {
|
||||||
|
|
@ -315,16 +316,41 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
final detailsContent = Column(
|
final detailsContent = Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Align(
|
Center(
|
||||||
alignment: Alignment.center,
|
child: Row(
|
||||||
child: Text(
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
task.title.isNotEmpty
|
task.title.isNotEmpty
|
||||||
? task.title
|
? task.title
|
||||||
: 'Task ${task.taskNumber ?? task.id}',
|
: 'Task ${task.taskNumber ?? task.id}',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
context,
|
fontWeight: FontWeight.w700,
|
||||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Builder(
|
||||||
|
builder: (ctx) {
|
||||||
|
final profile = profileAsync.maybeWhen(
|
||||||
|
data: (p) => p,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
final canEdit =
|
||||||
|
profile != null &&
|
||||||
|
(profile.role == 'admin' ||
|
||||||
|
profile.role == 'dispatcher' ||
|
||||||
|
profile.role == 'it_staff' ||
|
||||||
|
profile.id == task.creatorId);
|
||||||
|
if (!canEdit) return const SizedBox.shrink();
|
||||||
|
return IconButton(
|
||||||
|
tooltip: 'Edit task',
|
||||||
|
onPressed: () => _showEditTaskDialog(ctx, ref, task),
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
|
|
@ -2143,27 +2169,10 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
: assignedForTask.last;
|
: assignedForTask.last;
|
||||||
|
|
||||||
DateTime? firstMessageByAssignee;
|
DateTime? firstMessageByAssignee;
|
||||||
if (latestAssignment != null) {
|
|
||||||
messagesAsync.when(
|
|
||||||
data: (messages) {
|
|
||||||
final byAssignee =
|
|
||||||
messages
|
|
||||||
.where((m) => m.senderId == latestAssignment.userId)
|
|
||||||
.where((m) => m.createdAt.isAfter(latestAssignment.createdAt))
|
|
||||||
.toList()
|
|
||||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
|
||||||
if (byAssignee.isNotEmpty) {
|
|
||||||
firstMessageByAssignee = byAssignee.first.createdAt;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
loading: () {},
|
|
||||||
error: (err, stack) {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
DateTime? startedByAssignee;
|
DateTime? startedByAssignee;
|
||||||
|
if (latestAssignment != null) {
|
||||||
for (final l in logs) {
|
for (final l in logs) {
|
||||||
if (l.actionType == 'started' && latestAssignment != null) {
|
if (l.actionType == 'started') {
|
||||||
if (l.actorId == latestAssignment.userId &&
|
if (l.actorId == latestAssignment.userId &&
|
||||||
l.createdAt.isAfter(latestAssignment.createdAt)) {
|
l.createdAt.isAfter(latestAssignment.createdAt)) {
|
||||||
startedByAssignee = l.createdAt;
|
startedByAssignee = l.createdAt;
|
||||||
|
|
@ -2171,6 +2180,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Duration? responseDuration;
|
Duration? responseDuration;
|
||||||
DateTime? responseAt;
|
DateTime? responseAt;
|
||||||
|
|
@ -2178,7 +2188,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
final assignedAt = latestAssignment.createdAt;
|
final assignedAt = latestAssignment.createdAt;
|
||||||
final candidates = <DateTime>[];
|
final candidates = <DateTime>[];
|
||||||
if (firstMessageByAssignee != null) {
|
if (firstMessageByAssignee != null) {
|
||||||
candidates.add(firstMessageByAssignee!);
|
candidates.add(firstMessageByAssignee);
|
||||||
}
|
}
|
||||||
if (startedByAssignee != null) {
|
if (startedByAssignee != null) {
|
||||||
candidates.add(startedByAssignee);
|
candidates.add(startedByAssignee);
|
||||||
|
|
@ -2693,6 +2703,100 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
return '${names[0]}, ${names[1]} and others are typing...';
|
return '${names[0]}, ${names[1]} and others are typing...';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showEditTaskDialog(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
Task task,
|
||||||
|
) async {
|
||||||
|
final officesAsync = ref.watch(officesOnceProvider);
|
||||||
|
final titleCtrl = TextEditingController(text: task.title);
|
||||||
|
final descCtrl = TextEditingController(text: task.description);
|
||||||
|
String? selectedOffice = task.officeId;
|
||||||
|
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return AlertDialog(
|
||||||
|
shape: AppSurfaces.of(context).dialogShape,
|
||||||
|
title: const Text('Edit Task'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: titleCtrl,
|
||||||
|
decoration: const InputDecoration(labelText: 'Title'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: descCtrl,
|
||||||
|
decoration: const InputDecoration(labelText: 'Description'),
|
||||||
|
maxLines: 4,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
officesAsync.when(
|
||||||
|
data: (offices) {
|
||||||
|
final officesSorted = List<Office>.from(offices)
|
||||||
|
..sort(
|
||||||
|
(a, b) => a.name.toLowerCase().compareTo(
|
||||||
|
b.name.toLowerCase(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return DropdownButtonFormField<String?>(
|
||||||
|
initialValue: selectedOffice,
|
||||||
|
decoration: const InputDecoration(labelText: 'Office'),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem(
|
||||||
|
value: null,
|
||||||
|
child: Text('Unassigned'),
|
||||||
|
),
|
||||||
|
for (final o in officesSorted)
|
||||||
|
DropdownMenuItem(value: o.id, child: Text(o.name)),
|
||||||
|
],
|
||||||
|
onChanged: (v) => selectedOffice = v,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const SizedBox.shrink(),
|
||||||
|
error: (_, __) => const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final outerContext = context;
|
||||||
|
final title = titleCtrl.text.trim();
|
||||||
|
final desc = descCtrl.text.trim();
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(tasksControllerProvider)
|
||||||
|
.updateTaskFields(
|
||||||
|
taskId: task.id,
|
||||||
|
title: title.isEmpty ? null : title,
|
||||||
|
description: desc.isEmpty ? null : desc,
|
||||||
|
officeId: selectedOffice,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.of(outerContext).pop();
|
||||||
|
showSuccessSnackBar(outerContext, 'Task updated');
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
showErrorSnackBar(outerContext, 'Failed to update task: $e');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Task? _findTask(AsyncValue<List<Task>> tasksAsync, String taskId) {
|
Task? _findTask(AsyncValue<List<Task>> tasksAsync, String taskId) {
|
||||||
return tasksAsync.maybeWhen(
|
return tasksAsync.maybeWhen(
|
||||||
data: (tasks) => tasks.where((task) => task.id == taskId).firstOrNull,
|
data: (tasks) => tasks.where((task) => task.id == taskId).firstOrNull,
|
||||||
|
|
|
||||||
|
|
@ -106,12 +106,17 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||||
return const Center(child: Text('No tasks yet.'));
|
return const Center(child: Text('No tasks yet.'));
|
||||||
}
|
}
|
||||||
final offices = officesAsync.valueOrNull ?? <Office>[];
|
final offices = officesAsync.valueOrNull ?? <Office>[];
|
||||||
|
final officesSorted = List<Office>.from(offices)
|
||||||
|
..sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
||||||
|
);
|
||||||
final officeOptions = <DropdownMenuItem<String?>>[
|
final officeOptions = <DropdownMenuItem<String?>>[
|
||||||
const DropdownMenuItem<String?>(
|
const DropdownMenuItem<String?>(
|
||||||
value: null,
|
value: null,
|
||||||
child: Text('All offices'),
|
child: Text('All offices'),
|
||||||
),
|
),
|
||||||
...offices.map(
|
...officesSorted.map(
|
||||||
(office) => DropdownMenuItem<String?>(
|
(office) => DropdownMenuItem<String?>(
|
||||||
value: office.id,
|
value: office.id,
|
||||||
child: Text(office.name),
|
child: Text(office.name),
|
||||||
|
|
@ -461,7 +466,13 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||||
if (offices.isEmpty) {
|
if (offices.isEmpty) {
|
||||||
return const Text('No offices available.');
|
return const Text('No offices available.');
|
||||||
}
|
}
|
||||||
selectedOfficeId ??= offices.first.id;
|
final officesSorted = List<Office>.from(offices)
|
||||||
|
..sort(
|
||||||
|
(a, b) => a.name.toLowerCase().compareTo(
|
||||||
|
b.name.toLowerCase(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
selectedOfficeId ??= officesSorted.first.id;
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -470,7 +481,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Office',
|
labelText: 'Office',
|
||||||
),
|
),
|
||||||
items: offices
|
items: officesSorted
|
||||||
.map(
|
.map(
|
||||||
(office) => DropdownMenuItem(
|
(office) => DropdownMenuItem(
|
||||||
value: office.id,
|
value: office.id,
|
||||||
|
|
|
||||||
|
|
@ -163,9 +163,13 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
|
||||||
TasQColumn<Team>(
|
TasQColumn<Team>(
|
||||||
header: 'Offices',
|
header: 'Offices',
|
||||||
cellBuilder: (context, team) {
|
cellBuilder: (context, team) {
|
||||||
final officeNames = team.officeIds
|
final officeNamesList = team.officeIds
|
||||||
.map((id) => officeById[id]?.name ?? id)
|
.map((id) => officeById[id]?.name ?? id)
|
||||||
.join(', ');
|
.toList();
|
||||||
|
officeNamesList.sort(
|
||||||
|
(a, b) => a.toLowerCase().compareTo(b.toLowerCase()),
|
||||||
|
);
|
||||||
|
final officeNames = officeNamesList.join(', ');
|
||||||
return Text(officeNames);
|
return Text(officeNames);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -182,9 +186,13 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
|
||||||
],
|
],
|
||||||
mobileTileBuilder: (context, team, actions) {
|
mobileTileBuilder: (context, team, actions) {
|
||||||
final leader = profileById[team.leaderId];
|
final leader = profileById[team.leaderId];
|
||||||
final officeNames = team.officeIds
|
final officeNamesList = team.officeIds
|
||||||
.map((id) => officeById[id]?.name ?? id)
|
.map((id) => officeById[id]?.name ?? id)
|
||||||
.join(', ');
|
.toList();
|
||||||
|
officeNamesList.sort(
|
||||||
|
(a, b) => a.toLowerCase().compareTo(b.toLowerCase()),
|
||||||
|
);
|
||||||
|
final officeNames = officeNamesList.join(', ');
|
||||||
final members = team.members(teamMembers);
|
final members = team.members(teamMembers);
|
||||||
final memberNames = members
|
final memberNames = members
|
||||||
.map((id) => profileById[id]?.fullName ?? id)
|
.map((id) => profileById[id]?.fullName ?? id)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import '../../providers/profile_provider.dart';
|
||||||
import '../../providers/tasks_provider.dart';
|
import '../../providers/tasks_provider.dart';
|
||||||
import '../../providers/tickets_provider.dart';
|
import '../../providers/tickets_provider.dart';
|
||||||
import '../../providers/typing_provider.dart';
|
import '../../providers/typing_provider.dart';
|
||||||
|
import '../../utils/snackbar.dart';
|
||||||
import '../../widgets/app_breakpoints.dart';
|
import '../../widgets/app_breakpoints.dart';
|
||||||
import '../../widgets/mono_text.dart';
|
import '../../widgets/mono_text.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
|
|
@ -95,14 +96,40 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
final detailsContent = Column(
|
final detailsContent = Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Align(
|
Center(
|
||||||
alignment: Alignment.center,
|
child: Row(
|
||||||
child: Text(
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
ticket.subject,
|
ticket.subject,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
context,
|
fontWeight: FontWeight.w700,
|
||||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Builder(
|
||||||
|
builder: (ctx) {
|
||||||
|
final profile = currentProfileAsync.maybeWhen(
|
||||||
|
data: (p) => p,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
final canEdit =
|
||||||
|
profile != null &&
|
||||||
|
(profile.role == 'admin' ||
|
||||||
|
profile.role == 'dispatcher' ||
|
||||||
|
profile.role == 'it_staff' ||
|
||||||
|
profile.id == ticket.creatorId);
|
||||||
|
if (!canEdit) return const SizedBox.shrink();
|
||||||
|
return IconButton(
|
||||||
|
tooltip: 'Edit ticket',
|
||||||
|
onPressed: () =>
|
||||||
|
_showEditTicketDialog(ctx, ref, ticket),
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
|
|
@ -853,6 +880,103 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showEditTicketDialog(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
Ticket ticket,
|
||||||
|
) async {
|
||||||
|
final officesAsync = ref.watch(officesOnceProvider);
|
||||||
|
final subjectCtrl = TextEditingController(text: ticket.subject);
|
||||||
|
final descCtrl = TextEditingController(text: ticket.description);
|
||||||
|
String? selectedOffice = ticket.officeId;
|
||||||
|
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return AlertDialog(
|
||||||
|
shape: AppSurfaces.of(context).dialogShape,
|
||||||
|
title: const Text('Edit Ticket'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: subjectCtrl,
|
||||||
|
decoration: const InputDecoration(labelText: 'Subject'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: descCtrl,
|
||||||
|
decoration: const InputDecoration(labelText: 'Description'),
|
||||||
|
maxLines: 4,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
officesAsync.when(
|
||||||
|
data: (offices) {
|
||||||
|
final officesSorted = List<Office>.from(offices)
|
||||||
|
..sort(
|
||||||
|
(a, b) => a.name.toLowerCase().compareTo(
|
||||||
|
b.name.toLowerCase(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return DropdownButtonFormField<String?>(
|
||||||
|
initialValue: selectedOffice,
|
||||||
|
decoration: const InputDecoration(labelText: 'Office'),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem(
|
||||||
|
value: null,
|
||||||
|
child: Text('Unassigned'),
|
||||||
|
),
|
||||||
|
for (final o in officesSorted)
|
||||||
|
DropdownMenuItem(value: o.id, child: Text(o.name)),
|
||||||
|
],
|
||||||
|
onChanged: (v) => selectedOffice = v,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const SizedBox.shrink(),
|
||||||
|
error: (_, __) => const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final outerContext = context;
|
||||||
|
final subject = subjectCtrl.text.trim();
|
||||||
|
final desc = descCtrl.text.trim();
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(ticketsControllerProvider)
|
||||||
|
.updateTicket(
|
||||||
|
ticketId: ticket.id,
|
||||||
|
subject: subject.isEmpty ? null : subject,
|
||||||
|
description: desc.isEmpty ? null : desc,
|
||||||
|
officeId: selectedOffice,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.of(outerContext).pop();
|
||||||
|
showSuccessSnackBar(outerContext, 'Ticket updated');
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
showErrorSnackBar(
|
||||||
|
outerContext,
|
||||||
|
'Failed to update ticket: $e',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _timelineRow(String label, DateTime? value) {
|
Widget _timelineRow(String label, DateTime? value) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
|
|
||||||
|
|
@ -70,12 +70,17 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||||
};
|
};
|
||||||
final unreadByTicketId = _unreadByTicketId(notificationsAsync);
|
final unreadByTicketId = _unreadByTicketId(notificationsAsync);
|
||||||
final offices = officesAsync.valueOrNull ?? <Office>[];
|
final offices = officesAsync.valueOrNull ?? <Office>[];
|
||||||
|
final officesSorted = List<Office>.from(offices)
|
||||||
|
..sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
||||||
|
);
|
||||||
final officeOptions = <DropdownMenuItem<String?>>[
|
final officeOptions = <DropdownMenuItem<String?>>[
|
||||||
const DropdownMenuItem<String?>(
|
const DropdownMenuItem<String?>(
|
||||||
value: null,
|
value: null,
|
||||||
child: Text('All offices'),
|
child: Text('All offices'),
|
||||||
),
|
),
|
||||||
...offices.map(
|
...officesSorted.map(
|
||||||
(office) => DropdownMenuItem<String?>(
|
(office) => DropdownMenuItem<String?>(
|
||||||
value: office.id,
|
value: office.id,
|
||||||
child: Text(office.name),
|
child: Text(office.name),
|
||||||
|
|
@ -356,11 +361,17 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||||
if (offices.isEmpty) {
|
if (offices.isEmpty) {
|
||||||
return const Text('No offices assigned.');
|
return const Text('No offices assigned.');
|
||||||
}
|
}
|
||||||
selectedOffice ??= offices.first;
|
final officesSorted = List<Office>.from(offices)
|
||||||
|
..sort(
|
||||||
|
(a, b) => a.name.toLowerCase().compareTo(
|
||||||
|
b.name.toLowerCase(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
selectedOffice ??= officesSorted.first;
|
||||||
return DropdownButtonFormField<Office>(
|
return DropdownButtonFormField<Office>(
|
||||||
key: ValueKey(selectedOffice?.id),
|
key: ValueKey(selectedOffice?.id),
|
||||||
initialValue: selectedOffice,
|
initialValue: selectedOffice,
|
||||||
items: offices
|
items: officesSorted
|
||||||
.map(
|
.map(
|
||||||
(office) => DropdownMenuItem(
|
(office) => DropdownMenuItem(
|
||||||
value: office,
|
value: office,
|
||||||
|
|
|
||||||
103
lib/services/notification_bridge.dart
Normal file
103
lib/services/notification_bridge.dart
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
|
|
||||||
|
import '../models/notification_item.dart';
|
||||||
|
import '../providers/notifications_provider.dart';
|
||||||
|
|
||||||
|
/// Wraps the app and installs both a Supabase realtime listener and the
|
||||||
|
/// FCM handlers described in the frontend design.
|
||||||
|
///
|
||||||
|
/// The navigator key is required so that snackbars and navigation can be
|
||||||
|
/// triggered from outside the widget tree (e.g. from realtime callbacks).
|
||||||
|
class NotificationBridge extends ConsumerStatefulWidget {
|
||||||
|
const NotificationBridge({
|
||||||
|
required this.navigatorKey,
|
||||||
|
required this.child,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final GlobalKey<NavigatorState> navigatorKey;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<NotificationBridge> createState() => _NotificationBridgeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NotificationBridgeState extends ConsumerState<NotificationBridge> {
|
||||||
|
// store previous notifications to diff
|
||||||
|
List<NotificationItem> _prevList = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_setupFcmHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showBanner(String type, NotificationItem item) {
|
||||||
|
final ctx = widget.navigatorKey.currentState?.overlay?.context;
|
||||||
|
if (ctx == null) return;
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('New $type received!'),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'View',
|
||||||
|
onPressed: () => _navigateToNotification(item),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateToNotification(NotificationItem item) {
|
||||||
|
widget.navigatorKey.currentState?.pushNamed(
|
||||||
|
'/notification-detail',
|
||||||
|
arguments: item,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setupFcmHandlers() {
|
||||||
|
// ignore foreground messages; realtime websocket will surface them
|
||||||
|
FirebaseMessaging.onMessage.listen((_) {});
|
||||||
|
|
||||||
|
// handle taps when the app is backgrounded
|
||||||
|
FirebaseMessaging.onMessageOpenedApp.listen(_handleMessageTap);
|
||||||
|
|
||||||
|
// handle a tap that launched the app from a terminated state
|
||||||
|
FirebaseMessaging.instance.getInitialMessage().then((msg) {
|
||||||
|
if (msg != null) _handleMessageTap(msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleMessageTap(RemoteMessage message) {
|
||||||
|
final data = message.data.cast<String, dynamic>();
|
||||||
|
final item = NotificationItem.fromMap(data);
|
||||||
|
_navigateToNotification(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// listen inside build; safe with ConsumerState
|
||||||
|
ref.listen<AsyncValue<List<NotificationItem>>>(notificationsProvider, (
|
||||||
|
previous,
|
||||||
|
next,
|
||||||
|
) {
|
||||||
|
final prevList = _prevList;
|
||||||
|
final nextList = next.maybeWhen(
|
||||||
|
data: (d) => d,
|
||||||
|
orElse: () => <NotificationItem>[],
|
||||||
|
);
|
||||||
|
if (nextList.length > prevList.length) {
|
||||||
|
final newItem = nextList.last;
|
||||||
|
_showBanner(newItem.type, newItem);
|
||||||
|
}
|
||||||
|
_prevList = nextList;
|
||||||
|
});
|
||||||
|
return widget.child;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
pubspec.lock
56
pubspec.lock
|
|
@ -1,6 +1,14 @@
|
||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
_flutterfire_internals:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: _flutterfire_internals
|
||||||
|
sha256: cd83f7d6bd4e4c0b0b4fef802e8796784032e1cc23d7b0e982cf5d05d9bbe182
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.66"
|
||||||
adaptive_number:
|
adaptive_number:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -345,6 +353,54 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.9.3+5"
|
version: "0.9.3+5"
|
||||||
|
firebase_core:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: firebase_core
|
||||||
|
sha256: "923085c881663ef685269b013e241b428e1fb03cdd0ebde265d9b40ff18abf80"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.4.0"
|
||||||
|
firebase_core_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: firebase_core_platform_interface
|
||||||
|
sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.0.2"
|
||||||
|
firebase_core_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: firebase_core_web
|
||||||
|
sha256: "83e7356c704131ca4d8d8dd57e360d8acecbca38b1a3705c7ae46cc34c708084"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.4.0"
|
||||||
|
firebase_messaging:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: firebase_messaging
|
||||||
|
sha256: "06fad40ea14771e969a8f2bbce1944aa20ee2f4f57f4eca5b3ba346b65f3f644"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "16.1.1"
|
||||||
|
firebase_messaging_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: firebase_messaging_platform_interface
|
||||||
|
sha256: "6c49e901c77e6e10e86d98e32056a087eb1ca1b93acdf58524f1961e617657b7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.7.6"
|
||||||
|
firebase_messaging_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: firebase_messaging_web
|
||||||
|
sha256: "2756f8fea583ffb9d294d15ddecb3a9ad429b023b70c9990c151fc92c54a32b3"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.2"
|
||||||
fixnum:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,13 @@ dependencies:
|
||||||
flutter_quill: ^11.5.0
|
flutter_quill: ^11.5.0
|
||||||
file_picker: ^10.3.10
|
file_picker: ^10.3.10
|
||||||
pdf: ^3.11.3
|
pdf: ^3.11.3
|
||||||
printing: ^5.10.0
|
printing: ^5.14.2
|
||||||
flutter_keyboard_visibility: ^5.4.1
|
flutter_keyboard_visibility: ^5.4.1
|
||||||
awesome_snackbar_content: ^0.1.8
|
awesome_snackbar_content: ^0.1.8
|
||||||
permission_handler: ^12.0.1
|
permission_handler: ^12.0.1
|
||||||
flutter_local_notifications: ^20.1.0
|
flutter_local_notifications: ^20.1.0
|
||||||
|
firebase_core: ^4.4.0
|
||||||
|
firebase_messaging: ^16.1.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
77
supabase/functions/send_fcm/index.ts
Normal file
77
supabase/functions/send_fcm/index.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { createClient } from 'npm:@supabase/supabase-js@2';
|
||||||
|
|
||||||
|
// we no longer use google-auth-library or a service account; instead
|
||||||
|
// send FCM via a legacy server key provided as an environment variable
|
||||||
|
// (FCM_SERVER_KEY). This avoids compatibility problems on the edge.
|
||||||
|
|
||||||
|
const supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!);
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
try {
|
||||||
|
await supabase.from('send_fcm_errors').insert({ payload: null, error: 'step1', stack: null });
|
||||||
|
const body = await req.json();
|
||||||
|
try { await supabase.from('send_fcm_errors').insert({ payload: body, error: 'step2', stack: null }); } catch (_) {}
|
||||||
|
|
||||||
|
// gather tokens (same as before)
|
||||||
|
let tokens: any[] = [];
|
||||||
|
if (Array.isArray(body.tokens)) {
|
||||||
|
tokens = body.tokens;
|
||||||
|
} else if (Array.isArray(body.user_ids)) {
|
||||||
|
try {
|
||||||
|
const { data: rows } = await supabase
|
||||||
|
.from('fcm_tokens')
|
||||||
|
.select('token')
|
||||||
|
.in('user_id', body.user_ids);
|
||||||
|
if (rows) tokens = rows.map((r: any) => r.token);
|
||||||
|
try { await supabase.from('send_fcm_errors').insert({ payload: rows ?? null, error: 'step4a', stack: null }); } catch (_) {}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (body.record && body.record.user_id) {
|
||||||
|
try {
|
||||||
|
const { data: rows } = await supabase
|
||||||
|
.from('fcm_tokens')
|
||||||
|
.select('token')
|
||||||
|
.eq('user_id', body.record.user_id);
|
||||||
|
if (rows) tokens = rows.map((r: any) => r.token);
|
||||||
|
try { await supabase.from('send_fcm_errors').insert({ payload: rows ?? null, error: 'step4b', stack: null }); } catch (_) {}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
try { await supabase.from('send_fcm_errors').insert({ payload: tokens, error: 'step3', stack: null }); } catch (_) {}
|
||||||
|
|
||||||
|
// build notification text
|
||||||
|
let notificationTitle = '';
|
||||||
|
let notificationBody = '';
|
||||||
|
if (body.record) {
|
||||||
|
notificationTitle = `New ${body.record.type}`;
|
||||||
|
notificationBody = 'You have a new update in TasQ.';
|
||||||
|
if (body.record.ticket_id) {
|
||||||
|
notificationBody = 'You have a new update on your ticket.';
|
||||||
|
} else if (body.record.task_id) {
|
||||||
|
notificationBody = 'You have a new update on your task.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try { await supabase.from('send_fcm_errors').insert({ payload: {title: notificationTitle, body: notificationBody}, error: 'step5', stack: null }); } catch (_) {}
|
||||||
|
|
||||||
|
// use legacy server key for FCM send
|
||||||
|
const serverKey = Deno.env.get('FCM_SERVER_KEY');
|
||||||
|
if (serverKey && tokens.length) {
|
||||||
|
const sendPromises = tokens.map((tok) => {
|
||||||
|
return fetch('https://fcm.googleapis.com/fcm/send', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `key=${serverKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ to: tok, notification: { title: notificationTitle, body: notificationBody }, data: body.data || {} }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(sendPromises);
|
||||||
|
try { await supabase.from('send_fcm_errors').insert({ payload: results, error: 'step8', stack: null }); } catch (_) {}
|
||||||
|
} catch (e) {
|
||||||
|
try { await supabase.from('send_fcm_errors').insert({ payload: e.toString(), error: 'step8error', stack: null }); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return new Response('ok', { headers: { 'Access-Control-Allow-Origin': '*' } });
|
||||||
|
});
|
||||||
11
supabase/migrations/20260223130000_add_fcm_tokens.sql
Normal file
11
supabase/migrations/20260223130000_add_fcm_tokens.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- create a table to hold FCM device tokens for push notifications
|
||||||
|
|
||||||
|
create table if not exists public.fcm_tokens (
|
||||||
|
id uuid default uuid_generate_v4() primary key,
|
||||||
|
user_id uuid references auth.users(id) on delete cascade,
|
||||||
|
token text not null,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index if not exists fcm_tokens_user_token_idx
|
||||||
|
on public.fcm_tokens(user_id, token);
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
-- enable RLS and set policies for fcm_tokens
|
||||||
|
|
||||||
|
ALTER TABLE public.fcm_tokens ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- allow users to insert their own tokens
|
||||||
|
CREATE POLICY "Allow users insert their tokens" ON public.fcm_tokens
|
||||||
|
FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- allow users to delete their own tokens (e.g. sign out)
|
||||||
|
CREATE POLICY "Allow users delete their tokens" ON public.fcm_tokens
|
||||||
|
FOR DELETE USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- allow users to select their own tokens (if needed for debugging)
|
||||||
|
CREATE POLICY "Allow users select their tokens" ON public.fcm_tokens
|
||||||
|
FOR SELECT USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- optionally allow update of token value for same user
|
||||||
|
CREATE POLICY "Allow users update their tokens" ON public.fcm_tokens
|
||||||
|
FOR UPDATE USING (auth.uid() = user_id)
|
||||||
|
WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- drop unique index to allow multiple entries (e.g. same token across
|
||||||
|
-- device logins) for a single user. the application already keeps tokens
|
||||||
|
-- tidy by unregistering on sign-out, so duplicates are harmless.
|
||||||
|
|
||||||
|
drop index if exists fcm_tokens_user_token_idx;
|
||||||
|
|
||||||
|
-- maintain a regular index on user_id for performance
|
||||||
|
create index if not exists fcm_tokens_user_idx on public.fcm_tokens(user_id);
|
||||||
35
supabase/migrations/20260224130000_notifications_trigger.sql
Normal file
35
supabase/migrations/20260224130000_notifications_trigger.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
-- create a trigger that calls the `send_fcm` edge function whenever a
|
||||||
|
-- new row is inserted into `notifications`. This moves push logic entirely
|
||||||
|
-- to the backend, bypassing any CORS or auth issues that occur when web
|
||||||
|
-- clients try to invoke the function directly.
|
||||||
|
|
||||||
|
-- the http extension is available on Supabase databases; enable it if
|
||||||
|
-- it's not already installed.
|
||||||
|
create extension if not exists http;
|
||||||
|
|
||||||
|
create or replace function public.notifications_send_fcm_trigger()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql as $$
|
||||||
|
declare
|
||||||
|
_url text := 'https://pwbxgsuskvqwwaejxutj.supabase.co/functions/v1/send_fcm';
|
||||||
|
_body text;
|
||||||
|
begin
|
||||||
|
-- build a webhook-style payload that matches what the edge function
|
||||||
|
-- expects when it's invoked from a database trigger.
|
||||||
|
_body := json_build_object('record', row_to_json(NEW))::text;
|
||||||
|
|
||||||
|
-- fire the POST; ignore the result since we don't want inserts to fail
|
||||||
|
-- just because the push call returned an error.
|
||||||
|
perform http_post(_url, _body, 'content-type=application/json');
|
||||||
|
|
||||||
|
return NEW;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- drop any previous trigger (defensive) and create a new one.
|
||||||
|
drop trigger if exists trig_notifications_send_fcm on public.notifications;
|
||||||
|
|
||||||
|
create trigger trig_notifications_send_fcm
|
||||||
|
after insert on public.notifications
|
||||||
|
for each row
|
||||||
|
execute function public.notifications_send_fcm_trigger();
|
||||||
11
supabase/migrations/20260224131500_send_fcm_errors_table.sql
Normal file
11
supabase/migrations/20260224131500_send_fcm_errors_table.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- table for capturing errors that occur inside the send_fcm edge function
|
||||||
|
-- when it is invoked via database trigger. This makes it easier to debug
|
||||||
|
-- production failures without needing to inspect external logs.
|
||||||
|
|
||||||
|
create table if not exists public.send_fcm_errors (
|
||||||
|
id uuid default gen_random_uuid() primary key,
|
||||||
|
payload jsonb,
|
||||||
|
error text,
|
||||||
|
stack text,
|
||||||
|
created_at timestamptz default now()
|
||||||
|
);
|
||||||
|
|
@ -35,7 +35,28 @@
|
||||||
<!-- Use a pdfjs-dist version compatible with the printing package (API/Worker versions must match) -->
|
<!-- Use a pdfjs-dist version compatible with the printing package (API/Worker versions must match) -->
|
||||||
<script src="https://unpkg.com/pdfjs-dist@3.2.146/build/pdf.min.js"></script>
|
<script src="https://unpkg.com/pdfjs-dist@3.2.146/build/pdf.min.js"></script>
|
||||||
<script src="https://unpkg.com/pdfjs-dist@3.2.146/build/pdf.worker.min.js"></script>
|
<script src="https://unpkg.com/pdfjs-dist@3.2.146/build/pdf.worker.min.js"></script>
|
||||||
<script src="packages/printing/printing.js"></script>
|
<script>
|
||||||
|
// Load printing.js only if the file exists to avoid 404/MIME errors in strict browsers.
|
||||||
|
(function() {
|
||||||
|
var localPath = 'packages/printing/printing.js';
|
||||||
|
try {
|
||||||
|
fetch(localPath, { method: 'HEAD' }).then(function(resp) {
|
||||||
|
var contentType = resp.headers.get('content-type') || '';
|
||||||
|
if (resp.ok && contentType.indexOf('javascript') !== -1) {
|
||||||
|
var s = document.createElement('script');
|
||||||
|
s.src = localPath;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
} else {
|
||||||
|
console.warn('printing.js not found locally; skipping load to avoid 404/MIME errors.');
|
||||||
|
}
|
||||||
|
}).catch(function() {
|
||||||
|
console.warn('Error checking for printing.js; skipping script load.');
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Exception while attempting to load printing.js', e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="flutter_bootstrap.js" async></script>
|
<script src="flutter_bootstrap.js" async></script>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user