diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart index adda6e5..222d6f0 100644 --- a/lib/providers/providers.dart +++ b/lib/providers/providers.dart @@ -55,6 +55,27 @@ class CurrentHomeNotifier extends Notifier { await _initApi(home); } + /// Выбрать дом как активный и сразу проверить auth-state. + /// Если `auth/me` падает, откатываемся к предыдущему дому и auth-state. + Future select(HomeConfig home) async { + final previousHome = state; + final previousAuthState = ref.read(authInfoProvider); + + try { + await switchTo(home); + await ref.read(authInfoProvider.notifier).load(failOnError: true); + } catch (error) { + await _restoreSelection(previousHome); + ref.read(authInfoProvider.notifier).restore(previousAuthState); + rethrow; + } + } + + Future clear() async { + await ref.read(settingsServiceProvider).setCurrentHomeId(null); + state = null; + } + /// Инициализировать API-клиент текущим домом Future _initApi(HomeConfig home) async { final apiKey = await ref @@ -62,6 +83,18 @@ class CurrentHomeNotifier extends Notifier { .requireHomeApiKey(home.id); ref.read(apiProvider).init(home.url, apiKey); } + + Future _restoreSelection(HomeConfig? home) async { + if (home == null) { + await clear(); + return; + } + + final svc = ref.read(settingsServiceProvider); + await svc.setCurrentHomeId(home.id); + state = home; + await _initApi(home); + } } // ─── Список домов ──────────────────────────────────────────── @@ -798,26 +831,35 @@ class ApiKeysNotifier extends Notifier>> { // ─── Информация об авторизации ──────────────────────────────── -final authInfoProvider = NotifierProvider( - () => AuthInfoNotifier(), -); +final authInfoProvider = + NotifierProvider>( + () => AuthInfoNotifier(), + ); -class AuthInfoNotifier extends Notifier { +class AuthInfoNotifier extends Notifier> { @override - AuthInfo? build() => null; + LoadState build() => const LoadState.idle(null); - Future load({bool failOnError = false}) async { + Future load({bool failOnError = false}) async { + state = LoadState.loading(state.data); try { final api = ref.read(apiProvider); final res = await api.getAuthMe(); - state = AuthInfo.fromApi(res.data); + final authInfo = AuthInfo.fromApi(res.data); + state = LoadState.data(authInfo); + return authInfo; } catch (e) { - debugPrint("Ошибка загрузки auth/me: $e"); + state = LoadState.error(null, describeLoadError(e)); if (failOnError) rethrow; + return null; } } - bool get isAdmin => state?.isAdmin == true; + void clear() => state = const LoadState.idle(null); + + void restore(LoadState restoredState) => state = restoredState; + + bool get isAdmin => state.data?.isAdmin == true; } // ─── Геофенс: управление фоновым таском ───────────────────── diff --git a/lib/screens/home_edit_screen.dart b/lib/screens/home_edit_screen.dart index 22891d5..77a3a36 100644 --- a/lib/screens/home_edit_screen.dart +++ b/lib/screens/home_edit_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../app/error_message.dart'; import '../models/home_config.dart'; import '../providers/providers.dart'; import '../services/api_client.dart'; @@ -321,7 +322,7 @@ class _HomeEditScreenState extends ConsumerState { final currentHome = ref.read(currentHomeProvider); if (currentHome?.id == home.id) { - await ref.read(currentHomeProvider.notifier).switchTo(home); + await ref.read(currentHomeProvider.notifier).select(home); } // Синхронизировать фоновый таск с новыми настройками @@ -331,9 +332,13 @@ class _HomeEditScreenState extends ConsumerState { if (mounted) Navigator.of(context).pop(); } catch (e) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Не удалось проверить дом: $e'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Не удалось сохранить дом: ${describeLoadError(e)}', + ), + ), + ); } } finally { if (mounted) setState(() => _saving = false); diff --git a/lib/screens/homes_screen.dart b/lib/screens/homes_screen.dart index c9f7e80..1eeafd8 100644 --- a/lib/screens/homes_screen.dart +++ b/lib/screens/homes_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../app/error_message.dart'; import '../models/home_config.dart'; import '../providers/providers.dart'; import '../widgets/build_info_text.dart'; @@ -17,6 +18,8 @@ class HomesScreen extends ConsumerStatefulWidget { class _HomesScreenState extends ConsumerState { late final UserLocationNotifier _userLocationNotifier; + String? _switchingHomeId; + String? _deletingHomeId; @override void initState() { @@ -53,6 +56,9 @@ class _HomesScreenState extends ConsumerState { 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, @@ -61,6 +67,7 @@ class _HomesScreenState extends ConsumerState { return Card( margin: const EdgeInsets.only(bottom: 8), child: ListTile( + enabled: !isBusy, leading: Icon( Icons.home, color: isActive @@ -87,25 +94,35 @@ class _HomesScreenState extends ConsumerState { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - IconButton( - icon: const Icon( - Icons.edit, - size: 20, - color: Colors.white38, + 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), ), - onPressed: () => _editHome(context, home), - ), - IconButton( - icon: const Icon( - Icons.delete_outline, - size: 20, - color: Colors.redAccent, + IconButton( + icon: const Icon( + Icons.delete_outline, + size: 20, + color: Colors.redAccent, + ), + onPressed: () => _confirmDelete(context, home), ), - onPressed: () => _confirmDelete(context, home), - ), + ], ], ), - onTap: () => _selectHome(context, home), + onTap: isBusy ? null : () => _selectHome(context, home), ), ); }, @@ -126,9 +143,12 @@ class _HomesScreenState extends ConsumerState { } void _selectHome(BuildContext context, HomeConfig home) async { + if (_switchingHomeId != null || _deletingHomeId != null) return; + + setState(() => _switchingHomeId = home.id); + final messenger = ScaffoldMessenger.of(context); try { - await ref.read(currentHomeProvider.notifier).switchTo(home); - await ref.read(authInfoProvider.notifier).load(failOnError: true); + await ref.read(currentHomeProvider.notifier).select(home); if (context.mounted) { Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (_) => const RemoteScreen()), @@ -136,9 +156,17 @@ class _HomesScreenState extends ConsumerState { } } catch (e) { if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Не удалось выбрать дом: $e'))); + messenger.showSnackBar( + SnackBar( + content: Text( + 'Не удалось выбрать дом: ${describeLoadError(e)}', + ), + ), + ); + } + } finally { + if (mounted) { + setState(() => _switchingHomeId = null); } } } @@ -169,8 +197,7 @@ class _HomesScreenState extends ConsumerState { TextButton( onPressed: () async { Navigator.of(ctx).pop(); - await ref.read(homesProvider.notifier).remove(home.id); - await syncGeofenceTask(ref.read(homesProvider)); + await _deleteHome(context, home); }, child: const Text( 'Удалить', @@ -181,6 +208,37 @@ class _HomesScreenState extends ConsumerState { ), ); } + + Future _deleteHome(BuildContext context, HomeConfig home) async { + if (_switchingHomeId != null || _deletingHomeId != null) return; + + final deletedCurrentHome = ref.read(currentHomeProvider)?.id == home.id; + setState(() => _deletingHomeId = home.id); + final messenger = ScaffoldMessenger.of(context); + + try { + await ref.read(homesProvider.notifier).remove(home.id); + await ref.read(currentHomeProvider.notifier).load(); + if (deletedCurrentHome) { + ref.read(authInfoProvider.notifier).clear(); + } + await syncGeofenceTask(ref.read(homesProvider)); + } catch (e) { + if (context.mounted) { + messenger.showSnackBar( + SnackBar( + content: Text( + 'Не удалось удалить дом: ${describeLoadError(e)}', + ), + ), + ); + } + } finally { + if (mounted) { + setState(() => _deletingHomeId = null); + } + } + } } class _EmptyHomesView extends StatelessWidget { diff --git a/lib/screens/remote_screen.dart b/lib/screens/remote_screen.dart index 21d0f23..2f05d32 100644 --- a/lib/screens/remote_screen.dart +++ b/lib/screens/remote_screen.dart @@ -39,8 +39,8 @@ class _RemoteScreenState extends ConsumerState { final groups = ref.watch(groupsProvider); final groupsLoadState = ref.watch(groupsLoadStateProvider); final currentHome = ref.watch(currentHomeProvider); - final authInfo = ref.watch(authInfoProvider); - final isAdmin = authInfo?.isAdmin == true; + final authInfoState = ref.watch(authInfoProvider); + final isAdmin = authInfoState.data?.isAdmin == true; return Scaffold( appBar: AppBar( diff --git a/test/read_only_load_state_test.dart b/test/read_only_load_state_test.dart index 7a38d5e..8cc787f 100644 --- a/test/read_only_load_state_test.dart +++ b/test/read_only_load_state_test.dart @@ -2,9 +2,30 @@ import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ignis_app/app/load_state.dart'; +import 'package:ignis_app/models/home_config.dart'; import 'package:ignis_app/models/ignis_group.dart'; import 'package:ignis_app/providers/providers.dart'; +import 'package:ignis_app/services/credentials_storage.dart'; import 'package:ignis_app/services/api_client.dart'; +import 'package:ignis_app/services/settings_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class InMemoryCredentialsStorage implements CredentialsStorage { + final Map _apiKeys = {}; + + @override + Future getApiKey(String homeId) async => _apiKeys[homeId]; + + @override + Future setApiKey(String homeId, String apiKey) async { + _apiKeys[homeId] = apiKey; + } + + @override + Future deleteApiKey(String homeId) async { + _apiKeys.remove(homeId); + } +} class FakeIgnisApi extends IgnisApi { Object? groupsData; @@ -183,9 +204,18 @@ class FakeIgnisApi extends IgnisApi { } void main() { - ProviderContainer containerWith(FakeIgnisApi api) { + TestWidgetsFlutterBinding.ensureInitialized(); + + ProviderContainer containerWith( + FakeIgnisApi api, { + SettingsService? settingsService, + }) { + final overrides = [apiProvider.overrideWithValue(api)]; + if (settingsService != null) { + overrides.add(settingsServiceProvider.overrideWithValue(settingsService)); + } final container = ProviderContainer( - overrides: [apiProvider.overrideWithValue(api)], + overrides: overrides, ); addTearDown(container.dispose); return container; @@ -448,11 +478,32 @@ void main() { await container.read(authInfoProvider.notifier).load(); final state = container.read(authInfoProvider); - expect(state?.isAdmin, isTrue); - expect(state?.name, 'owner'); + expect(state.status, LoadStatus.data); + expect(state.data?.isAdmin, isTrue); + expect(state.data?.name, 'owner'); expect(container.read(authInfoProvider.notifier).isAdmin, isTrue); }); + test('auth info load error clears stale auth state and exposes message', () async { + final api = FakeIgnisApi(authData: {'is_admin': true, 'name': 'owner'}); + final container = containerWith(api); + + await container.read(authInfoProvider.notifier).load(); + api.authError = DioException( + requestOptions: RequestOptions(path: '/auth/me'), + type: DioExceptionType.connectionError, + message: 'No route to host', + ); + + await container.read(authInfoProvider.notifier).load(); + + final state = container.read(authInfoProvider); + expect(state.status, LoadStatus.error); + expect(state.data, isNull); + expect(state.errorMessage, contains('Backend недоступен')); + expect(container.read(authInfoProvider.notifier).isAdmin, isFalse); + }); + test('api keys load exposes empty state', () async { final api = FakeIgnisApi(apiKeysData: {'keys': []}); final container = containerWith(api); @@ -630,4 +681,54 @@ void main() { expect(error.message, contains('Backend недоступен')); expect(api.controlGroupParams, containsPair('brightness', 42)); }); + + test('home selection rolls back current home and auth state on auth failure', () async { + SharedPreferences.setMockInitialValues({ + 'ignis_homes': + '[{"id":"home-1","name":"Home 1","url":"https://one.example","geofenceEnabled":false},' + '{"id":"home-2","name":"Home 2","url":"https://two.example","geofenceEnabled":false}]', + 'ignis_current_home_id': 'home-1', + }); + + final settingsService = SettingsService( + credentialsStorage: InMemoryCredentialsStorage(), + ); + await settingsService.setHomeApiKey('home-1', 'key-1'); + await settingsService.setHomeApiKey('home-2', 'key-2'); + + final api = FakeIgnisApi(authData: {'is_admin': true, 'name': 'owner'}); + final container = containerWith(api, settingsService: settingsService); + + await container.read(currentHomeProvider.notifier).load(); + await container.read(authInfoProvider.notifier).load(); + + final secondHome = HomeConfig( + id: 'home-2', + name: 'Home 2', + url: 'https://two.example', + ); + final authError = DioException( + requestOptions: RequestOptions(path: '/auth/me'), + type: DioExceptionType.badResponse, + response: Response( + requestOptions: RequestOptions(path: '/auth/me'), + statusCode: 403, + ), + ); + api.authError = authError; + + await expectLater( + container.read(currentHomeProvider.notifier).select(secondHome), + throwsA(same(authError)), + ); + + final currentHome = container.read(currentHomeProvider); + final authState = container.read(authInfoProvider); + final prefs = await SharedPreferences.getInstance(); + + expect(currentHome?.id, 'home-1'); + expect(authState.status, LoadStatus.data); + expect(authState.data?.isAdmin, isTrue); + expect(prefs.getString('ignis_current_home_id'), 'home-1'); + }); }