feat: secure home credentials
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,7 +6,6 @@ build/
|
|||||||
.flutter-plugins-dependencies
|
.flutter-plugins-dependencies
|
||||||
.pub-cache/
|
.pub-cache/
|
||||||
.pub/
|
.pub/
|
||||||
pubspec.lock
|
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -30,7 +28,6 @@ class HomeConfig {
|
|||||||
'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,
|
||||||
@@ -40,7 +37,6 @@ class 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,
|
||||||
@@ -50,17 +46,14 @@ class HomeConfig {
|
|||||||
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,
|
||||||
apiKey: apiKey ?? this.apiKey,
|
|
||||||
latitude: clearCoordinates ? null : (latitude ?? this.latitude),
|
latitude: clearCoordinates ? null : (latitude ?? this.latitude),
|
||||||
longitude: clearCoordinates ? null : (longitude ?? this.longitude),
|
longitude: clearCoordinates ? null : (longitude ?? this.longitude),
|
||||||
// Если очищаем координаты -- геофенс тоже выключается
|
// Если очищаем координаты -- геофенс тоже выключается
|
||||||
|
|||||||
@@ -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,7 +285,8 @@ 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(
|
||||||
|
rawList.map((g) async {
|
||||||
final map = Map<String, dynamic>.from(g);
|
final map = Map<String, dynamic>.from(g);
|
||||||
final id = map['id'].toString();
|
final id = map['id'].toString();
|
||||||
|
|
||||||
@@ -316,11 +330,13 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
|
|||||||
(s) => s['id'].toString() == id,
|
(s) => s['id'].toString() == id,
|
||||||
orElse: () => null,
|
orElse: () => null,
|
||||||
);
|
);
|
||||||
map['last_state'] = existing?['last_state'] ??
|
map['last_state'] =
|
||||||
|
existing?['last_state'] ??
|
||||||
{'state': false, 'brightness': 100, 'temp': 4000};
|
{'state': false, 'brightness': 100, 'temp': 4000};
|
||||||
}
|
}
|
||||||
return map;
|
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) *
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -67,9 +67,7 @@ class _EventLogScreenState extends ConsumerState<EventLogScreen> {
|
|||||||
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'] ?? '';
|
||||||
|
|
||||||
|
|||||||
@@ -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')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,16 +305,23 @@ 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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ref.read(apiProvider).validateCredentials(url, key);
|
||||||
|
|
||||||
if (_isEdit) {
|
if (_isEdit) {
|
||||||
await ref.read(homesProvider.notifier).update(home);
|
await ref.read(homesProvider.notifier).update(home, apiKey: key);
|
||||||
} else {
|
} else {
|
||||||
await ref.read(homesProvider.notifier).add(home);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Синхронизировать фоновый таск с новыми настройками
|
// Синхронизировать фоновый таск с новыми настройками
|
||||||
@@ -284,5 +329,14 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
await syncGeofenceTask(allHomes);
|
await syncGeofenceTask(allHomes);
|
||||||
|
|
||||||
if (mounted) Navigator.of(context).pop();
|
if (mounted) Navigator.of(context).pop();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('Не удалось проверить дом: $e')));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _saving = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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>(
|
||||||
@@ -143,7 +143,11 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.lightbulb_outline, size: 64, color: Colors.white24),
|
Icon(
|
||||||
|
Icons.lightbulb_outline,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.white24,
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
const Text(
|
||||||
'Нет групп',
|
'Нет групп',
|
||||||
@@ -155,7 +159,8 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
|||||||
label: const Text('Создать группу'),
|
label: const Text('Создать группу'),
|
||||||
onPressed: () => Navigator.of(context).push(
|
onPressed: () => Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => const GroupEditScreen()),
|
builder: (_) => const GroupEditScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -163,8 +168,7 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
|||||||
)
|
)
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
color: Colors.deepOrange,
|
color: Colors.deepOrange,
|
||||||
onRefresh: () =>
|
onRefresh: () => ref.read(groupsProvider.notifier).refresh(),
|
||||||
ref.read(groupsProvider.notifier).refresh(),
|
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
padding: const EdgeInsets.only(top: 8, bottom: 80),
|
padding: const EdgeInsets.only(top: 8, bottom: 80),
|
||||||
itemCount: groups.length,
|
itemCount: groups.length,
|
||||||
@@ -177,7 +181,9 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
|||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
padding: const EdgeInsets.only(right: 20),
|
padding: const EdgeInsets.only(right: 20),
|
||||||
margin: const EdgeInsets.symmetric(
|
margin: const EdgeInsets.symmetric(
|
||||||
horizontal: 12, vertical: 6),
|
horizontal: 12,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.redAccent.withValues(alpha: 0.3),
|
color: Colors.redAccent.withValues(alpha: 0.3),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
@@ -196,7 +202,9 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
|||||||
|
|
||||||
/// Подтверждение удаления группы свайпом
|
/// Подтверждение удаления группы свайпом
|
||||||
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')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ 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(
|
||||||
@@ -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'];
|
||||||
@@ -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')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: [
|
||||||
// ─── Переключатель периода ───
|
// ─── Переключатель периода ───
|
||||||
@@ -84,9 +82,9 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
|
|||||||
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(
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
28
lib/services/credentials_storage.dart
Normal file
28
lib/services/credentials_storage.dart
Normal 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));
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
BaseOptions(
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
headers: {'X-API-Key': apiKey},
|
headers: {'X-API-Key': apiKey},
|
||||||
connectTimeout: const Duration(seconds: 15),
|
connectTimeout: const Duration(seconds: 15),
|
||||||
receiveTimeout: 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(
|
||||||
|
groupIds.map((id) async {
|
||||||
try {
|
try {
|
||||||
await dio.post('/control/group/$id', queryParameters: {'state': false});
|
await dio.post(
|
||||||
|
'/control/group/$id',
|
||||||
|
queryParameters: {'state': false},
|
||||||
|
);
|
||||||
success++;
|
success++;
|
||||||
} catch (_) {
|
} 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) *
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,7 +44,11 @@ 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(
|
||||||
|
Colors.orange,
|
||||||
|
Colors.blueAccent,
|
||||||
|
(tempValue - 2700) / 3800,
|
||||||
|
)!
|
||||||
: Color.fromARGB(255, r, gVal, b))
|
: Color.fromARGB(255, r, gVal, b))
|
||||||
: Colors.white12;
|
: Colors.white12;
|
||||||
|
|
||||||
@@ -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
1002
pubspec.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||||
|
|||||||
82
test/settings_service_test.dart
Normal file
82
test/settings_service_test.dart
Normal 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')));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user