fix: stabilize auth and home error flows
This commit is contained in:
@@ -55,6 +55,27 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
|
||||
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-клиент текущим домом
|
||||
Future<void> _initApi(HomeConfig home) async {
|
||||
final apiKey = await ref
|
||||
@@ -62,6 +83,18 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
|
||||
.requireHomeApiKey(home.id);
|
||||
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(),
|
||||
);
|
||||
|
||||
class AuthInfoNotifier extends Notifier<AuthInfo?> {
|
||||
class AuthInfoNotifier extends Notifier<LoadState<AuthInfo?>> {
|
||||
@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 {
|
||||
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<AuthInfo?> restoredState) => state = restoredState;
|
||||
|
||||
bool get isAdmin => state.data?.isAdmin == true;
|
||||
}
|
||||
|
||||
// ─── Геофенс: управление фоновым таском ─────────────────────
|
||||
|
||||
@@ -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<HomeEditScreen> {
|
||||
|
||||
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<HomeEditScreen> {
|
||||
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);
|
||||
|
||||
@@ -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<HomesScreen> {
|
||||
late final UserLocationNotifier _userLocationNotifier;
|
||||
String? _switchingHomeId;
|
||||
String? _deletingHomeId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -53,6 +56,9 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
||||
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<HomesScreen> {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
enabled: !isBusy,
|
||||
leading: Icon(
|
||||
Icons.home,
|
||||
color: isActive
|
||||
@@ -87,6 +94,15 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isBusy)
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.edit,
|
||||
@@ -104,8 +120,9 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
||||
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 {
|
||||
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<HomesScreen> {
|
||||
}
|
||||
} 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<HomesScreen> {
|
||||
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<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 {
|
||||
|
||||
@@ -39,8 +39,8 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
||||
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(
|
||||
|
||||
@@ -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<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 {
|
||||
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': <Object>[]});
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user