feat: secure home credentials

This commit is contained in:
Artem Kokos
2026-04-22 23:25:48 +07:00
parent 6a961209cc
commit 7c0a2675c6
22 changed files with 1782 additions and 397 deletions

1
.gitignore vendored
View File

@@ -6,7 +6,6 @@ build/
.flutter-plugins-dependencies .flutter-plugins-dependencies
.pub-cache/ .pub-cache/
.pub/ .pub/
pubspec.lock
# IDE # IDE
.idea/ .idea/

View File

@@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:workmanager/workmanager.dart'; import 'package:workmanager/workmanager.dart';
@@ -9,6 +11,8 @@ import 'services/geofence_worker.dart';
/// Top-level callback для workmanager (выполняется в отдельном изоляте). /// Top-level callback для workmanager (выполняется в отдельном изоляте).
@pragma('vm:entry-point') @pragma('vm:entry-point')
void callbackDispatcher() { void callbackDispatcher() {
DartPluginRegistrant.ensureInitialized();
Workmanager().executeTask((taskName, inputData) async { Workmanager().executeTask((taskName, inputData) async {
if (taskName == geofenceTaskName) { if (taskName == geofenceTaskName) {
return await executeGeofenceCheck(); return await executeGeofenceCheck();
@@ -48,7 +52,9 @@ class IgnisApp extends StatelessWidget {
cardTheme: CardThemeData( cardTheme: CardThemeData(
color: const Color(0xFF1E1E1E), color: const Color(0xFF1E1E1E),
elevation: 2, elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
), ),
sliderTheme: const SliderThemeData( sliderTheme: const SliderThemeData(
trackHeight: 4, trackHeight: 4,

View File

@@ -1,10 +1,9 @@
/// Модель "дома" -- один физический сервер Ignis. /// Модель "дома" -- один физический сервер Ignis.
/// Каждый дом имеет свой URL и API-ключ. /// Содержит только несекретные настройки. API-ключ хранится отдельно.
class HomeConfig { class HomeConfig {
final String id; // уникальный идентификатор (uuid или timestamp) final String id; // уникальный идентификатор (uuid или timestamp)
final String name; // человекочитаемое название ("Квартира", "Дача") final String name; // человекочитаемое название ("Квартира", "Дача")
final String url; // адрес сервера (например ignis.akokos.ru) final String url; // адрес сервера (например ignis.akokos.ru)
final String apiKey; // ключ авторизации
final double? latitude; // GPS-широта дома (для гео-автоматизации) final double? latitude; // GPS-широта дома (для гео-автоматизации)
final double? longitude; // GPS-долгота дома (для гео-автоматизации) final double? longitude; // GPS-долгота дома (для гео-автоматизации)
final bool geofenceEnabled; // автовыключение при уходе из дома final bool geofenceEnabled; // автовыключение при уходе из дома
@@ -13,7 +12,6 @@ class HomeConfig {
required this.id, required this.id,
required this.name, required this.name,
required this.url, required this.url,
required this.apiKey,
this.latitude, this.latitude,
this.longitude, this.longitude,
this.geofenceEnabled = false, this.geofenceEnabled = false,
@@ -27,45 +25,40 @@ class HomeConfig {
/// Сериализация в JSON для хранения в SharedPreferences /// Сериализация в JSON для хранения в SharedPreferences
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
'name': name, 'name': name,
'url': url, 'url': url,
'apiKey': apiKey, if (latitude != null) 'latitude': latitude,
if (latitude != null) 'latitude': latitude, if (longitude != null) 'longitude': longitude,
if (longitude != null) 'longitude': longitude, 'geofenceEnabled': geofenceEnabled,
'geofenceEnabled': geofenceEnabled, };
};
factory HomeConfig.fromJson(Map<String, dynamic> json) => HomeConfig( factory HomeConfig.fromJson(Map<String, dynamic> json) => HomeConfig(
id: json['id'] as String, id: json['id'] as String,
name: json['name'] as String, name: json['name'] as String,
url: json['url'] as String, url: json['url'] as String,
apiKey: json['apiKey'] as String, latitude: (json['latitude'] as num?)?.toDouble(),
latitude: (json['latitude'] as num?)?.toDouble(), longitude: (json['longitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num?)?.toDouble(), geofenceEnabled: json['geofenceEnabled'] as bool? ?? false,
geofenceEnabled: json['geofenceEnabled'] as bool? ?? false, );
);
/// Копирование с изменениями /// Копирование с изменениями
HomeConfig copyWith({ HomeConfig copyWith({
String? name, String? name,
String? url, String? url,
String? apiKey,
double? latitude, double? latitude,
double? longitude, double? longitude,
bool? geofenceEnabled, bool? geofenceEnabled,
bool clearCoordinates = false, bool clearCoordinates = false,
}) => }) => HomeConfig(
HomeConfig( id: id,
id: id, name: name ?? this.name,
name: name ?? this.name, url: url ?? this.url,
url: url ?? this.url, latitude: clearCoordinates ? null : (latitude ?? this.latitude),
apiKey: apiKey ?? this.apiKey, longitude: clearCoordinates ? null : (longitude ?? this.longitude),
latitude: clearCoordinates ? null : (latitude ?? this.latitude), // Если очищаем координаты -- геофенс тоже выключается
longitude: clearCoordinates ? null : (longitude ?? this.longitude), geofenceEnabled: clearCoordinates
// Если очищаем координаты -- геофенс тоже выключается ? false
geofenceEnabled: clearCoordinates : (geofenceEnabled ?? this.geofenceEnabled),
? false );
: (geofenceEnabled ?? this.geofenceEnabled),
);
} }

View File

@@ -20,8 +20,9 @@ final apiProvider = Provider((ref) => IgnisApi());
// ─── Текущий дом ───────────────────────────────────────────── // ─── Текущий дом ─────────────────────────────────────────────
/// Текущий выбранный дом (null если ни одного нет) /// Текущий выбранный дом (null если ни одного нет)
final currentHomeProvider = final currentHomeProvider = NotifierProvider<CurrentHomeNotifier, HomeConfig?>(
NotifierProvider<CurrentHomeNotifier, HomeConfig?>(() => CurrentHomeNotifier()); () => CurrentHomeNotifier(),
);
class CurrentHomeNotifier extends Notifier<HomeConfig?> { class CurrentHomeNotifier extends Notifier<HomeConfig?> {
@override @override
@@ -32,7 +33,7 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
final svc = ref.read(settingsServiceProvider); final svc = ref.read(settingsServiceProvider);
state = await svc.getCurrentHome(); state = await svc.getCurrentHome();
if (state != null) { if (state != null) {
_initApi(state!); await _initApi(state!);
} }
} }
@@ -41,21 +42,25 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
final svc = ref.read(settingsServiceProvider); final svc = ref.read(settingsServiceProvider);
await svc.setCurrentHomeId(home.id); await svc.setCurrentHomeId(home.id);
state = home; state = home;
_initApi(home); await _initApi(home);
// Перезагрузить группы для нового дома // Перезагрузить группы для нового дома
await ref.read(groupsProvider.notifier).initAndRefresh(); await ref.read(groupsProvider.notifier).initAndRefresh();
} }
/// Инициализировать API-клиент текущим домом /// Инициализировать API-клиент текущим домом
void _initApi(HomeConfig home) { Future<void> _initApi(HomeConfig home) async {
ref.read(apiProvider).init(home.url, home.apiKey); final apiKey = await ref
.read(settingsServiceProvider)
.requireHomeApiKey(home.id);
ref.read(apiProvider).init(home.url, apiKey);
} }
} }
// ─── Список домов ──────────────────────────────────────────── // ─── Список домов ────────────────────────────────────────────
final homesProvider = final homesProvider = NotifierProvider<HomesNotifier, List<HomeConfig>>(
NotifierProvider<HomesNotifier, List<HomeConfig>>(() => HomesNotifier()); () => HomesNotifier(),
);
class HomesNotifier extends Notifier<List<HomeConfig>> { class HomesNotifier extends Notifier<List<HomeConfig>> {
@override @override
@@ -65,8 +70,8 @@ class HomesNotifier extends Notifier<List<HomeConfig>> {
state = await ref.read(settingsServiceProvider).getHomes(); state = await ref.read(settingsServiceProvider).getHomes();
} }
Future<void> add(HomeConfig home) async { Future<void> add(HomeConfig home, {required String apiKey}) async {
await ref.read(settingsServiceProvider).upsertHome(home); await ref.read(settingsServiceProvider).upsertHome(home, apiKey: apiKey);
await load(); await load();
} }
@@ -75,8 +80,8 @@ class HomesNotifier extends Notifier<List<HomeConfig>> {
await load(); await load();
} }
Future<void> update(HomeConfig home) async { Future<void> update(HomeConfig home, {String? apiKey}) async {
await ref.read(settingsServiceProvider).upsertHome(home); await ref.read(settingsServiceProvider).upsertHome(home, apiKey: apiKey);
await load(); await load();
} }
} }
@@ -98,14 +103,18 @@ class UserLocation {
double? distanceToKm(double? lat, double? lon) { double? distanceToKm(double? lat, double? lon) {
if (position == null || lat == null || lon == null) return null; if (position == null || lat == null || lon == null) return null;
return calculateDistanceKm( return calculateDistanceKm(
position!.latitude, position!.longitude, lat, lon, position!.latitude,
position!.longitude,
lat,
lon,
); );
} }
} }
final userLocationProvider = final userLocationProvider =
NotifierProvider<UserLocationNotifier, UserLocation>( NotifierProvider<UserLocationNotifier, UserLocation>(
() => UserLocationNotifier()); () => UserLocationNotifier(),
);
class UserLocationNotifier extends Notifier<UserLocation> { class UserLocationNotifier extends Notifier<UserLocation> {
StreamSubscription<Position>? _sub; StreamSubscription<Position>? _sub;
@@ -221,8 +230,9 @@ class UserLocationNotifier extends Notifier<UserLocation> {
// ─── Группы текущего дома ──────────────────────────────────── // ─── Группы текущего дома ────────────────────────────────────
final groupsProvider = final groupsProvider = NotifierProvider<GroupsNotifier, List<dynamic>>(
NotifierProvider<GroupsNotifier, List<dynamic>>(() => GroupsNotifier()); () => GroupsNotifier(),
);
class GroupsNotifier extends Notifier<List<dynamic>> { class GroupsNotifier extends Notifier<List<dynamic>> {
IgnisApi get _api => ref.read(apiProvider); IgnisApi get _api => ref.read(apiProvider);
@@ -250,7 +260,10 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
Future<void> initAndRefresh() async { Future<void> initAndRefresh() async {
final home = ref.read(currentHomeProvider); final home = ref.read(currentHomeProvider);
if (home == null) return; 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(); await refresh();
_timer?.cancel(); _timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh()); _timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh());
@@ -272,55 +285,58 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
final now = DateTime.now(); final now = DateTime.now();
// Параллельный опрос статусов всех групп // Параллельный опрос статусов всех групп
final updatedList = await Future.wait(rawList.map((g) async { final updatedList = await Future.wait(
final map = Map<String, dynamic>.from(g); rawList.map((g) async {
final id = map['id'].toString(); final map = Map<String, dynamic>.from(g);
final id = map['id'].toString();
// Если группа залочена (недавно управляли) -- берём локальное состояние // Если группа залочена (недавно управляли) -- берём локальное состояние
if (_lockUntil.containsKey(id) && _lockUntil[id]!.isAfter(now)) { if (_lockUntil.containsKey(id) && _lockUntil[id]!.isAfter(now)) {
final existing = state.firstWhere( final existing = state.firstWhere(
(old) => old['id'].toString() == id, (old) => old['id'].toString() == id,
orElse: () => null, 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,
); );
final data = validResult['status']; return existing ?? map;
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) {
// При ошибке опроса -- сохраняем предыдущее состояние try {
final existing = state.firstWhere( final resStatus = await _api.getGroupStatus(id);
(s) => s['id'].toString() == id, // Формат ответа: { results: [ { status: { state, dimming, temp, ... } } ] }
orElse: () => null, if (resStatus.data != null &&
); resStatus.data['results'] is List &&
map['last_state'] = existing?['last_state'] ?? resStatus.data['results'].isNotEmpty) {
{'state': false, 'brightness': 100, 'temp': 4000}; // Берём первый результат без ошибки, или просто первый
} final results = resStatus.data['results'] as List;
return map; 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; state = updatedList;
} catch (e) { } catch (e) {
@@ -341,19 +357,23 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
...g, ...g,
'last_state': { 'last_state': {
...Map<String, dynamic>.from(g['last_state'] ?? {}), ...Map<String, dynamic>.from(g['last_state'] ?? {}),
...patch ...patch,
} },
} }
else else
g g,
]; ];
} }
/// Debounce: отправить API-запрос с задержкой, но UI обновить сразу. /// Debounce: отправить API-запрос с задержкой, но UI обновить сразу.
/// Если значение меняется быстро (слайдер тянут), отправляется только /// Если значение меняется быстро (слайдер тянут), отправляется только
/// последнее значение после паузы. /// последнее значение после паузы.
void _debouncedControl(String id, String key, Map<String, dynamic> localPatch, void _debouncedControl(
Map<String, dynamic> apiParams) { String id,
String key,
Map<String, dynamic> localPatch,
Map<String, dynamic> apiParams,
) {
_setLock(id); _setLock(id);
_updateLocal(id, localPatch); _updateLocal(id, localPatch);
@@ -386,7 +406,12 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
/// Установить яркость (0-100) -- с debounce /// Установить яркость (0-100) -- с debounce
void setBrightness(String id, int value) { void setBrightness(String id, int value) {
_debouncedControl(id, 'brightness', {'brightness': value}, {'brightness': value}); _debouncedControl(
id,
'brightness',
{'brightness': value},
{'brightness': value},
);
} }
/// Установить цветовую температуру (2700-6500K) -- с debounce /// Установить цветовую температуру (2700-6500K) -- с debounce
@@ -396,7 +421,12 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
/// Установить RGB-цвет -- с debounce /// Установить RGB-цвет -- с debounce
void setColor(String id, int r, int g, int b) { 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) /// Установить сцену (без debounce)
@@ -425,8 +455,9 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
// ─── Устройства (для создания групп) ───────────────────────── // ─── Устройства (для создания групп) ─────────────────────────
final devicesProvider = final devicesProvider = NotifierProvider<DevicesNotifier, List<dynamic>>(
NotifierProvider<DevicesNotifier, List<dynamic>>(() => DevicesNotifier()); () => DevicesNotifier(),
);
class DevicesNotifier extends Notifier<List<dynamic>> { class DevicesNotifier extends Notifier<List<dynamic>> {
@override @override
@@ -440,7 +471,8 @@ class DevicesNotifier extends Notifier<List<dynamic>> {
if (res.data is List) { if (res.data is List) {
state = res.data; state = res.data;
} else if (res.data is Map) { } 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) { } catch (e) {
debugPrint("Ошибка загрузки устройств: $e"); debugPrint("Ошибка загрузки устройств: $e");
@@ -450,8 +482,9 @@ class DevicesNotifier extends Notifier<List<dynamic>> {
// ─── Сцены ─────────────────────────────────────────────────── // ─── Сцены ───────────────────────────────────────────────────
final scenesProvider = final scenesProvider = NotifierProvider<ScenesNotifier, List<dynamic>>(
NotifierProvider<ScenesNotifier, List<dynamic>>(() => ScenesNotifier()); () => ScenesNotifier(),
);
class ScenesNotifier extends Notifier<List<dynamic>> { class ScenesNotifier extends Notifier<List<dynamic>> {
@override @override
@@ -486,8 +519,9 @@ class ScenesNotifier extends Notifier<List<dynamic>> {
// ─── Расписания ────────────────────────────────────────────── // ─── Расписания ──────────────────────────────────────────────
final tasksProvider = final tasksProvider = NotifierProvider<TasksNotifier, List<dynamic>>(
NotifierProvider<TasksNotifier, List<dynamic>>(() => TasksNotifier()); () => TasksNotifier(),
);
class TasksNotifier extends Notifier<List<dynamic>> { class TasksNotifier extends Notifier<List<dynamic>> {
@override @override
@@ -559,8 +593,9 @@ class TasksNotifier extends Notifier<List<dynamic>> {
// ─── Статистика ────────────────────────────────────────────── // ─── Статистика ──────────────────────────────────────────────
final statsProvider = final statsProvider = NotifierProvider<StatsNotifier, Map<String, dynamic>>(
NotifierProvider<StatsNotifier, Map<String, dynamic>>(() => StatsNotifier()); () => StatsNotifier(),
);
class StatsNotifier extends Notifier<Map<String, dynamic>> { class StatsNotifier extends Notifier<Map<String, dynamic>> {
@override @override
@@ -582,8 +617,9 @@ class StatsNotifier extends Notifier<Map<String, dynamic>> {
// ─── Лог событий ───────────────────────────────────────────── // ─── Лог событий ─────────────────────────────────────────────
final eventLogProvider = final eventLogProvider = NotifierProvider<EventLogNotifier, List<dynamic>>(
NotifierProvider<EventLogNotifier, List<dynamic>>(() => EventLogNotifier()); () => EventLogNotifier(),
);
class EventLogNotifier extends Notifier<List<dynamic>> { class EventLogNotifier extends Notifier<List<dynamic>> {
@override @override
@@ -607,8 +643,9 @@ class EventLogNotifier extends Notifier<List<dynamic>> {
// ─── API-ключи ─────────────────────────────────────────────── // ─── API-ключи ───────────────────────────────────────────────
final apiKeysProvider = final apiKeysProvider = NotifierProvider<ApiKeysNotifier, List<dynamic>>(
NotifierProvider<ApiKeysNotifier, List<dynamic>>(() => ApiKeysNotifier()); () => ApiKeysNotifier(),
);
class ApiKeysNotifier extends Notifier<List<dynamic>> { class ApiKeysNotifier extends Notifier<List<dynamic>> {
@override @override
@@ -666,7 +703,9 @@ class ApiKeysNotifier extends Notifier<List<dynamic>> {
// ─── Информация об авторизации ──────────────────────────────── // ─── Информация об авторизации ────────────────────────────────
final authInfoProvider = final authInfoProvider =
NotifierProvider<AuthInfoNotifier, Map<String, dynamic>?>(() => AuthInfoNotifier()); NotifierProvider<AuthInfoNotifier, Map<String, dynamic>?>(
() => AuthInfoNotifier(),
);
class AuthInfoNotifier extends Notifier<Map<String, dynamic>?> { class AuthInfoNotifier extends Notifier<Map<String, dynamic>?> {
@override @override
@@ -706,9 +745,7 @@ Future<void> syncGeofenceTask(List<HomeConfig> homes) async {
geofenceTaskUniqueName, geofenceTaskUniqueName,
geofenceTaskName, geofenceTaskName,
frequency: const Duration(minutes: 15), frequency: const Duration(minutes: 15),
constraints: Constraints( constraints: Constraints(networkType: NetworkType.connected),
networkType: NetworkType.connected,
),
existingWorkPolicy: ExistingPeriodicWorkPolicy.replace, existingWorkPolicy: ExistingPeriodicWorkPolicy.replace,
backoffPolicy: BackoffPolicy.linear, backoffPolicy: BackoffPolicy.linear,
backoffPolicyDelay: const Duration(minutes: 1), backoffPolicyDelay: const Duration(minutes: 1),
@@ -720,12 +757,12 @@ Future<void> syncGeofenceTask(List<HomeConfig> homes) async {
// ─── Утилита: расчёт расстояния (Haversine) ────────────────── // ─── Утилита: расчёт расстояния (Haversine) ──────────────────
double calculateDistanceKm( double calculateDistanceKm(double lat1, double lon1, double lat2, double lon2) {
double lat1, double lon1, double lat2, double lon2) {
const earthRadiusKm = 6371.0; const earthRadiusKm = 6371.0;
final dLat = _degToRad(lat2 - lat1); final dLat = _degToRad(lat2 - lat1);
final dLon = _degToRad(lon2 - lon1); 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(lat1)) *
math.cos(_degToRad(lat2)) * math.cos(_degToRad(lat2)) *
math.sin(dLon / 2) * math.sin(dLon / 2) *

View File

@@ -32,9 +32,7 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
final keys = ref.watch(apiKeysProvider); final keys = ref.watch(apiKeysProvider);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: const Text('API-КЛЮЧИ')),
title: const Text('API-КЛЮЧИ'),
),
body: _loading body: _loading
? const Center( ? const Center(
child: CircularProgressIndicator(color: Colors.deepOrange), child: CircularProgressIndicator(color: Colors.deepOrange),
@@ -83,7 +81,8 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
icon: const Icon(Icons.copy, size: 20), icon: const Icon(Icons.copy, size: 20),
onPressed: () { onPressed: () {
Clipboard.setData( Clipboard.setData(
ClipboardData(text: _lastCreatedKey!)); ClipboardData(text: _lastCreatedKey!),
);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Ключ скопирован'), content: Text('Ключ скопирован'),
@@ -210,8 +209,10 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
), ),
TextButton( TextButton(
onPressed: () => Navigator.of(ctx).pop(true), onPressed: () => Navigator.of(ctx).pop(true),
child: child: const Text(
const Text('Отозвать', style: TextStyle(color: Colors.redAccent)), 'Отозвать',
style: TextStyle(color: Colors.redAccent),
),
), ),
], ],
), ),
@@ -310,13 +311,20 @@ class _ApiKeyCard extends StatelessWidget {
: null, : null,
trailing: isActive trailing: isActive
? IconButton( ? IconButton(
icon: const Icon(Icons.block, size: 20, color: Colors.redAccent), icon: const Icon(
Icons.block,
size: 20,
color: Colors.redAccent,
),
tooltip: 'Отозвать', tooltip: 'Отозвать',
onPressed: onRevoke, onPressed: onRevoke,
) )
: IconButton( : IconButton(
icon: const Icon(Icons.check_circle_outline, icon: const Icon(
size: 20, color: Colors.green), Icons.check_circle_outline,
size: 20,
color: Colors.green,
),
tooltip: 'Активировать', tooltip: 'Активировать',
onPressed: onActivate, onPressed: onActivate,
), ),

View File

@@ -52,28 +52,26 @@ class _EventLogScreenState extends ConsumerState<EventLogScreen> {
child: CircularProgressIndicator(color: Colors.deepOrange), child: CircularProgressIndicator(color: Colors.deepOrange),
) )
: events.isEmpty : events.isEmpty
? const Center( ? const Center(
child: Text( child: Text(
'Нет событий', 'Нет событий',
style: TextStyle(color: Colors.white54), style: TextStyle(color: Colors.white54),
), ),
) )
: RefreshIndicator( : RefreshIndicator(
color: Colors.deepOrange, color: Colors.deepOrange,
onRefresh: _load, onRefresh: _load,
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
itemCount: events.length, itemCount: events.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final event = events[index]; final event = events[index];
return _EventRow( return _EventRow(
event: event is Map event: event is Map ? Map<String, dynamic>.from(event) : {},
? Map<String, dynamic>.from(event) );
: {}, },
); ),
}, ),
),
),
); );
} }
} }
@@ -85,9 +83,11 @@ class _EventRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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 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 params = event['params'] ?? event['details'] ?? '';
final actor = event['actor'] ?? event['user'] ?? event['key_name'] ?? ''; final actor = event['actor'] ?? event['user'] ?? event['key_name'] ?? '';

View File

@@ -47,9 +47,9 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
await ref.read(devicesProvider.notifier).load(); await ref.read(devicesProvider.notifier).load();
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Ошибка сканирования: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Ошибка сканирования: $e')));
} }
} }
if (mounted) setState(() => _rescanning = false); if (mounted) setState(() => _rescanning = false);
@@ -78,7 +78,9 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
], ],
), ),
body: _loading body: _loading
? const Center(child: CircularProgressIndicator(color: Colors.deepOrange)) ? const Center(
child: CircularProgressIndicator(color: Colors.deepOrange),
)
: Padding( : Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
@@ -166,7 +168,9 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
subtitle: Text( subtitle: Text(
'$mac${ip != null ? ' - $ip' : ''}', '$mac${ip != null ? ' - $ip' : ''}',
style: const TextStyle( style: const TextStyle(
fontSize: 11, color: Colors.white38), fontSize: 11,
color: Colors.white38,
),
), ),
onChanged: (v) { onChanged: (v) {
setState(() { setState(() {
@@ -201,7 +205,9 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
width: 20, width: 20,
height: 20, height: 20,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white), strokeWidth: 2,
color: Colors.white,
),
) )
: const Text('СОЗДАТЬ ГРУППУ'), : const Text('СОЗДАТЬ ГРУППУ'),
), ),
@@ -216,7 +222,8 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
/// Извлечь MAC-адрес из объекта устройства /// Извлечь MAC-адрес из объекта устройства
String? _extractMac(dynamic device) { String? _extractMac(dynamic device) {
if (device is Map) { 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(); return device?.toString();
} }
@@ -243,9 +250,9 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
final name = _nameCtrl.text.trim(); final name = _nameCtrl.text.trim();
if (id.isEmpty || name.isEmpty) { if (id.isEmpty || name.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
const SnackBar(content: Text('Укажите ID и название')), context,
); ).showSnackBar(const SnackBar(content: Text('Укажите ID и название')));
return; return;
} }
@@ -265,9 +272,9 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
if (mounted) Navigator.of(context).pop(); if (mounted) Navigator.of(context).pop();
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Ошибка создания: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Ошибка создания: $e')));
} }
} }

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/home_config.dart'; import '../models/home_config.dart';
import '../providers/providers.dart'; import '../providers/providers.dart';
import '../services/api_client.dart';
/// Экран создания или редактирования "дома" (сервера Ignis). /// Экран создания или редактирования "дома" (сервера Ignis).
class HomeEditScreen extends ConsumerStatefulWidget { class HomeEditScreen extends ConsumerStatefulWidget {
@@ -34,7 +35,6 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
if (_isEdit) { if (_isEdit) {
_nameCtrl.text = widget.home!.name; _nameCtrl.text = widget.home!.name;
_urlCtrl.text = widget.home!.url; _urlCtrl.text = widget.home!.url;
_keyCtrl.text = widget.home!.apiKey;
if (widget.home!.latitude != null) { if (widget.home!.latitude != null) {
_latCtrl.text = widget.home!.latitude.toString(); _latCtrl.text = widget.home!.latitude.toString();
} }
@@ -42,6 +42,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
_lonCtrl.text = widget.home!.longitude.toString(); _lonCtrl.text = widget.home!.longitude.toString();
} }
_geofenceEnabled = widget.home!.geofenceEnabled; _geofenceEnabled = widget.home!.geofenceEnabled;
_loadApiKey();
} }
// Следим за полями координат чтобы обновлять доступность Switch // Следим за полями координат чтобы обновлять доступность Switch
@@ -49,6 +50,15 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
_lonCtrl.addListener(_onCoordsChanged); _lonCtrl.addListener(_onCoordsChanged);
} }
Future<void> _loadApiKey() async {
final apiKey = await ref
.read(settingsServiceProvider)
.getHomeApiKey(widget.home!.id);
if (mounted && apiKey != null) {
_keyCtrl.text = apiKey;
}
}
void _onCoordsChanged() { void _onCoordsChanged() {
// Если координаты очистили -- выключаем геофенс // Если координаты очистили -- выключаем геофенс
if (!_hasCoordinates && _geofenceEnabled) { if (!_hasCoordinates && _geofenceEnabled) {
@@ -73,9 +83,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(_isEdit ? 'РЕДАКТИРОВАТЬ ДОМ' : 'НОВЫЙ ДОМ')),
title: Text(_isEdit ? 'РЕДАКТИРОВАТЬ ДОМ' : 'НОВЫЙ ДОМ'),
),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
@@ -136,7 +144,9 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
hintText: '51.128', hintText: '51.128',
), ),
keyboardType: const TextInputType.numberWithOptions( keyboardType: const TextInputType.numberWithOptions(
decimal: true, signed: true), decimal: true,
signed: true,
),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -149,7 +159,9 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
hintText: '71.430', hintText: '71.430',
), ),
keyboardType: const TextInputType.numberWithOptions( keyboardType: const TextInputType.numberWithOptions(
decimal: true, signed: true), decimal: true,
signed: true,
),
), ),
), ),
], ],
@@ -224,20 +236,41 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
Future<void> _save() async { Future<void> _save() async {
final name = _nameCtrl.text.trim(); final name = _nameCtrl.text.trim();
final url = _urlCtrl.text.trim(); final rawUrl = _urlCtrl.text.trim();
final key = _keyCtrl.text.trim(); final key = _keyCtrl.text.trim();
final latText = _latCtrl.text.trim(); final latText = _latCtrl.text.trim();
final lonText = _lonCtrl.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( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Заполните все обязательные поля')), const SnackBar(content: Text('Заполните все обязательные поля')),
); );
return; 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? lat;
double? lon; double? lon;
if (latText.isEmpty != lonText.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Введите обе координаты')));
return;
}
if (latText.isNotEmpty && lonText.isNotEmpty) { if (latText.isNotEmpty && lonText.isNotEmpty) {
lat = double.tryParse(latText); lat = double.tryParse(latText);
lon = double.tryParse(lonText); lon = double.tryParse(lonText);
@@ -247,6 +280,12 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
); );
return; return;
} }
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Координаты вне допустимого диапазона')),
);
return;
}
} }
setState(() => _saving = true); setState(() => _saving = true);
@@ -257,7 +296,6 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
? widget.home!.copyWith( ? widget.home!.copyWith(
name: name, name: name,
url: url, url: url,
apiKey: key,
latitude: lat, latitude: lat,
longitude: lon, longitude: lon,
geofenceEnabled: clearCoords ? false : _geofenceEnabled, geofenceEnabled: clearCoords ? false : _geofenceEnabled,
@@ -267,22 +305,38 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
id: DateTime.now().millisecondsSinceEpoch.toString(), id: DateTime.now().millisecondsSinceEpoch.toString(),
name: name, name: name,
url: url, url: url,
apiKey: key,
latitude: lat, latitude: lat,
longitude: lon, longitude: lon,
geofenceEnabled: _geofenceEnabled, geofenceEnabled: _geofenceEnabled,
); );
if (_isEdit) { try {
await ref.read(homesProvider.notifier).update(home); await ref.read(apiProvider).validateCredentials(url, key);
} else {
await ref.read(homesProvider.notifier).add(home); 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();
} }
} }

View File

@@ -69,7 +69,8 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
// Расстояние до дома (null если нет координат или геолокации) // Расстояние до дома (null если нет координат или геолокации)
final distKm = location.distanceToKm( final distKm = location.distanceToKm(
home.latitude, home.longitude, home.latitude,
home.longitude,
); );
return Card( return Card(
@@ -83,7 +84,9 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
title: Text( title: Text(
home.name, home.name,
style: TextStyle( style: TextStyle(
fontWeight: isActive ? FontWeight.bold : FontWeight.normal, fontWeight: isActive
? FontWeight.bold
: FontWeight.normal,
color: isActive ? Colors.deepOrange : Colors.white, color: isActive ? Colors.deepOrange : Colors.white,
), ),
), ),
@@ -92,14 +95,21 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
children: [ children: [
Text( Text(
home.url, home.url,
style: const TextStyle(color: Colors.white38, fontSize: 12), style: const TextStyle(
color: Colors.white38,
fontSize: 12,
),
), ),
if (distKm != null) if (distKm != null)
Padding( Padding(
padding: const EdgeInsets.only(top: 2), padding: const EdgeInsets.only(top: 2),
child: Row( child: Row(
children: [ 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), const SizedBox(width: 4),
Text( Text(
'~${formatDistance(distKm)}', '~${formatDistance(distKm)}',
@@ -115,11 +125,18 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
// Координаты заданы, но геолокация недоступна // Координаты заданы, но геолокация недоступна
Row( Row(
children: [ 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), const SizedBox(width: 4),
Text( Text(
location.error ?? 'Координаты заданы', 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<HomesScreen> {
children: [ children: [
// Кнопка редактирования // Кнопка редактирования
IconButton( 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), onPressed: () => _editHome(context, home),
), ),
// Кнопка удаления // Кнопка удаления
IconButton( 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), onPressed: () => _confirmDelete(context, home),
), ),
], ],
@@ -166,16 +191,16 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
/// Добавить новый дом /// Добавить новый дом
void _addHome(BuildContext context) { void _addHome(BuildContext context) {
Navigator.of(context).push( Navigator.of(
MaterialPageRoute(builder: (_) => const HomeEditScreen()), context,
); ).push(MaterialPageRoute(builder: (_) => const HomeEditScreen()));
} }
/// Редактировать дом /// Редактировать дом
void _editHome(BuildContext context, HomeConfig home) { void _editHome(BuildContext context, HomeConfig home) {
Navigator.of(context).push( Navigator.of(
MaterialPageRoute(builder: (_) => HomeEditScreen(home: home)), context,
); ).push(MaterialPageRoute(builder: (_) => HomeEditScreen(home: home)));
} }
/// Подтвердить удаление /// Подтвердить удаление
@@ -197,7 +222,10 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
// Синхронизировать фоновый таск (мог быть удалён дом с геофенсом) // Синхронизировать фоновый таск (мог быть удалён дом с геофенсом)
await syncGeofenceTask(ref.read(homesProvider)); await syncGeofenceTask(ref.read(homesProvider));
}, },
child: const Text('Удалить', style: TextStyle(color: Colors.redAccent)), child: const Text(
'Удалить',
style: TextStyle(color: Colors.redAccent),
),
), ),
], ],
), ),

View File

@@ -55,9 +55,9 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
IconButton( IconButton(
icon: const Icon(Icons.add_circle_outline), icon: const Icon(Icons.add_circle_outline),
tooltip: 'Создать группу', tooltip: 'Создать группу',
onPressed: () => Navigator.of(context).push( onPressed: () => Navigator.of(
MaterialPageRoute(builder: (_) => const GroupEditScreen()), context,
), ).push(MaterialPageRoute(builder: (_) => const GroupEditScreen())),
), ),
// Меню // Меню
PopupMenuButton<String>( PopupMenuButton<String>(
@@ -139,64 +139,72 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
), ),
) )
: groups.isEmpty : groups.isEmpty
? Center( ? Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.lightbulb_outline, size: 64, color: Colors.white24), Icon(
const SizedBox(height: 16), Icons.lightbulb_outline,
const Text( size: 64,
'Нет групп', color: Colors.white24,
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()),
),
),
],
), ),
) const SizedBox(height: 16),
: RefreshIndicator( const Text(
color: Colors.deepOrange, 'Нет групп',
onRefresh: () => style: TextStyle(color: Colors.white54, fontSize: 16),
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<String, dynamic>.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: 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<String, dynamic>.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<bool> _confirmDeleteGroup( Future<bool> _confirmDeleteGroup(
BuildContext context, Map<String, dynamic> g) async { BuildContext context,
Map<String, dynamic> g,
) async {
return await showDialog<bool>( return await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
@@ -209,8 +217,10 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
), ),
TextButton( TextButton(
onPressed: () => Navigator.of(ctx).pop(true), onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Удалить', child: const Text(
style: TextStyle(color: Colors.redAccent)), 'Удалить',
style: TextStyle(color: Colors.redAccent),
),
), ),
], ],
), ),
@@ -224,9 +234,9 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
await ref.read(groupsProvider.notifier).refresh(); await ref.read(groupsProvider.notifier).refresh();
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Ошибка удаления: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Ошибка удаления: $e')));
} }
} }
} }

View File

@@ -30,37 +30,37 @@ class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
final tasks = ref.watch(tasksProvider); final tasks = ref.watch(tasksProvider);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: const Text('РАСПИСАНИЯ')),
title: const Text('РАСПИСАНИЯ'),
),
body: _loading body: _loading
? const Center(child: CircularProgressIndicator(color: Colors.deepOrange)) ? const Center(
child: CircularProgressIndicator(color: Colors.deepOrange),
)
: tasks.isEmpty : tasks.isEmpty
? const Center( ? const Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.schedule, size: 64, color: Colors.white24), Icon(Icons.schedule, size: 64, color: Colors.white24),
SizedBox(height: 16), SizedBox(height: 16),
Text( Text(
'Нет активных расписаний', 'Нет активных расписаний',
style: TextStyle(color: Colors.white54, fontSize: 16), style: TextStyle(color: Colors.white54, fontSize: 16),
),
],
), ),
) ],
: RefreshIndicator( ),
color: Colors.deepOrange, )
onRefresh: () => ref.read(tasksProvider.notifier).load(), : RefreshIndicator(
child: ListView.builder( color: Colors.deepOrange,
padding: const EdgeInsets.all(12), onRefresh: () => ref.read(tasksProvider.notifier).load(),
itemCount: tasks.length, child: ListView.builder(
itemBuilder: (context, index) { padding: const EdgeInsets.all(12),
final task = tasks[index]; itemCount: tasks.length,
return _TaskCard(task: task); itemBuilder: (context, index) {
}, final task = tasks[index];
), return _TaskCard(task: task);
), },
),
),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
backgroundColor: Colors.deepOrange, backgroundColor: Colors.deepOrange,
onPressed: () => _showAddDialog(context), onPressed: () => _showAddDialog(context),
@@ -91,7 +91,9 @@ class _TaskCard extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final map = task is Map ? Map<String, dynamic>.from(task) : <String, dynamic>{}; final map = task is Map
? Map<String, dynamic>.from(task)
: <String, dynamic>{};
final jobId = (map['id'] ?? map['job_id'] ?? '').toString(); final jobId = (map['id'] ?? map['job_id'] ?? '').toString();
final targetId = (map['target_id'] ?? map['target'] ?? '').toString(); final targetId = (map['target_id'] ?? map['target'] ?? '').toString();
final state = map['state']; final state = map['state'];
@@ -102,8 +104,8 @@ class _TaskCard extends ConsumerWidget {
final stateStr = state == true final stateStr = state == true
? 'Включить' ? 'Включить'
: state == false : state == false
? 'Выключить' ? 'Выключить'
: '?'; : '?';
String subtitle = 'Цель: $targetId'; String subtitle = 'Цель: $targetId';
if (runAt != null) subtitle += '\nЗапуск: $runAt'; if (runAt != null) subtitle += '\nЗапуск: $runAt';
@@ -286,7 +288,9 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> {
Expanded( Expanded(
child: TextField( child: TextField(
controller: _hourCtrl, controller: _hourCtrl,
decoration: const InputDecoration(labelText: 'Час (0-23)'), decoration: const InputDecoration(
labelText: 'Час (0-23)',
),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
), ),
), ),
@@ -294,7 +298,9 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> {
Expanded( Expanded(
child: TextField( child: TextField(
controller: _minuteCtrl, controller: _minuteCtrl,
decoration: const InputDecoration(labelText: 'Минута (0-59)'), decoration: const InputDecoration(
labelText: 'Минута (0-59)',
),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
), ),
), ),
@@ -336,13 +342,17 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> {
try { try {
if (_type == 'once') { if (_type == 'once') {
await ref.read(tasksProvider.notifier).addOnce( await ref
.read(tasksProvider.notifier)
.addOnce(
targetId: _selectedGroupId!, targetId: _selectedGroupId!,
targetState: _targetState, targetState: _targetState,
hoursFromNow: _hoursFromNow, hoursFromNow: _hoursFromNow,
); );
} else { } else {
await ref.read(tasksProvider.notifier).addCron( await ref
.read(tasksProvider.notifier)
.addCron(
targetId: _selectedGroupId!, targetId: _selectedGroupId!,
hour: _hourCtrl.text.trim(), hour: _hourCtrl.text.trim(),
minute: _minuteCtrl.text.trim(), minute: _minuteCtrl.text.trim(),
@@ -353,9 +363,9 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> {
if (mounted) Navigator.of(context).pop(); if (mounted) Navigator.of(context).pop();
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Ошибка: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Ошибка: $e')));
} }
} }
} }

View File

@@ -33,9 +33,7 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
final groups = (stats['groups'] as List<dynamic>?) ?? []; final groups = (stats['groups'] as List<dynamic>?) ?? [];
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: const Text('СТАТИСТИКА')),
title: const Text('СТАТИСТИКА'),
),
body: Column( body: Column(
children: [ children: [
// ─── Переключатель периода ─── // ─── Переключатель периода ───
@@ -70,26 +68,26 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
child: CircularProgressIndicator(color: Colors.deepOrange), child: CircularProgressIndicator(color: Colors.deepOrange),
) )
: groups.isEmpty : groups.isEmpty
? const Center( ? const Center(
child: Text( child: Text(
'Нет данных', 'Нет данных',
style: TextStyle(color: Colors.white54), style: TextStyle(color: Colors.white54),
), ),
) )
: RefreshIndicator( : RefreshIndicator(
color: Colors.deepOrange, color: Colors.deepOrange,
onRefresh: _load, onRefresh: _load,
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
itemCount: groups.length, itemCount: groups.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final g = groups[index]; final g = groups[index];
return _StatsCard(data: g is Map return _StatsCard(
? Map<String, dynamic>.from(g) data: g is Map ? Map<String, dynamic>.from(g) : {},
: {}); );
}, },
), ),
), ),
), ),
], ],
), ),
@@ -105,7 +103,8 @@ class _StatsCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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 name = (data['name'] ?? targetId).toString();
final totalCommands = data['total_commands'] ?? 0; final totalCommands = data['total_commands'] ?? 0;
final togglesOn = data['toggles_on'] ?? 0; final togglesOn = data['toggles_on'] ?? 0;
@@ -121,10 +120,7 @@ class _StatsCard extends StatelessWidget {
children: [ children: [
Text( Text(
name, name,
style: const TextStyle( style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
fontSize: 16,
fontWeight: FontWeight.bold,
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_StatRow( _StatRow(

View File

@@ -6,22 +6,36 @@ class IgnisApi {
final Dio _dio = Dio(); final Dio _dio = Dio();
Dio get dioInstance => _dio; Dio get dioInstance => _dio;
/// Инициализация базового URL и API-ключа static String normalizeBaseUrl(String baseUrl) {
void init(String baseUrl, String apiKey) { var url = baseUrl.trim();
String url = baseUrl.trim(); final lowerUrl = url.toLowerCase();
if (!url.startsWith('http')) { if (!lowerUrl.startsWith('http://') && !lowerUrl.startsWith('https://')) {
url = 'https://$url'; url = 'https://$url';
} }
// Убираем trailing slash while (url.endsWith('/')) {
if (url.endsWith('/')) url = url.substring(0, url.length - 1); 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; _dio.options.headers['X-API-Key'] = apiKey;
// Бэкенд WiZ ламп тормозит -- даём запас // Бэкенд WiZ ламп тормозит -- даём запас
_dio.options.connectTimeout = const Duration(seconds: 15); _dio.options.connectTimeout = const Duration(seconds: 15);
_dio.options.receiveTimeout = const Duration(seconds: 15); _dio.options.receiveTimeout = const Duration(seconds: 15);
} }
Future<void> validateCredentials(String baseUrl, String apiKey) async {
final probe = IgnisApi()..init(baseUrl, apiKey);
try {
await probe.getAuthMe();
} finally {
probe.dioInstance.close();
}
}
// ─── Авторизация ─────────────────────────────────────────── // ─── Авторизация ───────────────────────────────────────────
/// Проверка текущего ключа: возвращает {is_admin, name} /// Проверка текущего ключа: возвращает {is_admin, name}
@@ -40,7 +54,10 @@ class IgnisApi {
/// Создать группу /// Создать группу
Future<Response> createGroup(String id, String name, List<String> macs) => Future<Response> createGroup(String id, String name, List<String> macs) =>
_dio.post('/devices/groups', data: {'id': id, 'name': name, 'macs': macs}); _dio.post(
'/devices/groups',
data: {'id': id, 'name': name, 'macs': macs},
);
/// Удалить группу /// Удалить группу
Future<Response> deleteGroup(String groupId) => Future<Response> deleteGroup(String groupId) =>
@@ -85,8 +102,7 @@ class IgnisApi {
Future<Response> getTasks() => _dio.get('/schedules/tasks'); Future<Response> getTasks() => _dio.get('/schedules/tasks');
/// Отменить задачу расписания /// Отменить задачу расписания
Future<Response> cancelTask(String jobId) => Future<Response> cancelTask(String jobId) => _dio.delete('/schedules/$jobId');
_dio.delete('/schedules/$jobId');
// ─── API-ключи ───────────────────────────────────────────── // ─── API-ключи ─────────────────────────────────────────────
@@ -94,11 +110,8 @@ class IgnisApi {
Future<Response> getApiKeys() => _dio.get('/api-keys'); Future<Response> getApiKeys() => _dio.get('/api-keys');
/// Создать гостевой ключ /// Создать гостевой ключ
Future<Response> createApiKey(String name, {bool isAdmin = false}) => Future<Response> createApiKey(String name, {bool isAdmin = false}) => _dio
_dio.post('/api-keys', queryParameters: { .post('/api-keys', queryParameters: {'name': name, 'is_admin': isAdmin});
'name': name,
'is_admin': isAdmin,
});
/// Отозвать ключ (body: {key: ...}) /// Отозвать ключ (body: {key: ...})
Future<Response> revokeApiKey(String key) => Future<Response> revokeApiKey(String key) =>

View File

@@ -0,0 +1,28 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
abstract class CredentialsStorage {
Future<String?> getApiKey(String homeId);
Future<void> setApiKey(String homeId, String apiKey);
Future<void> deleteApiKey(String homeId);
}
class SecureCredentialsStorage implements CredentialsStorage {
static const _storage = FlutterSecureStorage();
static String _apiKeyStorageKey(String homeId) =>
'ignis_home_api_key_$homeId';
@override
Future<String?> getApiKey(String homeId) =>
_storage.read(key: _apiKeyStorageKey(homeId));
@override
Future<void> setApiKey(String homeId, String apiKey) =>
_storage.write(key: _apiKeyStorageKey(homeId), value: apiKey);
@override
Future<void> deleteApiKey(String homeId) =>
_storage.delete(key: _apiKeyStorageKey(homeId));
}

View File

@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:dio/dio.dart'; 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:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -10,6 +11,7 @@ const double geofenceThresholdMeters = 500.0;
/// Ключ в SharedPreferences: геофенс уже сработал, таск можно не запускать /// Ключ в SharedPreferences: геофенс уже сработал, таск можно не запускать
const String _firedKey = 'ignis_geofence_fired'; const String _firedKey = 'ignis_geofence_fired';
const String _apiKeyPrefix = 'ignis_home_api_key_';
/// Имя задачи в workmanager /// Имя задачи в workmanager
const String geofenceTaskName = 'ignis_geofence_check'; const String geofenceTaskName = 'ignis_geofence_check';
@@ -93,8 +95,12 @@ Future<bool> executeGeofenceCheck() async {
// 4. Считаем расстояние // 4. Считаем расстояние
final homeLat = (targetHome['latitude'] as num).toDouble(); final homeLat = (targetHome['latitude'] as num).toDouble();
final homeLon = (targetHome['longitude'] as num).toDouble(); final homeLon = (targetHome['longitude'] as num).toDouble();
final distMeters = final distMeters = _haversineMeters(
_haversineMeters(pos.latitude, pos.longitude, homeLat, homeLon); pos.latitude,
pos.longitude,
homeLat,
homeLon,
);
if (distMeters <= geofenceThresholdMeters) { if (distMeters <= geofenceThresholdMeters) {
return true; // всё ещё рядом с домом return true; // всё ещё рядом с домом
@@ -102,7 +108,8 @@ Future<bool> executeGeofenceCheck() async {
// 5. Ушли за порог -- выключаем все группы // 5. Ушли за порог -- выключаем все группы
final url = _normalizeUrl(targetHome['url'] as String); 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; final homeName = (targetHome['name'] ?? 'Дом') as String;
int groupCount = 0; int groupCount = 0;
@@ -122,7 +129,8 @@ Future<bool> executeGeofenceCheck() async {
: '${(distMeters / 1000).toStringAsFixed(1)} км'; : '${(distMeters / 1000).toStringAsFixed(1)} км';
await _showNotification( await _showNotification(
title: 'Свет выключен', title: 'Свет выключен',
body: '$homeName -- вы ушли на $distText. ' body:
'$homeName -- вы ушли на $distText. '
'Выключено групп: $groupCount.', 'Выключено групп: $groupCount.',
); );
@@ -133,6 +141,19 @@ Future<bool> executeGeofenceCheck() async {
} }
} }
Future<String?> _getHomeApiKey(Map<String, dynamic> 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<void> resetGeofenceFired() async { Future<void> resetGeofenceFired() async {
@@ -183,12 +204,14 @@ Future<void> _showNotification({
/// Выключить все группы на сервере. Возвращает кол-во выключенных. /// Выключить все группы на сервере. Возвращает кол-во выключенных.
Future<int> _turnOffAllGroups(String baseUrl, String apiKey) async { Future<int> _turnOffAllGroups(String baseUrl, String apiKey) async {
final dio = Dio(BaseOptions( final dio = Dio(
baseUrl: baseUrl, BaseOptions(
headers: {'X-API-Key': apiKey}, baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15), headers: {'X-API-Key': apiKey},
receiveTimeout: const Duration(seconds: 15), connectTimeout: const Duration(seconds: 15),
)); receiveTimeout: const Duration(seconds: 15),
),
);
try { try {
// Получаем список групп // Получаем список групп
@@ -212,14 +235,19 @@ Future<int> _turnOffAllGroups(String baseUrl, String apiKey) async {
// Выключаем каждую группу // Выключаем каждую группу
int success = 0; int success = 0;
await Future.wait(groupIds.map((id) async { await Future.wait(
try { groupIds.map((id) async {
await dio.post('/control/group/$id', queryParameters: {'state': false}); try {
success++; await dio.post(
} catch (_) { '/control/group/$id',
// Одна группа упала -- не останавливаем остальные queryParameters: {'state': false},
} );
})); success++;
} catch (_) {
// Одна группа упала -- не останавливаем остальные
}
}),
);
return success; return success;
} finally { } finally {
@@ -236,12 +264,12 @@ String _normalizeUrl(String url) {
} }
/// Расстояние в метрах (Haversine) /// Расстояние в метрах (Haversine)
double _haversineMeters( double _haversineMeters(double lat1, double lon1, double lat2, double lon2) {
double lat1, double lon1, double lat2, double lon2) {
const earthRadiusM = 6371000.0; const earthRadiusM = 6371000.0;
final dLat = _degToRad(lat2 - lat1); final dLat = _degToRad(lat2 - lat1);
final dLon = _degToRad(lon2 - lon1); 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(lat1)) *
math.cos(_degToRad(lat2)) * math.cos(_degToRad(lat2)) *
math.sin(dLon / 2) * math.sin(dLon / 2) *

View File

@@ -1,30 +1,40 @@
import 'dart:convert'; import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/home_config.dart'; import '../models/home_config.dart';
import 'credentials_storage.dart';
/// Сервис для хранения списка "домов" и текущего выбранного. /// Сервис для хранения списка "домов" и текущего выбранного.
/// Данные лежат в SharedPreferences как JSON-массив. /// Несекретные данные лежат в SharedPreferences, API-ключи -- отдельно.
class SettingsService { class SettingsService {
static const String _homesKey = 'ignis_homes'; static const String _homesKey = 'ignis_homes';
static const String _currentHomeKey = 'ignis_current_home_id'; static const String _currentHomeKey = 'ignis_current_home_id';
final CredentialsStorage _credentialsStorage;
SettingsService({CredentialsStorage? credentialsStorage})
: _credentialsStorage = credentialsStorage ?? SecureCredentialsStorage();
/// Загрузить все дома /// Загрузить все дома
Future<List<HomeConfig>> getHomes() async { Future<List<HomeConfig>> getHomes() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_homesKey); final raw = prefs.getString(_homesKey);
if (raw == null || raw.isEmpty) return []; if (raw == null || raw.isEmpty) return [];
final list = jsonDecode(raw) as List<dynamic>; final list = jsonDecode(raw) as List<dynamic>;
return list.map((e) => HomeConfig.fromJson(e as Map<String, dynamic>)).toList(); final migrated = await _migrateApiKeysIfNeeded(prefs, list);
return migrated.map(HomeConfig.fromJson).toList();
} }
/// Сохранить весь список домов /// Сохранить весь список домов
Future<void> saveHomes(List<HomeConfig> homes) async { Future<void> saveHomes(List<HomeConfig> homes) async {
final prefs = await SharedPreferences.getInstance(); 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<void> upsertHome(HomeConfig home) async { Future<void> upsertHome(HomeConfig home, {String? apiKey}) async {
final homes = await getHomes(); final homes = await getHomes();
final idx = homes.indexWhere((h) => h.id == home.id); final idx = homes.indexWhere((h) => h.id == home.id);
if (idx >= 0) { if (idx >= 0) {
@@ -32,6 +42,9 @@ class SettingsService {
} else { } else {
homes.add(home); homes.add(home);
} }
if (apiKey != null) {
await setHomeApiKey(home.id, apiKey);
}
await saveHomes(homes); await saveHomes(homes);
} }
@@ -40,6 +53,7 @@ class SettingsService {
final homes = await getHomes(); final homes = await getHomes();
homes.removeWhere((h) => h.id == id); homes.removeWhere((h) => h.id == id);
await saveHomes(homes); await saveHomes(homes);
await deleteHomeApiKey(id);
// Если удалили текущий -- сбрасываем выбор // Если удалили текущий -- сбрасываем выбор
final currentId = await getCurrentHomeId(); final currentId = await getCurrentHomeId();
@@ -75,4 +89,53 @@ class SettingsService {
return homes.isNotEmpty ? homes.first : null; return homes.isNotEmpty ? homes.first : null;
} }
} }
Future<String?> getHomeApiKey(String homeId) =>
_credentialsStorage.getApiKey(homeId);
Future<String> 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<void> setHomeApiKey(String homeId, String apiKey) =>
_credentialsStorage.setApiKey(homeId, apiKey);
Future<void> deleteHomeApiKey(String homeId) =>
_credentialsStorage.deleteApiKey(homeId);
Future<List<Map<String, dynamic>>> _migrateApiKeysIfNeeded(
SharedPreferences prefs,
List<dynamic> rawList,
) async {
var changed = false;
final result = <Map<String, dynamic>>[];
for (final item in rawList) {
final map = Map<String, dynamic>.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;
}
} }

View File

@@ -121,7 +121,10 @@ class _SimpleColorPickerState extends State<SimpleColorPicker> {
children: [ children: [
SizedBox( SizedBox(
width: 60, 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( Expanded(
child: Slider( child: Slider(

View File

@@ -23,8 +23,7 @@ class _GroupCardState extends ConsumerState<GroupCard> {
double? _localBrightness; double? _localBrightness;
double? _localTemp; double? _localTemp;
int _channelValue(double channel) => int _channelValue(double channel) => (channel * 255.0).round().clamp(0, 255);
(channel * 255.0).round().clamp(0, 255);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -45,8 +44,12 @@ class _GroupCardState extends ConsumerState<GroupCard> {
// Цвет подсветки карточки зависит от режима и состояния // Цвет подсветки карточки зависит от режима и состояния
final cardAccent = isOn final cardAccent = isOn
? (_mode == 'temp' ? (_mode == 'temp'
? Color.lerp(Colors.orange, Colors.blueAccent, (tempValue - 2700) / 3800)! ? Color.lerp(
: Color.fromARGB(255, r, gVal, b)) Colors.orange,
Colors.blueAccent,
(tempValue - 2700) / 3800,
)!
: Color.fromARGB(255, r, gVal, b))
: Colors.white12; : Colors.white12;
return Card( return Card(
@@ -56,10 +59,7 @@ class _GroupCardState extends ConsumerState<GroupCard> {
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: isOn border: isOn
? Border.all( ? Border.all(color: cardAccent.withValues(alpha: 0.3), width: 1)
color: cardAccent.withValues(alpha: 0.3),
width: 1,
)
: null, : null,
), ),
child: Padding( child: Padding(
@@ -84,9 +84,14 @@ class _GroupCardState extends ConsumerState<GroupCard> {
// Кнопка "таймер на 4 часа" // Кнопка "таймер на 4 часа"
if (isOn) if (isOn)
IconButton( IconButton(
icon: const Icon(Icons.timer, size: 20, color: Colors.white38), icon: const Icon(
Icons.timer,
size: 20,
color: Colors.white38,
),
tooltip: 'Включить на 4 часа', tooltip: 'Включить на 4 часа',
onPressed: () => ref.read(groupsProvider.notifier).setTimer4h(id), onPressed: () =>
ref.read(groupsProvider.notifier).setTimer4h(id),
), ),
Switch( Switch(
value: isOn, value: isOn,
@@ -112,7 +117,9 @@ class _GroupCardState extends ConsumerState<GroupCard> {
activeColor: Colors.amber, activeColor: Colors.amber,
onChanged: (v) { onChanged: (v) {
setState(() => _localBrightness = v); setState(() => _localBrightness = v);
ref.read(groupsProvider.notifier).setBrightness(id, v.toInt()); ref
.read(groupsProvider.notifier)
.setBrightness(id, v.toInt());
}, },
onChangeEnd: (v) { onChangeEnd: (v) {
setState(() => _localBrightness = null); setState(() => _localBrightness = null);
@@ -156,10 +163,15 @@ class _GroupCardState extends ConsumerState<GroupCard> {
divisions: 38, // шаг 100K divisions: 38, // шаг 100K
label: "${tempValue.toInt()}K", label: "${tempValue.toInt()}K",
activeColor: Color.lerp( activeColor: Color.lerp(
Colors.orange, Colors.blueAccent, (tempValue - 2700) / 3800), Colors.orange,
Colors.blueAccent,
(tempValue - 2700) / 3800,
),
onChanged: (v) { onChanged: (v) {
setState(() => _localTemp = v); setState(() => _localTemp = v);
ref.read(groupsProvider.notifier).setTemperature(id, v.toInt()); ref
.read(groupsProvider.notifier)
.setTemperature(id, v.toInt());
}, },
onChangeEnd: (v) { onChangeEnd: (v) {
setState(() => _localTemp = null); setState(() => _localTemp = null);
@@ -172,7 +184,9 @@ class _GroupCardState extends ConsumerState<GroupCard> {
initialColor: Color.fromARGB(255, r, gVal, b), initialColor: Color.fromARGB(255, r, gVal, b),
onColorChanged: (c) { onColorChanged: (c) {
// Обновление UI-превью -- через debounce отправляется на сервер // Обновление UI-превью -- через debounce отправляется на сервер
ref.read(groupsProvider.notifier).setColor( ref
.read(groupsProvider.notifier)
.setColor(
id, id,
_channelValue(c.r), _channelValue(c.r),
_channelValue(c.g), _channelValue(c.g),
@@ -279,7 +293,11 @@ class _ModeChip extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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), const SizedBox(width: 4),
Text( Text(
label, label,
@@ -349,8 +367,10 @@ class _SceneSelectorState extends ConsumerState<_SceneSelector> {
sceneName = scene; sceneName = scene;
sceneId = scene; sceneId = scene;
} else if (scene is Map) { } else if (scene is Map) {
sceneName = (scene['name'] ?? scene['id'] ?? scene.toString()).toString(); sceneName = (scene['name'] ?? scene['id'] ?? scene.toString())
sceneId = (scene['id'] ?? scene['name'] ?? scene.toString()).toString(); .toString();
sceneId = (scene['id'] ?? scene['name'] ?? scene.toString())
.toString();
} else { } else {
sceneName = scene.toString(); sceneName = scene.toString();
sceneId = scene.toString(); sceneId = scene.toString();

1002
pubspec.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,7 @@ dependencies:
geolocator: ^13.0.2 geolocator: ^13.0.2
workmanager: ^0.9.0+3 workmanager: ^0.9.0+3
flutter_local_notifications: ^19.0.0 flutter_local_notifications: ^19.0.0
flutter_secure_storage: ^10.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -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<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);
}
}
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<dynamic>;
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<dynamic>;
expect(storedHomes.single, isNot(contains('apiKey')));
});
}

View File

@@ -7,15 +7,12 @@ import 'package:ignis_app/main.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
testWidgets('app opens homes screen when no homes are configured', testWidgets('app opens homes screen when no homes are configured', (
(WidgetTester tester) async { WidgetTester tester,
) async {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
await tester.pumpWidget( await tester.pumpWidget(const ProviderScope(child: IgnisApp()));
const ProviderScope(
child: IgnisApp(),
),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('ДОМА'), findsOneWidget); expect(find.text('ДОМА'), findsOneWidget);