Allow OS notifications
This commit is contained in:
@@ -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 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<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;
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user