fix: stabilize auth and home error flows

This commit is contained in:
Artem Kokos
2026-04-27 23:11:45 +07:00
parent c2d7ce5bdc
commit eed04e9122
5 changed files with 247 additions and 41 deletions

View File

@@ -55,6 +55,27 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
await _initApi(home); await _initApi(home);
} }
/// Выбрать дом как активный и сразу проверить auth-state.
/// Если `auth/me` падает, откатываемся к предыдущему дому и auth-state.
Future<void> 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<void> clear() async {
await ref.read(settingsServiceProvider).setCurrentHomeId(null);
state = null;
}
/// Инициализировать API-клиент текущим домом /// Инициализировать API-клиент текущим домом
Future<void> _initApi(HomeConfig home) async { Future<void> _initApi(HomeConfig home) async {
final apiKey = await ref final apiKey = await ref
@@ -62,6 +83,18 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
.requireHomeApiKey(home.id); .requireHomeApiKey(home.id);
ref.read(apiProvider).init(home.url, apiKey); ref.read(apiProvider).init(home.url, apiKey);
} }
Future<void> _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<LoadState<List<ApiKeyInfo>>> {
// ─── Информация об авторизации ──────────────────────────────── // ─── Информация об авторизации ────────────────────────────────
final authInfoProvider = NotifierProvider<AuthInfoNotifier, AuthInfo?>( final authInfoProvider =
NotifierProvider<AuthInfoNotifier, LoadState<AuthInfo?>>(
() => AuthInfoNotifier(), () => AuthInfoNotifier(),
); );
class AuthInfoNotifier extends Notifier<AuthInfo?> { class AuthInfoNotifier extends Notifier<LoadState<AuthInfo?>> {
@override @override
AuthInfo? build() => null; LoadState<AuthInfo?> build() => const LoadState.idle(null);
Future<void> load({bool failOnError = false}) async { Future<AuthInfo?> load({bool failOnError = false}) async {
state = LoadState.loading(state.data);
try { try {
final api = ref.read(apiProvider); final api = ref.read(apiProvider);
final res = await api.getAuthMe(); final res = await api.getAuthMe();
state = AuthInfo.fromApi(res.data); final authInfo = AuthInfo.fromApi(res.data);
state = LoadState.data(authInfo);
return authInfo;
} catch (e) { } catch (e) {
debugPrint("Ошибка загрузки auth/me: $e"); state = LoadState.error(null, describeLoadError(e));
if (failOnError) rethrow; if (failOnError) rethrow;
return null;
} }
} }
bool get isAdmin => state?.isAdmin == true; void clear() => state = const LoadState.idle(null);
void restore(LoadState<AuthInfo?> restoredState) => state = restoredState;
bool get isAdmin => state.data?.isAdmin == true;
} }
// ─── Геофенс: управление фоновым таском ───────────────────── // ─── Геофенс: управление фоновым таском ─────────────────────

View File

@@ -1,5 +1,6 @@
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 '../app/error_message.dart';
import '../models/home_config.dart'; import '../models/home_config.dart';
import '../providers/providers.dart'; import '../providers/providers.dart';
import '../services/api_client.dart'; import '../services/api_client.dart';
@@ -321,7 +322,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
final currentHome = ref.read(currentHomeProvider); final currentHome = ref.read(currentHomeProvider);
if (currentHome?.id == home.id) { 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<HomeEditScreen> {
if (mounted) Navigator.of(context).pop(); if (mounted) Navigator.of(context).pop();
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
).showSnackBar(SnackBar(content: Text('Не удалось проверить дом: $e'))); content: Text(
'Не удалось сохранить дом: ${describeLoadError(e)}',
),
),
);
} }
} finally { } finally {
if (mounted) setState(() => _saving = false); if (mounted) setState(() => _saving = false);

View File

@@ -1,5 +1,6 @@
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 '../app/error_message.dart';
import '../models/home_config.dart'; import '../models/home_config.dart';
import '../providers/providers.dart'; import '../providers/providers.dart';
import '../widgets/build_info_text.dart'; import '../widgets/build_info_text.dart';
@@ -17,6 +18,8 @@ class HomesScreen extends ConsumerStatefulWidget {
class _HomesScreenState extends ConsumerState<HomesScreen> { class _HomesScreenState extends ConsumerState<HomesScreen> {
late final UserLocationNotifier _userLocationNotifier; late final UserLocationNotifier _userLocationNotifier;
String? _switchingHomeId;
String? _deletingHomeId;
@override @override
void initState() { void initState() {
@@ -53,6 +56,9 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final home = homes[index]; final home = homes[index];
final isActive = currentHome?.id == home.id; final isActive = currentHome?.id == home.id;
final isSwitching = _switchingHomeId == home.id;
final isDeleting = _deletingHomeId == home.id;
final isBusy = isSwitching || isDeleting;
final distKm = location.distanceToKm( final distKm = location.distanceToKm(
home.latitude, home.latitude,
home.longitude, home.longitude,
@@ -61,6 +67,7 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
return Card( return Card(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
child: ListTile( child: ListTile(
enabled: !isBusy,
leading: Icon( leading: Icon(
Icons.home, Icons.home,
color: isActive color: isActive
@@ -87,6 +94,15 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (isBusy)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
else ...[
IconButton( IconButton(
icon: const Icon( icon: const Icon(
Icons.edit, Icons.edit,
@@ -104,8 +120,9 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
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<HomesScreen> {
} }
void _selectHome(BuildContext context, HomeConfig home) async { void _selectHome(BuildContext context, HomeConfig home) async {
if (_switchingHomeId != null || _deletingHomeId != null) return;
setState(() => _switchingHomeId = home.id);
final messenger = ScaffoldMessenger.of(context);
try { try {
await ref.read(currentHomeProvider.notifier).switchTo(home); await ref.read(currentHomeProvider.notifier).select(home);
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()),
@@ -136,9 +156,17 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
} }
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of( messenger.showSnackBar(
context, SnackBar(
).showSnackBar(SnackBar(content: Text('Не удалось выбрать дом: $e'))); content: Text(
'Не удалось выбрать дом: ${describeLoadError(e)}',
),
),
);
}
} finally {
if (mounted) {
setState(() => _switchingHomeId = null);
} }
} }
} }
@@ -169,8 +197,7 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
TextButton( TextButton(
onPressed: () async { onPressed: () async {
Navigator.of(ctx).pop(); Navigator.of(ctx).pop();
await ref.read(homesProvider.notifier).remove(home.id); await _deleteHome(context, home);
await syncGeofenceTask(ref.read(homesProvider));
}, },
child: const Text( child: const Text(
'Удалить', 'Удалить',
@@ -181,6 +208,37 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
), ),
); );
} }
Future<void> _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 { class _EmptyHomesView extends StatelessWidget {

View File

@@ -39,8 +39,8 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
final groups = ref.watch(groupsProvider); final groups = ref.watch(groupsProvider);
final groupsLoadState = ref.watch(groupsLoadStateProvider); final groupsLoadState = ref.watch(groupsLoadStateProvider);
final currentHome = ref.watch(currentHomeProvider); final currentHome = ref.watch(currentHomeProvider);
final authInfo = ref.watch(authInfoProvider); final authInfoState = ref.watch(authInfoProvider);
final isAdmin = authInfo?.isAdmin == true; final isAdmin = authInfoState.data?.isAdmin == true;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(

View File

@@ -2,9 +2,30 @@ import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:ignis_app/app/load_state.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/models/ignis_group.dart';
import 'package:ignis_app/providers/providers.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/api_client.dart';
import 'package:ignis_app/services/settings_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
class InMemoryCredentialsStorage implements CredentialsStorage {
final Map<String, String> _apiKeys = {};
@override
Future<String?> getApiKey(String homeId) async => _apiKeys[homeId];
@override
Future<void> setApiKey(String homeId, String apiKey) async {
_apiKeys[homeId] = apiKey;
}
@override
Future<void> deleteApiKey(String homeId) async {
_apiKeys.remove(homeId);
}
}
class FakeIgnisApi extends IgnisApi { class FakeIgnisApi extends IgnisApi {
Object? groupsData; Object? groupsData;
@@ -183,9 +204,18 @@ class FakeIgnisApi extends IgnisApi {
} }
void main() { 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( final container = ProviderContainer(
overrides: [apiProvider.overrideWithValue(api)], overrides: overrides,
); );
addTearDown(container.dispose); addTearDown(container.dispose);
return container; return container;
@@ -448,11 +478,32 @@ void main() {
await container.read(authInfoProvider.notifier).load(); await container.read(authInfoProvider.notifier).load();
final state = container.read(authInfoProvider); final state = container.read(authInfoProvider);
expect(state?.isAdmin, isTrue); expect(state.status, LoadStatus.data);
expect(state?.name, 'owner'); expect(state.data?.isAdmin, isTrue);
expect(state.data?.name, 'owner');
expect(container.read(authInfoProvider.notifier).isAdmin, isTrue); 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 { test('api keys load exposes empty state', () async {
final api = FakeIgnisApi(apiKeysData: {'keys': <Object>[]}); final api = FakeIgnisApi(apiKeysData: {'keys': <Object>[]});
final container = containerWith(api); final container = containerWith(api);
@@ -630,4 +681,54 @@ void main() {
expect(error.message, contains('Backend недоступен')); expect(error.message, contains('Backend недоступен'));
expect(api.controlGroupParams, containsPair('brightness', 42)); 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');
});
} }