From 762d16dc7826b4ff579b2c8c27d9a8d64dcb350e Mon Sep 17 00:00:00 2001 From: Artem Kokos Date: Wed, 22 Apr 2026 23:37:15 +0700 Subject: [PATCH] refactor: stabilize app bootstrap and polling --- lib/app/app_bootstrap.dart | 108 +++++++++++++++++++++++++++++ lib/main.dart | 102 ++++++++++++++++++---------- lib/providers/providers.dart | 120 ++++++++++++++++++++++++++++++--- lib/screens/homes_screen.dart | 20 ++++-- lib/screens/remote_screen.dart | 62 ++++++++++++++--- 5 files changed, 353 insertions(+), 59 deletions(-) create mode 100644 lib/app/app_bootstrap.dart diff --git a/lib/app/app_bootstrap.dart b/lib/app/app_bootstrap.dart new file mode 100644 index 0000000..13fd744 --- /dev/null +++ b/lib/app/app_bootstrap.dart @@ -0,0 +1,108 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/providers.dart'; + +enum AppBootstrapStatus { + bootstrapping, + noHomes, + ready, + authFailed, + networkFailed, + invalidConfig, + failed, +} + +class AppBootstrapState { + final AppBootstrapStatus status; + final String? message; + + const AppBootstrapState._(this.status, {this.message}); + + const AppBootstrapState.bootstrapping() + : this._(AppBootstrapStatus.bootstrapping); + + const AppBootstrapState.noHomes() : this._(AppBootstrapStatus.noHomes); + + const AppBootstrapState.ready() : this._(AppBootstrapStatus.ready); + + const AppBootstrapState.authFailed(String message) + : this._(AppBootstrapStatus.authFailed, message: message); + + const AppBootstrapState.networkFailed(String message) + : this._(AppBootstrapStatus.networkFailed, message: message); + + const AppBootstrapState.invalidConfig(String message) + : this._(AppBootstrapStatus.invalidConfig, message: message); + + const AppBootstrapState.failed(String message) + : this._(AppBootstrapStatus.failed, message: message); + + bool get isBootstrapping => status == AppBootstrapStatus.bootstrapping; +} + +final appBootstrapProvider = + NotifierProvider( + AppBootstrapNotifier.new, + ); + +class AppBootstrapNotifier extends Notifier { + @override + AppBootstrapState build() => const AppBootstrapState.bootstrapping(); + + Future bootstrap() async { + state = const AppBootstrapState.bootstrapping(); + + try { + await ref.read(homesProvider.notifier).load(); + await ref.read(currentHomeProvider.notifier).load(); + + final home = ref.read(currentHomeProvider); + if (home == null) { + state = const AppBootstrapState.noHomes(); + return; + } + + await ref.read(authInfoProvider.notifier).load(failOnError: true); + await syncGeofenceTask(ref.read(homesProvider)); + + state = const AppBootstrapState.ready(); + } catch (e) { + state = _failureState(e); + } + } + + AppBootstrapState _failureState(Object error) { + if (error is StateError || error is FormatException) { + return AppBootstrapState.invalidConfig(error.toString()); + } + + if (error is DioException) { + final statusCode = error.response?.statusCode; + if (statusCode == 401 || statusCode == 403) { + return AppBootstrapState.authFailed( + 'API key не прошёл авторизацию ($statusCode)', + ); + } + + if (_isNetworkFailure(error)) { + return AppBootstrapState.networkFailed( + 'Backend недоступен: ${error.message ?? error.type.name}', + ); + } + } + + return AppBootstrapState.failed(error.toString()); + } + + bool _isNetworkFailure(DioException error) { + return switch (error.type) { + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout || + DioExceptionType.connectionError || + DioExceptionType.unknown => true, + _ => false, + }; + } +} diff --git a/lib/main.dart b/lib/main.dart index 75f2015..86e7c06 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,7 +3,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:workmanager/workmanager.dart'; -import 'providers/providers.dart'; +import 'app/app_bootstrap.dart'; import 'screens/homes_screen.dart'; import 'screens/remote_screen.dart'; import 'services/geofence_worker.dart'; @@ -78,53 +78,83 @@ class _MainGateState extends ConsumerState { @override void initState() { super.initState(); - _bootstrap(); + Future.microtask(_bootstrap); } Future _bootstrap() async { - try { - // Загружаем список домов - await ref.read(homesProvider.notifier).load(); - await ref.read(currentHomeProvider.notifier).load(); + await ref.read(appBootstrapProvider.notifier).bootstrap(); + if (!mounted) return; - if (!mounted) return; - - final home = ref.read(currentHomeProvider); - - if (home != null) { - // Есть дом -- идём на пульт управления - await ref.read(groupsProvider.notifier).initAndRefresh(); - // Загружаем info об авторизации (admin / не admin) - await ref.read(authInfoProvider.notifier).load(); - - // Запускаем / обновляем геофенс-таск если нужно - await syncGeofenceTask(ref.read(homesProvider)); - - if (mounted) { - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const RemoteScreen()), - ); - } - } else { - // Нет домов -- на экран управления домами - if (mounted) { - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const HomesScreen()), - ); - } - } - } catch (e) { - debugPrint("Ошибка бутстрапа: $e"); - if (mounted) { + final bootstrap = ref.read(appBootstrapProvider); + switch (bootstrap.status) { + case AppBootstrapStatus.ready: + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const RemoteScreen()), + ); + break; + case AppBootstrapStatus.noHomes: Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (_) => const HomesScreen()), ); - } + break; + case AppBootstrapStatus.bootstrapping: + case AppBootstrapStatus.authFailed: + case AppBootstrapStatus.networkFailed: + case AppBootstrapStatus.invalidConfig: + case AppBootstrapStatus.failed: + break; } } @override Widget build(BuildContext context) { + final bootstrap = ref.watch(appBootstrapProvider); + final message = bootstrap.message; + + if (!bootstrap.isBootstrapping && message != null) { + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.warning_amber_rounded, + color: Colors.deepOrange, + size: 56, + ), + const SizedBox(height: 16), + const Text( + 'Не удалось запустить приложение', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white54), + ), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: _bootstrap, + icon: const Icon(Icons.refresh), + label: const Text('Повторить'), + ), + TextButton( + onPressed: () => Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const HomesScreen()), + ), + child: const Text('Открыть список домов'), + ), + ], + ), + ), + ), + ); + } + return const Scaffold( body: Center( child: Column( diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart index c7f2233..26713e7 100644 --- a/lib/providers/providers.dart +++ b/lib/providers/providers.dart @@ -14,7 +14,7 @@ import 'package:workmanager/workmanager.dart'; /// Синглтон сервиса настроек final settingsServiceProvider = Provider((ref) => SettingsService()); -/// API-клиент -- пересоздаётся при смене дома +/// API-клиент текущего дома. Конфигурация меняется через init(). final apiProvider = Provider((ref) => IgnisApi()); // ─── Текущий дом ───────────────────────────────────────────── @@ -43,8 +43,6 @@ class CurrentHomeNotifier extends Notifier { await svc.setCurrentHomeId(home.id); state = home; await _initApi(home); - // Перезагрузить группы для нового дома - await ref.read(groupsProvider.notifier).initAndRefresh(); } /// Инициализировать API-клиент текущим домом @@ -234,9 +232,61 @@ final groupsProvider = NotifierProvider>( () => GroupsNotifier(), ); +enum GroupsLoadStatus { idle, loading, data, empty, error } + +class GroupsLoadState { + final GroupsLoadStatus status; + final String? errorMessage; + + const GroupsLoadState._(this.status, {this.errorMessage}); + + const GroupsLoadState.idle() : this._(GroupsLoadStatus.idle); + + const GroupsLoadState.loading() : this._(GroupsLoadStatus.loading); + + const GroupsLoadState.data() : this._(GroupsLoadStatus.data); + + const GroupsLoadState.empty() : this._(GroupsLoadStatus.empty); + + const GroupsLoadState.error(String message) + : this._(GroupsLoadStatus.error, errorMessage: message); + + bool get isLoading => status == GroupsLoadStatus.loading; + + bool get hasError => status == GroupsLoadStatus.error; +} + +final groupsLoadStateProvider = + NotifierProvider( + GroupsLoadStateNotifier.new, + ); + +class GroupsLoadStateNotifier extends Notifier { + @override + GroupsLoadState build() => const GroupsLoadState.idle(); + + void setIdle() => state = const GroupsLoadState.idle(); + + void setLoading() => state = const GroupsLoadState.loading(); + + void setData(List groups) { + state = groups.isEmpty + ? const GroupsLoadState.empty() + : const GroupsLoadState.data(); + } + + void setError(Object error) => + state = GroupsLoadState.error(error.toString()); +} + class GroupsNotifier extends Notifier> { IgnisApi get _api => ref.read(apiProvider); Timer? _timer; + bool _polling = false; + bool _refreshInFlight = false; + int? _refreshGeneration; + int _pollingGeneration = 0; + String? _pollingHomeId; /// Блокировка обновления для группы после управления -- /// чтобы UI не прыгал пока лампа ещё не ответила. @@ -248,7 +298,7 @@ class GroupsNotifier extends Notifier> { @override List build() { ref.onDispose(() { - _timer?.cancel(); + _stopPolling(resetStatus: false); for (final t in _debounceTimers.values) { t.cancel(); } @@ -256,21 +306,58 @@ class GroupsNotifier extends Notifier> { return []; } - /// Инициализация: настроить API и начать периодический опрос - Future initAndRefresh() async { + /// Настроить API и начать периодический опрос для текущего дома. + Future startPolling() async { final home = ref.read(currentHomeProvider); - if (home == null) return; + if (home == null) { + stopPolling(); + return; + } + + if (_polling && _pollingHomeId == home.id) { + return; + } + + _stopPolling(resetStatus: false); + _polling = true; + _pollingHomeId = home.id; + final generation = ++_pollingGeneration; + final apiKey = await ref .read(settingsServiceProvider) .requireHomeApiKey(home.id); _api.init(home.url, apiKey); + await refresh(); - _timer?.cancel(); + if (!_isActiveGeneration(generation)) return; + _timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh()); } + void stopPolling() => _stopPolling(); + + void _stopPolling({bool resetStatus = true}) { + _timer?.cancel(); + _timer = null; + _polling = false; + _pollingHomeId = null; + _pollingGeneration++; + if (resetStatus) { + ref.read(groupsLoadStateProvider.notifier).setIdle(); + } + } + /// Полный опрос: загрузить группы + статус каждой Future refresh() async { + final generation = _pollingGeneration; + if (_refreshInFlight && _refreshGeneration == generation) return; + + _refreshInFlight = true; + _refreshGeneration = generation; + if (state.isEmpty) { + ref.read(groupsLoadStateProvider.notifier).setLoading(); + } + try { final resGroups = await _api.getGroups(); List rawList = []; @@ -338,12 +425,26 @@ class GroupsNotifier extends Notifier> { }), ); + if (!_isActiveGeneration(generation)) return; + state = updatedList; + ref.read(groupsLoadStateProvider.notifier).setData(updatedList); } catch (e) { debugPrint("Ошибка глобального опроса: $e"); + if (_isActiveGeneration(generation)) { + ref.read(groupsLoadStateProvider.notifier).setError(e); + } + } finally { + if (_refreshGeneration == generation) { + _refreshInFlight = false; + _refreshGeneration = null; + } } } + bool _isActiveGeneration(int generation) => + generation == _pollingGeneration && _polling; + /// Установить блокировку на 5 секунд (чтобы UI не перетирал значения) void _setLock(String id) => _lockUntil[id] = DateTime.now().add(const Duration(seconds: 5)); @@ -711,7 +812,7 @@ class AuthInfoNotifier extends Notifier?> { @override Map? build() => null; - Future load() async { + Future load({bool failOnError = false}) async { try { final api = ref.read(apiProvider); final res = await api.getAuthMe(); @@ -720,6 +821,7 @@ class AuthInfoNotifier extends Notifier?> { } } catch (e) { debugPrint("Ошибка загрузки auth/me: $e"); + if (failOnError) rethrow; } } diff --git a/lib/screens/homes_screen.dart b/lib/screens/homes_screen.dart index afc6c96..10c5b15 100644 --- a/lib/screens/homes_screen.dart +++ b/lib/screens/homes_screen.dart @@ -180,12 +180,20 @@ class _HomesScreenState extends ConsumerState { /// Выбрать дом и перейти на пульт void _selectHome(BuildContext context, HomeConfig home) async { - await ref.read(currentHomeProvider.notifier).switchTo(home); - await ref.read(authInfoProvider.notifier).load(); - if (context.mounted) { - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const RemoteScreen()), - ); + try { + await ref.read(currentHomeProvider.notifier).switchTo(home); + await ref.read(authInfoProvider.notifier).load(failOnError: true); + if (context.mounted) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const RemoteScreen()), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Не удалось выбрать дом: $e'))); + } } } diff --git a/lib/screens/remote_screen.dart b/lib/screens/remote_screen.dart index 585803b..5743b01 100644 --- a/lib/screens/remote_screen.dart +++ b/lib/screens/remote_screen.dart @@ -19,23 +19,22 @@ class RemoteScreen extends ConsumerStatefulWidget { } class _RemoteScreenState extends ConsumerState { - bool _loading = true; - @override void initState() { super.initState(); - _bootstrap(); + Future.microtask(() => ref.read(groupsProvider.notifier).startPolling()); } - Future _bootstrap() async { - await ref.read(groupsProvider.notifier).initAndRefresh(); - await ref.read(authInfoProvider.notifier).load(); - if (mounted) setState(() => _loading = false); + @override + void dispose() { + ref.read(groupsProvider.notifier).stopPolling(); + super.dispose(); } @override Widget build(BuildContext context) { final groups = ref.watch(groupsProvider); + final groupsLoadState = ref.watch(groupsLoadStateProvider); final currentHome = ref.watch(currentHomeProvider); final authInfo = ref.watch(authInfoProvider); final isAdmin = authInfo?['is_admin'] == true; @@ -124,7 +123,7 @@ class _RemoteScreenState extends ConsumerState { ), ], ), - body: _loading + body: groupsLoadState.isLoading && groups.isEmpty ? const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -138,6 +137,8 @@ class _RemoteScreenState extends ConsumerState { ], ), ) + : groupsLoadState.hasError && groups.isEmpty + ? _GroupsErrorView(message: groupsLoadState.errorMessage) : groups.isEmpty ? Center( child: Column( @@ -241,3 +242,48 @@ class _RemoteScreenState extends ConsumerState { } } } + +class _GroupsErrorView extends ConsumerWidget { + final String? message; + + const _GroupsErrorView({this.message}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.wifi_off_rounded, + size: 64, + color: Colors.deepOrange, + ), + const SizedBox(height: 16), + const Text( + 'Не удалось загрузить группы', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white70, fontSize: 16), + ), + if (message != null) ...[ + const SizedBox(height: 8), + Text( + message!, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white38, fontSize: 12), + ), + ], + const SizedBox(height: 16), + FilledButton.icon( + onPressed: () => ref.read(groupsProvider.notifier).refresh(), + icon: const Icon(Icons.refresh), + label: const Text('Повторить'), + ), + ], + ), + ), + ); + } +}