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

View File

@@ -20,8 +20,9 @@ final apiProvider = Provider((ref) => IgnisApi());
// ─── Текущий дом ─────────────────────────────────────────────
/// Текущий выбранный дом (null если ни одного нет)
final currentHomeProvider =
NotifierProvider<CurrentHomeNotifier, HomeConfig?>(() => CurrentHomeNotifier());
final currentHomeProvider = NotifierProvider<CurrentHomeNotifier, HomeConfig?>(
() => CurrentHomeNotifier(),
);
class CurrentHomeNotifier extends Notifier<HomeConfig?> {
@override
@@ -32,7 +33,7 @@ class CurrentHomeNotifier extends Notifier<HomeConfig?> {
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<HomeConfig?> {
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<void> _initApi(HomeConfig home) async {
final apiKey = await ref
.read(settingsServiceProvider)
.requireHomeApiKey(home.id);
ref.read(apiProvider).init(home.url, apiKey);
}
}
// ─── Список домов ────────────────────────────────────────────
final homesProvider =
NotifierProvider<HomesNotifier, List<HomeConfig>>(() => HomesNotifier());
final homesProvider = NotifierProvider<HomesNotifier, List<HomeConfig>>(
() => HomesNotifier(),
);
class HomesNotifier extends Notifier<List<HomeConfig>> {
@override
@@ -65,8 +70,8 @@ class HomesNotifier extends Notifier<List<HomeConfig>> {
state = await ref.read(settingsServiceProvider).getHomes();
}
Future<void> add(HomeConfig home) async {
await ref.read(settingsServiceProvider).upsertHome(home);
Future<void> 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<List<HomeConfig>> {
await load();
}
Future<void> update(HomeConfig home) async {
await ref.read(settingsServiceProvider).upsertHome(home);
Future<void> 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, UserLocation>(
() => UserLocationNotifier());
() => UserLocationNotifier(),
);
class UserLocationNotifier extends Notifier<UserLocation> {
StreamSubscription<Position>? _sub;
@@ -221,8 +230,9 @@ class UserLocationNotifier extends Notifier<UserLocation> {
// ─── Группы текущего дома ────────────────────────────────────
final groupsProvider =
NotifierProvider<GroupsNotifier, List<dynamic>>(() => GroupsNotifier());
final groupsProvider = NotifierProvider<GroupsNotifier, List<dynamic>>(
() => GroupsNotifier(),
);
class GroupsNotifier extends Notifier<List<dynamic>> {
IgnisApi get _api => ref.read(apiProvider);
@@ -250,7 +260,10 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
Future<void> 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<List<dynamic>> {
final now = DateTime.now();
// Параллельный опрос статусов всех групп
final updatedList = await Future.wait(rawList.map((g) async {
final map = Map<String, dynamic>.from(g);
final id = map['id'].toString();
final updatedList = await Future.wait(
rawList.map((g) async {
final map = Map<String, dynamic>.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<List<dynamic>> {
...g,
'last_state': {
...Map<String, dynamic>.from(g['last_state'] ?? {}),
...patch
}
...patch,
},
}
else
g
g,
];
}
/// Debounce: отправить API-запрос с задержкой, но UI обновить сразу.
/// Если значение меняется быстро (слайдер тянут), отправляется только
/// последнее значение после паузы.
void _debouncedControl(String id, String key, Map<String, dynamic> localPatch,
Map<String, dynamic> apiParams) {
void _debouncedControl(
String id,
String key,
Map<String, dynamic> localPatch,
Map<String, dynamic> apiParams,
) {
_setLock(id);
_updateLocal(id, localPatch);
@@ -386,7 +406,12 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
/// Установить яркость (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<List<dynamic>> {
/// Установить 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<List<dynamic>> {
// ─── Устройства (для создания групп) ─────────────────────────
final devicesProvider =
NotifierProvider<DevicesNotifier, List<dynamic>>(() => DevicesNotifier());
final devicesProvider = NotifierProvider<DevicesNotifier, List<dynamic>>(
() => DevicesNotifier(),
);
class DevicesNotifier extends Notifier<List<dynamic>> {
@override
@@ -440,7 +471,8 @@ class DevicesNotifier extends Notifier<List<dynamic>> {
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<List<dynamic>> {
// ─── Сцены ───────────────────────────────────────────────────
final scenesProvider =
NotifierProvider<ScenesNotifier, List<dynamic>>(() => ScenesNotifier());
final scenesProvider = NotifierProvider<ScenesNotifier, List<dynamic>>(
() => ScenesNotifier(),
);
class ScenesNotifier extends Notifier<List<dynamic>> {
@override
@@ -486,8 +519,9 @@ class ScenesNotifier extends Notifier<List<dynamic>> {
// ─── Расписания ──────────────────────────────────────────────
final tasksProvider =
NotifierProvider<TasksNotifier, List<dynamic>>(() => TasksNotifier());
final tasksProvider = NotifierProvider<TasksNotifier, List<dynamic>>(
() => TasksNotifier(),
);
class TasksNotifier extends Notifier<List<dynamic>> {
@override
@@ -559,8 +593,9 @@ class TasksNotifier extends Notifier<List<dynamic>> {
// ─── Статистика ──────────────────────────────────────────────
final statsProvider =
NotifierProvider<StatsNotifier, Map<String, dynamic>>(() => StatsNotifier());
final statsProvider = NotifierProvider<StatsNotifier, Map<String, dynamic>>(
() => StatsNotifier(),
);
class StatsNotifier extends Notifier<Map<String, dynamic>> {
@override
@@ -582,8 +617,9 @@ class StatsNotifier extends Notifier<Map<String, dynamic>> {
// ─── Лог событий ─────────────────────────────────────────────
final eventLogProvider =
NotifierProvider<EventLogNotifier, List<dynamic>>(() => EventLogNotifier());
final eventLogProvider = NotifierProvider<EventLogNotifier, List<dynamic>>(
() => EventLogNotifier(),
);
class EventLogNotifier extends Notifier<List<dynamic>> {
@override
@@ -607,8 +643,9 @@ class EventLogNotifier extends Notifier<List<dynamic>> {
// ─── API-ключи ───────────────────────────────────────────────
final apiKeysProvider =
NotifierProvider<ApiKeysNotifier, List<dynamic>>(() => ApiKeysNotifier());
final apiKeysProvider = NotifierProvider<ApiKeysNotifier, List<dynamic>>(
() => ApiKeysNotifier(),
);
class ApiKeysNotifier extends Notifier<List<dynamic>> {
@override
@@ -666,7 +703,9 @@ class ApiKeysNotifier extends Notifier<List<dynamic>> {
// ─── Информация об авторизации ────────────────────────────────
final authInfoProvider =
NotifierProvider<AuthInfoNotifier, Map<String, dynamic>?>(() => AuthInfoNotifier());
NotifierProvider<AuthInfoNotifier, Map<String, dynamic>?>(
() => AuthInfoNotifier(),
);
class AuthInfoNotifier extends Notifier<Map<String, dynamic>?> {
@override
@@ -706,9 +745,7 @@ Future<void> syncGeofenceTask(List<HomeConfig> 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<void> syncGeofenceTask(List<HomeConfig> 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) *