From 7c0a2675c65d46070fbe75e2e112985d553a8b31 Mon Sep 17 00:00:00 2001 From: Artem Kokos Date: Wed, 22 Apr 2026 23:25:48 +0700 Subject: [PATCH] feat: secure home credentials --- .gitignore | 1 - lib/main.dart | 8 +- lib/models/home_config.dart | 61 +- lib/providers/providers.dart | 217 +++--- lib/screens/api_keys_screen.dart | 26 +- lib/screens/event_log_screen.dart | 48 +- lib/screens/group_edit_screen.dart | 33 +- lib/screens/home_edit_screen.dart | 94 ++- lib/screens/homes_screen.dart | 58 +- lib/screens/remote_screen.dart | 128 ++-- lib/screens/schedules_screen.dart | 86 ++- lib/screens/stats_screen.dart | 52 +- lib/services/api_client.dart | 43 +- lib/services/credentials_storage.dart | 28 + lib/services/geofence_worker.dart | 70 +- lib/services/settings_service.dart | 71 +- lib/widgets/color_picker.dart | 5 +- lib/widgets/group_card.dart | 54 +- pubspec.lock | 1002 +++++++++++++++++++++++++ pubspec.yaml | 1 + test/settings_service_test.dart | 82 ++ test/widget_test.dart | 11 +- 22 files changed, 1782 insertions(+), 397 deletions(-) create mode 100644 lib/services/credentials_storage.dart create mode 100644 pubspec.lock create mode 100644 test/settings_service_test.dart diff --git a/.gitignore b/.gitignore index 76bef27..e7def50 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,6 @@ build/ .flutter-plugins-dependencies .pub-cache/ .pub/ -pubspec.lock # IDE .idea/ diff --git a/lib/main.dart b/lib/main.dart index bbcfad3..75f2015 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:workmanager/workmanager.dart'; @@ -9,6 +11,8 @@ import 'services/geofence_worker.dart'; /// Top-level callback для workmanager (выполняется в отдельном изоляте). @pragma('vm:entry-point') void callbackDispatcher() { + DartPluginRegistrant.ensureInitialized(); + Workmanager().executeTask((taskName, inputData) async { if (taskName == geofenceTaskName) { return await executeGeofenceCheck(); @@ -48,7 +52,9 @@ class IgnisApp extends StatelessWidget { cardTheme: CardThemeData( color: const Color(0xFF1E1E1E), elevation: 2, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), ), sliderTheme: const SliderThemeData( trackHeight: 4, diff --git a/lib/models/home_config.dart b/lib/models/home_config.dart index 8d6dc37..5cb633c 100644 --- a/lib/models/home_config.dart +++ b/lib/models/home_config.dart @@ -1,10 +1,9 @@ /// Модель "дома" -- один физический сервер Ignis. -/// Каждый дом имеет свой URL и API-ключ. +/// Содержит только несекретные настройки. API-ключ хранится отдельно. class HomeConfig { final String id; // уникальный идентификатор (uuid или timestamp) final String name; // человекочитаемое название ("Квартира", "Дача") final String url; // адрес сервера (например ignis.akokos.ru) - final String apiKey; // ключ авторизации final double? latitude; // GPS-широта дома (для гео-автоматизации) final double? longitude; // GPS-долгота дома (для гео-автоматизации) final bool geofenceEnabled; // автовыключение при уходе из дома @@ -13,7 +12,6 @@ class HomeConfig { required this.id, required this.name, required this.url, - required this.apiKey, this.latitude, this.longitude, this.geofenceEnabled = false, @@ -27,45 +25,40 @@ class HomeConfig { /// Сериализация в JSON для хранения в SharedPreferences Map toJson() => { - 'id': id, - 'name': name, - 'url': url, - 'apiKey': apiKey, - if (latitude != null) 'latitude': latitude, - if (longitude != null) 'longitude': longitude, - 'geofenceEnabled': geofenceEnabled, - }; + 'id': id, + 'name': name, + 'url': url, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + 'geofenceEnabled': geofenceEnabled, + }; factory HomeConfig.fromJson(Map json) => HomeConfig( - id: json['id'] as String, - name: json['name'] as String, - url: json['url'] as String, - apiKey: json['apiKey'] as String, - latitude: (json['latitude'] as num?)?.toDouble(), - longitude: (json['longitude'] as num?)?.toDouble(), - geofenceEnabled: json['geofenceEnabled'] as bool? ?? false, - ); + id: json['id'] as String, + name: json['name'] as String, + url: json['url'] as String, + latitude: (json['latitude'] as num?)?.toDouble(), + longitude: (json['longitude'] as num?)?.toDouble(), + geofenceEnabled: json['geofenceEnabled'] as bool? ?? false, + ); /// Копирование с изменениями HomeConfig copyWith({ String? name, String? url, - String? apiKey, double? latitude, double? longitude, bool? geofenceEnabled, bool clearCoordinates = false, - }) => - HomeConfig( - id: id, - name: name ?? this.name, - url: url ?? this.url, - apiKey: apiKey ?? this.apiKey, - latitude: clearCoordinates ? null : (latitude ?? this.latitude), - longitude: clearCoordinates ? null : (longitude ?? this.longitude), - // Если очищаем координаты -- геофенс тоже выключается - geofenceEnabled: clearCoordinates - ? false - : (geofenceEnabled ?? this.geofenceEnabled), - ); -} \ No newline at end of file + }) => HomeConfig( + id: id, + name: name ?? this.name, + url: url ?? this.url, + latitude: clearCoordinates ? null : (latitude ?? this.latitude), + longitude: clearCoordinates ? null : (longitude ?? this.longitude), + // Если очищаем координаты -- геофенс тоже выключается + geofenceEnabled: clearCoordinates + ? false + : (geofenceEnabled ?? this.geofenceEnabled), + ); +} diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart index ae43cbd..c7f2233 100644 --- a/lib/providers/providers.dart +++ b/lib/providers/providers.dart @@ -20,8 +20,9 @@ final apiProvider = Provider((ref) => IgnisApi()); // ─── Текущий дом ───────────────────────────────────────────── /// Текущий выбранный дом (null если ни одного нет) -final currentHomeProvider = - NotifierProvider(() => CurrentHomeNotifier()); +final currentHomeProvider = NotifierProvider( + () => CurrentHomeNotifier(), +); class CurrentHomeNotifier extends Notifier { @override @@ -32,7 +33,7 @@ class CurrentHomeNotifier extends Notifier { final svc = ref.read(settingsServiceProvider); state = await svc.getCurrentHome(); if (state != null) { - _initApi(state!); + await _initApi(state!); } } @@ -41,21 +42,25 @@ class CurrentHomeNotifier extends Notifier { final svc = ref.read(settingsServiceProvider); await svc.setCurrentHomeId(home.id); state = home; - _initApi(home); + await _initApi(home); // Перезагрузить группы для нового дома await ref.read(groupsProvider.notifier).initAndRefresh(); } /// Инициализировать API-клиент текущим домом - void _initApi(HomeConfig home) { - ref.read(apiProvider).init(home.url, home.apiKey); + Future _initApi(HomeConfig home) async { + final apiKey = await ref + .read(settingsServiceProvider) + .requireHomeApiKey(home.id); + ref.read(apiProvider).init(home.url, apiKey); } } // ─── Список домов ──────────────────────────────────────────── -final homesProvider = - NotifierProvider>(() => HomesNotifier()); +final homesProvider = NotifierProvider>( + () => HomesNotifier(), +); class HomesNotifier extends Notifier> { @override @@ -65,8 +70,8 @@ class HomesNotifier extends Notifier> { state = await ref.read(settingsServiceProvider).getHomes(); } - Future add(HomeConfig home) async { - await ref.read(settingsServiceProvider).upsertHome(home); + Future add(HomeConfig home, {required String apiKey}) async { + await ref.read(settingsServiceProvider).upsertHome(home, apiKey: apiKey); await load(); } @@ -75,8 +80,8 @@ class HomesNotifier extends Notifier> { await load(); } - Future update(HomeConfig home) async { - await ref.read(settingsServiceProvider).upsertHome(home); + Future update(HomeConfig home, {String? apiKey}) async { + await ref.read(settingsServiceProvider).upsertHome(home, apiKey: apiKey); await load(); } } @@ -98,14 +103,18 @@ class UserLocation { double? distanceToKm(double? lat, double? lon) { if (position == null || lat == null || lon == null) return null; return calculateDistanceKm( - position!.latitude, position!.longitude, lat, lon, + position!.latitude, + position!.longitude, + lat, + lon, ); } } final userLocationProvider = NotifierProvider( - () => UserLocationNotifier()); + () => UserLocationNotifier(), + ); class UserLocationNotifier extends Notifier { StreamSubscription? _sub; @@ -221,8 +230,9 @@ class UserLocationNotifier extends Notifier { // ─── Группы текущего дома ──────────────────────────────────── -final groupsProvider = - NotifierProvider>(() => GroupsNotifier()); +final groupsProvider = NotifierProvider>( + () => GroupsNotifier(), +); class GroupsNotifier extends Notifier> { IgnisApi get _api => ref.read(apiProvider); @@ -250,7 +260,10 @@ class GroupsNotifier extends Notifier> { Future initAndRefresh() async { final home = ref.read(currentHomeProvider); if (home == null) return; - _api.init(home.url, home.apiKey); + final apiKey = await ref + .read(settingsServiceProvider) + .requireHomeApiKey(home.id); + _api.init(home.url, apiKey); await refresh(); _timer?.cancel(); _timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh()); @@ -272,55 +285,58 @@ class GroupsNotifier extends Notifier> { final now = DateTime.now(); // Параллельный опрос статусов всех групп - final updatedList = await Future.wait(rawList.map((g) async { - final map = Map.from(g); - final id = map['id'].toString(); + final updatedList = await Future.wait( + rawList.map((g) async { + final map = Map.from(g); + final id = map['id'].toString(); - // Если группа залочена (недавно управляли) -- берём локальное состояние - if (_lockUntil.containsKey(id) && _lockUntil[id]!.isAfter(now)) { - final existing = state.firstWhere( - (old) => old['id'].toString() == id, - orElse: () => null, - ); - return existing ?? map; - } - - try { - final resStatus = await _api.getGroupStatus(id); - // Формат ответа: { results: [ { status: { state, dimming, temp, ... } } ] } - if (resStatus.data != null && - resStatus.data['results'] is List && - resStatus.data['results'].isNotEmpty) { - // Берём первый результат без ошибки, или просто первый - final results = resStatus.data['results'] as List; - final validResult = results.firstWhere( - (r) => r['status'] != null && r['error'] == null, - orElse: () => results.first, + // Если группа залочена (недавно управляли) -- берём локальное состояние + if (_lockUntil.containsKey(id) && _lockUntil[id]!.isAfter(now)) { + final existing = state.firstWhere( + (old) => old['id'].toString() == id, + orElse: () => null, ); - final data = validResult['status']; - if (data != null) { - map['last_state'] = { - 'state': data['state'] == true, - 'brightness': (data['dimming'] ?? 100).toInt(), - 'temp': (data['temp'] ?? 4000).toInt(), - 'r': (data['r'] ?? 0).toInt(), - 'g': (data['g'] ?? 0).toInt(), - 'b': (data['b'] ?? 0).toInt(), - 'scene': data['scene'], - }; - } + return existing ?? map; } - } catch (e) { - // При ошибке опроса -- сохраняем предыдущее состояние - final existing = state.firstWhere( - (s) => s['id'].toString() == id, - orElse: () => null, - ); - map['last_state'] = existing?['last_state'] ?? - {'state': false, 'brightness': 100, 'temp': 4000}; - } - return map; - })); + + try { + final resStatus = await _api.getGroupStatus(id); + // Формат ответа: { results: [ { status: { state, dimming, temp, ... } } ] } + if (resStatus.data != null && + resStatus.data['results'] is List && + resStatus.data['results'].isNotEmpty) { + // Берём первый результат без ошибки, или просто первый + final results = resStatus.data['results'] as List; + final validResult = results.firstWhere( + (r) => r['status'] != null && r['error'] == null, + orElse: () => results.first, + ); + final data = validResult['status']; + if (data != null) { + map['last_state'] = { + 'state': data['state'] == true, + 'brightness': (data['dimming'] ?? 100).toInt(), + 'temp': (data['temp'] ?? 4000).toInt(), + 'r': (data['r'] ?? 0).toInt(), + 'g': (data['g'] ?? 0).toInt(), + 'b': (data['b'] ?? 0).toInt(), + 'scene': data['scene'], + }; + } + } + } catch (e) { + // При ошибке опроса -- сохраняем предыдущее состояние + final existing = state.firstWhere( + (s) => s['id'].toString() == id, + orElse: () => null, + ); + map['last_state'] = + existing?['last_state'] ?? + {'state': false, 'brightness': 100, 'temp': 4000}; + } + return map; + }), + ); state = updatedList; } catch (e) { @@ -341,19 +357,23 @@ class GroupsNotifier extends Notifier> { ...g, 'last_state': { ...Map.from(g['last_state'] ?? {}), - ...patch - } + ...patch, + }, } else - g + g, ]; } /// Debounce: отправить API-запрос с задержкой, но UI обновить сразу. /// Если значение меняется быстро (слайдер тянут), отправляется только /// последнее значение после паузы. - void _debouncedControl(String id, String key, Map localPatch, - Map apiParams) { + void _debouncedControl( + String id, + String key, + Map localPatch, + Map apiParams, + ) { _setLock(id); _updateLocal(id, localPatch); @@ -386,7 +406,12 @@ class GroupsNotifier extends Notifier> { /// Установить яркость (0-100) -- с debounce void setBrightness(String id, int value) { - _debouncedControl(id, 'brightness', {'brightness': value}, {'brightness': value}); + _debouncedControl( + id, + 'brightness', + {'brightness': value}, + {'brightness': value}, + ); } /// Установить цветовую температуру (2700-6500K) -- с debounce @@ -396,7 +421,12 @@ class GroupsNotifier extends Notifier> { /// Установить RGB-цвет -- с debounce void setColor(String id, int r, int g, int b) { - _debouncedControl(id, 'color', {'r': r, 'g': g, 'b': b}, {'r': r, 'g': g, 'b': b}); + _debouncedControl( + id, + 'color', + {'r': r, 'g': g, 'b': b}, + {'r': r, 'g': g, 'b': b}, + ); } /// Установить сцену (без debounce) @@ -425,8 +455,9 @@ class GroupsNotifier extends Notifier> { // ─── Устройства (для создания групп) ───────────────────────── -final devicesProvider = - NotifierProvider>(() => DevicesNotifier()); +final devicesProvider = NotifierProvider>( + () => DevicesNotifier(), +); class DevicesNotifier extends Notifier> { @override @@ -440,7 +471,8 @@ class DevicesNotifier extends Notifier> { if (res.data is List) { state = res.data; } else if (res.data is Map) { - state = res.data['data'] ?? res.data['devices'] ?? res.data.values.toList(); + state = + res.data['data'] ?? res.data['devices'] ?? res.data.values.toList(); } } catch (e) { debugPrint("Ошибка загрузки устройств: $e"); @@ -450,8 +482,9 @@ class DevicesNotifier extends Notifier> { // ─── Сцены ─────────────────────────────────────────────────── -final scenesProvider = - NotifierProvider>(() => ScenesNotifier()); +final scenesProvider = NotifierProvider>( + () => ScenesNotifier(), +); class ScenesNotifier extends Notifier> { @override @@ -486,8 +519,9 @@ class ScenesNotifier extends Notifier> { // ─── Расписания ────────────────────────────────────────────── -final tasksProvider = - NotifierProvider>(() => TasksNotifier()); +final tasksProvider = NotifierProvider>( + () => TasksNotifier(), +); class TasksNotifier extends Notifier> { @override @@ -559,8 +593,9 @@ class TasksNotifier extends Notifier> { // ─── Статистика ────────────────────────────────────────────── -final statsProvider = - NotifierProvider>(() => StatsNotifier()); +final statsProvider = NotifierProvider>( + () => StatsNotifier(), +); class StatsNotifier extends Notifier> { @override @@ -582,8 +617,9 @@ class StatsNotifier extends Notifier> { // ─── Лог событий ───────────────────────────────────────────── -final eventLogProvider = - NotifierProvider>(() => EventLogNotifier()); +final eventLogProvider = NotifierProvider>( + () => EventLogNotifier(), +); class EventLogNotifier extends Notifier> { @override @@ -607,8 +643,9 @@ class EventLogNotifier extends Notifier> { // ─── API-ключи ─────────────────────────────────────────────── -final apiKeysProvider = - NotifierProvider>(() => ApiKeysNotifier()); +final apiKeysProvider = NotifierProvider>( + () => ApiKeysNotifier(), +); class ApiKeysNotifier extends Notifier> { @override @@ -666,7 +703,9 @@ class ApiKeysNotifier extends Notifier> { // ─── Информация об авторизации ──────────────────────────────── final authInfoProvider = - NotifierProvider?>(() => AuthInfoNotifier()); + NotifierProvider?>( + () => AuthInfoNotifier(), + ); class AuthInfoNotifier extends Notifier?> { @override @@ -706,9 +745,7 @@ Future syncGeofenceTask(List homes) async { geofenceTaskUniqueName, geofenceTaskName, frequency: const Duration(minutes: 15), - constraints: Constraints( - networkType: NetworkType.connected, - ), + constraints: Constraints(networkType: NetworkType.connected), existingWorkPolicy: ExistingPeriodicWorkPolicy.replace, backoffPolicy: BackoffPolicy.linear, backoffPolicyDelay: const Duration(minutes: 1), @@ -720,12 +757,12 @@ Future syncGeofenceTask(List homes) async { // ─── Утилита: расчёт расстояния (Haversine) ────────────────── -double calculateDistanceKm( - double lat1, double lon1, double lat2, double lon2) { +double calculateDistanceKm(double lat1, double lon1, double lat2, double lon2) { const earthRadiusKm = 6371.0; final dLat = _degToRad(lat2 - lat1); final dLon = _degToRad(lon2 - lon1); - final a = math.sin(dLat / 2) * math.sin(dLat / 2) + + final a = + math.sin(dLat / 2) * math.sin(dLat / 2) + math.cos(_degToRad(lat1)) * math.cos(_degToRad(lat2)) * math.sin(dLon / 2) * diff --git a/lib/screens/api_keys_screen.dart b/lib/screens/api_keys_screen.dart index f48b9bd..0fa3871 100644 --- a/lib/screens/api_keys_screen.dart +++ b/lib/screens/api_keys_screen.dart @@ -32,9 +32,7 @@ class _ApiKeysScreenState extends ConsumerState { final keys = ref.watch(apiKeysProvider); return Scaffold( - appBar: AppBar( - title: const Text('API-КЛЮЧИ'), - ), + appBar: AppBar(title: const Text('API-КЛЮЧИ')), body: _loading ? const Center( child: CircularProgressIndicator(color: Colors.deepOrange), @@ -83,7 +81,8 @@ class _ApiKeysScreenState extends ConsumerState { icon: const Icon(Icons.copy, size: 20), onPressed: () { Clipboard.setData( - ClipboardData(text: _lastCreatedKey!)); + ClipboardData(text: _lastCreatedKey!), + ); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Ключ скопирован'), @@ -210,8 +209,10 @@ class _ApiKeysScreenState extends ConsumerState { ), TextButton( onPressed: () => Navigator.of(ctx).pop(true), - child: - const Text('Отозвать', style: TextStyle(color: Colors.redAccent)), + child: const Text( + 'Отозвать', + style: TextStyle(color: Colors.redAccent), + ), ), ], ), @@ -310,13 +311,20 @@ class _ApiKeyCard extends StatelessWidget { : null, trailing: isActive ? IconButton( - icon: const Icon(Icons.block, size: 20, color: Colors.redAccent), + icon: const Icon( + Icons.block, + size: 20, + color: Colors.redAccent, + ), tooltip: 'Отозвать', onPressed: onRevoke, ) : IconButton( - icon: const Icon(Icons.check_circle_outline, - size: 20, color: Colors.green), + icon: const Icon( + Icons.check_circle_outline, + size: 20, + color: Colors.green, + ), tooltip: 'Активировать', onPressed: onActivate, ), diff --git a/lib/screens/event_log_screen.dart b/lib/screens/event_log_screen.dart index aa4eb77..4a6ca5d 100644 --- a/lib/screens/event_log_screen.dart +++ b/lib/screens/event_log_screen.dart @@ -52,28 +52,26 @@ class _EventLogScreenState extends ConsumerState { child: CircularProgressIndicator(color: Colors.deepOrange), ) : events.isEmpty - ? const Center( - child: Text( - 'Нет событий', - style: TextStyle(color: Colors.white54), - ), - ) - : RefreshIndicator( - color: Colors.deepOrange, - onRefresh: _load, - child: ListView.builder( - padding: const EdgeInsets.all(8), - itemCount: events.length, - itemBuilder: (context, index) { - final event = events[index]; - return _EventRow( - event: event is Map - ? Map.from(event) - : {}, - ); - }, - ), - ), + ? const Center( + child: Text( + 'Нет событий', + style: TextStyle(color: Colors.white54), + ), + ) + : RefreshIndicator( + color: Colors.deepOrange, + onRefresh: _load, + child: ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: events.length, + itemBuilder: (context, index) { + final event = events[index]; + return _EventRow( + event: event is Map ? Map.from(event) : {}, + ); + }, + ), + ), ); } } @@ -85,9 +83,11 @@ class _EventRow extends StatelessWidget { @override Widget build(BuildContext context) { - final timestamp = event['timestamp'] ?? event['time'] ?? event['created_at'] ?? ''; + final timestamp = + event['timestamp'] ?? event['time'] ?? event['created_at'] ?? ''; final action = event['action'] ?? event['command'] ?? event['type'] ?? ''; - final targetId = event['target_id'] ?? event['target'] ?? event['group_id'] ?? ''; + final targetId = + event['target_id'] ?? event['target'] ?? event['group_id'] ?? ''; final params = event['params'] ?? event['details'] ?? ''; final actor = event['actor'] ?? event['user'] ?? event['key_name'] ?? ''; diff --git a/lib/screens/group_edit_screen.dart b/lib/screens/group_edit_screen.dart index 945286c..d95e3d6 100644 --- a/lib/screens/group_edit_screen.dart +++ b/lib/screens/group_edit_screen.dart @@ -47,9 +47,9 @@ class _GroupEditScreenState extends ConsumerState { await ref.read(devicesProvider.notifier).load(); } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Ошибка сканирования: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Ошибка сканирования: $e'))); } } if (mounted) setState(() => _rescanning = false); @@ -78,7 +78,9 @@ class _GroupEditScreenState extends ConsumerState { ], ), body: _loading - ? const Center(child: CircularProgressIndicator(color: Colors.deepOrange)) + ? const Center( + child: CircularProgressIndicator(color: Colors.deepOrange), + ) : Padding( padding: const EdgeInsets.all(16), child: Column( @@ -166,7 +168,9 @@ class _GroupEditScreenState extends ConsumerState { subtitle: Text( '$mac${ip != null ? ' - $ip' : ''}', style: const TextStyle( - fontSize: 11, color: Colors.white38), + fontSize: 11, + color: Colors.white38, + ), ), onChanged: (v) { setState(() { @@ -201,7 +205,9 @@ class _GroupEditScreenState extends ConsumerState { width: 20, height: 20, child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white), + strokeWidth: 2, + color: Colors.white, + ), ) : const Text('СОЗДАТЬ ГРУППУ'), ), @@ -216,7 +222,8 @@ class _GroupEditScreenState extends ConsumerState { /// Извлечь MAC-адрес из объекта устройства String? _extractMac(dynamic device) { if (device is Map) { - return (device['mac'] ?? device['id'] ?? device['mac_address'])?.toString(); + return (device['mac'] ?? device['id'] ?? device['mac_address']) + ?.toString(); } return device?.toString(); } @@ -243,9 +250,9 @@ class _GroupEditScreenState extends ConsumerState { final name = _nameCtrl.text.trim(); if (id.isEmpty || name.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Укажите ID и название')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Укажите ID и название'))); return; } @@ -265,9 +272,9 @@ class _GroupEditScreenState 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('Ошибка создания: $e'))); } } diff --git a/lib/screens/home_edit_screen.dart b/lib/screens/home_edit_screen.dart index 4043765..22891d5 100644 --- a/lib/screens/home_edit_screen.dart +++ b/lib/screens/home_edit_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/home_config.dart'; import '../providers/providers.dart'; +import '../services/api_client.dart'; /// Экран создания или редактирования "дома" (сервера Ignis). class HomeEditScreen extends ConsumerStatefulWidget { @@ -34,7 +35,6 @@ class _HomeEditScreenState extends ConsumerState { if (_isEdit) { _nameCtrl.text = widget.home!.name; _urlCtrl.text = widget.home!.url; - _keyCtrl.text = widget.home!.apiKey; if (widget.home!.latitude != null) { _latCtrl.text = widget.home!.latitude.toString(); } @@ -42,6 +42,7 @@ class _HomeEditScreenState extends ConsumerState { _lonCtrl.text = widget.home!.longitude.toString(); } _geofenceEnabled = widget.home!.geofenceEnabled; + _loadApiKey(); } // Следим за полями координат чтобы обновлять доступность Switch @@ -49,6 +50,15 @@ class _HomeEditScreenState extends ConsumerState { _lonCtrl.addListener(_onCoordsChanged); } + Future _loadApiKey() async { + final apiKey = await ref + .read(settingsServiceProvider) + .getHomeApiKey(widget.home!.id); + if (mounted && apiKey != null) { + _keyCtrl.text = apiKey; + } + } + void _onCoordsChanged() { // Если координаты очистили -- выключаем геофенс if (!_hasCoordinates && _geofenceEnabled) { @@ -73,9 +83,7 @@ class _HomeEditScreenState extends ConsumerState { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: Text(_isEdit ? 'РЕДАКТИРОВАТЬ ДОМ' : 'НОВЫЙ ДОМ'), - ), + appBar: AppBar(title: Text(_isEdit ? 'РЕДАКТИРОВАТЬ ДОМ' : 'НОВЫЙ ДОМ')), body: SingleChildScrollView( padding: const EdgeInsets.all(20), child: Column( @@ -136,7 +144,9 @@ class _HomeEditScreenState extends ConsumerState { hintText: '51.128', ), keyboardType: const TextInputType.numberWithOptions( - decimal: true, signed: true), + decimal: true, + signed: true, + ), ), ), const SizedBox(width: 12), @@ -149,7 +159,9 @@ class _HomeEditScreenState extends ConsumerState { hintText: '71.430', ), keyboardType: const TextInputType.numberWithOptions( - decimal: true, signed: true), + decimal: true, + signed: true, + ), ), ), ], @@ -224,20 +236,41 @@ class _HomeEditScreenState extends ConsumerState { Future _save() async { final name = _nameCtrl.text.trim(); - final url = _urlCtrl.text.trim(); + final rawUrl = _urlCtrl.text.trim(); final key = _keyCtrl.text.trim(); final latText = _latCtrl.text.trim(); final lonText = _lonCtrl.text.trim(); - if (name.isEmpty || url.isEmpty || key.isEmpty) { + if (name.isEmpty || rawUrl.isEmpty || key.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Заполните все обязательные поля')), ); return; } + late final String url; + try { + url = IgnisApi.normalizeBaseUrl(rawUrl); + final parsed = Uri.parse(url); + if ((parsed.scheme != 'http' && parsed.scheme != 'https') || + parsed.host.isEmpty) { + throw const FormatException(); + } + } catch (_) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Некорректный адрес сервера')), + ); + return; + } + double? lat; double? lon; + if (latText.isEmpty != lonText.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Введите обе координаты'))); + return; + } if (latText.isNotEmpty && lonText.isNotEmpty) { lat = double.tryParse(latText); lon = double.tryParse(lonText); @@ -247,6 +280,12 @@ class _HomeEditScreenState extends ConsumerState { ); return; } + if (lat < -90 || lat > 90 || lon < -180 || lon > 180) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Координаты вне допустимого диапазона')), + ); + return; + } } setState(() => _saving = true); @@ -257,7 +296,6 @@ class _HomeEditScreenState extends ConsumerState { ? widget.home!.copyWith( name: name, url: url, - apiKey: key, latitude: lat, longitude: lon, geofenceEnabled: clearCoords ? false : _geofenceEnabled, @@ -267,22 +305,38 @@ class _HomeEditScreenState extends ConsumerState { id: DateTime.now().millisecondsSinceEpoch.toString(), name: name, url: url, - apiKey: key, latitude: lat, longitude: lon, geofenceEnabled: _geofenceEnabled, ); - if (_isEdit) { - await ref.read(homesProvider.notifier).update(home); - } else { - await ref.read(homesProvider.notifier).add(home); + try { + await ref.read(apiProvider).validateCredentials(url, key); + + if (_isEdit) { + await ref.read(homesProvider.notifier).update(home, apiKey: key); + } else { + await ref.read(homesProvider.notifier).add(home, apiKey: key); + } + + final currentHome = ref.read(currentHomeProvider); + if (currentHome?.id == home.id) { + await ref.read(currentHomeProvider.notifier).switchTo(home); + } + + // Синхронизировать фоновый таск с новыми настройками + final allHomes = ref.read(homesProvider); + await syncGeofenceTask(allHomes); + + if (mounted) Navigator.of(context).pop(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Не удалось проверить дом: $e'))); + } + } finally { + if (mounted) setState(() => _saving = false); } - - // Синхронизировать фоновый таск с новыми настройками - final allHomes = ref.read(homesProvider); - await syncGeofenceTask(allHomes); - - if (mounted) Navigator.of(context).pop(); } } diff --git a/lib/screens/homes_screen.dart b/lib/screens/homes_screen.dart index 759f57c..afc6c96 100644 --- a/lib/screens/homes_screen.dart +++ b/lib/screens/homes_screen.dart @@ -69,7 +69,8 @@ class _HomesScreenState extends ConsumerState { // Расстояние до дома (null если нет координат или геолокации) final distKm = location.distanceToKm( - home.latitude, home.longitude, + home.latitude, + home.longitude, ); return Card( @@ -83,7 +84,9 @@ class _HomesScreenState extends ConsumerState { title: Text( home.name, style: TextStyle( - fontWeight: isActive ? FontWeight.bold : FontWeight.normal, + fontWeight: isActive + ? FontWeight.bold + : FontWeight.normal, color: isActive ? Colors.deepOrange : Colors.white, ), ), @@ -92,14 +95,21 @@ class _HomesScreenState extends ConsumerState { children: [ Text( home.url, - style: const TextStyle(color: Colors.white38, fontSize: 12), + style: const TextStyle( + color: Colors.white38, + fontSize: 12, + ), ), if (distKm != null) Padding( padding: const EdgeInsets.only(top: 2), child: Row( children: [ - const Icon(Icons.near_me, size: 11, color: Colors.white30), + const Icon( + Icons.near_me, + size: 11, + color: Colors.white30, + ), const SizedBox(width: 4), Text( '~${formatDistance(distKm)}', @@ -115,11 +125,18 @@ class _HomesScreenState extends ConsumerState { // Координаты заданы, но геолокация недоступна Row( children: [ - const Icon(Icons.location_on, size: 12, color: Colors.white24), + const Icon( + Icons.location_on, + size: 12, + color: Colors.white24, + ), const SizedBox(width: 4), Text( location.error ?? 'Координаты заданы', - style: const TextStyle(color: Colors.white24, fontSize: 11), + style: const TextStyle( + color: Colors.white24, + fontSize: 11, + ), ), ], ), @@ -130,12 +147,20 @@ class _HomesScreenState extends ConsumerState { children: [ // Кнопка редактирования IconButton( - icon: const Icon(Icons.edit, size: 20, color: Colors.white38), + icon: const Icon( + Icons.edit, + size: 20, + color: Colors.white38, + ), onPressed: () => _editHome(context, home), ), // Кнопка удаления IconButton( - icon: const Icon(Icons.delete_outline, size: 20, color: Colors.redAccent), + icon: const Icon( + Icons.delete_outline, + size: 20, + color: Colors.redAccent, + ), onPressed: () => _confirmDelete(context, home), ), ], @@ -166,16 +191,16 @@ class _HomesScreenState extends ConsumerState { /// Добавить новый дом void _addHome(BuildContext context) { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const HomeEditScreen()), - ); + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const HomeEditScreen())); } /// Редактировать дом void _editHome(BuildContext context, HomeConfig home) { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => HomeEditScreen(home: home)), - ); + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => HomeEditScreen(home: home))); } /// Подтвердить удаление @@ -197,7 +222,10 @@ class _HomesScreenState extends ConsumerState { // Синхронизировать фоновый таск (мог быть удалён дом с геофенсом) await syncGeofenceTask(ref.read(homesProvider)); }, - child: const Text('Удалить', style: TextStyle(color: Colors.redAccent)), + child: const Text( + 'Удалить', + style: TextStyle(color: Colors.redAccent), + ), ), ], ), diff --git a/lib/screens/remote_screen.dart b/lib/screens/remote_screen.dart index 831ca6c..585803b 100644 --- a/lib/screens/remote_screen.dart +++ b/lib/screens/remote_screen.dart @@ -55,9 +55,9 @@ class _RemoteScreenState extends ConsumerState { IconButton( icon: const Icon(Icons.add_circle_outline), tooltip: 'Создать группу', - onPressed: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const GroupEditScreen()), - ), + onPressed: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const GroupEditScreen())), ), // Меню PopupMenuButton( @@ -139,64 +139,72 @@ class _RemoteScreenState extends ConsumerState { ), ) : groups.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.lightbulb_outline, size: 64, color: Colors.white24), - const SizedBox(height: 16), - const Text( - 'Нет групп', - style: TextStyle(color: Colors.white54, fontSize: 16), - ), - const SizedBox(height: 8), - TextButton.icon( - icon: const Icon(Icons.add), - label: const Text('Создать группу'), - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const GroupEditScreen()), - ), - ), - ], + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.lightbulb_outline, + size: 64, + color: Colors.white24, ), - ) - : RefreshIndicator( - color: Colors.deepOrange, - onRefresh: () => - ref.read(groupsProvider.notifier).refresh(), - child: ListView.builder( - padding: const EdgeInsets.only(top: 8, bottom: 80), - itemCount: groups.length, - itemBuilder: (context, index) { - final g = Map.from(groups[index]); - return Dismissible( - key: Key(g['id'].toString()), - direction: DismissDirection.endToStart, - background: Container( - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 20), - margin: const EdgeInsets.symmetric( - horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Colors.redAccent.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(16), - ), - child: const Icon(Icons.delete, color: Colors.redAccent), - ), - confirmDismiss: (_) => _confirmDeleteGroup(context, g), - onDismissed: (_) => _deleteGroup(g['id'].toString()), - child: GroupCard(group: g), - ); - }, + const SizedBox(height: 16), + const Text( + 'Нет групп', + style: TextStyle(color: Colors.white54, fontSize: 16), ), - ), + const SizedBox(height: 8), + TextButton.icon( + icon: const Icon(Icons.add), + label: const Text('Создать группу'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const GroupEditScreen(), + ), + ), + ), + ], + ), + ) + : RefreshIndicator( + color: Colors.deepOrange, + onRefresh: () => ref.read(groupsProvider.notifier).refresh(), + child: ListView.builder( + padding: const EdgeInsets.only(top: 8, bottom: 80), + itemCount: groups.length, + itemBuilder: (context, index) { + final g = Map.from(groups[index]); + return Dismissible( + key: Key(g['id'].toString()), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + margin: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.redAccent.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(16), + ), + child: const Icon(Icons.delete, color: Colors.redAccent), + ), + confirmDismiss: (_) => _confirmDeleteGroup(context, g), + onDismissed: (_) => _deleteGroup(g['id'].toString()), + child: GroupCard(group: g), + ); + }, + ), + ), ); } /// Подтверждение удаления группы свайпом Future _confirmDeleteGroup( - BuildContext context, Map g) async { + BuildContext context, + Map g, + ) async { return await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -209,8 +217,10 @@ class _RemoteScreenState extends ConsumerState { ), TextButton( onPressed: () => Navigator.of(ctx).pop(true), - child: const Text('Удалить', - style: TextStyle(color: Colors.redAccent)), + child: const Text( + 'Удалить', + style: TextStyle(color: Colors.redAccent), + ), ), ], ), @@ -224,9 +234,9 @@ class _RemoteScreenState extends ConsumerState { await ref.read(groupsProvider.notifier).refresh(); } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Ошибка удаления: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Ошибка удаления: $e'))); } } } diff --git a/lib/screens/schedules_screen.dart b/lib/screens/schedules_screen.dart index a5114d8..1d723f2 100644 --- a/lib/screens/schedules_screen.dart +++ b/lib/screens/schedules_screen.dart @@ -30,37 +30,37 @@ class _SchedulesScreenState extends ConsumerState { final tasks = ref.watch(tasksProvider); return Scaffold( - appBar: AppBar( - title: const Text('РАСПИСАНИЯ'), - ), + appBar: AppBar(title: const Text('РАСПИСАНИЯ')), body: _loading - ? const Center(child: CircularProgressIndicator(color: Colors.deepOrange)) + ? const Center( + child: CircularProgressIndicator(color: Colors.deepOrange), + ) : tasks.isEmpty - ? const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.schedule, size: 64, color: Colors.white24), - SizedBox(height: 16), - Text( - 'Нет активных расписаний', - style: TextStyle(color: Colors.white54, fontSize: 16), - ), - ], + ? const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.schedule, size: 64, color: Colors.white24), + SizedBox(height: 16), + Text( + 'Нет активных расписаний', + style: TextStyle(color: Colors.white54, fontSize: 16), ), - ) - : RefreshIndicator( - color: Colors.deepOrange, - onRefresh: () => ref.read(tasksProvider.notifier).load(), - child: ListView.builder( - padding: const EdgeInsets.all(12), - itemCount: tasks.length, - itemBuilder: (context, index) { - final task = tasks[index]; - return _TaskCard(task: task); - }, - ), - ), + ], + ), + ) + : RefreshIndicator( + color: Colors.deepOrange, + onRefresh: () => ref.read(tasksProvider.notifier).load(), + child: ListView.builder( + padding: const EdgeInsets.all(12), + itemCount: tasks.length, + itemBuilder: (context, index) { + final task = tasks[index]; + return _TaskCard(task: task); + }, + ), + ), floatingActionButton: FloatingActionButton( backgroundColor: Colors.deepOrange, onPressed: () => _showAddDialog(context), @@ -91,7 +91,9 @@ class _TaskCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final map = task is Map ? Map.from(task) : {}; + final map = task is Map + ? Map.from(task) + : {}; final jobId = (map['id'] ?? map['job_id'] ?? '').toString(); final targetId = (map['target_id'] ?? map['target'] ?? '').toString(); final state = map['state']; @@ -102,8 +104,8 @@ class _TaskCard extends ConsumerWidget { final stateStr = state == true ? 'Включить' : state == false - ? 'Выключить' - : '?'; + ? 'Выключить' + : '?'; String subtitle = 'Цель: $targetId'; if (runAt != null) subtitle += '\nЗапуск: $runAt'; @@ -286,7 +288,9 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> { Expanded( child: TextField( controller: _hourCtrl, - decoration: const InputDecoration(labelText: 'Час (0-23)'), + decoration: const InputDecoration( + labelText: 'Час (0-23)', + ), keyboardType: TextInputType.number, ), ), @@ -294,7 +298,9 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> { Expanded( child: TextField( controller: _minuteCtrl, - decoration: const InputDecoration(labelText: 'Минута (0-59)'), + decoration: const InputDecoration( + labelText: 'Минута (0-59)', + ), keyboardType: TextInputType.number, ), ), @@ -336,13 +342,17 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> { try { if (_type == 'once') { - await ref.read(tasksProvider.notifier).addOnce( + await ref + .read(tasksProvider.notifier) + .addOnce( targetId: _selectedGroupId!, targetState: _targetState, hoursFromNow: _hoursFromNow, ); } else { - await ref.read(tasksProvider.notifier).addCron( + await ref + .read(tasksProvider.notifier) + .addCron( targetId: _selectedGroupId!, hour: _hourCtrl.text.trim(), minute: _minuteCtrl.text.trim(), @@ -353,9 +363,9 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> { 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('Ошибка: $e'))); } } } diff --git a/lib/screens/stats_screen.dart b/lib/screens/stats_screen.dart index e29c756..a1da0bb 100644 --- a/lib/screens/stats_screen.dart +++ b/lib/screens/stats_screen.dart @@ -33,9 +33,7 @@ class _StatsScreenState extends ConsumerState { final groups = (stats['groups'] as List?) ?? []; return Scaffold( - appBar: AppBar( - title: const Text('СТАТИСТИКА'), - ), + appBar: AppBar(title: const Text('СТАТИСТИКА')), body: Column( children: [ // ─── Переключатель периода ─── @@ -70,26 +68,26 @@ class _StatsScreenState extends ConsumerState { child: CircularProgressIndicator(color: Colors.deepOrange), ) : groups.isEmpty - ? const Center( - child: Text( - 'Нет данных', - style: TextStyle(color: Colors.white54), - ), - ) - : RefreshIndicator( - color: Colors.deepOrange, - onRefresh: _load, - child: ListView.builder( - padding: const EdgeInsets.all(12), - itemCount: groups.length, - itemBuilder: (context, index) { - final g = groups[index]; - return _StatsCard(data: g is Map - ? Map.from(g) - : {}); - }, - ), - ), + ? const Center( + child: Text( + 'Нет данных', + style: TextStyle(color: Colors.white54), + ), + ) + : RefreshIndicator( + color: Colors.deepOrange, + onRefresh: _load, + child: ListView.builder( + padding: const EdgeInsets.all(12), + itemCount: groups.length, + itemBuilder: (context, index) { + final g = groups[index]; + return _StatsCard( + data: g is Map ? Map.from(g) : {}, + ); + }, + ), + ), ), ], ), @@ -105,7 +103,8 @@ class _StatsCard extends StatelessWidget { @override Widget build(BuildContext context) { - final targetId = (data['target_id'] ?? data['group_id'] ?? data['id'] ?? '').toString(); + final targetId = (data['target_id'] ?? data['group_id'] ?? data['id'] ?? '') + .toString(); final name = (data['name'] ?? targetId).toString(); final totalCommands = data['total_commands'] ?? 0; final togglesOn = data['toggles_on'] ?? 0; @@ -121,10 +120,7 @@ class _StatsCard extends StatelessWidget { children: [ Text( name, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), const SizedBox(height: 12), _StatRow( diff --git a/lib/services/api_client.dart b/lib/services/api_client.dart index 83401b6..5a1eec8 100644 --- a/lib/services/api_client.dart +++ b/lib/services/api_client.dart @@ -6,22 +6,36 @@ class IgnisApi { final Dio _dio = Dio(); Dio get dioInstance => _dio; - /// Инициализация базового URL и API-ключа - void init(String baseUrl, String apiKey) { - String url = baseUrl.trim(); - if (!url.startsWith('http')) { + static String normalizeBaseUrl(String baseUrl) { + var url = baseUrl.trim(); + final lowerUrl = url.toLowerCase(); + if (!lowerUrl.startsWith('http://') && !lowerUrl.startsWith('https://')) { url = 'https://$url'; } - // Убираем trailing slash - if (url.endsWith('/')) url = url.substring(0, url.length - 1); + while (url.endsWith('/')) { + url = url.substring(0, url.length - 1); + } + return url; + } - _dio.options.baseUrl = url; + /// Инициализация базового URL и API-ключа + void init(String baseUrl, String apiKey) { + _dio.options.baseUrl = normalizeBaseUrl(baseUrl); _dio.options.headers['X-API-Key'] = apiKey; // Бэкенд WiZ ламп тормозит -- даём запас _dio.options.connectTimeout = const Duration(seconds: 15); _dio.options.receiveTimeout = const Duration(seconds: 15); } + Future validateCredentials(String baseUrl, String apiKey) async { + final probe = IgnisApi()..init(baseUrl, apiKey); + try { + await probe.getAuthMe(); + } finally { + probe.dioInstance.close(); + } + } + // ─── Авторизация ─────────────────────────────────────────── /// Проверка текущего ключа: возвращает {is_admin, name} @@ -40,7 +54,10 @@ class IgnisApi { /// Создать группу Future createGroup(String id, String name, List macs) => - _dio.post('/devices/groups', data: {'id': id, 'name': name, 'macs': macs}); + _dio.post( + '/devices/groups', + data: {'id': id, 'name': name, 'macs': macs}, + ); /// Удалить группу Future deleteGroup(String groupId) => @@ -85,8 +102,7 @@ class IgnisApi { Future getTasks() => _dio.get('/schedules/tasks'); /// Отменить задачу расписания - Future cancelTask(String jobId) => - _dio.delete('/schedules/$jobId'); + Future cancelTask(String jobId) => _dio.delete('/schedules/$jobId'); // ─── API-ключи ───────────────────────────────────────────── @@ -94,11 +110,8 @@ class IgnisApi { Future getApiKeys() => _dio.get('/api-keys'); /// Создать гостевой ключ - Future createApiKey(String name, {bool isAdmin = false}) => - _dio.post('/api-keys', queryParameters: { - 'name': name, - 'is_admin': isAdmin, - }); + Future createApiKey(String name, {bool isAdmin = false}) => _dio + .post('/api-keys', queryParameters: {'name': name, 'is_admin': isAdmin}); /// Отозвать ключ (body: {key: ...}) Future revokeApiKey(String key) => diff --git a/lib/services/credentials_storage.dart b/lib/services/credentials_storage.dart new file mode 100644 index 0000000..ff20471 --- /dev/null +++ b/lib/services/credentials_storage.dart @@ -0,0 +1,28 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +abstract class CredentialsStorage { + Future getApiKey(String homeId); + + Future setApiKey(String homeId, String apiKey); + + Future deleteApiKey(String homeId); +} + +class SecureCredentialsStorage implements CredentialsStorage { + static const _storage = FlutterSecureStorage(); + + static String _apiKeyStorageKey(String homeId) => + 'ignis_home_api_key_$homeId'; + + @override + Future getApiKey(String homeId) => + _storage.read(key: _apiKeyStorageKey(homeId)); + + @override + Future setApiKey(String homeId, String apiKey) => + _storage.write(key: _apiKeyStorageKey(homeId), value: apiKey); + + @override + Future deleteApiKey(String homeId) => + _storage.delete(key: _apiKeyStorageKey(homeId)); +} diff --git a/lib/services/geofence_worker.dart b/lib/services/geofence_worker.dart index 3196093..a82f31b 100644 --- a/lib/services/geofence_worker.dart +++ b/lib/services/geofence_worker.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:math' as math; import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:geolocator/geolocator.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -10,6 +11,7 @@ const double geofenceThresholdMeters = 500.0; /// Ключ в SharedPreferences: геофенс уже сработал, таск можно не запускать const String _firedKey = 'ignis_geofence_fired'; +const String _apiKeyPrefix = 'ignis_home_api_key_'; /// Имя задачи в workmanager const String geofenceTaskName = 'ignis_geofence_check'; @@ -93,8 +95,12 @@ Future executeGeofenceCheck() async { // 4. Считаем расстояние final homeLat = (targetHome['latitude'] as num).toDouble(); final homeLon = (targetHome['longitude'] as num).toDouble(); - final distMeters = - _haversineMeters(pos.latitude, pos.longitude, homeLat, homeLon); + final distMeters = _haversineMeters( + pos.latitude, + pos.longitude, + homeLat, + homeLon, + ); if (distMeters <= geofenceThresholdMeters) { return true; // всё ещё рядом с домом @@ -102,7 +108,8 @@ Future executeGeofenceCheck() async { // 5. Ушли за порог -- выключаем все группы final url = _normalizeUrl(targetHome['url'] as String); - final apiKey = targetHome['apiKey'] as String; + final apiKey = await _getHomeApiKey(targetHome); + if (apiKey == null || apiKey.isEmpty) return true; final homeName = (targetHome['name'] ?? 'Дом') as String; int groupCount = 0; @@ -122,7 +129,8 @@ Future executeGeofenceCheck() async { : '${(distMeters / 1000).toStringAsFixed(1)} км'; await _showNotification( title: 'Свет выключен', - body: '$homeName -- вы ушли на $distText. ' + body: + '$homeName -- вы ушли на $distText. ' 'Выключено групп: $groupCount.', ); @@ -133,6 +141,19 @@ Future executeGeofenceCheck() async { } } +Future _getHomeApiKey(Map home) async { + final id = home['id']?.toString(); + if (id == null || id.isEmpty) return null; + + const secureStorage = FlutterSecureStorage(); + final secureKey = await secureStorage.read(key: '$_apiKeyPrefix$id'); + if (secureKey != null && secureKey.isNotEmpty) return secureKey; + + // Backward compatibility: if the app has not run after migration yet, + // old background tasks can still read the legacy key once. + return home['apiKey']?.toString(); +} + /// Сбросить флаг "сработал" -- вызывать при включении геофенса /// или при возврате в приложение. Future resetGeofenceFired() async { @@ -183,12 +204,14 @@ Future _showNotification({ /// Выключить все группы на сервере. Возвращает кол-во выключенных. Future _turnOffAllGroups(String baseUrl, String apiKey) async { - final dio = Dio(BaseOptions( - baseUrl: baseUrl, - headers: {'X-API-Key': apiKey}, - connectTimeout: const Duration(seconds: 15), - receiveTimeout: const Duration(seconds: 15), - )); + final dio = Dio( + BaseOptions( + baseUrl: baseUrl, + headers: {'X-API-Key': apiKey}, + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 15), + ), + ); try { // Получаем список групп @@ -212,14 +235,19 @@ Future _turnOffAllGroups(String baseUrl, String apiKey) async { // Выключаем каждую группу int success = 0; - await Future.wait(groupIds.map((id) async { - try { - await dio.post('/control/group/$id', queryParameters: {'state': false}); - success++; - } catch (_) { - // Одна группа упала -- не останавливаем остальные - } - })); + await Future.wait( + groupIds.map((id) async { + try { + await dio.post( + '/control/group/$id', + queryParameters: {'state': false}, + ); + success++; + } catch (_) { + // Одна группа упала -- не останавливаем остальные + } + }), + ); return success; } finally { @@ -236,12 +264,12 @@ String _normalizeUrl(String url) { } /// Расстояние в метрах (Haversine) -double _haversineMeters( - double lat1, double lon1, double lat2, double lon2) { +double _haversineMeters(double lat1, double lon1, double lat2, double lon2) { const earthRadiusM = 6371000.0; final dLat = _degToRad(lat2 - lat1); final dLon = _degToRad(lon2 - lon1); - final a = math.sin(dLat / 2) * math.sin(dLat / 2) + + final a = + math.sin(dLat / 2) * math.sin(dLat / 2) + math.cos(_degToRad(lat1)) * math.cos(_degToRad(lat2)) * math.sin(dLon / 2) * diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index db4776a..7abc0c5 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -1,30 +1,40 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/home_config.dart'; +import 'credentials_storage.dart'; /// Сервис для хранения списка "домов" и текущего выбранного. -/// Данные лежат в SharedPreferences как JSON-массив. +/// Несекретные данные лежат в SharedPreferences, API-ключи -- отдельно. class SettingsService { static const String _homesKey = 'ignis_homes'; static const String _currentHomeKey = 'ignis_current_home_id'; + final CredentialsStorage _credentialsStorage; + + SettingsService({CredentialsStorage? credentialsStorage}) + : _credentialsStorage = credentialsStorage ?? SecureCredentialsStorage(); + /// Загрузить все дома Future> getHomes() async { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_homesKey); if (raw == null || raw.isEmpty) return []; final list = jsonDecode(raw) as List; - return list.map((e) => HomeConfig.fromJson(e as Map)).toList(); + final migrated = await _migrateApiKeysIfNeeded(prefs, list); + return migrated.map(HomeConfig.fromJson).toList(); } /// Сохранить весь список домов Future saveHomes(List homes) async { final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_homesKey, jsonEncode(homes.map((h) => h.toJson()).toList())); + await prefs.setString( + _homesKey, + jsonEncode(homes.map((h) => h.toJson()).toList()), + ); } /// Добавить или обновить дом - Future upsertHome(HomeConfig home) async { + Future upsertHome(HomeConfig home, {String? apiKey}) async { final homes = await getHomes(); final idx = homes.indexWhere((h) => h.id == home.id); if (idx >= 0) { @@ -32,6 +42,9 @@ class SettingsService { } else { homes.add(home); } + if (apiKey != null) { + await setHomeApiKey(home.id, apiKey); + } await saveHomes(homes); } @@ -40,6 +53,7 @@ class SettingsService { final homes = await getHomes(); homes.removeWhere((h) => h.id == id); await saveHomes(homes); + await deleteHomeApiKey(id); // Если удалили текущий -- сбрасываем выбор final currentId = await getCurrentHomeId(); @@ -75,4 +89,53 @@ class SettingsService { return homes.isNotEmpty ? homes.first : null; } } + + Future getHomeApiKey(String homeId) => + _credentialsStorage.getApiKey(homeId); + + Future requireHomeApiKey(String homeId) async { + final key = await getHomeApiKey(homeId); + if (key == null || key.isEmpty) { + throw StateError('API key is missing for home $homeId'); + } + return key; + } + + Future setHomeApiKey(String homeId, String apiKey) => + _credentialsStorage.setApiKey(homeId, apiKey); + + Future deleteHomeApiKey(String homeId) => + _credentialsStorage.deleteApiKey(homeId); + + Future>> _migrateApiKeysIfNeeded( + SharedPreferences prefs, + List rawList, + ) async { + var changed = false; + final result = >[]; + + for (final item in rawList) { + final map = Map.from(item as Map); + final id = map['id']?.toString(); + final legacyApiKey = map['apiKey']?.toString(); + + if (id != null && legacyApiKey != null && legacyApiKey.isNotEmpty) { + final existingKey = await getHomeApiKey(id); + if (existingKey == null || existingKey.isEmpty) { + await setHomeApiKey(id, legacyApiKey); + } + } + + if (map.remove('apiKey') != null) { + changed = true; + } + result.add(map); + } + + if (changed) { + await prefs.setString(_homesKey, jsonEncode(result)); + } + + return result; + } } diff --git a/lib/widgets/color_picker.dart b/lib/widgets/color_picker.dart index c4fc9a8..978d9ac 100644 --- a/lib/widgets/color_picker.dart +++ b/lib/widgets/color_picker.dart @@ -121,7 +121,10 @@ class _SimpleColorPickerState extends State { children: [ SizedBox( width: 60, - child: Text(label, style: const TextStyle(fontSize: 12, color: Colors.white54)), + child: Text( + label, + style: const TextStyle(fontSize: 12, color: Colors.white54), + ), ), Expanded( child: Slider( diff --git a/lib/widgets/group_card.dart b/lib/widgets/group_card.dart index cbd4123..c2e309b 100644 --- a/lib/widgets/group_card.dart +++ b/lib/widgets/group_card.dart @@ -23,8 +23,7 @@ class _GroupCardState extends ConsumerState { double? _localBrightness; double? _localTemp; - int _channelValue(double channel) => - (channel * 255.0).round().clamp(0, 255); + int _channelValue(double channel) => (channel * 255.0).round().clamp(0, 255); @override Widget build(BuildContext context) { @@ -45,8 +44,12 @@ class _GroupCardState extends ConsumerState { // Цвет подсветки карточки зависит от режима и состояния final cardAccent = isOn ? (_mode == 'temp' - ? Color.lerp(Colors.orange, Colors.blueAccent, (tempValue - 2700) / 3800)! - : Color.fromARGB(255, r, gVal, b)) + ? Color.lerp( + Colors.orange, + Colors.blueAccent, + (tempValue - 2700) / 3800, + )! + : Color.fromARGB(255, r, gVal, b)) : Colors.white12; return Card( @@ -56,10 +59,7 @@ class _GroupCardState extends ConsumerState { decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), border: isOn - ? Border.all( - color: cardAccent.withValues(alpha: 0.3), - width: 1, - ) + ? Border.all(color: cardAccent.withValues(alpha: 0.3), width: 1) : null, ), child: Padding( @@ -84,9 +84,14 @@ class _GroupCardState extends ConsumerState { // Кнопка "таймер на 4 часа" if (isOn) IconButton( - icon: const Icon(Icons.timer, size: 20, color: Colors.white38), + icon: const Icon( + Icons.timer, + size: 20, + color: Colors.white38, + ), tooltip: 'Включить на 4 часа', - onPressed: () => ref.read(groupsProvider.notifier).setTimer4h(id), + onPressed: () => + ref.read(groupsProvider.notifier).setTimer4h(id), ), Switch( value: isOn, @@ -112,7 +117,9 @@ class _GroupCardState extends ConsumerState { activeColor: Colors.amber, onChanged: (v) { setState(() => _localBrightness = v); - ref.read(groupsProvider.notifier).setBrightness(id, v.toInt()); + ref + .read(groupsProvider.notifier) + .setBrightness(id, v.toInt()); }, onChangeEnd: (v) { setState(() => _localBrightness = null); @@ -156,10 +163,15 @@ class _GroupCardState extends ConsumerState { divisions: 38, // шаг 100K label: "${tempValue.toInt()}K", activeColor: Color.lerp( - Colors.orange, Colors.blueAccent, (tempValue - 2700) / 3800), + Colors.orange, + Colors.blueAccent, + (tempValue - 2700) / 3800, + ), onChanged: (v) { setState(() => _localTemp = v); - ref.read(groupsProvider.notifier).setTemperature(id, v.toInt()); + ref + .read(groupsProvider.notifier) + .setTemperature(id, v.toInt()); }, onChangeEnd: (v) { setState(() => _localTemp = null); @@ -172,7 +184,9 @@ class _GroupCardState extends ConsumerState { initialColor: Color.fromARGB(255, r, gVal, b), onColorChanged: (c) { // Обновление UI-превью -- через debounce отправляется на сервер - ref.read(groupsProvider.notifier).setColor( + ref + .read(groupsProvider.notifier) + .setColor( id, _channelValue(c.r), _channelValue(c.g), @@ -279,7 +293,11 @@ class _ModeChip extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 14, color: selected ? Colors.deepOrange : Colors.white54), + Icon( + icon, + size: 14, + color: selected ? Colors.deepOrange : Colors.white54, + ), const SizedBox(width: 4), Text( label, @@ -349,8 +367,10 @@ class _SceneSelectorState extends ConsumerState<_SceneSelector> { sceneName = scene; sceneId = scene; } else if (scene is Map) { - sceneName = (scene['name'] ?? scene['id'] ?? scene.toString()).toString(); - sceneId = (scene['id'] ?? scene['name'] ?? scene.toString()).toString(); + sceneName = (scene['name'] ?? scene['id'] ?? scene.toString()) + .toString(); + sceneId = (scene['id'] ?? scene['name'] ?? scene.toString()) + .toString(); } else { sceneName = scene.toString(); sceneId = scene.toString(); diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..4f9dad7 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1002 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" + url: "https://pub.dev" + source: hosted + version: "93.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b + url: "https://pub.dev" + source: hosted + version: "10.0.1" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" + dio: + dependency: "direct main" + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.dev" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875" + url: "https://pub.dev" + source: hosted + version: "19.5.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe" + url: "https://pub.dev" + source: hosted + version: "9.1.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2" + url: "https://pub.dev" + source: hosted + version: "3.3.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40 + url: "https://pub.dev" + source: hosted + version: "10.0.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2 + url: "https://pub.dev" + source: hosted + version: "13.0.4" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + url: "https://pub.dev" + source: hosted + version: "4.6.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + url: "https://pub.dev" + source: hosted + version: "4.2.6" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + url: "https://pub.dev" + source: hosted + version: "4.1.3" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + url: "https://pub.dev" + source: hosted + version: "4.11.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + url: "https://pub.dev" + source: hosted + version: "1.30.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + url: "https://pub.dev" + source: hosted + version: "0.6.16" + timezone: + dependency: transitive + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + workmanager: + dependency: "direct main" + description: + name: workmanager + sha256: "065673b2a465865183093806925419d311a9a5e0995aa74ccf8920fd695e2d10" + url: "https://pub.dev" + source: hosted + version: "0.9.0+3" + workmanager_android: + dependency: transitive + description: + name: workmanager_android + sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7" + url: "https://pub.dev" + source: hosted + version: "0.9.0+2" + workmanager_apple: + dependency: transitive + description: + name: workmanager_apple + sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca" + url: "https://pub.dev" + source: hosted + version: "0.9.1+2" + workmanager_platform_interface: + dependency: transitive + description: + name: workmanager_platform_interface + sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47 + url: "https://pub.dev" + source: hosted + version: "0.9.1+1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.4 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 81eef71..f64e213 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: geolocator: ^13.0.2 workmanager: ^0.9.0+3 flutter_local_notifications: ^19.0.0 + flutter_secure_storage: ^10.0.0 dev_dependencies: flutter_test: diff --git a/test/settings_service_test.dart b/test/settings_service_test.dart new file mode 100644 index 0000000..37cd03b --- /dev/null +++ b/test/settings_service_test.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:ignis_app/models/home_config.dart'; +import 'package:ignis_app/services/credentials_storage.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); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'migrates legacy apiKey from SharedPreferences to credentials storage', + () async { + final legacyHomes = [ + { + 'id': 'home-1', + 'name': 'Квартира', + 'url': 'ignis.akokos.ru', + 'apiKey': 'secret-key', + 'geofenceEnabled': false, + }, + ]; + SharedPreferences.setMockInitialValues({ + 'ignis_homes': jsonEncode(legacyHomes), + }); + + final credentials = InMemoryCredentialsStorage(); + final service = SettingsService(credentialsStorage: credentials); + + final homes = await service.getHomes(); + + expect(homes, hasLength(1)); + expect(homes.single.id, 'home-1'); + expect(await service.getHomeApiKey('home-1'), 'secret-key'); + + final prefs = await SharedPreferences.getInstance(); + final storedHomes = + jsonDecode(prefs.getString('ignis_homes')!) as List; + expect(storedHomes.single, isNot(containsPair('apiKey', 'secret-key'))); + }, + ); + + test('stores new api keys outside serialized homes', () async { + SharedPreferences.setMockInitialValues({}); + + final service = SettingsService( + credentialsStorage: InMemoryCredentialsStorage(), + ); + final home = HomeConfig( + id: 'home-1', + name: 'Квартира', + url: 'https://ignis.akokos.ru', + ); + + await service.upsertHome(home, apiKey: 'secret-key'); + + expect(await service.getHomeApiKey('home-1'), 'secret-key'); + + final prefs = await SharedPreferences.getInstance(); + final storedHomes = + jsonDecode(prefs.getString('ignis_homes')!) as List; + expect(storedHomes.single, isNot(contains('apiKey'))); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 1c4c0bd..e9a5723 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -7,15 +7,12 @@ import 'package:ignis_app/main.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - testWidgets('app opens homes screen when no homes are configured', - (WidgetTester tester) async { + testWidgets('app opens homes screen when no homes are configured', ( + WidgetTester tester, + ) async { SharedPreferences.setMockInitialValues({}); - await tester.pumpWidget( - const ProviderScope( - child: IgnisApp(), - ), - ); + await tester.pumpWidget(const ProviderScope(child: IgnisApp())); await tester.pumpAndSettle(); expect(find.text('ДОМА'), findsOneWidget);