Allow OS notifications

This commit is contained in:
Marc Rejohn Castillano 2026-02-23 22:19:23 +08:00
parent 98355c3707
commit cc6fda0e79
11 changed files with 398 additions and 4 deletions

View File

@ -13,6 +13,9 @@ android {
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
// required by flutter_local_notifications and other libraries using
// Java 8+ APIs at runtime
isCoreLibraryDesugaringEnabled = true
} }
kotlinOptions { kotlinOptions {
@ -37,6 +40,14 @@ android {
signingConfig = signingConfigs.getByName("debug") 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 { flutter {

View File

@ -2,6 +2,10 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- Required on Android 13+ to post notifications. Without this the system
will automatically block notifications and the user cannot enable them
from settings. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:label="tasq" android:label="tasq"
android:name="${applicationName}" android:name="${applicationName}"

View File

@ -7,6 +7,8 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'app.dart'; import 'app.dart';
import 'providers/notifications_provider.dart'; import 'providers/notifications_provider.dart';
import 'utils/app_time.dart'; import 'utils/app_time.dart';
import 'utils/notification_permission.dart';
import 'services/notification_service.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -25,6 +27,29 @@ Future<void> main() async {
await Supabase.initialize(url: supabaseUrl, anonKey: supabaseAnonKey); 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 reenabled from the
// system settings. The helper uses `permission_handler`.
final granted = await ensureNotificationPermission();
if (!granted) {
// we dont block startup, but its 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( runApp(
ProviderScope( ProviderScope(
observers: [NotificationSoundObserver()], observers: [NotificationSoundObserver()],
@ -48,6 +73,12 @@ class NotificationSoundObserver extends ProviderObserver {
final next = newValue as int?; final next = newValue as int?;
if (prev != null && next != null && next > prev) { if (prev != null && next != null && next > prev) {
_player.play(AssetSource('tasq_notification.wav')); _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.',
);
} }
} }
} }

View File

@ -15,6 +15,7 @@ import '../screens/dashboard/dashboard_screen.dart';
import '../screens/notifications/notifications_screen.dart'; import '../screens/notifications/notifications_screen.dart';
import '../screens/profile/profile_screen.dart'; import '../screens/profile/profile_screen.dart';
import '../screens/shared/under_development_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/task_detail_screen.dart';
import '../screens/tasks/tasks_list_screen.dart'; import '../screens/tasks/tasks_list_screen.dart';
import '../screens/tickets/ticket_detail_screen.dart'; import '../screens/tickets/ticket_detail_screen.dart';
@ -138,6 +139,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: '/settings/geofence-test', path: '/settings/geofence-test',
builder: (context, state) => const GeofenceTestScreen(), builder: (context, state) => const GeofenceTestScreen(),
), ),
GoRoute(
path: '/settings/permissions',
builder: (context, state) => const PermissionsScreen(),
),
GoRoute( GoRoute(
path: '/notifications', path: '/notifications',
builder: (context, state) => const NotificationsScreen(), builder: (context, state) => const NotificationsScreen(),

View File

@ -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<PermissionsScreen> createState() => _PermissionsScreenState();
}
class _PermissionsScreenState extends ConsumerState<PermissionsScreen> {
late Map<Permission, PermissionStatus> _statuses;
bool _loading = true;
@override
void initState() {
super.initState();
_refreshStatuses();
}
Future<void> _refreshStatuses() async {
setState(() => _loading = true);
final statuses = await getAllStatuses();
setState(() {
_statuses = statuses;
_loading = false;
});
}
Future<void> _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),
),
);
}
}

View File

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

View File

@ -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<PermissionInfo> 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<Map<Permission, PermissionStatus>> getAllStatuses() async {
final map = <Permission, PermissionStatus>{};
for (final info in appPermissions) {
map[info.permission] = await info.permission.status;
}
return map;
}
/// Requests [permission] and returns the resulting status.
Future<PermissionStatus> 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<Map<Permission, PermissionStatus>> 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;
}

View File

@ -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 backwardscompatibility; the real logic lives in
/// [permission_service].
Future<PermissionStatus> requestNotificationPermission() async {
return requestPermission(Permission.notification);
}
Future<bool> 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;
}

View File

@ -387,6 +387,12 @@ List<NavSection> _buildSections(String role) {
icon: Icons.groups_2_outlined, icon: Icons.groups_2_outlined,
selectedIcon: Icons.groups_2, selectedIcon: Icons.groups_2,
), ),
NavItem(
label: 'Permissions',
route: '/settings/permissions',
icon: Icons.lock_open,
selectedIcon: Icons.lock,
),
NavItem( NavItem(
label: 'Logout', label: 'Logout',
route: '', route: '',
@ -398,7 +404,23 @@ List<NavSection> _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<NavItem> _standardNavItems() { List<NavItem> _standardNavItems() {

View File

@ -446,6 +446,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" 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: flutter_localizations:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -893,6 +925,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.4" 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: petitparser:
dependency: transitive dependency: transitive
description: description:
@ -1230,10 +1310,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: timezone name: timezone
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.9.4" version: "0.10.1"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:

View File

@ -17,7 +17,7 @@ dependencies:
google_fonts: ^6.2.1 google_fonts: ^6.2.1
audioplayers: ^6.1.0 audioplayers: ^6.1.0
geolocator: ^13.0.1 geolocator: ^13.0.1
timezone: ^0.9.4 timezone: ^0.10.1
flutter_map: ^8.2.2 flutter_map: ^8.2.2
latlong2: ^0.9.0 latlong2: ^0.9.0
flutter_typeahead: ^4.1.0 flutter_typeahead: ^4.1.0
@ -27,6 +27,8 @@ dependencies:
printing: ^5.10.0 printing: ^5.10.0
flutter_keyboard_visibility: ^5.4.1 flutter_keyboard_visibility: ^5.4.1
awesome_snackbar_content: ^0.1.8 awesome_snackbar_content: ^0.1.8
permission_handler: ^12.0.1
flutter_local_notifications: ^20.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: