Allow OS notifications

This commit is contained in:
2026-02-23 22:19:23 +08:00
parent 98355c3707
commit cc6fda0e79
11 changed files with 398 additions and 4 deletions
+31
View File
@@ -7,6 +7,8 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'app.dart';
import 'providers/notifications_provider.dart';
import 'utils/app_time.dart';
import 'utils/notification_permission.dart';
import 'services/notification_service.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -25,6 +27,29 @@ Future<void> 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 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(
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.',
);
}
}
}
+5
View File
@@ -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<GoRouter>((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(),
@@ -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),
),
);
}
}
+56
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,
);
}
}
+66
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;
}
+23
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;
}
+23 -1
View File
@@ -387,6 +387,12 @@ List<NavSection> _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<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() {