diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 8c589a69..254d71a3 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,3 +1,8 @@ +import com.android.build.gradle.LibraryExtension +import org.gradle.api.tasks.Delete +import com.android.build.gradle.BaseExtension +import org.gradle.api.JavaVersion + allprojects { repositories { google() @@ -19,6 +24,50 @@ subprojects { project.evaluationDependsOn(":app") } +// Ensure older plugin modules that don't declare a `namespace` still build with AGP. +subprojects.forEach { project -> + project.plugins.withId("com.android.library") { + try { + val androidExt = project.extensions.findByName("android") as? LibraryExtension + if (androidExt != null) { + try { + val current = androidExt.namespace + if (current.isNullOrBlank()) { + androidExt.namespace = "com.tasq.${project.name.replace('-', '_')}" + } + } catch (e: Throwable) { + try { + androidExt.namespace = "com.tasq.${project.name.replace('-', '_')}" + } catch (_: Throwable) {} + } + } + } catch (_: Throwable) {} + } +} + + +// Ensure Kotlin JVM target matches Java compatibility to avoid mismatches in third-party modules. +// Align Java compile options to Java 17 for Android modules so Kotlin JVM target mismatch is avoided. +subprojects.forEach { project -> + project.plugins.withId("com.android.library") { + val androidExt = project.extensions.findByName("android") as? BaseExtension + if (androidExt != null) { + try { + androidExt.compileOptions.sourceCompatibility = JavaVersion.VERSION_17 + androidExt.compileOptions.targetCompatibility = JavaVersion.VERSION_17 + } catch (_: Throwable) {} + } + } + project.plugins.withId("com.android.application") { + val androidExt = project.extensions.findByName("android") as? BaseExtension + if (androidExt != null) { + try { + androidExt.compileOptions.sourceCompatibility = JavaVersion.VERSION_17 + androidExt.compileOptions.targetCompatibility = JavaVersion.VERSION_17 + } catch (_: Throwable) {} + } + } +} tasks.register("clean") { delete(rootProject.layout.buildDirectory) diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html index 2b7cf0af..2a73be04 100644 --- a/android/build/reports/problems/problems-report.html +++ b/android/build/reports/problems/problems-report.html @@ -650,7 +650,7 @@ code + .copy-button { diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index 513dbff5..548375b4 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -198,8 +198,9 @@ List> _processTasksInIsolate( {}; if (!isGlobal) { - if (allowedTicketIds.isEmpty && allowedOfficeIds.isEmpty) + if (allowedTicketIds.isEmpty && allowedOfficeIds.isEmpty) { return >[]; + } list = list.where((t) { final tid = t['ticket_id'] as String?; final oid = t['office_id'] as String?; @@ -262,7 +263,7 @@ List> _processTasksInIsolate( return int.tryParse(m.group(0)!); } - int _parseCreatedAt(Map m) { + int parseCreatedAt(Map m) { final v = m['created_at']; if (v == null) return 0; if (v is int) return v; @@ -293,11 +294,11 @@ List> _processTasksInIsolate( final bOrder = (b['queue_order'] as int?) ?? 0x7fffffff; final qcmp = aOrder.compareTo(bOrder); if (qcmp != 0) return qcmp; - return _parseCreatedAt(a).compareTo(_parseCreatedAt(b)); + return parseCreatedAt(a).compareTo(parseCreatedAt(b)); } if (ra == 1) { - return _parseCreatedAt(a).compareTo(_parseCreatedAt(b)); + return parseCreatedAt(a).compareTo(parseCreatedAt(b)); } if (ra == 2) { @@ -306,14 +307,14 @@ List> _processTasksInIsolate( if (an != null && bn != null) return bn.compareTo(an); if (an != null) return -1; if (bn != null) return 1; - return _parseCreatedAt(b).compareTo(_parseCreatedAt(a)); + return parseCreatedAt(b).compareTo(parseCreatedAt(a)); } final aOrder = (a['queue_order'] as int?) ?? 0x7fffffff; final bOrder = (b['queue_order'] as int?) ?? 0x7fffffff; final cmp = aOrder.compareTo(bOrder); if (cmp != 0) return cmp; - return _parseCreatedAt(a).compareTo(_parseCreatedAt(b)); + return parseCreatedAt(a).compareTo(parseCreatedAt(b)); }); return list; diff --git a/lib/providers/tickets_provider.dart b/lib/providers/tickets_provider.dart index e3086437..bd638bb6 100644 --- a/lib/providers/tickets_provider.dart +++ b/lib/providers/tickets_provider.dart @@ -185,7 +185,9 @@ List> _processTicketsInIsolate( {}; if (!isGlobal) { - if (allowedOfficeIds.isEmpty) return >[]; + if (allowedOfficeIds.isEmpty) { + return >[]; + } list = list .where((t) => allowedOfficeIds.contains(t['office_id'])) .toList(); @@ -212,7 +214,7 @@ List> _processTicketsInIsolate( // Sort newest first. `created_at` may be ISO strings or timestamps; // handle strings and numeric values. - int _parseCreatedAt(Map m) { + int parseCreatedAt(Map m) { final v = m['created_at']; if (v == null) return 0; if (v is int) return v; @@ -227,7 +229,7 @@ List> _processTicketsInIsolate( return 0; } - list.sort((a, b) => _parseCreatedAt(b).compareTo(_parseCreatedAt(a))); + list.sort((a, b) => parseCreatedAt(b).compareTo(parseCreatedAt(a))); final start = (payload['offset'] as int?) ?? 0; final limit = (payload['limit'] as int?) ?? 50; diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index 037a1efc..012b588b 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -33,7 +33,7 @@ final appRouterProvider = Provider((ref) { refreshListenable: notifier, redirect: (context, state) { final authState = ref.read(authStateChangesProvider); - var session; + dynamic session; if (authState is AsyncData) { final state = authState.value; session = state?.session; diff --git a/lib/screens/dashboard/dashboard_screen.dart b/lib/screens/dashboard/dashboard_screen.dart index af74f552..b118119b 100644 --- a/lib/screens/dashboard/dashboard_screen.dart +++ b/lib/screens/dashboard/dashboard_screen.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:permission_handler/permission_handler.dart'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/profile.dart'; @@ -299,9 +302,49 @@ final dashboardMetricsProvider = Provider>((ref) { ); }); -class DashboardScreen extends StatelessWidget { +class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); + @override + State createState() => _DashboardScreenState(); +} + +class _DashboardScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + final prefs = await SharedPreferences.getInstance(); + final seen = prefs.getBool('has_seen_notif_showcase') ?? false; + if (!seen) { + if (!mounted) return; + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Never miss an update'), + content: const Text( + 'Ensure notification sounds and vibration are enabled for important alerts.', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + openAppSettings(); + }, + child: const Text('Open settings'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Got it'), + ), + ], + ), + ); + await prefs.setBool('has_seen_notif_showcase', true); + } + }); + } + @override Widget build(BuildContext context) { final realtime = ProviderScope.containerOf( diff --git a/lib/screens/notifications/notifications_screen.dart b/lib/screens/notifications/notifications_screen.dart index 3dec026d..e74b5272 100644 --- a/lib/screens/notifications/notifications_screen.dart +++ b/lib/screens/notifications/notifications_screen.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../../services/notification_service.dart'; import '../../providers/notifications_provider.dart'; import '../../providers/profile_provider.dart'; @@ -10,11 +13,34 @@ import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; import '../../theme/app_surfaces.dart'; -class NotificationsScreen extends ConsumerWidget { +class NotificationsScreen extends ConsumerStatefulWidget { const NotificationsScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => + _NotificationsScreenState(); +} + +class _NotificationsScreenState extends ConsumerState { + bool _showBanner = false; + bool _dismissed = false; + + @override + void initState() { + super.initState(); + _checkChannel(); + } + + Future _checkChannel() async { + final muted = await NotificationService.isHighPriorityChannelMuted(); + if (!mounted) return; + if (muted) { + setState(() => _showBanner = true); + } + } + + @override + Widget build(BuildContext context) { final notificationsAsync = ref.watch(notificationsProvider); final profilesAsync = ref.watch(profilesProvider); final ticketsAsync = ref.watch(ticketsProvider); @@ -49,6 +75,29 @@ class NotificationsScreen extends ConsumerWidget { ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), ), ), + if (_showBanner && !_dismissed) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: MaterialBanner( + content: const Text( + 'Push notifications are currently silenced. Tap here to fix.', + ), + actions: [ + TextButton( + onPressed: () { + openAppSettings(); + }, + child: const Text('Open settings'), + ), + TextButton( + onPressed: () { + setState(() => _dismissed = true); + }, + child: const Text('Dismiss'), + ), + ], + ), + ), Expanded( child: ListView.separated( padding: const EdgeInsets.only(bottom: 24), diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 8007484d..d6cd067e 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -94,4 +94,33 @@ class NotificationService { payload: payload, ); } + + /// Returns true when the high-priority channel appears to be muted or + /// has sound disabled on the device. + static Future isHighPriorityChannelMuted() async { + final androidImpl = _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + if (androidImpl == null) return false; + try { + final impl = androidImpl as dynamic; + final channel = await impl.getNotificationChannel(_highChannelId); + if (channel == null) return false; + final importance = channel.importance as dynamic; + final playSound = channel.playSound as bool?; + if (importance == null) return false; + // importance may be an enum-like value; compare using index if present. + try { + final impIndex = (importance.index is int) + ? importance.index as int + : int.parse(importance.toString()); + if (impIndex < Importance.high.index) return true; + } catch (_) {} + if (playSound == false) return true; + return false; + } catch (_) { + return false; + } + } } diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index a787a26f..e01a1464 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +// showcaseview removed due to null-safety incompatibility; onboarding shown via dialog import '../providers/auth_provider.dart'; import '../providers/notifications_provider.dart'; import '../providers/profile_provider.dart'; import 'app_breakpoints.dart'; +final GlobalKey notificationBellKey = GlobalKey(); + class AppScaffold extends ConsumerWidget { const AppScaffold({super.key, required this.child});