From 8b9a25e74697d2c34f2e13b5f5d6bab503875f81 Mon Sep 17 00:00:00 2001 From: Artem Kokos Date: Fri, 15 May 2026 10:32:23 +0700 Subject: [PATCH] Show explicit geofence permission status --- .../ru/akokos/ignis_app/MainActivity.kt | 32 ++++ .../notification_permission_status.dart | 13 ++ .../providers/settings_providers.dart | 44 +++++ lib/screens/settings_screen.dart | 163 +++++++++++++++++- ...ation_permission_status_provider_test.dart | 43 +++++ 5 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 lib/features/settings/models/notification_permission_status.dart create mode 100644 test/notification_permission_status_provider_test.dart diff --git a/android/app/src/main/kotlin/ru/akokos/ignis_app/MainActivity.kt b/android/app/src/main/kotlin/ru/akokos/ignis_app/MainActivity.kt index 25c6c3d..eaa9f4c 100644 --- a/android/app/src/main/kotlin/ru/akokos/ignis_app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/akokos/ignis_app/MainActivity.kt @@ -1,5 +1,12 @@ package ru.akokos.ignis_app +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.provider.Settings +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.android.FlutterActivity import io.flutter.plugin.common.MethodChannel @@ -59,8 +66,33 @@ class MainActivity : FlutterActivity() { result.success(null) } } + "getNotificationPermissionStatus" -> { + result.success( + if (isNotificationPermissionEnabled()) "enabled" else "disabled", + ) + } + "openNotificationSettings" -> { + val intent = + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + putExtra("android.provider.extra.APP_PACKAGE", packageName) + } + startActivity(intent) + result.success(null) + } else -> result.notImplemented() } } } + + private fun isNotificationPermissionEnabled(): Boolean { + val runtimePermissionGranted = + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + return runtimePermissionGranted && + NotificationManagerCompat.from(this).areNotificationsEnabled() + } } diff --git a/lib/features/settings/models/notification_permission_status.dart b/lib/features/settings/models/notification_permission_status.dart new file mode 100644 index 0000000..86fce43 --- /dev/null +++ b/lib/features/settings/models/notification_permission_status.dart @@ -0,0 +1,13 @@ +enum NotificationPermissionStatus { + enabled, + disabled, + unsupported; + + static NotificationPermissionStatus fromPlatformValue(String? value) { + return switch (value) { + 'enabled' => NotificationPermissionStatus.enabled, + 'disabled' => NotificationPermissionStatus.disabled, + _ => NotificationPermissionStatus.unsupported, + }; + } +} diff --git a/lib/features/settings/providers/settings_providers.dart b/lib/features/settings/providers/settings_providers.dart index 149d3a0..7a11980 100644 --- a/lib/features/settings/providers/settings_providers.dart +++ b/lib/features/settings/providers/settings_providers.dart @@ -1,3 +1,4 @@ +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; @@ -5,6 +6,7 @@ import '../../homes/providers/homes_providers.dart'; import '../../shared/providers/core_providers.dart'; import '../models/app_theme_preset.dart'; import '../models/geofence_system_state.dart'; +import '../models/notification_permission_status.dart'; final initialAppThemePresetProvider = Provider( (ref) => AppThemePreset.fallback, @@ -87,3 +89,45 @@ final geofenceSystemStatusProvider = FutureProvider(( hasCoordinates: currentHome?.hasCoordinates == true, ); }); + +abstract class NotificationPermissionStatusService { + Future inspect(); + + Future openSettings(); +} + +class DeviceNotificationPermissionStatusService + implements NotificationPermissionStatusService { + static const _channel = MethodChannel('ignis/geofence_automation'); + + @override + Future inspect() async { + try { + final value = await _channel.invokeMethod( + 'getNotificationPermissionStatus', + ); + return NotificationPermissionStatus.fromPlatformValue(value); + } on MissingPluginException { + return NotificationPermissionStatus.unsupported; + } + } + + @override + Future openSettings() async { + try { + await _channel.invokeMethod('openNotificationSettings'); + } on MissingPluginException { + return; + } + } +} + +final notificationPermissionStatusServiceProvider = + Provider( + (ref) => DeviceNotificationPermissionStatusService(), + ); + +final notificationPermissionStatusProvider = + FutureProvider((ref) async { + return ref.watch(notificationPermissionStatusServiceProvider).inspect(); + }); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 8ec0817..d214c77 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -7,6 +7,7 @@ import '../app/error_message.dart'; import '../app/load_state.dart'; import '../features/settings/models/app_theme_preset.dart'; import '../features/settings/models/geofence_system_state.dart'; +import '../features/settings/models/notification_permission_status.dart'; import '../features/settings/providers/settings_providers.dart'; import '../models/home_config.dart'; import '../providers/providers.dart'; @@ -44,6 +45,7 @@ class _SettingsScreenState extends ConsumerState void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { ref.invalidate(geofenceSystemStatusProvider); + ref.invalidate(notificationPermissionStatusProvider); } } @@ -54,6 +56,8 @@ class _SettingsScreenState extends ConsumerState final themePreset = ref.watch(appThemeProvider); final geofenceStatus = ref.watch(geofenceSystemStatusProvider); final geofenceState = geofenceStatus.asData?.value; + final notificationStatus = ref.watch(notificationPermissionStatusProvider); + final notificationPermission = notificationStatus.asData?.value; return Scaffold( appBar: AppBar(title: const Text('НАСТРОЙКИ')), @@ -140,6 +144,25 @@ class _SettingsScreenState extends ConsumerState title: _statusTitle(geofenceState, currentHome), subtitle: _statusSubtitle(geofenceState, currentHome), ), + const SizedBox(height: 12), + _PermissionTile( + icon: _locationPermissionIcon(geofenceState), + color: _locationPermissionColor(context, geofenceState), + title: 'Геолокация', + subtitle: _locationPermissionSubtitle(geofenceState), + ), + const SizedBox(height: 8), + _PermissionTile( + icon: _notificationPermissionIcon(notificationPermission), + color: _notificationPermissionColor( + context, + notificationPermission, + ), + title: 'Уведомления', + subtitle: _notificationPermissionSubtitle( + notificationPermission, + ), + ), if (_savingGeofence) const Padding( padding: EdgeInsets.only(top: 12), @@ -165,6 +188,7 @@ class _SettingsScreenState extends ConsumerState context: context, home: currentHome, systemState: geofenceState, + notificationPermission: notificationPermission, ), ), ], @@ -238,8 +262,12 @@ class _SettingsScreenState extends ConsumerState required BuildContext context, required HomeConfig home, required GeofenceSystemState? systemState, + required NotificationPermissionStatus? notificationPermission, }) { final locationNotifier = ref.read(userLocationProvider.notifier); + final notificationNotifier = ref.read( + notificationPermissionStatusServiceProvider, + ); final issue = systemState?.issue; if (!home.hasCoordinates) { @@ -252,7 +280,7 @@ class _SettingsScreenState extends ConsumerState ]; } - return switch (issue) { + final actions = switch (issue) { GeofenceSystemIssue.locationServicesDisabled => [ FilledButton.tonalIcon( onPressed: () async { @@ -292,6 +320,21 @@ class _SettingsScreenState extends ConsumerState ), ], }; + + if (notificationPermission == NotificationPermissionStatus.disabled) { + actions.add( + OutlinedButton.icon( + onPressed: () async { + await notificationNotifier.openSettings(); + ref.invalidate(notificationPermissionStatusProvider); + }, + icon: const Icon(Icons.notifications_outlined), + label: const Text('Уведомления Android'), + ), + ); + } + + return actions; } Future _setGeofenceEnabled(HomeConfig home, bool enabled) async { @@ -536,6 +579,89 @@ class _SettingsScreenState extends ConsumerState }; } + IconData _locationPermissionIcon(GeofenceSystemState? state) { + return switch (state?.issue) { + GeofenceSystemIssue.ready => Icons.verified_outlined, + GeofenceSystemIssue.locationServicesDisabled => Icons.location_disabled, + GeofenceSystemIssue.permissionDenied || + GeofenceSystemIssue.permissionDeniedForever || + GeofenceSystemIssue.backgroundPermissionRequired => + Icons.gpp_bad_outlined, + _ => Icons.my_location_outlined, + }; + } + + Color _locationPermissionColor( + BuildContext context, + GeofenceSystemState? state, + ) { + return switch (state?.issue) { + GeofenceSystemIssue.ready => Colors.green, + GeofenceSystemIssue.locationServicesDisabled || + GeofenceSystemIssue.permissionDenied || + GeofenceSystemIssue.permissionDeniedForever || + GeofenceSystemIssue.backgroundPermissionRequired => Theme.of( + context, + ).colorScheme.error, + _ => Colors.white70, + }; + } + + String _locationPermissionSubtitle(GeofenceSystemState? state) { + return switch (state?.issue) { + GeofenceSystemIssue.ready => + 'Доступ «Всегда», geofence может работать в фоне.', + GeofenceSystemIssue.locationServicesDisabled => + 'Системная геолокация сейчас выключена.', + GeofenceSystemIssue.permissionDenied => + 'Разрешение на геолокацию ещё не выдано.', + GeofenceSystemIssue.permissionDeniedForever => + 'Доступ к геолокации запрещён в Android.', + GeofenceSystemIssue.backgroundPermissionRequired => + 'Есть только доступ при использовании приложения.', + GeofenceSystemIssue.missingCoordinates => + 'Сначала задай координаты дома, потом появится полный статус.', + GeofenceSystemIssue.noActiveHome => + 'Без активного дома проверять нечего.', + null => 'Проверяем системный статус геолокации.', + }; + } + + IconData _notificationPermissionIcon(NotificationPermissionStatus? status) { + return switch (status) { + NotificationPermissionStatus.enabled => + Icons.notifications_active_outlined, + NotificationPermissionStatus.disabled => Icons.notifications_off_outlined, + NotificationPermissionStatus.unsupported || + null => Icons.notifications_none_outlined, + }; + } + + Color _notificationPermissionColor( + BuildContext context, + NotificationPermissionStatus? status, + ) { + return switch (status) { + NotificationPermissionStatus.enabled => Colors.green, + NotificationPermissionStatus.disabled => Theme.of( + context, + ).colorScheme.error, + NotificationPermissionStatus.unsupported || null => Colors.white70, + }; + } + + String _notificationPermissionSubtitle(NotificationPermissionStatus? status) { + return switch (status) { + NotificationPermissionStatus.enabled => + 'После срабатывания geofence приложение сможет прислать подтверждение.', + NotificationPermissionStatus.disabled => + 'Автовыключение сработает и без них, но подтверждение ты не увидишь.', + NotificationPermissionStatus.unsupported => + 'На этой платформе отдельный статус уведомлений не используется.', + null => 'Проверяем, доступны ли уведомления для подтверждения.', + }; + } + Future _openHomeEditor(BuildContext context, HomeConfig home) async { await Navigator.of( context, @@ -679,6 +805,41 @@ class _StatusTile extends StatelessWidget { } } +class _PermissionTile extends StatelessWidget { + final IconData icon; + final Color color; + final String title; + final String subtitle; + + const _PermissionTile({ + required this.icon, + required this.color, + required this.title, + required this.subtitle, + }); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 2), + Text(subtitle, style: const TextStyle(color: Colors.white54)), + ], + ), + ), + ], + ); + } +} + class _EmptySectionState extends StatelessWidget { final IconData icon; final String title; diff --git a/test/notification_permission_status_provider_test.dart b/test/notification_permission_status_provider_test.dart new file mode 100644 index 0000000..2bf56a3 --- /dev/null +++ b/test/notification_permission_status_provider_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ignis_app/features/settings/models/notification_permission_status.dart'; +import 'package:ignis_app/features/settings/providers/settings_providers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'notification permission status provider returns service result', + () async { + final container = ProviderContainer( + overrides: [ + notificationPermissionStatusServiceProvider.overrideWithValue( + _FakeNotificationPermissionStatusService( + NotificationPermissionStatus.disabled, + ), + ), + ], + ); + addTearDown(container.dispose); + + final status = await container.read( + notificationPermissionStatusProvider.future, + ); + + expect(status, NotificationPermissionStatus.disabled); + }, + ); +} + +class _FakeNotificationPermissionStatusService + implements NotificationPermissionStatusService { + final NotificationPermissionStatus result; + + _FakeNotificationPermissionStatusService(this.result); + + @override + Future inspect() async => result; + + @override + Future openSettings() async {} +}