refactor: stabilize app bootstrap and polling

This commit is contained in:
Artem Kokos
2026-04-22 23:37:15 +07:00
parent 7c0a2675c6
commit 762d16dc78
5 changed files with 353 additions and 59 deletions

108
lib/app/app_bootstrap.dart Normal file
View File

@@ -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, AppBootstrapState>(
AppBootstrapNotifier.new,
);
class AppBootstrapNotifier extends Notifier<AppBootstrapState> {
@override
AppBootstrapState build() => const AppBootstrapState.bootstrapping();
Future<void> 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,
};
}
}

View File

@@ -3,7 +3,7 @@ import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:workmanager/workmanager.dart'; import 'package:workmanager/workmanager.dart';
import 'providers/providers.dart'; import 'app/app_bootstrap.dart';
import 'screens/homes_screen.dart'; import 'screens/homes_screen.dart';
import 'screens/remote_screen.dart'; import 'screens/remote_screen.dart';
import 'services/geofence_worker.dart'; import 'services/geofence_worker.dart';
@@ -78,53 +78,83 @@ class _MainGateState extends ConsumerState<MainGate> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_bootstrap(); Future.microtask(_bootstrap);
} }
Future<void> _bootstrap() async { Future<void> _bootstrap() async {
try { await ref.read(appBootstrapProvider.notifier).bootstrap();
// Загружаем список домов
await ref.read(homesProvider.notifier).load();
await ref.read(currentHomeProvider.notifier).load();
if (!mounted) return; if (!mounted) return;
final home = ref.read(currentHomeProvider); final bootstrap = ref.read(appBootstrapProvider);
switch (bootstrap.status) {
if (home != null) { case AppBootstrapStatus.ready:
// Есть дом -- идём на пульт управления
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( Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const RemoteScreen()), MaterialPageRoute(builder: (_) => const RemoteScreen()),
); );
} break;
} else { case AppBootstrapStatus.noHomes:
// Нет домов -- на экран управления домами
if (mounted) {
Navigator.of(context).pushReplacement( Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const HomesScreen()), MaterialPageRoute(builder: (_) => const HomesScreen()),
); );
} break;
} case AppBootstrapStatus.bootstrapping:
} catch (e) { case AppBootstrapStatus.authFailed:
debugPrint("Ошибка бутстрапа: $e"); case AppBootstrapStatus.networkFailed:
if (mounted) { case AppBootstrapStatus.invalidConfig:
Navigator.of(context).pushReplacement( case AppBootstrapStatus.failed:
MaterialPageRoute(builder: (_) => const HomesScreen()), break;
);
}
} }
} }
@override @override
Widget build(BuildContext context) { 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( return const Scaffold(
body: Center( body: Center(
child: Column( child: Column(

View File

@@ -14,7 +14,7 @@ import 'package:workmanager/workmanager.dart';
/// Синглтон сервиса настроек /// Синглтон сервиса настроек
final settingsServiceProvider = Provider((ref) => SettingsService()); final settingsServiceProvider = Provider((ref) => SettingsService());
/// API-клиент -- пересоздаётся при смене дома /// API-клиент текущего дома. Конфигурация меняется через init().
final apiProvider = Provider((ref) => IgnisApi()); final apiProvider = Provider((ref) => IgnisApi());
// ─── Текущий дом ───────────────────────────────────────────── // ─── Текущий дом ─────────────────────────────────────────────
@@ -43,8 +43,6 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
await svc.setCurrentHomeId(home.id); await svc.setCurrentHomeId(home.id);
state = home; state = home;
await _initApi(home); await _initApi(home);
// Перезагрузить группы для нового дома
await ref.read(groupsProvider.notifier).initAndRefresh();
} }
/// Инициализировать API-клиент текущим домом /// Инициализировать API-клиент текущим домом
@@ -234,9 +232,61 @@ final groupsProvider = NotifierProvider<GroupsNotifier, List<dynamic>>(
() => GroupsNotifier(), () => 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, GroupsLoadState>(
GroupsLoadStateNotifier.new,
);
class GroupsLoadStateNotifier extends Notifier<GroupsLoadState> {
@override
GroupsLoadState build() => const GroupsLoadState.idle();
void setIdle() => state = const GroupsLoadState.idle();
void setLoading() => state = const GroupsLoadState.loading();
void setData(List<dynamic> groups) {
state = groups.isEmpty
? const GroupsLoadState.empty()
: const GroupsLoadState.data();
}
void setError(Object error) =>
state = GroupsLoadState.error(error.toString());
}
class GroupsNotifier extends Notifier<List<dynamic>> { class GroupsNotifier extends Notifier<List<dynamic>> {
IgnisApi get _api => ref.read(apiProvider); IgnisApi get _api => ref.read(apiProvider);
Timer? _timer; Timer? _timer;
bool _polling = false;
bool _refreshInFlight = false;
int? _refreshGeneration;
int _pollingGeneration = 0;
String? _pollingHomeId;
/// Блокировка обновления для группы после управления -- /// Блокировка обновления для группы после управления --
/// чтобы UI не прыгал пока лампа ещё не ответила. /// чтобы UI не прыгал пока лампа ещё не ответила.
@@ -248,7 +298,7 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
@override @override
List<dynamic> build() { List<dynamic> build() {
ref.onDispose(() { ref.onDispose(() {
_timer?.cancel(); _stopPolling(resetStatus: false);
for (final t in _debounceTimers.values) { for (final t in _debounceTimers.values) {
t.cancel(); t.cancel();
} }
@@ -256,21 +306,58 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
return []; return [];
} }
/// Инициализация: настроить API и начать периодический опрос /// Настроить API и начать периодический опрос для текущего дома.
Future<void> initAndRefresh() async { Future<void> startPolling() async {
final home = ref.read(currentHomeProvider); 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 final apiKey = await ref
.read(settingsServiceProvider) .read(settingsServiceProvider)
.requireHomeApiKey(home.id); .requireHomeApiKey(home.id);
_api.init(home.url, apiKey); _api.init(home.url, apiKey);
await refresh(); await refresh();
_timer?.cancel(); if (!_isActiveGeneration(generation)) return;
_timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh()); _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<void> refresh() async { Future<void> refresh() async {
final generation = _pollingGeneration;
if (_refreshInFlight && _refreshGeneration == generation) return;
_refreshInFlight = true;
_refreshGeneration = generation;
if (state.isEmpty) {
ref.read(groupsLoadStateProvider.notifier).setLoading();
}
try { try {
final resGroups = await _api.getGroups(); final resGroups = await _api.getGroups();
List<dynamic> rawList = []; List<dynamic> rawList = [];
@@ -338,11 +425,25 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
}), }),
); );
if (!_isActiveGeneration(generation)) return;
state = updatedList; state = updatedList;
ref.read(groupsLoadStateProvider.notifier).setData(updatedList);
} catch (e) { } catch (e) {
debugPrint("Ошибка глобального опроса: $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 не перетирал значения) /// Установить блокировку на 5 секунд (чтобы UI не перетирал значения)
void _setLock(String id) => void _setLock(String id) =>
@@ -711,7 +812,7 @@ class AuthInfoNotifier extends Notifier<Map<String, dynamic>?> {
@override @override
Map<String, dynamic>? build() => null; Map<String, dynamic>? build() => null;
Future<void> load() async { Future<void> load({bool failOnError = false}) async {
try { try {
final api = ref.read(apiProvider); final api = ref.read(apiProvider);
final res = await api.getAuthMe(); final res = await api.getAuthMe();
@@ -720,6 +821,7 @@ class AuthInfoNotifier extends Notifier<Map<String, dynamic>?> {
} }
} catch (e) { } catch (e) {
debugPrint("Ошибка загрузки auth/me: $e"); debugPrint("Ошибка загрузки auth/me: $e");
if (failOnError) rethrow;
} }
} }

View File

@@ -180,13 +180,21 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
/// Выбрать дом и перейти на пульт /// Выбрать дом и перейти на пульт
void _selectHome(BuildContext context, HomeConfig home) async { void _selectHome(BuildContext context, HomeConfig home) async {
try {
await ref.read(currentHomeProvider.notifier).switchTo(home); await ref.read(currentHomeProvider.notifier).switchTo(home);
await ref.read(authInfoProvider.notifier).load(); await ref.read(authInfoProvider.notifier).load(failOnError: true);
if (context.mounted) { if (context.mounted) {
Navigator.of(context).pushReplacement( Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const RemoteScreen()), MaterialPageRoute(builder: (_) => const RemoteScreen()),
); );
} }
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Не удалось выбрать дом: $e')));
}
}
} }
/// Добавить новый дом /// Добавить новый дом

View File

@@ -19,23 +19,22 @@ class RemoteScreen extends ConsumerStatefulWidget {
} }
class _RemoteScreenState extends ConsumerState<RemoteScreen> { class _RemoteScreenState extends ConsumerState<RemoteScreen> {
bool _loading = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_bootstrap(); Future.microtask(() => ref.read(groupsProvider.notifier).startPolling());
} }
Future<void> _bootstrap() async { @override
await ref.read(groupsProvider.notifier).initAndRefresh(); void dispose() {
await ref.read(authInfoProvider.notifier).load(); ref.read(groupsProvider.notifier).stopPolling();
if (mounted) setState(() => _loading = false); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final groups = ref.watch(groupsProvider); final groups = ref.watch(groupsProvider);
final groupsLoadState = ref.watch(groupsLoadStateProvider);
final currentHome = ref.watch(currentHomeProvider); final currentHome = ref.watch(currentHomeProvider);
final authInfo = ref.watch(authInfoProvider); final authInfo = ref.watch(authInfoProvider);
final isAdmin = authInfo?['is_admin'] == true; final isAdmin = authInfo?['is_admin'] == true;
@@ -124,7 +123,7 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
), ),
], ],
), ),
body: _loading body: groupsLoadState.isLoading && groups.isEmpty
? const Center( ? const Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -138,6 +137,8 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
], ],
), ),
) )
: groupsLoadState.hasError && groups.isEmpty
? _GroupsErrorView(message: groupsLoadState.errorMessage)
: groups.isEmpty : groups.isEmpty
? Center( ? Center(
child: Column( child: Column(
@@ -241,3 +242,48 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
} }
} }
} }
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('Повторить'),
),
],
),
),
);
}
}