Allow OS notifications
This commit is contained in:
parent
98355c3707
commit
cc6fda0e79
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<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
|
||||
android:label="tasq"
|
||||
android:name="${applicationName}"
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
94
lib/screens/shared/permissions_screen.dart
Normal file
94
lib/screens/shared/permissions_screen.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
56
lib/services/notification_service.dart
Normal file
56
lib/services/notification_service.dart
Normal 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
lib/services/permission_service.dart
Normal file
66
lib/services/permission_service.dart
Normal 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
lib/utils/notification_permission.dart
Normal file
23
lib/utils/notification_permission.dart
Normal 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 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() {
|
||||
|
|
|
|||
84
pubspec.lock
84
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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user