diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d2f5ba6b..63cbf522 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -13,6 +13,9 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 + // required by flutter_local_notifications and other libraries using + // Java 8+ APIs at runtime + isCoreLibraryDesugaringEnabled = true } kotlinOptions { @@ -37,6 +40,14 @@ android { signingConfig = signingConfigs.getByName("debug") } } + + // allow desugaring of Java 8+ library APIs + +} + +// dependencies section for additional compile-only libraries +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") } flutter { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 60864924..f3895c1c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,10 @@ + + main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -25,6 +27,29 @@ Future main() async { await Supabase.initialize(url: supabaseUrl, anonKey: supabaseAnonKey); + // 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`. + final granted = await ensureNotificationPermission(); + if (!granted) { + // we don’t block startup, but it’s worth logging so developers notice. A + // real app might show a dialog pointing the user to settings. + // debugPrint('notification permission not granted'); + } + + // initialize the local notifications plugin so we can post alerts later + await NotificationService.initialize( + onDidReceiveNotificationResponse: (response) { + // handle user tapping a notification; the payload format is up to us + final payload = response.payload; + if (payload != null && payload.startsWith('ticket:')) { + // ignore if context not mounted; we might use a navigator key in real + // app, but keep this simple for now + // TODO: navigate to ticket/task as appropriate + } + }, + ); + runApp( ProviderScope( observers: [NotificationSoundObserver()], @@ -48,6 +73,12 @@ class NotificationSoundObserver extends ProviderObserver { 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', + body: 'You have $next unread notifications.', + ); } } } diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index c6c6b191..0f86d0b0 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -15,6 +15,7 @@ import '../screens/dashboard/dashboard_screen.dart'; import '../screens/notifications/notifications_screen.dart'; import '../screens/profile/profile_screen.dart'; import '../screens/shared/under_development_screen.dart'; +import '../screens/shared/permissions_screen.dart'; import '../screens/tasks/task_detail_screen.dart'; import '../screens/tasks/tasks_list_screen.dart'; import '../screens/tickets/ticket_detail_screen.dart'; @@ -138,6 +139,10 @@ final appRouterProvider = Provider((ref) { path: '/settings/geofence-test', builder: (context, state) => const GeofenceTestScreen(), ), + GoRoute( + path: '/settings/permissions', + builder: (context, state) => const PermissionsScreen(), + ), GoRoute( path: '/notifications', builder: (context, state) => const NotificationsScreen(), diff --git a/lib/screens/shared/permissions_screen.dart b/lib/screens/shared/permissions_screen.dart new file mode 100644 index 00000000..cd7055a1 --- /dev/null +++ b/lib/screens/shared/permissions_screen.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../../services/permission_service.dart'; + +/// A simple screen showing every permission the app declares in +/// [appPermissions] along with its current status. Users can tap "Grant" to +/// request a permission, or open the system settings if it has been permanently +/// denied. +class PermissionsScreen extends ConsumerStatefulWidget { + const PermissionsScreen({super.key}); + + @override + ConsumerState createState() => _PermissionsScreenState(); +} + +class _PermissionsScreenState extends ConsumerState { + late Map _statuses; + bool _loading = true; + + @override + void initState() { + super.initState(); + _refreshStatuses(); + } + + Future _refreshStatuses() async { + setState(() => _loading = true); + final statuses = await getAllStatuses(); + setState(() { + _statuses = statuses; + _loading = false; + }); + } + + Future _request(Permission permission) async { + final status = await requestPermission(permission); + setState(() { + _statuses[permission] = status; + }); + } + + @override + Widget build(BuildContext context) { + if (_loading) { + return Scaffold( + appBar: AppBar(title: const Text('Permissions')), + body: const Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + appBar: AppBar(title: const Text('Permissions')), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: appPermissions.length, + separatorBuilder: (context, index) => const Divider(), + itemBuilder: (context, index) { + final info = appPermissions[index]; + final status = _statuses[info.permission]; + final granted = status?.isGranted == true; + final permanent = status?.isPermanentlyDenied == true; + + return ListTile( + title: Text(info.label), + subtitle: Text(status?.toString() ?? 'unknown'), + trailing: granted + ? const Icon(Icons.check, color: Colors.green) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (permanent) + TextButton( + onPressed: openAppSettings, + child: const Text('Settings'), + ), + TextButton( + onPressed: () => _request(info.permission), + child: const Text('Grant'), + ), + ], + ), + ); + }, + ), + floatingActionButton: FloatingActionButton( + onPressed: _refreshStatuses, + tooltip: 'Refresh', + child: const Icon(Icons.refresh), + ), + ); + } +} diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart new file mode 100644 index 00000000..290d3708 --- /dev/null +++ b/lib/services/notification_service.dart @@ -0,0 +1,56 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +/// A thin wrapper around `flutter_local_notifications` that centralizes the +/// plugin instance and provides easy initialization/show helpers. +class NotificationService { + NotificationService._(); + + static final FlutterLocalNotificationsPlugin _plugin = + FlutterLocalNotificationsPlugin(); + + /// Call during app startup, after any necessary permissions have been + /// granted. The callback receives a [NotificationResponse] when the user + /// taps the alert. + static Future initialize({ + void Function(NotificationResponse)? onDidReceiveNotificationResponse, + }) async { + const androidSettings = AndroidInitializationSettings( + '@mipmap/ic_launcher', + ); + const iosSettings = DarwinInitializationSettings(); + + await _plugin.initialize( + settings: const InitializationSettings( + android: androidSettings, + iOS: iosSettings, + ), + onDidReceiveNotificationResponse: onDidReceiveNotificationResponse, + ); + } + + /// Show a simple notification immediately. + static Future show({ + required int id, + required String title, + required String body, + String? payload, + }) async { + const androidDetails = AndroidNotificationDetails( + 'default_channel', + 'General', + importance: Importance.high, + priority: Priority.high, + ); + const iosDetails = DarwinNotificationDetails(); + await _plugin.show( + id: id, + title: title, + body: body, + notificationDetails: const NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ), + payload: payload, + ); + } +} diff --git a/lib/services/permission_service.dart b/lib/services/permission_service.dart new file mode 100644 index 00000000..721a3b6a --- /dev/null +++ b/lib/services/permission_service.dart @@ -0,0 +1,66 @@ +import 'package:permission_handler/permission_handler.dart'; + +/// Information about a permission that the application cares about. +/// +/// Add new entries to [appPermissions] when the app requires additional +/// runtime permissions. The UI that drives the "permissions screen" reads this +/// list and will automatically include new permissions without any further +/// changes. +class PermissionInfo { + const PermissionInfo({ + required this.permission, + required this.label, + this.description, + }); + + final Permission permission; + final String label; + final String? description; +} + +/// The set of permissions the app will check/request from the user. +/// +/// Currently contains the permissions actually referenced in the codebase, but +/// it should be expanded as new features are added. Declaring a permission +/// here ensures it appears on the permissions screen and is considered when +/// performing bulk checks/requests. +const List appPermissions = [ + PermissionInfo( + permission: Permission.notification, + label: 'Notifications', + description: 'Deliver alerts and sounds', + ), + PermissionInfo( + permission: Permission.location, + label: 'Location', + description: 'Access your location for geofencing', + ), + // add new permissions here as necessary +]; + +/// Returns the current status for every permission in [appPermissions]. +Future> getAllStatuses() async { + final map = {}; + for (final info in appPermissions) { + map[info.permission] = await info.permission.status; + } + return map; +} + +/// Requests [permission] and returns the resulting status. +Future requestPermission(Permission permission) async { + return permission.request(); +} + +/// Convenience that requests every permission that is currently not granted. +/// +/// Returns the full status map after requesting. +Future> requestAllNeeded() async { + final statuses = await getAllStatuses(); + for (final entry in statuses.entries) { + if (!entry.value.isGranted) { + statuses[entry.key] = await entry.key.request(); + } + } + return statuses; +} diff --git a/lib/utils/notification_permission.dart b/lib/utils/notification_permission.dart new file mode 100644 index 00000000..c3de8623 --- /dev/null +++ b/lib/utils/notification_permission.dart @@ -0,0 +1,23 @@ +import 'package:permission_handler/permission_handler.dart'; + +import '../services/permission_service.dart'; + +/// Helpers for requesting and checking the platform notification permission. +/// +/// This file exists mostly for backwards‑compatibility; the real logic lives in +/// [permission_service]. + +Future requestNotificationPermission() async { + return requestPermission(Permission.notification); +} + +Future ensureNotificationPermission() async { + final status = await Permission.notification.status; + if (status.isGranted) return true; + if (status.isDenied || status.isRestricted || status.isLimited) { + final newStatus = await requestNotificationPermission(); + return newStatus.isGranted; + } + // permanently denied requires user to open settings + return false; +} diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 1cb0f31f..a787a26f 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -387,6 +387,12 @@ List _buildSections(String role) { icon: Icons.groups_2_outlined, selectedIcon: Icons.groups_2, ), + NavItem( + label: 'Permissions', + route: '/settings/permissions', + icon: Icons.lock_open, + selectedIcon: Icons.lock, + ), NavItem( label: 'Logout', route: '', @@ -398,7 +404,23 @@ List _buildSections(String role) { ]; } - return [NavSection(label: 'Operations', items: mainItems)]; + // non-admin users still get a simple Settings section containing only + // permissions. this keeps the screen accessible without exposing the + // administrative management screens. + return [ + NavSection(label: 'Operations', items: mainItems), + NavSection( + label: 'Settings', + items: [ + NavItem( + label: 'Permissions', + route: '/settings/permissions', + icon: Icons.lock_open, + selectedIcon: Icons.lock, + ), + ], + ), + ]; } List _standardNavItems() { diff --git a/pubspec.lock b/pubspec.lock index 5d3b36bc..8658d015 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -446,6 +446,38 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "2b50e938a275e1ad77352d6a25e25770f4130baa61eaf02de7a9a884680954ad" + url: "https://pub.dev" + source: hosted + version: "20.1.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0 + url: "https://pub.dev" + source: hosted + version: "7.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899" + url: "https://pub.dev" + source: hosted + version: "10.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: e97a1a3016512437d9c0b12fae7d1491c3c7b9aa7f03a69b974308840656b02a + url: "https://pub.dev" + source: hosted + version: "2.0.1" flutter_localizations: dependency: transitive description: flutter @@ -893,6 +925,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + url: "https://pub.dev" + source: hosted + version: "12.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: @@ -1230,10 +1310,10 @@ packages: dependency: "direct main" description: name: timezone - sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 url: "https://pub.dev" source: hosted - version: "0.9.4" + version: "0.10.1" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2cab06c6..07d7891e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ dependencies: google_fonts: ^6.2.1 audioplayers: ^6.1.0 geolocator: ^13.0.1 - timezone: ^0.9.4 + timezone: ^0.10.1 flutter_map: ^8.2.2 latlong2: ^0.9.0 flutter_typeahead: ^4.1.0 @@ -27,6 +27,8 @@ dependencies: printing: ^5.10.0 flutter_keyboard_visibility: ^5.4.1 awesome_snackbar_content: ^0.1.8 + permission_handler: ^12.0.1 + flutter_local_notifications: ^20.1.0 dev_dependencies: flutter_test: