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