* 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 {
|
||||
id("com.android.application")
|
||||
// START: FlutterFire Configuration
|
||||
id("com.google.gms.google-services")
|
||||
// END: FlutterFire Configuration
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
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 {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
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',
|
||||
);
|
||||
}
|
||||
104
lib/main.dart
104
lib/main.dart
|
|
@ -1,18 +1,45 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
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');
|
||||
|
|
@ -27,6 +54,29 @@ Future<void> main() async {
|
|||
|
||||
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
|
||||
// notifications are automatically denied and cannot be re‑enabled from the
|
||||
// system settings. The helper uses `permission_handler`.
|
||||
|
|
@ -37,6 +87,20 @@ Future<void> main() async {
|
|||
// 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) {
|
||||
|
|
@ -50,16 +114,23 @@ Future<void> main() async {
|
|||
},
|
||||
);
|
||||
|
||||
// global navigator key used for snackbars/navigation from notification
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
observers: [NotificationSoundObserver()],
|
||||
child: const TasqApp(),
|
||||
child: NotificationBridge(
|
||||
navigatorKey: navigatorKey,
|
||||
child: const TasqApp(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class NotificationSoundObserver extends ProviderObserver {
|
||||
static final AudioPlayer _player = AudioPlayer();
|
||||
StreamSubscription<String?>? _tokenSub;
|
||||
|
||||
@override
|
||||
void didUpdateProvider(
|
||||
|
|
@ -68,12 +139,12 @@ class NotificationSoundObserver extends ProviderObserver {
|
|||
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'));
|
||||
// also post a system notification so the user sees it outside the app
|
||||
NotificationService.show(
|
||||
id: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
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:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
|
|
@ -40,6 +41,72 @@ class NotificationsController {
|
|||
|
||||
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({
|
||||
required List<String> userIds,
|
||||
required String actorId,
|
||||
|
|
@ -47,24 +114,22 @@ class NotificationsController {
|
|||
String? ticketId,
|
||||
String? taskId,
|
||||
}) async {
|
||||
if (userIds.isEmpty) return;
|
||||
if ((ticketId == null || ticketId.isEmpty) &&
|
||||
(taskId == null || taskId.isEmpty)) {
|
||||
return;
|
||||
}
|
||||
final rows = userIds
|
||||
.map(
|
||||
(userId) => {
|
||||
'user_id': userId,
|
||||
'actor_id': actorId,
|
||||
'ticket_id': ticketId,
|
||||
'task_id': taskId,
|
||||
'message_id': messageId,
|
||||
'type': 'mention',
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
await _client.from('notifications').insert(rows);
|
||||
return createNotification(
|
||||
userIds: userIds,
|
||||
type: 'mention',
|
||||
actorId: actorId,
|
||||
fields: {
|
||||
'message_id': messageId,
|
||||
if (ticketId != null) 'ticket_id': ticketId,
|
||||
if (taskId != null) 'task_id': taskId,
|
||||
},
|
||||
pushTitle: 'New mention',
|
||||
pushBody: 'You were mentioned in a message',
|
||||
pushData: {
|
||||
if (ticketId != null) 'ticket_id': ticketId,
|
||||
if (taskId != null) 'task_id': taskId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> markRead(String id) async {
|
||||
|
|
@ -95,4 +160,85 @@ class NotificationsController {
|
|||
.eq('user_id', userId)
|
||||
.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);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
Future<void> _autoAssignTask({
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -212,22 +212,36 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
|||
if (_selectedOfficeIds.isEmpty)
|
||||
const Text('No office selected.')
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _selectedOfficeIds.map((id) {
|
||||
final name = officeNameById[id] ?? id;
|
||||
return Chip(
|
||||
label: Text(name),
|
||||
onDeleted: _isLoading
|
||||
? null
|
||||
: () {
|
||||
setState(
|
||||
() => _selectedOfficeIds.remove(id),
|
||||
);
|
||||
},
|
||||
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,
|
||||
runSpacing: 8,
|
||||
children: sortedIds.map((id) {
|
||||
final name = officeNameById[id] ?? id;
|
||||
return Chip(
|
||||
label: Text(name),
|
||||
onDeleted: _isLoading
|
||||
? null
|
||||
: () {
|
||||
setState(
|
||||
() =>
|
||||
_selectedOfficeIds.remove(id),
|
||||
);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}).toList(),
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import '../../models/task_assignment.dart';
|
|||
import '../../models/task_activity_log.dart';
|
||||
import '../../models/ticket.dart';
|
||||
import '../../models/ticket_message.dart';
|
||||
import '../../models/office.dart';
|
||||
import '../../providers/notifications_provider.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
|
@ -22,6 +23,7 @@ import '../../providers/tasks_provider.dart';
|
|||
import '../../providers/tickets_provider.dart';
|
||||
import '../../providers/typing_provider.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
import '../../widgets/app_breakpoints.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
|
|
@ -29,7 +31,6 @@ import '../../widgets/status_pill.dart';
|
|||
import '../../theme/app_surfaces.dart';
|
||||
import '../../widgets/task_assignment_section.dart';
|
||||
import '../../widgets/typing_dots.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
|
||||
// Simple image embed builder to support data-URI and network images
|
||||
class _ImageEmbedBuilder extends quill.EmbedBuilder {
|
||||
|
|
@ -315,16 +316,41 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
final detailsContent = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
task.title.isNotEmpty
|
||||
? task.title
|
||||
: 'Task ${task.taskNumber ?? task.id}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
task.title.isNotEmpty
|
||||
? task.title
|
||||
: 'Task ${task.taskNumber ?? task.id}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).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),
|
||||
|
|
@ -2143,31 +2169,15 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
: assignedForTask.last;
|
||||
|
||||
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;
|
||||
for (final l in logs) {
|
||||
if (l.actionType == 'started' && latestAssignment != null) {
|
||||
if (l.actorId == latestAssignment.userId &&
|
||||
l.createdAt.isAfter(latestAssignment.createdAt)) {
|
||||
startedByAssignee = l.createdAt;
|
||||
break;
|
||||
if (latestAssignment != null) {
|
||||
for (final l in logs) {
|
||||
if (l.actionType == 'started') {
|
||||
if (l.actorId == latestAssignment.userId &&
|
||||
l.createdAt.isAfter(latestAssignment.createdAt)) {
|
||||
startedByAssignee = l.createdAt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2178,7 +2188,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
final assignedAt = latestAssignment.createdAt;
|
||||
final candidates = <DateTime>[];
|
||||
if (firstMessageByAssignee != null) {
|
||||
candidates.add(firstMessageByAssignee!);
|
||||
candidates.add(firstMessageByAssignee);
|
||||
}
|
||||
if (startedByAssignee != null) {
|
||||
candidates.add(startedByAssignee);
|
||||
|
|
@ -2693,6 +2703,100 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
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) {
|
||||
return tasksAsync.maybeWhen(
|
||||
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.'));
|
||||
}
|
||||
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?>>[
|
||||
const DropdownMenuItem<String?>(
|
||||
value: null,
|
||||
child: Text('All offices'),
|
||||
),
|
||||
...offices.map(
|
||||
...officesSorted.map(
|
||||
(office) => DropdownMenuItem<String?>(
|
||||
value: office.id,
|
||||
child: Text(office.name),
|
||||
|
|
@ -461,7 +466,13 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
if (offices.isEmpty) {
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
|
@ -470,7 +481,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
decoration: const InputDecoration(
|
||||
labelText: 'Office',
|
||||
),
|
||||
items: offices
|
||||
items: officesSorted
|
||||
.map(
|
||||
(office) => DropdownMenuItem(
|
||||
value: office.id,
|
||||
|
|
|
|||
|
|
@ -163,9 +163,13 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
|
|||
TasQColumn<Team>(
|
||||
header: 'Offices',
|
||||
cellBuilder: (context, team) {
|
||||
final officeNames = team.officeIds
|
||||
final officeNamesList = team.officeIds
|
||||
.map((id) => officeById[id]?.name ?? id)
|
||||
.join(', ');
|
||||
.toList();
|
||||
officeNamesList.sort(
|
||||
(a, b) => a.toLowerCase().compareTo(b.toLowerCase()),
|
||||
);
|
||||
final officeNames = officeNamesList.join(', ');
|
||||
return Text(officeNames);
|
||||
},
|
||||
),
|
||||
|
|
@ -182,9 +186,13 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
|
|||
],
|
||||
mobileTileBuilder: (context, team, actions) {
|
||||
final leader = profileById[team.leaderId];
|
||||
final officeNames = team.officeIds
|
||||
final officeNamesList = team.officeIds
|
||||
.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 memberNames = members
|
||||
.map((id) => profileById[id]?.fullName ?? id)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import '../../providers/profile_provider.dart';
|
|||
import '../../providers/tasks_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../providers/typing_provider.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
import '../../widgets/app_breakpoints.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
|
|
@ -95,14 +96,40 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||
final detailsContent = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
ticket.subject,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
ticket.subject,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).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),
|
||||
|
|
@ -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) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
|
|
|
|||
|
|
@ -70,12 +70,17 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
|||
};
|
||||
final unreadByTicketId = _unreadByTicketId(notificationsAsync);
|
||||
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?>>[
|
||||
const DropdownMenuItem<String?>(
|
||||
value: null,
|
||||
child: Text('All offices'),
|
||||
),
|
||||
...offices.map(
|
||||
...officesSorted.map(
|
||||
(office) => DropdownMenuItem<String?>(
|
||||
value: office.id,
|
||||
child: Text(office.name),
|
||||
|
|
@ -356,11 +361,17 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
|||
if (offices.isEmpty) {
|
||||
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>(
|
||||
key: ValueKey(selectedOffice?.id),
|
||||
initialValue: selectedOffice,
|
||||
items: offices
|
||||
items: officesSorted
|
||||
.map(
|
||||
(office) => DropdownMenuItem(
|
||||
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
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_flutterfire_internals:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _flutterfire_internals
|
||||
sha256: cd83f7d6bd4e4c0b0b4fef802e8796784032e1cc23d7b0e982cf5d05d9bbe182
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.66"
|
||||
adaptive_number:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -345,6 +353,54 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -24,11 +24,13 @@ dependencies:
|
|||
flutter_quill: ^11.5.0
|
||||
file_picker: ^10.3.10
|
||||
pdf: ^3.11.3
|
||||
printing: ^5.10.0
|
||||
printing: ^5.14.2
|
||||
flutter_keyboard_visibility: ^5.4.1
|
||||
awesome_snackbar_content: ^0.1.8
|
||||
permission_handler: ^12.0.1
|
||||
flutter_local_notifications: ^20.1.0
|
||||
firebase_core: ^4.4.0
|
||||
firebase_messaging: ^16.1.1
|
||||
|
||||
dev_dependencies:
|
||||
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) -->
|
||||
<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="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>
|
||||
<body>
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user