* Push Notification Setup and attempt

* Office Ordering
* Allow editing of Task and Ticket Details after creation
This commit is contained in:
Marc Rejohn Castillano 2026-02-24 21:06:46 +08:00
parent cc6fda0e79
commit 5979a04254
25 changed files with 1130 additions and 91 deletions

View File

@ -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")

View 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"
}

View File

@ -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
View 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
View 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',
);
}

View File

@ -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 reenabled 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);
}
});
}
}
}
}

View File

@ -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');
}
}
}

View File

@ -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,

View File

@ -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 {

View File

@ -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(),
},
),
],
);

View File

@ -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,

View File

@ -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,

View File

@ -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)

View File

@ -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),

View File

@ -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,

View 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;
}
}

View File

@ -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:

View File

@ -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:

View 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': '*' } });
});

View 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);

View File

@ -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);

View File

@ -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);

View 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();

View 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()
);

View File

@ -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>