Replace geofence polling with native Android geofence

This commit is contained in:
Artem Kokos
2026-05-12 11:23:44 +07:00
parent 0a5ef9af17
commit 1963488479
38 changed files with 1099 additions and 1931 deletions

View File

@@ -2,7 +2,6 @@ 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';
@@ -21,6 +20,7 @@ class HomesScreen extends ConsumerStatefulWidget {
class _HomesScreenState extends ConsumerState<HomesScreen>
with WidgetsBindingObserver {
late final UserLocationNotifier _userLocationNotifier;
bool _isWatchingLocation = false;
String? _switchingHomeId;
String? _deletingHomeId;
@@ -30,15 +30,17 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
WidgetsBinding.instance.addObserver(this);
_userLocationNotifier = ref.read(userLocationProvider.notifier);
Future.microtask(() async {
await _userLocationNotifier.startWatching();
await ref.read(geofenceDiagnosticsProvider.notifier).refresh();
await _syncLocationWatching();
await _syncGeofenceAutomation();
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_userLocationNotifier.stopWatching();
if (_isWatchingLocation) {
_userLocationNotifier.stopWatching();
}
super.dispose();
}
@@ -54,10 +56,6 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
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(
@@ -69,101 +67,90 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
Expanded(
child: homes.isEmpty
? const _EmptyHomesView()
: ListView(
padding: const EdgeInsets.all(12),
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,
);
: RefreshIndicator(
color: Colors.deepOrange,
onRefresh: _refreshEnvironmentState,
child: ListView(
padding: const EdgeInsets.all(12),
children: [
...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,
isActive: isActive,
),
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 SafeArea(
@@ -264,10 +251,6 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
if (deletedCurrentHome) {
ref.read(authInfoProvider.notifier).clear();
}
await syncGeofenceTask(
ref.read(homesProvider),
currentHome: ref.read(currentHomeProvider),
);
await _refreshEnvironmentState();
} catch (e) {
if (context.mounted) {
@@ -285,42 +268,33 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
}
Future<void> _refreshEnvironmentState() async {
await _userLocationNotifier.refresh();
await _refreshGeofenceDiagnostics();
await _syncLocationWatching();
if (_isWatchingLocation) {
await _userLocationNotifier.refresh();
}
await _syncGeofenceAutomation();
}
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 {
Future<void> _syncGeofenceAutomation() async {
await ref
.read(geofenceDiagnosticsProvider.notifier)
.requestBackgroundLocationPermission();
await _userLocationNotifier.refresh();
.read(geofenceAutomationServiceProvider)
.syncActiveHome(ref.read(currentHomeProvider));
}
Future<void> _requestGeofenceNotificationPermission() async {
await ref
.read(geofenceDiagnosticsProvider.notifier)
.requestNotificationPermission();
}
Future<void> _openRelevantAppSettings() async {
if (ref.read(userLocationProvider).needsAppSettings) {
await _userLocationNotifier.openAppSettings();
Future<void> _syncLocationWatching() async {
final shouldWatch = ref.read(homesProvider).any((home) => home.hasCoordinates);
if (shouldWatch == _isWatchingLocation) {
return;
}
await ref.read(geofenceDiagnosticsProvider.notifier).openAppSettings();
if (shouldWatch) {
await _userLocationNotifier.startWatching();
_isWatchingLocation = true;
return;
}
_userLocationNotifier.stopWatching();
_isWatchingLocation = false;
}
}
@@ -401,19 +375,19 @@ class _HomeSubtitle extends StatelessWidget {
),
)
else if (home.geofenceReady && isActive)
const Padding(
padding: EdgeInsets.only(top: 2),
Padding(
padding: const EdgeInsets.only(top: 2),
child: Row(
children: [
Icon(
const Icon(
Icons.shield_moon_outlined,
size: 12,
color: Colors.deepOrangeAccent,
),
SizedBox(width: 4),
const SizedBox(width: 4),
Text(
'Geofence включён',
style: TextStyle(
'Автовыключение: ${home.geofenceRadiusMeters} м',
style: const TextStyle(
color: Colors.deepOrangeAccent,
fontSize: 11,
),
@@ -422,19 +396,19 @@ class _HomeSubtitle extends StatelessWidget {
),
)
else if (home.geofenceReady)
const Padding(
padding: EdgeInsets.only(top: 2),
Padding(
padding: const EdgeInsets.only(top: 2),
child: Row(
children: [
Icon(
const Icon(
Icons.shield_moon_outlined,
size: 12,
color: Colors.white24,
),
SizedBox(width: 4),
const SizedBox(width: 4),
Text(
'Geofence включён',
style: TextStyle(color: Colors.white24, fontSize: 11),
'Автовыключение: ${home.geofenceRadiusMeters} м',
style: const TextStyle(color: Colors.white24, fontSize: 11),
),
],
),
@@ -443,214 +417,3 @@ class _HomeSubtitle extends StatelessWidget {
);
}
}
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';
}