From 1d31767ee03036b973a1d1fc8157c5f04a513354 Mon Sep 17 00:00:00 2001 From: Artem Kokos Date: Mon, 13 Apr 2026 22:13:56 +0700 Subject: [PATCH] Geo location on 'homes' screen --- android/app/src/main/AndroidManifest.xml | 2 + ios/Runner/Info.plist | 2 + lib/providers/providers.dart | 141 ++++++++++++++++++++++- lib/screens/homes_screen.dart | 63 ++++++++-- pubspec.yaml | 3 +- 5 files changed, 198 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index dd98557..08a912e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,7 @@ + + UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSLocationWhenInUseUsageDescription + Для отображения расстояния до дома diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart index 65bcecc..e84841d 100644 --- a/lib/providers/providers.dart +++ b/lib/providers/providers.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:geolocator/geolocator.dart'; import '../models/home_config.dart'; import '../services/api_client.dart'; import '../services/settings_service.dart'; @@ -78,6 +79,144 @@ class HomesNotifier extends Notifier> { } } +// ─── Геолокация пользователя ───────────────────────────────── + +/// Состояние геолокации: позиция или причина отсутствия. +/// Запрашивается один раз, кешируется до перезапуска провайдера. +class UserLocation { + final Position? position; + final String? error; // null -- всё ок, иначе причина + + const UserLocation({this.position, this.error}); + + bool get hasPosition => position != null; + + /// Расстояние в км до точки. Возвращает null если нет позиции + /// или у цели нет координат. + double? distanceToKm(double? lat, double? lon) { + if (position == null || lat == null || lon == null) return null; + return calculateDistanceKm( + position!.latitude, position!.longitude, lat, lon, + ); + } +} + +final userLocationProvider = + NotifierProvider( + () => UserLocationNotifier()); + +class UserLocationNotifier extends Notifier { + StreamSubscription? _sub; + int _watchers = 0; + + @override + UserLocation build() { + ref.onDispose(() { + _sub?.cancel(); + _sub = null; + }); + return const UserLocation(); + } + + /// Запросить текущую позицию. Первый вызов проверяет пермишены + /// и отдаёт lastKnown мгновенно (если есть). + Future fetch() async { + if (state.hasPosition) return; + + final err = await _ensurePermission(); + if (err != null) { + state = UserLocation(error: err); + return; + } + + try { + // getLastKnownPosition -- мгновенно, без GPS-фикса + final last = await Geolocator.getLastKnownPosition(); + if (last != null) { + state = UserLocation(position: last); + return; + } + + // Если lastKnown нет -- одноразовый запрос + final pos = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.low, + timeLimit: Duration(seconds: 10), + ), + ); + state = UserLocation(position: pos); + } catch (e) { + state = UserLocation(error: 'Ошибка: $e'); + } + } + + /// Начать непрерывное отслеживание. Вызывать из initState экрана. + /// Ref-counted: несколько экранов могут вызвать startWatching, + /// стрим остановится только когда все вызовут stopWatching. + Future startWatching() async { + _watchers++; + if (_sub != null) return; // уже слушаем + + final err = await _ensurePermission(); + if (err != null) { + state = UserLocation(error: err); + return; + } + + // Отдать lastKnown сразу, пока стрим ещё не дал первый event + if (!state.hasPosition) { + try { + final last = await Geolocator.getLastKnownPosition(); + if (last != null) { + state = UserLocation(position: last); + } + } catch (_) {} + } + + const settings = LocationSettings( + accuracy: LocationAccuracy.low, + distanceFilter: 20, // минимум 20м между событиями + ); + + _sub = Geolocator.getPositionStream(locationSettings: settings).listen( + (pos) => state = UserLocation(position: pos), + onError: (e) { + // Не затираем последнюю позицию -- просто логируем + debugPrint('Ошибка стрима геолокации: $e'); + }, + ); + } + + /// Остановить отслеживание. Вызывать из dispose экрана. + void stopWatching() { + _watchers = (_watchers - 1).clamp(0, 999); + if (_watchers == 0) { + _sub?.cancel(); + _sub = null; + } + } + + /// Проверить сервис и пермишены. Возвращает null если всё ок, + /// иначе строку с причиной ошибки. + Future _ensurePermission() async { + if (!await Geolocator.isLocationServiceEnabled()) { + return 'Геолокация выключена'; + } + + var perm = await Geolocator.checkPermission(); + if (perm == LocationPermission.denied) { + perm = await Geolocator.requestPermission(); + } + if (perm == LocationPermission.denied) { + return 'Нет разрешения'; + } + if (perm == LocationPermission.deniedForever) { + return 'Разрешение запрещено навсегда'; + } + return null; + } +} + // ─── Группы текущего дома ──────────────────────────────────── final groupsProvider = @@ -571,4 +710,4 @@ String formatDistance(double km) { } else { return '${km.round()} км'; } -} +} \ No newline at end of file diff --git a/lib/screens/homes_screen.dart b/lib/screens/homes_screen.dart index 8cd471a..a9b2d22 100644 --- a/lib/screens/homes_screen.dart +++ b/lib/screens/homes_screen.dart @@ -7,13 +7,31 @@ import 'remote_screen.dart'; /// Экран "Дома" -- список серверов Ignis. /// Пользователь может добавить, удалить, переключить активный дом. -class HomesScreen extends ConsumerWidget { +class HomesScreen extends ConsumerStatefulWidget { const HomesScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _HomesScreenState(); +} + +class _HomesScreenState extends ConsumerState { + @override + void initState() { + super.initState(); + Future.microtask(() => ref.read(userLocationProvider.notifier).startWatching()); + } + + @override + void dispose() { + ref.read(userLocationProvider.notifier).stopWatching(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { final homes = ref.watch(homesProvider); final currentHome = ref.watch(currentHomeProvider); + final location = ref.watch(userLocationProvider); return Scaffold( appBar: AppBar( @@ -46,6 +64,11 @@ class HomesScreen extends ConsumerWidget { final home = homes[index]; final isActive = currentHome?.id == home.id; + // Расстояние до дома (null если нет координат или геолокации) + final distKm = location.distanceToKm( + home.latitude, home.longitude, + ); + return Card( margin: const EdgeInsets.only(bottom: 8), child: ListTile( @@ -68,13 +91,31 @@ class HomesScreen extends ConsumerWidget { home.url, style: const TextStyle(color: Colors.white38, fontSize: 12), ), - if (home.hasCoordinates) + if (distKm != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Row( + children: [ + const Icon(Icons.near_me, size: 11, color: Colors.white30), + const SizedBox(width: 4), + Text( + '~${formatDistance(distKm)}', + style: const TextStyle( + color: Colors.white30, + fontSize: 11, + ), + ), + ], + ), + ) + 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), ), ], @@ -87,16 +128,16 @@ class HomesScreen extends ConsumerWidget { // Кнопка редактирования IconButton( icon: const Icon(Icons.edit, size: 20, color: Colors.white38), - onPressed: () => _editHome(context, ref, home), + onPressed: () => _editHome(context, home), ), // Кнопка удаления IconButton( icon: const Icon(Icons.delete_outline, size: 20, color: Colors.redAccent), - onPressed: () => _confirmDelete(context, ref, home), + onPressed: () => _confirmDelete(context, home), ), ], ), - onTap: () => _selectHome(context, ref, home), + onTap: () => _selectHome(context, home), ), ); }, @@ -110,7 +151,7 @@ class HomesScreen extends ConsumerWidget { } /// Выбрать дом и перейти на пульт - void _selectHome(BuildContext context, WidgetRef ref, HomeConfig home) async { + void _selectHome(BuildContext context, HomeConfig home) async { await ref.read(currentHomeProvider.notifier).switchTo(home); await ref.read(authInfoProvider.notifier).load(); if (context.mounted) { @@ -128,14 +169,14 @@ class HomesScreen extends ConsumerWidget { } /// Редактировать дом - void _editHome(BuildContext context, WidgetRef ref, HomeConfig home) { + void _editHome(BuildContext context, HomeConfig home) { Navigator.of(context).push( MaterialPageRoute(builder: (_) => HomeEditScreen(home: home)), ); } /// Подтвердить удаление - void _confirmDelete(BuildContext context, WidgetRef ref, HomeConfig home) { + void _confirmDelete(BuildContext context, HomeConfig home) { showDialog( context: context, builder: (ctx) => AlertDialog( @@ -157,4 +198,4 @@ class HomesScreen extends ConsumerWidget { ), ); } -} +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 722e613..2c7d705 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: dio: ^5.9.2 flutter_riverpod: ^3.3.1 shared_preferences: ^2.5.5 + geolocator: ^13.0.2 dev_dependencies: flutter_test: @@ -97,4 +98,4 @@ flutter_launcher_icons: ios: true image_path: "assets/icon.png" adaptive_icon_background: "#1A1A1A" - adaptive_icon_foreground: "assets/icon.png" + adaptive_icon_foreground: "assets/icon.png" \ No newline at end of file