feat: harden geofence and distance diagnostics

This commit is contained in:
Artem Kokos
2026-05-01 09:13:23 +07:00
parent 872ddf9513
commit 91a494adf5
20 changed files with 1639 additions and 260 deletions

View File

@@ -200,7 +200,8 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
padding: EdgeInsets.only(left: 40, bottom: 4),
child: Text(
'Проверка раз в ~15 мин (ограничение Android).\n'
'Работает в фоне, без постоянной нотификации.',
'Работает только для текущего активного дома.\n'
'Нужны фоновые разрешения на геолокацию и уведомления.',
style: TextStyle(fontSize: 11, color: Colors.white24),
),
),
@@ -327,16 +328,17 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
// Синхронизировать фоновый таск с новыми настройками
final allHomes = ref.read(homesProvider);
await syncGeofenceTask(allHomes);
await syncGeofenceTask(
allHomes,
currentHome: ref.read(currentHomeProvider),
);
if (mounted) Navigator.of(context).pop();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Не удалось сохранить дом: ${describeLoadError(e)}',
),
content: Text('Не удалось сохранить дом: ${describeLoadError(e)}'),
),
);
}

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/error_message.dart';
import '../features/homes/models/geofence_diagnostics.dart';
import '../models/home_config.dart';
import '../providers/providers.dart';
import '../widgets/build_info_text.dart';
@@ -16,7 +18,8 @@ class HomesScreen extends ConsumerStatefulWidget {
ConsumerState<HomesScreen> createState() => _HomesScreenState();
}
class _HomesScreenState extends ConsumerState<HomesScreen> {
class _HomesScreenState extends ConsumerState<HomesScreen>
with WidgetsBindingObserver {
late final UserLocationNotifier _userLocationNotifier;
String? _switchingHomeId;
String? _deletingHomeId;
@@ -24,21 +27,37 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_userLocationNotifier = ref.read(userLocationProvider.notifier);
Future.microtask(() => _userLocationNotifier.startWatching());
Future.microtask(() async {
await _userLocationNotifier.startWatching();
await ref.read(geofenceDiagnosticsProvider.notifier).refresh();
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_userLocationNotifier.stopWatching();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
Future.microtask(_refreshEnvironmentState);
}
}
@override
Widget build(BuildContext context) {
final homes = ref.watch(homesProvider);
final currentHome = ref.watch(currentHomeProvider);
final location = ref.watch(userLocationProvider);
final geofenceState = ref.watch(geofenceDiagnosticsProvider);
final activeDistanceKm = currentHome == null
? null
: location.distanceToKm(currentHome.latitude, currentHome.longitude);
return Scaffold(
appBar: AppBar(
@@ -50,87 +69,110 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
Expanded(
child: homes.isEmpty
? const _EmptyHomesView()
: ListView.builder(
: ListView(
padding: const EdgeInsets.all(12),
itemCount: homes.length,
itemBuilder: (context, index) {
final home = homes[index];
final isActive = currentHome?.id == home.id;
final isSwitching = _switchingHomeId == home.id;
final isDeleting = _deletingHomeId == home.id;
final isBusy = isSwitching || isDeleting;
final distKm = location.distanceToKm(
home.latitude,
home.longitude,
);
children: [
_HomesOverviewCard(
location: location,
diagnostics: geofenceState.data,
activeHome: currentHome,
activeDistanceKm: activeDistanceKm,
onRefresh: _refreshEnvironmentState,
onRequestLocationPermission: _requestLocationPermission,
onRequestBackgroundPermission:
_requestGeofenceBackgroundPermission,
onRequestNotificationsPermission:
_requestGeofenceNotificationPermission,
onOpenAppSettings: _openRelevantAppSettings,
onOpenLocationSettings: _openLocationSettings,
),
const SizedBox(height: 14),
...homes.map((home) {
final isActive = currentHome?.id == home.id;
final isSwitching = _switchingHomeId == home.id;
final isDeleting = _deletingHomeId == home.id;
final isBusy = isSwitching || isDeleting;
final distKm = location.distanceToKm(
home.latitude,
home.longitude,
);
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
enabled: !isBusy,
leading: Icon(
Icons.home,
color: isActive
? Colors.deepOrange
: Colors.white38,
size: 28,
),
title: Text(
home.name,
style: TextStyle(
fontWeight: isActive
? FontWeight.bold
: FontWeight.normal,
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
enabled: !isBusy,
leading: Icon(
Icons.home,
color: isActive
? Colors.deepOrange
: Colors.white,
: Colors.white38,
size: 28,
),
),
subtitle: _HomeSubtitle(
home: home,
location: location,
distKm: distKm,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isBusy)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
title: Text(
home.name,
style: TextStyle(
fontWeight: isActive
? FontWeight.bold
: FontWeight.normal,
color: isActive
? Colors.deepOrange
: Colors.white,
),
),
subtitle: _HomeSubtitle(
home: home,
location: location,
distKm: distKm,
isActive: isActive,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isBusy)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
else ...[
IconButton(
icon: const Icon(
Icons.edit,
size: 20,
color: Colors.white38,
),
onPressed: () => _editHome(context, home),
),
)
else ...[
IconButton(
icon: const Icon(
Icons.edit,
size: 20,
color: Colors.white38,
IconButton(
icon: const Icon(
Icons.delete_outline,
size: 20,
color: Colors.redAccent,
),
onPressed: () =>
_confirmDelete(context, home),
),
onPressed: () => _editHome(context, home),
),
IconButton(
icon: const Icon(
Icons.delete_outline,
size: 20,
color: Colors.redAccent,
),
onPressed: () => _confirmDelete(context, home),
),
],
],
],
),
onTap: isBusy
? null
: () => _selectHome(context, home),
),
onTap: isBusy ? null : () => _selectHome(context, home),
),
);
},
);
}),
],
),
),
const Padding(
padding: EdgeInsets.only(bottom: 10),
child: BuildInfoText(),
const SafeArea(
top: false,
minimum: EdgeInsets.only(bottom: 10),
child: Padding(
padding: EdgeInsets.only(bottom: 6),
child: BuildInfoText(),
),
),
],
),
@@ -158,9 +200,7 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
if (context.mounted) {
messenger.showSnackBar(
SnackBar(
content: Text(
'Не удалось выбрать дом: ${describeLoadError(e)}',
),
content: Text('Не удалось выбрать дом: ${describeLoadError(e)}'),
),
);
}
@@ -171,16 +211,18 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
}
}
void _addHome(BuildContext context) {
Navigator.of(
Future<void> _addHome(BuildContext context) async {
await Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const HomeEditScreen()));
await _refreshEnvironmentState();
}
void _editHome(BuildContext context, HomeConfig home) {
Navigator.of(
Future<void> _editHome(BuildContext context, HomeConfig home) async {
await Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => HomeEditScreen(home: home)));
await _refreshEnvironmentState();
}
void _confirmDelete(BuildContext context, HomeConfig home) {
@@ -222,14 +264,16 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
if (deletedCurrentHome) {
ref.read(authInfoProvider.notifier).clear();
}
await syncGeofenceTask(ref.read(homesProvider));
await syncGeofenceTask(
ref.read(homesProvider),
currentHome: ref.read(currentHomeProvider),
);
await _refreshEnvironmentState();
} catch (e) {
if (context.mounted) {
messenger.showSnackBar(
SnackBar(
content: Text(
'Не удалось удалить дом: ${describeLoadError(e)}',
),
content: Text('Не удалось удалить дом: ${describeLoadError(e)}'),
),
);
}
@@ -239,6 +283,45 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
}
}
}
Future<void> _refreshEnvironmentState() async {
await _userLocationNotifier.refresh();
await _refreshGeofenceDiagnostics();
}
Future<void> _refreshGeofenceDiagnostics() async {
await ref.read(geofenceDiagnosticsProvider.notifier).refresh();
}
Future<void> _requestLocationPermission() async {
await _userLocationNotifier.requestPermission();
await _refreshGeofenceDiagnostics();
}
Future<void> _openLocationSettings() async {
await _userLocationNotifier.openLocationSettings();
}
Future<void> _requestGeofenceBackgroundPermission() async {
await ref
.read(geofenceDiagnosticsProvider.notifier)
.requestBackgroundLocationPermission();
await _userLocationNotifier.refresh();
}
Future<void> _requestGeofenceNotificationPermission() async {
await ref
.read(geofenceDiagnosticsProvider.notifier)
.requestNotificationPermission();
}
Future<void> _openRelevantAppSettings() async {
if (ref.read(userLocationProvider).needsAppSettings) {
await _userLocationNotifier.openAppSettings();
return;
}
await ref.read(geofenceDiagnosticsProvider.notifier).openAppSettings();
}
}
class _EmptyHomesView extends StatelessWidget {
@@ -271,11 +354,13 @@ class _HomeSubtitle extends StatelessWidget {
final HomeConfig home;
final UserLocation location;
final double? distKm;
final bool isActive;
const _HomeSubtitle({
required this.home,
required this.location,
required this.distKm,
required this.isActive,
});
@override
@@ -302,17 +387,270 @@ class _HomeSubtitle extends StatelessWidget {
),
)
else if (home.hasCoordinates && !location.hasPosition)
Row(
children: [
const Icon(Icons.location_on, size: 12, color: Colors.white24),
const SizedBox(width: 4),
Text(
location.error ?? 'Координаты заданы',
style: const TextStyle(color: Colors.white24, fontSize: 11),
),
],
Padding(
padding: const EdgeInsets.only(top: 2),
child: Row(
children: [
const Icon(Icons.location_on, size: 12, color: Colors.white24),
const SizedBox(width: 4),
Text(
location.error ?? 'Координаты заданы',
style: const TextStyle(color: Colors.white24, fontSize: 11),
),
],
),
)
else if (home.geofenceReady && isActive)
const Padding(
padding: EdgeInsets.only(top: 2),
child: Row(
children: [
Icon(
Icons.shield_moon_outlined,
size: 12,
color: Colors.deepOrangeAccent,
),
SizedBox(width: 4),
Text(
'Geofence включён',
style: TextStyle(
color: Colors.deepOrangeAccent,
fontSize: 11,
),
),
],
),
)
else if (home.geofenceReady)
const Padding(
padding: EdgeInsets.only(top: 2),
child: Row(
children: [
Icon(
Icons.shield_moon_outlined,
size: 12,
color: Colors.white24,
),
SizedBox(width: 4),
Text(
'Geofence включён',
style: TextStyle(color: Colors.white24, fontSize: 11),
),
],
),
),
],
);
}
}
class _HomesOverviewCard extends StatelessWidget {
final UserLocation location;
final GeofenceDiagnostics diagnostics;
final HomeConfig? activeHome;
final double? activeDistanceKm;
final Future<void> Function() onRefresh;
final Future<void> Function() onRequestLocationPermission;
final Future<void> Function() onRequestBackgroundPermission;
final Future<void> Function() onRequestNotificationsPermission;
final Future<void> Function() onOpenAppSettings;
final Future<void> Function() onOpenLocationSettings;
const _HomesOverviewCard({
required this.location,
required this.diagnostics,
required this.activeHome,
required this.activeDistanceKm,
required this.onRefresh,
required this.onRequestLocationPermission,
required this.onRequestBackgroundPermission,
required this.onRequestNotificationsPermission,
required this.onOpenAppSettings,
required this.onOpenLocationSettings,
});
@override
Widget build(BuildContext context) {
final title = activeHome == null
? 'Статус автоматизации'
: 'Активный дом: ${activeHome!.name}';
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_overviewIcon(location, diagnostics),
color: _overviewColor(location, diagnostics),
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: const TextStyle(fontWeight: FontWeight.w700),
),
),
],
),
const SizedBox(height: 8),
Text(
_overviewPrimaryText(location, diagnostics),
style: const TextStyle(color: Colors.white70),
),
if (_overviewSecondaryText(location, diagnostics, activeDistanceKm)
case final secondary?)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
secondary,
style: const TextStyle(color: Colors.white38, fontSize: 12),
),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.icon(
onPressed: onRefresh,
icon: const Icon(Icons.refresh),
label: const Text('Обновить'),
),
if (location.canRequestPermission)
OutlinedButton.icon(
onPressed: onRequestLocationPermission,
icon: const Icon(Icons.my_location),
label: const Text('Разрешить геолокацию'),
),
if (location.needsLocationSettings)
OutlinedButton.icon(
onPressed: onOpenLocationSettings,
icon: const Icon(Icons.location_searching),
label: const Text('Включить GPS'),
),
if (diagnostics.canRequestBackgroundLocation)
OutlinedButton.icon(
onPressed: onRequestBackgroundPermission,
icon: const Icon(Icons.shield_moon_outlined),
label: const Text('Доступ всегда'),
),
if (diagnostics.canRequestNotifications)
OutlinedButton.icon(
onPressed: onRequestNotificationsPermission,
icon: const Icon(Icons.notifications_active_outlined),
label: const Text('Уведомления'),
),
if (location.needsAppSettings ||
diagnostics.canRequestBackgroundLocation)
OutlinedButton.icon(
onPressed: onOpenAppSettings,
icon: const Icon(Icons.settings),
label: const Text('Настройки'),
),
],
),
],
),
),
);
}
}
String _overviewPrimaryText(
UserLocation location,
GeofenceDiagnostics diagnostics,
) {
if (!location.hasPosition) {
return location.error ?? 'Позиция устройства пока недоступна.';
}
return switch (diagnostics.status) {
GeofenceStatusKind.ready =>
'Расстояние считается, geofence активен и готов выключить свет.',
GeofenceStatusKind.triggered =>
'Расстояние считается, geofence уже сработал для активного дома.',
GeofenceStatusKind.cooldown =>
'Расстояние считается, но после сбоя geofence сейчас на паузе.',
GeofenceStatusKind.notificationsPermissionDenied =>
'Расстояние считается, но уведомления для geofence сейчас запрещены.',
GeofenceStatusKind.backgroundPermissionDenied =>
'Расстояние считается, но без доступа "Всегда" geofence в фоне будет кастрирован.',
GeofenceStatusKind.locationServicesDisabled =>
'Геолокация выключена, поэтому и расстояния, и geofence сейчас мёртвые.',
GeofenceStatusKind.locationPermissionDenied =>
'Без разрешения на геолокацию тут нечего считать.',
GeofenceStatusKind.disabled =>
'Расстояние считается, но geofence для активного дома выключен.',
GeofenceStatusKind.missingCoordinates =>
'Расстояние считается, но у активного дома нет координат для geofence.',
GeofenceStatusKind.noActiveHome =>
'Выбери активный дом, и тогда автоматика станет осмысленной.',
};
}
String? _overviewSecondaryText(
UserLocation location,
GeofenceDiagnostics diagnostics,
double? activeDistanceKm,
) {
final parts = <String>[];
if (activeDistanceKm != null) {
parts.add('До активного дома: ${formatDistance(activeDistanceKm)}.');
}
if (location.updatedAt != null) {
parts.add('Точка: ${_formatTimestamp(location.updatedAt!)}.');
}
final secondary = diagnostics.secondaryMessage;
if (secondary != null && secondary.isNotEmpty) {
parts.add(secondary);
}
if (parts.isEmpty) return null;
return parts.join(' ');
}
IconData _overviewIcon(UserLocation location, GeofenceDiagnostics diagnostics) {
if (!location.hasPosition) {
return Icons.location_off_outlined;
}
return switch (diagnostics.status) {
GeofenceStatusKind.ready => Icons.shield_moon_outlined,
GeofenceStatusKind.triggered => Icons.check_circle_outline,
GeofenceStatusKind.cooldown => Icons.timer_outlined,
GeofenceStatusKind.notificationsPermissionDenied =>
Icons.notifications_off_outlined,
GeofenceStatusKind.backgroundPermissionDenied => Icons.location_searching,
_ => Icons.info_outline,
};
}
Color _overviewColor(UserLocation location, GeofenceDiagnostics diagnostics) {
if (!location.hasPosition) {
return Colors.redAccent;
}
return switch (diagnostics.status) {
GeofenceStatusKind.ready => Colors.greenAccent,
GeofenceStatusKind.triggered => Colors.deepOrangeAccent,
GeofenceStatusKind.cooldown => Colors.amberAccent,
GeofenceStatusKind.notificationsPermissionDenied ||
GeofenceStatusKind.backgroundPermissionDenied => Colors.deepOrangeAccent,
_ => Colors.white54,
};
}
String _formatTimestamp(DateTime timestamp) {
final local = timestamp.toLocal();
final hour = local.hour.toString().padLeft(2, '0');
final minute = local.minute.toString().padLeft(2, '0');
final second = local.second.toString().padLeft(2, '0');
return '$hour:$minute:$second';
}

View File

@@ -126,7 +126,7 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
enabled: false,
child: Padding(
padding: EdgeInsets.symmetric(vertical: 4),
child: BuildInfoText(),
child: BuildInfoText(compact: false, alignStart: true),
),
),
],