Show explicit geofence permission status

This commit is contained in:
Artem Kokos
2026-05-15 10:32:23 +07:00
parent d796537917
commit 8b9a25e746
5 changed files with 294 additions and 1 deletions

View File

@@ -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()
}
}

View File

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

View File

@@ -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<AppThemePreset>(
(ref) => AppThemePreset.fallback,
@@ -87,3 +89,45 @@ final geofenceSystemStatusProvider = FutureProvider<GeofenceSystemState>((
hasCoordinates: currentHome?.hasCoordinates == true,
);
});
abstract class NotificationPermissionStatusService {
Future<NotificationPermissionStatus> inspect();
Future<void> openSettings();
}
class DeviceNotificationPermissionStatusService
implements NotificationPermissionStatusService {
static const _channel = MethodChannel('ignis/geofence_automation');
@override
Future<NotificationPermissionStatus> inspect() async {
try {
final value = await _channel.invokeMethod<String>(
'getNotificationPermissionStatus',
);
return NotificationPermissionStatus.fromPlatformValue(value);
} on MissingPluginException {
return NotificationPermissionStatus.unsupported;
}
}
@override
Future<void> openSettings() async {
try {
await _channel.invokeMethod<void>('openNotificationSettings');
} on MissingPluginException {
return;
}
}
}
final notificationPermissionStatusServiceProvider =
Provider<NotificationPermissionStatusService>(
(ref) => DeviceNotificationPermissionStatusService(),
);
final notificationPermissionStatusProvider =
FutureProvider<NotificationPermissionStatus>((ref) async {
return ref.watch(notificationPermissionStatusServiceProvider).inspect();
});

View File

@@ -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<SettingsScreen>
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
ref.invalidate(geofenceSystemStatusProvider);
ref.invalidate(notificationPermissionStatusProvider);
}
}
@@ -54,6 +56,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen>
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<SettingsScreen>
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<SettingsScreen>
context: context,
home: currentHome,
systemState: geofenceState,
notificationPermission: notificationPermission,
),
),
],
@@ -238,8 +262,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen>
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<SettingsScreen>
];
}
return switch (issue) {
final actions = switch (issue) {
GeofenceSystemIssue.locationServicesDisabled => [
FilledButton.tonalIcon(
onPressed: () async {
@@ -292,6 +320,21 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen>
),
],
};
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<void> _setGeofenceEnabled(HomeConfig home, bool enabled) async {
@@ -536,6 +579,89 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen>
};
}
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<void> _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;

View File

@@ -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<NotificationPermissionStatus> inspect() async => result;
@override
Future<void> openSettings() async {}
}