Waaaay big enchancements
This commit is contained in:
@@ -70,6 +70,8 @@ class _MainGateState extends ConsumerState<MainGate> {
|
|||||||
if (home != null) {
|
if (home != null) {
|
||||||
// Есть дом -- идём на пульт управления
|
// Есть дом -- идём на пульт управления
|
||||||
await ref.read(groupsProvider.notifier).initAndRefresh();
|
await ref.read(groupsProvider.notifier).initAndRefresh();
|
||||||
|
// Загружаем info об авторизации (admin / не admin)
|
||||||
|
await ref.read(authInfoProvider.notifier).load();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.of(context).pushReplacement(
|
Navigator.of(context).pushReplacement(
|
||||||
MaterialPageRoute(builder: (_) => const RemoteScreen()),
|
MaterialPageRoute(builder: (_) => const RemoteScreen()),
|
||||||
|
|||||||
@@ -5,20 +5,29 @@ class HomeConfig {
|
|||||||
final String name; // человекочитаемое название ("Квартира", "Дача")
|
final String name; // человекочитаемое название ("Квартира", "Дача")
|
||||||
final String url; // адрес сервера (например ignis.akokos.ru)
|
final String url; // адрес сервера (например ignis.akokos.ru)
|
||||||
final String apiKey; // ключ авторизации
|
final String apiKey; // ключ авторизации
|
||||||
|
final double? latitude; // GPS-широта дома (для гео-автоматизации)
|
||||||
|
final double? longitude; // GPS-долгота дома (для гео-автоматизации)
|
||||||
|
|
||||||
HomeConfig({
|
HomeConfig({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.url,
|
required this.url,
|
||||||
required this.apiKey,
|
required this.apiKey,
|
||||||
|
this.latitude,
|
||||||
|
this.longitude,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Есть ли координаты у дома
|
||||||
|
bool get hasCoordinates => latitude != null && longitude != null;
|
||||||
|
|
||||||
/// Сериализация в JSON для хранения в SharedPreferences
|
/// Сериализация в JSON для хранения в SharedPreferences
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
'name': name,
|
'name': name,
|
||||||
'url': url,
|
'url': url,
|
||||||
'apiKey': apiKey,
|
'apiKey': apiKey,
|
||||||
|
if (latitude != null) 'latitude': latitude,
|
||||||
|
if (longitude != null) 'longitude': longitude,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory HomeConfig.fromJson(Map<String, dynamic> json) => HomeConfig(
|
factory HomeConfig.fromJson(Map<String, dynamic> json) => HomeConfig(
|
||||||
@@ -26,13 +35,25 @@ class HomeConfig {
|
|||||||
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,
|
apiKey: json['apiKey'] as String,
|
||||||
|
latitude: (json['latitude'] as num?)?.toDouble(),
|
||||||
|
longitude: (json['longitude'] as num?)?.toDouble(),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Копирование с изменениями
|
/// Копирование с изменениями
|
||||||
HomeConfig copyWith({String? name, String? url, String? apiKey}) => HomeConfig(
|
HomeConfig copyWith({
|
||||||
|
String? name,
|
||||||
|
String? url,
|
||||||
|
String? apiKey,
|
||||||
|
double? latitude,
|
||||||
|
double? longitude,
|
||||||
|
bool clearCoordinates = false,
|
||||||
|
}) =>
|
||||||
|
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,
|
apiKey: apiKey ?? this.apiKey,
|
||||||
|
latitude: clearCoordinates ? null : (latitude ?? this.latitude),
|
||||||
|
longitude: clearCoordinates ? null : (longitude ?? this.longitude),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
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 '../models/home_config.dart';
|
import '../models/home_config.dart';
|
||||||
@@ -90,9 +91,17 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
|
|||||||
/// чтобы UI не прыгал пока лампа ещё не ответила.
|
/// чтобы UI не прыгал пока лампа ещё не ответила.
|
||||||
final Map<String, DateTime> _lockUntil = {};
|
final Map<String, DateTime> _lockUntil = {};
|
||||||
|
|
||||||
|
/// Debounce-таймеры для слайдеров (яркость, темп, цвет)
|
||||||
|
final Map<String, Timer> _debounceTimers = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<dynamic> build() {
|
List<dynamic> build() {
|
||||||
ref.onDispose(() => _timer?.cancel());
|
ref.onDispose(() {
|
||||||
|
_timer?.cancel();
|
||||||
|
for (final t in _debounceTimers.values) {
|
||||||
|
t.cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +149,14 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
|
|||||||
if (resStatus.data != null &&
|
if (resStatus.data != null &&
|
||||||
resStatus.data['results'] is List &&
|
resStatus.data['results'] is List &&
|
||||||
resStatus.data['results'].isNotEmpty) {
|
resStatus.data['results'].isNotEmpty) {
|
||||||
final data = resStatus.data['results'][0]['status'];
|
// Берём первый результат без ошибки, или просто первый
|
||||||
|
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'] = {
|
map['last_state'] = {
|
||||||
'state': data['state'] == true,
|
'state': data['state'] == true,
|
||||||
'brightness': (data['dimming'] ?? 100).toInt(),
|
'brightness': (data['dimming'] ?? 100).toInt(),
|
||||||
@@ -151,6 +167,7 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
|
|||||||
'scene': data['scene'],
|
'scene': data['scene'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// При ошибке опроса -- сохраняем предыдущее состояние
|
// При ошибке опроса -- сохраняем предыдущее состояние
|
||||||
final existing = state.cast<dynamic?>().firstWhere(
|
final existing = state.cast<dynamic?>().firstWhere(
|
||||||
@@ -189,7 +206,30 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Включить/выключить группу
|
/// Debounce: отправить API-запрос с задержкой, но UI обновить сразу.
|
||||||
|
/// Если значение меняется быстро (слайдер тянут), отправляется только
|
||||||
|
/// последнее значение после паузы.
|
||||||
|
void _debouncedControl(String id, String key, Map<String, dynamic> localPatch,
|
||||||
|
Map<String, dynamic> apiParams) {
|
||||||
|
_setLock(id);
|
||||||
|
_updateLocal(id, localPatch);
|
||||||
|
|
||||||
|
final timerKey = '$id:$key';
|
||||||
|
_debounceTimers[timerKey]?.cancel();
|
||||||
|
_debounceTimers[timerKey] = Timer(
|
||||||
|
const Duration(milliseconds: 300),
|
||||||
|
() async {
|
||||||
|
try {
|
||||||
|
await _api.controlGroup(id, apiParams);
|
||||||
|
} catch (e) {
|
||||||
|
_lockUntil.remove(id);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Включить/выключить группу (без debounce -- мгновенно)
|
||||||
Future<void> toggleGroup(String id, bool on) async {
|
Future<void> toggleGroup(String id, bool on) async {
|
||||||
_setLock(id);
|
_setLock(id);
|
||||||
_updateLocal(id, {'state': on});
|
_updateLocal(id, {'state': on});
|
||||||
@@ -201,32 +241,31 @@ class GroupsNotifier extends Notifier<List<dynamic>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Установить яркость (0-100)
|
/// Установить яркость (0-100) -- с debounce
|
||||||
Future<void> setBrightness(String id, int value) async {
|
void setBrightness(String id, int value) {
|
||||||
_setLock(id);
|
_debouncedControl(id, 'brightness', {'brightness': value}, {'brightness': value});
|
||||||
_updateLocal(id, {'brightness': value});
|
|
||||||
await _api.controlGroup(id, {'brightness': value});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Установить цветовую температуру (2700-6500K)
|
/// Установить цветовую температуру (2700-6500K) -- с debounce
|
||||||
Future<void> setTemperature(String id, int value) async {
|
void setTemperature(String id, int value) {
|
||||||
_setLock(id);
|
_debouncedControl(id, 'temp', {'temp': value}, {'temp': value});
|
||||||
_updateLocal(id, {'temp': value});
|
|
||||||
await _api.controlGroup(id, {'temp': value});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Установить RGB-цвет
|
/// Установить RGB-цвет -- с debounce
|
||||||
Future<void> setColor(String id, int r, int g, int b) async {
|
void setColor(String id, int r, int g, int b) {
|
||||||
_setLock(id);
|
_debouncedControl(id, 'color', {'r': r, 'g': g, 'b': b}, {'r': r, 'g': g, 'b': b});
|
||||||
_updateLocal(id, {'r': r, 'g': g, 'b': b});
|
|
||||||
await _api.controlGroup(id, {'r': r, 'g': g, 'b': b});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Установить сцену
|
/// Установить сцену (без debounce)
|
||||||
Future<void> setScene(String id, String scene) async {
|
Future<void> setScene(String id, String scene) async {
|
||||||
_setLock(id);
|
_setLock(id);
|
||||||
_updateLocal(id, {'scene': scene});
|
_updateLocal(id, {'scene': scene});
|
||||||
|
try {
|
||||||
await _api.controlGroup(id, {'scene': scene});
|
await _api.controlGroup(id, {'scene': scene});
|
||||||
|
} catch (e) {
|
||||||
|
_lockUntil.remove(id);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Таймер: включить на 4 часа
|
/// Таймер: включить на 4 часа
|
||||||
@@ -279,10 +318,22 @@ class ScenesNotifier extends Notifier<List<dynamic>> {
|
|||||||
try {
|
try {
|
||||||
final api = ref.read(apiProvider);
|
final api = ref.read(apiProvider);
|
||||||
final res = await api.getScenes();
|
final res = await api.getScenes();
|
||||||
if (res.data is List) {
|
final data = res.data;
|
||||||
state = res.data;
|
if (data is List) {
|
||||||
} else if (res.data is Map) {
|
state = data;
|
||||||
state = res.data['data'] ?? res.data['scenes'] ?? res.data.values.toList();
|
} else if (data is Map) {
|
||||||
|
// Бэкенд может вернуть {scene_id: "Scene Name", ...}
|
||||||
|
// или {data: [...]} или {scenes: [...]}
|
||||||
|
if (data.containsKey('data') && data['data'] is List) {
|
||||||
|
state = data['data'];
|
||||||
|
} else if (data.containsKey('scenes') && data['scenes'] is List) {
|
||||||
|
state = data['scenes'];
|
||||||
|
} else {
|
||||||
|
// Map вида {id: name} -- преобразуем в список
|
||||||
|
state = data.entries
|
||||||
|
.map((e) => {'id': e.key.toString(), 'name': e.value.toString()})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("Ошибка загрузки сцен: $e");
|
debugPrint("Ошибка загрузки сцен: $e");
|
||||||
@@ -303,10 +354,11 @@ class TasksNotifier extends Notifier<List<dynamic>> {
|
|||||||
try {
|
try {
|
||||||
final api = ref.read(apiProvider);
|
final api = ref.read(apiProvider);
|
||||||
final res = await api.getTasks();
|
final res = await api.getTasks();
|
||||||
if (res.data is List) {
|
final data = res.data;
|
||||||
state = res.data;
|
if (data is List) {
|
||||||
} else if (res.data is Map) {
|
state = data;
|
||||||
state = res.data['data'] ?? res.data['tasks'] ?? res.data.values.toList();
|
} else if (data is Map) {
|
||||||
|
state = data['tasks'] ?? data['data'] ?? data.values.toList();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("Ошибка загрузки расписаний: $e");
|
debugPrint("Ошибка загрузки расписаний: $e");
|
||||||
@@ -361,3 +413,162 @@ class TasksNotifier extends Notifier<List<dynamic>> {
|
|||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Статистика ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
final statsProvider =
|
||||||
|
NotifierProvider<StatsNotifier, Map<String, dynamic>>(() => StatsNotifier());
|
||||||
|
|
||||||
|
class StatsNotifier extends Notifier<Map<String, dynamic>> {
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> build() => {};
|
||||||
|
|
||||||
|
Future<void> load({int days = 7}) async {
|
||||||
|
try {
|
||||||
|
final api = ref.read(apiProvider);
|
||||||
|
final res = await api.getStatsSummary(days: days);
|
||||||
|
final data = res.data;
|
||||||
|
if (data is Map) {
|
||||||
|
state = Map<String, dynamic>.from(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Ошибка загрузки статистики: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Лог событий ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
final eventLogProvider =
|
||||||
|
NotifierProvider<EventLogNotifier, List<dynamic>>(() => EventLogNotifier());
|
||||||
|
|
||||||
|
class EventLogNotifier extends Notifier<List<dynamic>> {
|
||||||
|
@override
|
||||||
|
List<dynamic> build() => [];
|
||||||
|
|
||||||
|
Future<void> load({int limit = 100}) async {
|
||||||
|
try {
|
||||||
|
final api = ref.read(apiProvider);
|
||||||
|
final res = await api.getStatsLog(limit: limit);
|
||||||
|
final data = res.data;
|
||||||
|
if (data is List) {
|
||||||
|
state = data;
|
||||||
|
} else if (data is Map) {
|
||||||
|
state = data['data'] ?? data['events'] ?? data.values.toList();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Ошибка загрузки логов: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── API-ключи ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
final apiKeysProvider =
|
||||||
|
NotifierProvider<ApiKeysNotifier, List<dynamic>>(() => ApiKeysNotifier());
|
||||||
|
|
||||||
|
class ApiKeysNotifier extends Notifier<List<dynamic>> {
|
||||||
|
@override
|
||||||
|
List<dynamic> build() => [];
|
||||||
|
|
||||||
|
Future<void> load() async {
|
||||||
|
try {
|
||||||
|
final api = ref.read(apiProvider);
|
||||||
|
final res = await api.getApiKeys();
|
||||||
|
final data = res.data;
|
||||||
|
if (data is List) {
|
||||||
|
state = data;
|
||||||
|
} else if (data is Map) {
|
||||||
|
state = data['data'] ?? data['keys'] ?? data.values.toList();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Ошибка загрузки API-ключей: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> create(String name, {bool isAdmin = false}) async {
|
||||||
|
try {
|
||||||
|
final api = ref.read(apiProvider);
|
||||||
|
final res = await api.createApiKey(name, isAdmin: isAdmin);
|
||||||
|
await load();
|
||||||
|
if (res.data is Map) {
|
||||||
|
return res.data['key']?.toString();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Ошибка создания ключа: $e");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> revoke(String key) async {
|
||||||
|
try {
|
||||||
|
await ref.read(apiProvider).revokeApiKey(key);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Ошибка отзыва ключа: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> activate(String key) async {
|
||||||
|
try {
|
||||||
|
await ref.read(apiProvider).activateApiKey(key);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Ошибка активации ключа: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Информация об авторизации ────────────────────────────────
|
||||||
|
|
||||||
|
final authInfoProvider =
|
||||||
|
NotifierProvider<AuthInfoNotifier, Map<String, dynamic>?>(() => AuthInfoNotifier());
|
||||||
|
|
||||||
|
class AuthInfoNotifier extends Notifier<Map<String, dynamic>?> {
|
||||||
|
@override
|
||||||
|
Map<String, dynamic>? build() => null;
|
||||||
|
|
||||||
|
Future<void> load() async {
|
||||||
|
try {
|
||||||
|
final api = ref.read(apiProvider);
|
||||||
|
final res = await api.getAuthMe();
|
||||||
|
if (res.data is Map) {
|
||||||
|
state = Map<String, dynamic>.from(res.data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Ошибка загрузки auth/me: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isAdmin => state?['is_admin'] == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Утилита: расчёт расстояния (Haversine) ──────────────────
|
||||||
|
|
||||||
|
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) +
|
||||||
|
math.cos(_degToRad(lat1)) *
|
||||||
|
math.cos(_degToRad(lat2)) *
|
||||||
|
math.sin(dLon / 2) *
|
||||||
|
math.sin(dLon / 2);
|
||||||
|
final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
|
||||||
|
return earthRadiusKm * c;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _degToRad(double deg) => deg * (math.pi / 180);
|
||||||
|
|
||||||
|
/// Форматирование расстояния в человекочитаемый вид
|
||||||
|
String formatDistance(double km) {
|
||||||
|
if (km < 1.0) {
|
||||||
|
return '${(km * 1000).round()} м';
|
||||||
|
} else if (km < 10.0) {
|
||||||
|
return '${km.toStringAsFixed(1)} км';
|
||||||
|
} else {
|
||||||
|
return '${km.round()} км';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
334
lib/screens/api_keys_screen.dart
Normal file
334
lib/screens/api_keys_screen.dart
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../providers/providers.dart';
|
||||||
|
|
||||||
|
/// Экран управления гостевыми API-ключами.
|
||||||
|
/// Доступен только администраторам.
|
||||||
|
class ApiKeysScreen extends ConsumerStatefulWidget {
|
||||||
|
const ApiKeysScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ApiKeysScreen> createState() => _ApiKeysScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
|
||||||
|
bool _loading = true;
|
||||||
|
String? _lastCreatedKey;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
await ref.read(apiKeysProvider.notifier).load();
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final keys = ref.watch(apiKeysProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('API-КЛЮЧИ'),
|
||||||
|
),
|
||||||
|
body: _loading
|
||||||
|
? const Center(
|
||||||
|
child: CircularProgressIndicator(color: Colors.deepOrange),
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
children: [
|
||||||
|
// ─── Последний созданный ключ (для копирования) ───
|
||||||
|
if (_lastCreatedKey != null)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.all(12),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.deepOrange.withOpacity(0.15),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.deepOrange.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Новый ключ создан! Скопируйте его сейчас:',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.deepOrange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_lastCreatedKey!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: Colors.white70,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.copy, size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
Clipboard.setData(
|
||||||
|
ClipboardData(text: _lastCreatedKey!));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Ключ скопирован'),
|
||||||
|
duration: Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ─── Список ключей ───
|
||||||
|
Expanded(
|
||||||
|
child: keys.isEmpty
|
||||||
|
? const Center(
|
||||||
|
child: Text(
|
||||||
|
'Нет гостевых ключей',
|
||||||
|
style: TextStyle(color: Colors.white54),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: RefreshIndicator(
|
||||||
|
color: Colors.deepOrange,
|
||||||
|
onRefresh: _load,
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
itemCount: keys.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final k = keys[index];
|
||||||
|
final map = k is Map
|
||||||
|
? Map<String, dynamic>.from(k)
|
||||||
|
: <String, dynamic>{};
|
||||||
|
return _ApiKeyCard(
|
||||||
|
data: map,
|
||||||
|
onRevoke: () => _revokeKey(map),
|
||||||
|
onActivate: () => _activateKey(map),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
backgroundColor: Colors.deepOrange,
|
||||||
|
onPressed: () => _showCreateDialog(context),
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showCreateDialog(BuildContext context) {
|
||||||
|
final nameCtrl = TextEditingController();
|
||||||
|
bool isAdmin = false;
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => StatefulBuilder(
|
||||||
|
builder: (ctx, setDialogState) => AlertDialog(
|
||||||
|
title: const Text('Новый API-ключ'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: nameCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Имя ключа',
|
||||||
|
hintText: 'Например: "Гость"',
|
||||||
|
),
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Администратор'),
|
||||||
|
value: isAdmin,
|
||||||
|
activeColor: Colors.deepOrange,
|
||||||
|
onChanged: (v) => setDialogState(() => isAdmin = v),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.deepOrange,
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
final name = nameCtrl.text.trim();
|
||||||
|
if (name.isEmpty) return;
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
final key = await ref
|
||||||
|
.read(apiKeysProvider.notifier)
|
||||||
|
.create(name, isAdmin: isAdmin);
|
||||||
|
if (key != null && mounted) {
|
||||||
|
setState(() => _lastCreatedKey = key);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Создать'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _revokeKey(Map<String, dynamic> data) async {
|
||||||
|
final key = (data['key'] ?? data['token'] ?? '').toString();
|
||||||
|
final name = (data['name'] ?? '').toString();
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Отозвать ключ?'),
|
||||||
|
content: Text('Отозвать "$name"?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
child:
|
||||||
|
const Text('Отозвать', style: TextStyle(color: Colors.redAccent)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed == true) {
|
||||||
|
await ref.read(apiKeysProvider.notifier).revoke(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _activateKey(Map<String, dynamic> data) async {
|
||||||
|
final key = (data['key'] ?? data['token'] ?? '').toString();
|
||||||
|
await ref.read(apiKeysProvider.notifier).activate(key);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Ключ активирован'),
|
||||||
|
duration: Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Карточка одного API-ключа
|
||||||
|
class _ApiKeyCard extends StatelessWidget {
|
||||||
|
final Map<String, dynamic> data;
|
||||||
|
final VoidCallback onRevoke;
|
||||||
|
final VoidCallback onActivate;
|
||||||
|
|
||||||
|
const _ApiKeyCard({
|
||||||
|
required this.data,
|
||||||
|
required this.onRevoke,
|
||||||
|
required this.onActivate,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final name = (data['name'] ?? 'Без имени').toString();
|
||||||
|
final isAdmin = data['is_admin'] == true;
|
||||||
|
final isActive = data['is_active'] ?? data['active'] ?? true;
|
||||||
|
final createdAt = data['created_at'] ?? '';
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
Icons.vpn_key,
|
||||||
|
color: isActive
|
||||||
|
? (isAdmin ? Colors.amber : Colors.deepOrange)
|
||||||
|
: Colors.white24,
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: isActive ? Colors.white : Colors.white38,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isAdmin) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.amber.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'admin',
|
||||||
|
style: TextStyle(fontSize: 10, color: Colors.amber),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (!isActive) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.redAccent.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'отозван',
|
||||||
|
style: TextStyle(fontSize: 10, color: Colors.redAccent),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: createdAt.toString().isNotEmpty
|
||||||
|
? Text(
|
||||||
|
'Создан: ${_formatDate(createdAt.toString())}',
|
||||||
|
style: const TextStyle(fontSize: 11, color: Colors.white30),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
trailing: isActive
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.block, size: 20, color: Colors.redAccent),
|
||||||
|
tooltip: 'Отозвать',
|
||||||
|
onPressed: onRevoke,
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.check_circle_outline,
|
||||||
|
size: 20, color: Colors.green),
|
||||||
|
tooltip: 'Активировать',
|
||||||
|
onPressed: onActivate,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(String iso) {
|
||||||
|
try {
|
||||||
|
final d = DateTime.parse(iso);
|
||||||
|
final pad = (int n) => n.toString().padLeft(2, '0');
|
||||||
|
return '${pad(d.day)}.${pad(d.month)}.${d.year}';
|
||||||
|
} catch (_) {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
165
lib/screens/event_log_screen.dart
Normal file
165
lib/screens/event_log_screen.dart
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../providers/providers.dart';
|
||||||
|
|
||||||
|
/// Экран просмотра лога событий.
|
||||||
|
class EventLogScreen extends ConsumerStatefulWidget {
|
||||||
|
const EventLogScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<EventLogScreen> createState() => _EventLogScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EventLogScreenState extends ConsumerState<EventLogScreen> {
|
||||||
|
bool _loading = true;
|
||||||
|
int _limit = 100;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() => _loading = true);
|
||||||
|
await ref.read(eventLogProvider.notifier).load(limit: _limit);
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final events = ref.watch(eventLogProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('ЛОГ СОБЫТИЙ'),
|
||||||
|
actions: [
|
||||||
|
PopupMenuButton<int>(
|
||||||
|
icon: const Icon(Icons.filter_list),
|
||||||
|
tooltip: 'Количество записей',
|
||||||
|
onSelected: (v) {
|
||||||
|
_limit = v;
|
||||||
|
_load();
|
||||||
|
},
|
||||||
|
itemBuilder: (_) => [50, 100, 200, 500]
|
||||||
|
.map((n) => PopupMenuItem(value: n, child: Text('$n записей')))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _loading
|
||||||
|
? const Center(
|
||||||
|
child: CircularProgressIndicator(color: Colors.deepOrange),
|
||||||
|
)
|
||||||
|
: events.isEmpty
|
||||||
|
? const Center(
|
||||||
|
child: Text(
|
||||||
|
'Нет событий',
|
||||||
|
style: TextStyle(color: Colors.white54),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: RefreshIndicator(
|
||||||
|
color: Colors.deepOrange,
|
||||||
|
onRefresh: _load,
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
itemCount: events.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final event = events[index];
|
||||||
|
return _EventRow(
|
||||||
|
event: event is Map
|
||||||
|
? Map<String, dynamic>.from(event)
|
||||||
|
: {},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EventRow extends StatelessWidget {
|
||||||
|
final Map<String, dynamic> event;
|
||||||
|
|
||||||
|
const _EventRow({required this.event});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final timestamp = event['timestamp'] ?? event['time'] ?? event['created_at'] ?? '';
|
||||||
|
final action = event['action'] ?? event['command'] ?? event['type'] ?? '';
|
||||||
|
final targetId = event['target_id'] ?? event['target'] ?? event['group_id'] ?? '';
|
||||||
|
final params = event['params'] ?? event['details'] ?? '';
|
||||||
|
final actor = event['actor'] ?? event['user'] ?? event['key_name'] ?? '';
|
||||||
|
|
||||||
|
final formattedTime = _formatTime(timestamp.toString());
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Время
|
||||||
|
SizedBox(
|
||||||
|
width: 80,
|
||||||
|
child: Text(
|
||||||
|
formattedTime,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Colors.white38,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
// Контент
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$action - $targetId',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (params.toString().isNotEmpty)
|
||||||
|
Text(
|
||||||
|
params.toString(),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Colors.white38,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (actor.toString().isNotEmpty)
|
||||||
|
Text(
|
||||||
|
actor.toString(),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.white24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTime(String iso) {
|
||||||
|
if (iso.isEmpty) return '';
|
||||||
|
try {
|
||||||
|
final d = DateTime.parse(iso);
|
||||||
|
final pad = (int n) => n.toString().padLeft(2, '0');
|
||||||
|
return '${pad(d.day)}.${pad(d.month)} ${pad(d.hour)}:${pad(d.minute)}:${pad(d.second)}';
|
||||||
|
} catch (_) {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -164,7 +164,7 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
|||||||
activeColor: Colors.deepOrange,
|
activeColor: Colors.deepOrange,
|
||||||
title: Text(name),
|
title: Text(name),
|
||||||
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),
|
||||||
),
|
),
|
||||||
@@ -183,7 +183,11 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Кнопка сохранения
|
// Кнопка сохранения
|
||||||
SizedBox(
|
Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 48,
|
height: 48,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
@@ -202,6 +206,7 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
|||||||
: const Text('СОЗДАТЬ ГРУППУ'),
|
: const Text('СОЗДАТЬ ГРУППУ'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
final _nameCtrl = TextEditingController();
|
final _nameCtrl = TextEditingController();
|
||||||
final _urlCtrl = TextEditingController();
|
final _urlCtrl = TextEditingController();
|
||||||
final _keyCtrl = TextEditingController();
|
final _keyCtrl = TextEditingController();
|
||||||
|
final _latCtrl = TextEditingController();
|
||||||
|
final _lonCtrl = TextEditingController();
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
|
|
||||||
bool get _isEdit => widget.home != null;
|
bool get _isEdit => widget.home != null;
|
||||||
@@ -28,6 +30,12 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
_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;
|
_keyCtrl.text = widget.home!.apiKey;
|
||||||
|
if (widget.home!.latitude != null) {
|
||||||
|
_latCtrl.text = widget.home!.latitude.toString();
|
||||||
|
}
|
||||||
|
if (widget.home!.longitude != null) {
|
||||||
|
_lonCtrl.text = widget.home!.longitude.toString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +44,8 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
_nameCtrl.dispose();
|
_nameCtrl.dispose();
|
||||||
_urlCtrl.dispose();
|
_urlCtrl.dispose();
|
||||||
_keyCtrl.dispose();
|
_keyCtrl.dispose();
|
||||||
|
_latCtrl.dispose();
|
||||||
|
_lonCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,9 +55,10 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(_isEdit ? 'РЕДАКТИРОВАТЬ ДОМ' : 'НОВЫЙ ДОМ'),
|
title: Text(_isEdit ? 'РЕДАКТИРОВАТЬ ДОМ' : 'НОВЫЙ ДОМ'),
|
||||||
),
|
),
|
||||||
body: Padding(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
TextField(
|
||||||
controller: _nameCtrl,
|
controller: _nameCtrl,
|
||||||
@@ -75,6 +86,54 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
),
|
),
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// ─── GPS-координаты (опционально) ───
|
||||||
|
const Text(
|
||||||
|
'Координаты дома (опционально)',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white54,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Text(
|
||||||
|
'Для автоматизации по геолокации',
|
||||||
|
style: TextStyle(color: Colors.white30, fontSize: 12),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _latCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Широта',
|
||||||
|
prefixIcon: Icon(Icons.location_on, size: 20),
|
||||||
|
hintText: '51.128',
|
||||||
|
),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
|
decimal: true, signed: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _lonCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Долгота',
|
||||||
|
prefixIcon: Icon(Icons.location_on, size: 20),
|
||||||
|
hintText: '71.430',
|
||||||
|
),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
|
decimal: true, signed: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -97,6 +156,8 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
: Text(_isEdit ? 'СОХРАНИТЬ' : 'ДОБАВИТЬ'),
|
: Text(_isEdit ? 'СОХРАНИТЬ' : 'ДОБАВИТЬ'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Отступ внизу для системных кнопок
|
||||||
|
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -107,23 +168,47 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
final name = _nameCtrl.text.trim();
|
final name = _nameCtrl.text.trim();
|
||||||
final url = _urlCtrl.text.trim();
|
final url = _urlCtrl.text.trim();
|
||||||
final key = _keyCtrl.text.trim();
|
final key = _keyCtrl.text.trim();
|
||||||
|
final latText = _latCtrl.text.trim();
|
||||||
|
final lonText = _lonCtrl.text.trim();
|
||||||
|
|
||||||
if (name.isEmpty || url.isEmpty || key.isEmpty) {
|
if (name.isEmpty || url.isEmpty || key.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Заполните все поля')),
|
const SnackBar(content: Text('Заполните все обязательные поля')),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double? lat;
|
||||||
|
double? lon;
|
||||||
|
if (latText.isNotEmpty && lonText.isNotEmpty) {
|
||||||
|
lat = double.tryParse(latText);
|
||||||
|
lon = double.tryParse(lonText);
|
||||||
|
if (lat == null || lon == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Некорректные координаты')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
|
|
||||||
final home = _isEdit
|
final home = _isEdit
|
||||||
? widget.home!.copyWith(name: name, url: url, apiKey: key)
|
? widget.home!.copyWith(
|
||||||
|
name: name,
|
||||||
|
url: url,
|
||||||
|
apiKey: key,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
clearCoordinates: latText.isEmpty && lonText.isEmpty,
|
||||||
|
)
|
||||||
: HomeConfig(
|
: HomeConfig(
|
||||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
name: name,
|
name: name,
|
||||||
url: url,
|
url: url,
|
||||||
apiKey: key,
|
apiKey: key,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (_isEdit) {
|
if (_isEdit) {
|
||||||
|
|||||||
@@ -61,10 +61,26 @@ class HomesScreen extends ConsumerWidget {
|
|||||||
color: isActive ? Colors.deepOrange : Colors.white,
|
color: isActive ? Colors.deepOrange : Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
home.url,
|
home.url,
|
||||||
style: const TextStyle(color: Colors.white38, fontSize: 12),
|
style: const TextStyle(color: Colors.white38, fontSize: 12),
|
||||||
),
|
),
|
||||||
|
if (home.hasCoordinates)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.location_on, size: 12, color: Colors.white24),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'Координаты заданы',
|
||||||
|
style: const TextStyle(color: Colors.white24, fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
trailing: Row(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -96,6 +112,7 @@ class HomesScreen extends ConsumerWidget {
|
|||||||
/// Выбрать дом и перейти на пульт
|
/// Выбрать дом и перейти на пульт
|
||||||
void _selectHome(BuildContext context, WidgetRef ref, HomeConfig home) async {
|
void _selectHome(BuildContext context, WidgetRef ref, HomeConfig home) async {
|
||||||
await ref.read(currentHomeProvider.notifier).switchTo(home);
|
await ref.read(currentHomeProvider.notifier).switchTo(home);
|
||||||
|
await ref.read(authInfoProvider.notifier).load();
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.of(context).pushReplacement(
|
Navigator.of(context).pushReplacement(
|
||||||
MaterialPageRoute(builder: (_) => const RemoteScreen()),
|
MaterialPageRoute(builder: (_) => const RemoteScreen()),
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import '../widgets/group_card.dart';
|
|||||||
import 'homes_screen.dart';
|
import 'homes_screen.dart';
|
||||||
import 'group_edit_screen.dart';
|
import 'group_edit_screen.dart';
|
||||||
import 'schedules_screen.dart';
|
import 'schedules_screen.dart';
|
||||||
|
import 'stats_screen.dart';
|
||||||
|
import 'event_log_screen.dart';
|
||||||
|
import 'api_keys_screen.dart';
|
||||||
|
|
||||||
/// Основной экран пульта управления.
|
/// Основной экран пульта управления.
|
||||||
/// Показывает группы текущего дома с управлением.
|
/// Показывает группы текущего дома с управлением.
|
||||||
@@ -26,6 +29,7 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
|||||||
|
|
||||||
Future<void> _bootstrap() async {
|
Future<void> _bootstrap() async {
|
||||||
await ref.read(groupsProvider.notifier).initAndRefresh();
|
await ref.read(groupsProvider.notifier).initAndRefresh();
|
||||||
|
await ref.read(authInfoProvider.notifier).load();
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +37,8 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final groups = ref.watch(groupsProvider);
|
final groups = ref.watch(groupsProvider);
|
||||||
final currentHome = ref.watch(currentHomeProvider);
|
final currentHome = ref.watch(currentHomeProvider);
|
||||||
|
final authInfo = ref.watch(authInfoProvider);
|
||||||
|
final isAdmin = authInfo?['is_admin'] == true;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -53,14 +59,69 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
|||||||
MaterialPageRoute(builder: (_) => const GroupEditScreen()),
|
MaterialPageRoute(builder: (_) => const GroupEditScreen()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Расписания
|
// Меню
|
||||||
IconButton(
|
PopupMenuButton<String>(
|
||||||
icon: const Icon(Icons.schedule),
|
icon: const Icon(Icons.more_vert),
|
||||||
tooltip: 'Расписания',
|
onSelected: (value) {
|
||||||
onPressed: () => Navigator.of(context).push(
|
switch (value) {
|
||||||
|
case 'schedules':
|
||||||
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(builder: (_) => const SchedulesScreen()),
|
MaterialPageRoute(builder: (_) => const SchedulesScreen()),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'stats':
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => const StatsScreen()),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'log':
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => const EventLogScreen()),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'api_keys':
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => const ApiKeysScreen()),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'schedules',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.schedule),
|
||||||
|
title: Text('Расписания'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'stats',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.bar_chart),
|
||||||
|
title: Text('Статистика'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'log',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.list_alt),
|
||||||
|
title: Text('Лог событий'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isAdmin)
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'api_keys',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.vpn_key),
|
||||||
|
title: Text('API-ключи'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: _loading
|
body: _loading
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
|
|||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
builder: (ctx) => _AddScheduleSheet(),
|
builder: (ctx) => const _AddScheduleSheet(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,6 +163,8 @@ class _TaskCard extends ConsumerWidget {
|
|||||||
|
|
||||||
/// Нижний лист для создания расписания
|
/// Нижний лист для создания расписания
|
||||||
class _AddScheduleSheet extends ConsumerStatefulWidget {
|
class _AddScheduleSheet extends ConsumerStatefulWidget {
|
||||||
|
const _AddScheduleSheet();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<_AddScheduleSheet> createState() => _AddScheduleSheetState();
|
ConsumerState<_AddScheduleSheet> createState() => _AddScheduleSheetState();
|
||||||
}
|
}
|
||||||
@@ -194,13 +196,15 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final groups = ref.watch(groupsProvider);
|
final groups = ref.watch(groupsProvider);
|
||||||
|
final bottomPadding = MediaQuery.of(context).viewInsets.bottom;
|
||||||
|
final systemPadding = MediaQuery.of(context).padding.bottom;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
left: 20,
|
left: 20,
|
||||||
right: 20,
|
right: 20,
|
||||||
top: 20,
|
top: 20,
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
bottom: bottomPadding + systemPadding + 20,
|
||||||
),
|
),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
208
lib/screens/stats_screen.dart
Normal file
208
lib/screens/stats_screen.dart
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../providers/providers.dart';
|
||||||
|
|
||||||
|
/// Экран просмотра статистики.
|
||||||
|
/// Показывает сводку по группам за выбранный период.
|
||||||
|
class StatsScreen extends ConsumerStatefulWidget {
|
||||||
|
const StatsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<StatsScreen> createState() => _StatsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatsScreenState extends ConsumerState<StatsScreen> {
|
||||||
|
bool _loading = true;
|
||||||
|
int _days = 7;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() => _loading = true);
|
||||||
|
await ref.read(statsProvider.notifier).load(days: _days);
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final stats = ref.watch(statsProvider);
|
||||||
|
final groups = (stats['groups'] as List<dynamic>?) ?? [];
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('СТАТИСТИКА'),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// ─── Переключатель периода ───
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Text('Период:', style: TextStyle(color: Colors.white54)),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
...[1, 7, 14, 30].map(
|
||||||
|
(d) => Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: ChoiceChip(
|
||||||
|
label: Text('$d д.'),
|
||||||
|
selected: _days == d,
|
||||||
|
selectedColor: Colors.deepOrange,
|
||||||
|
onSelected: (_) {
|
||||||
|
setState(() => _days = d);
|
||||||
|
_load();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ─── Содержимое ───
|
||||||
|
Expanded(
|
||||||
|
child: _loading
|
||||||
|
? const Center(
|
||||||
|
child: CircularProgressIndicator(color: Colors.deepOrange),
|
||||||
|
)
|
||||||
|
: groups.isEmpty
|
||||||
|
? const Center(
|
||||||
|
child: Text(
|
||||||
|
'Нет данных',
|
||||||
|
style: TextStyle(color: Colors.white54),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: RefreshIndicator(
|
||||||
|
color: Colors.deepOrange,
|
||||||
|
onRefresh: _load,
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
itemCount: groups.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final g = groups[index];
|
||||||
|
return _StatsCard(data: g is Map
|
||||||
|
? Map<String, dynamic>.from(g)
|
||||||
|
: {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Карточка статистики одной группы
|
||||||
|
class _StatsCard extends StatelessWidget {
|
||||||
|
final Map<String, dynamic> data;
|
||||||
|
|
||||||
|
const _StatsCard({required this.data});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final targetId = (data['target_id'] ?? data['group_id'] ?? data['id'] ?? '').toString();
|
||||||
|
final name = (data['name'] ?? targetId).toString();
|
||||||
|
final totalCommands = data['total_commands'] ?? 0;
|
||||||
|
final togglesOn = data['toggles_on'] ?? 0;
|
||||||
|
final togglesOff = data['toggles_off'] ?? 0;
|
||||||
|
final estimatedHours = data['estimated_hours'];
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_StatRow(
|
||||||
|
icon: Icons.touch_app,
|
||||||
|
label: 'Всего команд',
|
||||||
|
value: totalCommands.toString(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_StatRow(
|
||||||
|
icon: Icons.power_settings_new,
|
||||||
|
label: 'Включений',
|
||||||
|
value: togglesOn.toString(),
|
||||||
|
color: Colors.green,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_StatRow(
|
||||||
|
icon: Icons.power_off,
|
||||||
|
label: 'Выключений',
|
||||||
|
value: togglesOff.toString(),
|
||||||
|
color: Colors.redAccent,
|
||||||
|
),
|
||||||
|
if (estimatedHours != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_StatRow(
|
||||||
|
icon: Icons.access_time,
|
||||||
|
label: 'Примерное время работы',
|
||||||
|
value: _formatHours(estimatedHours),
|
||||||
|
color: Colors.amber,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatHours(dynamic hours) {
|
||||||
|
final h = (hours is num) ? hours.toDouble() : 0.0;
|
||||||
|
if (h < 1) return '${(h * 60).round()} мин';
|
||||||
|
return '${h.toStringAsFixed(1)} ч';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Строка с иконкой, меткой и значением
|
||||||
|
class _StatRow extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final Color? color;
|
||||||
|
|
||||||
|
const _StatRow({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 16, color: color ?? Colors.white38),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontSize: 13, color: Colors.white54),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color ?? Colors.white70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,11 @@ class IgnisApi {
|
|||||||
_dio.options.receiveTimeout = const Duration(seconds: 15);
|
_dio.options.receiveTimeout = const Duration(seconds: 15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Авторизация ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Проверка текущего ключа: возвращает {is_admin, name}
|
||||||
|
Future<Response> getAuthMe() => _dio.get('/auth/me');
|
||||||
|
|
||||||
// ─── Устройства и группы ───────────────────────────────────
|
// ─── Устройства и группы ───────────────────────────────────
|
||||||
|
|
||||||
/// Все устройства (лампы)
|
/// Все устройства (лампы)
|
||||||
@@ -82,4 +87,34 @@ class IgnisApi {
|
|||||||
/// Отменить задачу расписания
|
/// Отменить задачу расписания
|
||||||
Future<Response> cancelTask(String jobId) =>
|
Future<Response> cancelTask(String jobId) =>
|
||||||
_dio.delete('/schedules/$jobId');
|
_dio.delete('/schedules/$jobId');
|
||||||
|
|
||||||
|
// ─── API-ключи ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Список всех гостевых ключей
|
||||||
|
Future<Response> getApiKeys() => _dio.get('/api-keys');
|
||||||
|
|
||||||
|
/// Создать гостевой ключ
|
||||||
|
Future<Response> createApiKey(String name, {bool isAdmin = false}) =>
|
||||||
|
_dio.post('/api-keys', queryParameters: {
|
||||||
|
'name': name,
|
||||||
|
'is_admin': isAdmin,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Отозвать ключ (body: {key: ...})
|
||||||
|
Future<Response> revokeApiKey(String key) =>
|
||||||
|
_dio.post('/api-keys/revoke', data: {'key': key});
|
||||||
|
|
||||||
|
/// Активировать ключ (body: {key: ...})
|
||||||
|
Future<Response> activateApiKey(String key) =>
|
||||||
|
_dio.post('/api-keys/activate', data: {'key': key});
|
||||||
|
|
||||||
|
// ─── Статистика ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Сводная статистика за N дней
|
||||||
|
Future<Response> getStatsSummary({int days = 7}) =>
|
||||||
|
_dio.get('/stats/summary', queryParameters: {'days': days});
|
||||||
|
|
||||||
|
/// Лог последних N событий
|
||||||
|
Future<Response> getStatsLog({int limit = 100}) =>
|
||||||
|
_dio.get('/stats/log', queryParameters: {'limit': limit});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Простой цветовой пикер в виде кольца HSV.
|
/// Простой цветовой пикер в виде HSV-слайдеров.
|
||||||
/// Возвращает RGB через callback.
|
/// Возвращает RGB через callback.
|
||||||
|
///
|
||||||
|
/// [onColorChanged] -- вызывается при каждом движении (для превью UI).
|
||||||
|
/// [onColorChangeEnd] -- вызывается при отпускании слайдера (для отправки на сервер).
|
||||||
class SimpleColorPicker extends StatefulWidget {
|
class SimpleColorPicker extends StatefulWidget {
|
||||||
final Color initialColor;
|
final Color initialColor;
|
||||||
final ValueChanged<Color> onColorChanged;
|
final ValueChanged<Color> onColorChanged;
|
||||||
|
final ValueChanged<Color>? onColorChangeEnd;
|
||||||
|
|
||||||
const SimpleColorPicker({
|
const SimpleColorPicker({
|
||||||
super.key,
|
super.key,
|
||||||
this.initialColor = Colors.red,
|
this.initialColor = Colors.red,
|
||||||
required this.onColorChanged,
|
required this.onColorChanged,
|
||||||
|
this.onColorChangeEnd,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -34,6 +39,10 @@ class _SimpleColorPickerState extends State<SimpleColorPicker> {
|
|||||||
Color get _currentColor =>
|
Color get _currentColor =>
|
||||||
HSVColor.fromAHSV(1.0, _hue, _saturation, _value).toColor();
|
HSVColor.fromAHSV(1.0, _hue, _saturation, _value).toColor();
|
||||||
|
|
||||||
|
void _notifyEnd() {
|
||||||
|
(widget.onColorChangeEnd ?? widget.onColorChanged)(_currentColor);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
@@ -63,6 +72,7 @@ class _SimpleColorPickerState extends State<SimpleColorPicker> {
|
|||||||
setState(() => _hue = v);
|
setState(() => _hue = v);
|
||||||
widget.onColorChanged(_currentColor);
|
widget.onColorChanged(_currentColor);
|
||||||
},
|
},
|
||||||
|
onChangeEnd: (_) => _notifyEnd(),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Saturation -- насыщенность (0-1)
|
// Saturation -- насыщенность (0-1)
|
||||||
@@ -77,6 +87,7 @@ class _SimpleColorPickerState extends State<SimpleColorPicker> {
|
|||||||
setState(() => _saturation = v);
|
setState(() => _saturation = v);
|
||||||
widget.onColorChanged(_currentColor);
|
widget.onColorChanged(_currentColor);
|
||||||
},
|
},
|
||||||
|
onChangeEnd: (_) => _notifyEnd(),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Value -- яркость (0-1)
|
// Value -- яркость (0-1)
|
||||||
@@ -91,6 +102,7 @@ class _SimpleColorPickerState extends State<SimpleColorPicker> {
|
|||||||
setState(() => _value = v);
|
setState(() => _value = v);
|
||||||
widget.onColorChanged(_currentColor);
|
widget.onColorChanged(_currentColor);
|
||||||
},
|
},
|
||||||
|
onChangeEnd: (_) => _notifyEnd(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -104,6 +116,7 @@ class _SimpleColorPickerState extends State<SimpleColorPicker> {
|
|||||||
required int divisions,
|
required int divisions,
|
||||||
required Color activeColor,
|
required Color activeColor,
|
||||||
required ValueChanged<double> onChanged,
|
required ValueChanged<double> onChanged,
|
||||||
|
ValueChanged<double>? onChangeEnd,
|
||||||
}) {
|
}) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -119,6 +132,7 @@ class _SimpleColorPickerState extends State<SimpleColorPicker> {
|
|||||||
divisions: divisions,
|
divisions: divisions,
|
||||||
activeColor: activeColor,
|
activeColor: activeColor,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
|
onChangeEnd: onChangeEnd,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ class GroupCard extends ConsumerStatefulWidget {
|
|||||||
class _GroupCardState extends ConsumerState<GroupCard> {
|
class _GroupCardState extends ConsumerState<GroupCard> {
|
||||||
/// Текущий режим управления: temp (температура) или color (RGB)
|
/// Текущий режим управления: temp (температура) или color (RGB)
|
||||||
String _mode = 'temp';
|
String _mode = 'temp';
|
||||||
bool _showColorPicker = false;
|
|
||||||
|
// Локальные значения слайдеров -- обновляются мгновенно,
|
||||||
|
// а на сервер отправляются через debounce в провайдере.
|
||||||
|
double? _localBrightness;
|
||||||
|
double? _localTemp;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -31,10 +35,14 @@ class _GroupCardState extends ConsumerState<GroupCard> {
|
|||||||
final int gVal = g['last_state']?['g'] ?? 200;
|
final int gVal = g['last_state']?['g'] ?? 200;
|
||||||
final int b = g['last_state']?['b'] ?? 100;
|
final int b = g['last_state']?['b'] ?? 100;
|
||||||
|
|
||||||
|
// Значения слайдеров: локальные (если тянем) или серверные
|
||||||
|
final briValue = (_localBrightness ?? bri.toDouble()).clamp(10.0, 100.0);
|
||||||
|
final tempValue = (_localTemp ?? temp.toDouble()).clamp(2700.0, 6500.0);
|
||||||
|
|
||||||
// Цвет подсветки карточки зависит от режима и состояния
|
// Цвет подсветки карточки зависит от режима и состояния
|
||||||
final cardAccent = isOn
|
final cardAccent = isOn
|
||||||
? (_mode == 'temp'
|
? (_mode == 'temp'
|
||||||
? Color.lerp(Colors.orange, Colors.blueAccent, (temp - 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;
|
||||||
|
|
||||||
@@ -90,15 +98,19 @@ class _GroupCardState extends ConsumerState<GroupCard> {
|
|||||||
// Яркость
|
// Яркость
|
||||||
_SliderRow(
|
_SliderRow(
|
||||||
icon: Icons.sunny,
|
icon: Icons.sunny,
|
||||||
value: bri.toDouble().clamp(10, 100),
|
value: briValue,
|
||||||
min: 10,
|
min: 10,
|
||||||
max: 100,
|
max: 100,
|
||||||
divisions: 9,
|
divisions: 9,
|
||||||
label: "$bri%",
|
label: "${briValue.toInt()}%",
|
||||||
activeColor: Colors.amber,
|
activeColor: Colors.amber,
|
||||||
onChanged: (v) => ref
|
onChanged: (v) {
|
||||||
.read(groupsProvider.notifier)
|
setState(() => _localBrightness = v);
|
||||||
.setBrightness(id, v.toInt()),
|
ref.read(groupsProvider.notifier).setBrightness(id, v.toInt());
|
||||||
|
},
|
||||||
|
onChangeEnd: (v) {
|
||||||
|
setState(() => _localBrightness = null);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
// Переключатель режима: температура / цвет / сцена
|
// Переключатель режима: температура / цвет / сцена
|
||||||
@@ -108,30 +120,21 @@ class _GroupCardState extends ConsumerState<GroupCard> {
|
|||||||
label: 'Темп.',
|
label: 'Темп.',
|
||||||
icon: Icons.wb_twilight,
|
icon: Icons.wb_twilight,
|
||||||
selected: _mode == 'temp',
|
selected: _mode == 'temp',
|
||||||
onTap: () => setState(() {
|
onTap: () => setState(() => _mode = 'temp'),
|
||||||
_mode = 'temp';
|
|
||||||
_showColorPicker = false;
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_ModeChip(
|
_ModeChip(
|
||||||
label: 'Цвет',
|
label: 'Цвет',
|
||||||
icon: Icons.palette,
|
icon: Icons.palette,
|
||||||
selected: _mode == 'color',
|
selected: _mode == 'color',
|
||||||
onTap: () => setState(() {
|
onTap: () => setState(() => _mode = 'color'),
|
||||||
_mode = 'color';
|
|
||||||
_showColorPicker = true;
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_ModeChip(
|
_ModeChip(
|
||||||
label: 'Сцена',
|
label: 'Сцена',
|
||||||
icon: Icons.auto_awesome,
|
icon: Icons.auto_awesome,
|
||||||
selected: _mode == 'scene',
|
selected: _mode == 'scene',
|
||||||
onTap: () => setState(() {
|
onTap: () => setState(() => _mode = 'scene'),
|
||||||
_mode = 'scene';
|
|
||||||
_showColorPicker = false;
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -141,25 +144,30 @@ class _GroupCardState extends ConsumerState<GroupCard> {
|
|||||||
if (_mode == 'temp')
|
if (_mode == 'temp')
|
||||||
_SliderRow(
|
_SliderRow(
|
||||||
icon: Icons.wb_twilight,
|
icon: Icons.wb_twilight,
|
||||||
value: temp.toDouble().clamp(2700, 6500),
|
value: tempValue,
|
||||||
min: 2700,
|
min: 2700,
|
||||||
max: 6500,
|
max: 6500,
|
||||||
divisions: 38, // шаг 100K
|
divisions: 38, // шаг 100K
|
||||||
label: "${temp}K",
|
label: "${tempValue.toInt()}K",
|
||||||
activeColor: Color.lerp(
|
activeColor: Color.lerp(
|
||||||
Colors.orange, Colors.blueAccent, (temp - 2700) / 3800),
|
Colors.orange, Colors.blueAccent, (tempValue - 2700) / 3800),
|
||||||
onChanged: (v) => ref
|
onChanged: (v) {
|
||||||
.read(groupsProvider.notifier)
|
setState(() => _localTemp = v);
|
||||||
.setTemperature(id, v.toInt()),
|
ref.read(groupsProvider.notifier).setTemperature(id, v.toInt());
|
||||||
|
},
|
||||||
|
onChangeEnd: (v) {
|
||||||
|
setState(() => _localTemp = null);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
// ─── Режим: цвет ───
|
// ─── Режим: цвет ───
|
||||||
if (_mode == 'color')
|
if (_mode == 'color')
|
||||||
SimpleColorPicker(
|
SimpleColorPicker(
|
||||||
initialColor: Color.fromARGB(255, r, gVal, b),
|
initialColor: Color.fromARGB(255, r, gVal, b),
|
||||||
onColorChanged: (c) => ref
|
onColorChanged: (c) {
|
||||||
.read(groupsProvider.notifier)
|
// Обновление UI-превью -- через debounce отправляется на сервер
|
||||||
.setColor(id, c.red, c.green, c.blue),
|
ref.read(groupsProvider.notifier).setColor(id, c.red, c.green, c.blue);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
// ─── Режим: сцена ───
|
// ─── Режим: сцена ───
|
||||||
@@ -183,6 +191,7 @@ class _SliderRow extends StatelessWidget {
|
|||||||
final String label;
|
final String label;
|
||||||
final Color? activeColor;
|
final Color? activeColor;
|
||||||
final ValueChanged<double> onChanged;
|
final ValueChanged<double> onChanged;
|
||||||
|
final ValueChanged<double>? onChangeEnd;
|
||||||
|
|
||||||
const _SliderRow({
|
const _SliderRow({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
@@ -193,6 +202,7 @@ class _SliderRow extends StatelessWidget {
|
|||||||
required this.label,
|
required this.label,
|
||||||
this.activeColor,
|
this.activeColor,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
|
this.onChangeEnd,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -209,6 +219,7 @@ class _SliderRow extends StatelessWidget {
|
|||||||
label: label,
|
label: label,
|
||||||
activeColor: activeColor,
|
activeColor: activeColor,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
|
onChangeEnd: onChangeEnd,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -272,17 +283,25 @@ class _ModeChip extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Выбор сцены из списка, загруженного с сервера
|
/// Выбор сцены из списка, загруженного с сервера
|
||||||
class _SceneSelector extends ConsumerWidget {
|
class _SceneSelector extends ConsumerStatefulWidget {
|
||||||
final String groupId;
|
final String groupId;
|
||||||
|
|
||||||
const _SceneSelector({required this.groupId});
|
const _SceneSelector({required this.groupId});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<_SceneSelector> createState() => _SceneSelectorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SceneSelectorState extends ConsumerState<_SceneSelector> {
|
||||||
|
bool _loadStarted = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final scenes = ref.watch(scenesProvider);
|
final scenes = ref.watch(scenesProvider);
|
||||||
|
|
||||||
if (scenes.isEmpty) {
|
if (scenes.isEmpty && !_loadStarted) {
|
||||||
// Загрузить сцены при первом показе
|
// Загрузить сцены при первом показе
|
||||||
|
_loadStarted = true;
|
||||||
Future.microtask(() => ref.read(scenesProvider.notifier).load());
|
Future.microtask(() => ref.read(scenesProvider.notifier).load());
|
||||||
return const Padding(
|
return const Padding(
|
||||||
padding: EdgeInsets.all(8.0),
|
padding: EdgeInsets.all(8.0),
|
||||||
@@ -296,24 +315,40 @@ class _SceneSelector extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (scenes.isEmpty) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(8.0),
|
||||||
|
child: Text(
|
||||||
|
'Сцены не найдены',
|
||||||
|
style: TextStyle(color: Colors.white38, fontSize: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Wrap(
|
return Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 4,
|
runSpacing: 4,
|
||||||
children: scenes.map((scene) {
|
children: scenes.map((scene) {
|
||||||
// Сцена может быть строкой или Map с полем 'name'/'id'
|
String sceneName;
|
||||||
final sceneName = scene is String
|
String sceneId;
|
||||||
? scene
|
|
||||||
: (scene['name'] ?? scene['id'] ?? scene.toString());
|
if (scene is String) {
|
||||||
final sceneId = scene is String
|
sceneName = scene;
|
||||||
? scene
|
sceneId = scene;
|
||||||
: (scene['id'] ?? scene['name'] ?? scene.toString());
|
} else if (scene is Map) {
|
||||||
|
sceneName = (scene['name'] ?? scene['id'] ?? scene.toString()).toString();
|
||||||
|
sceneId = (scene['id'] ?? scene['name'] ?? scene.toString()).toString();
|
||||||
|
} else {
|
||||||
|
sceneName = scene.toString();
|
||||||
|
sceneId = scene.toString();
|
||||||
|
}
|
||||||
|
|
||||||
return ActionChip(
|
return ActionChip(
|
||||||
label: Text(sceneName.toString(), style: const TextStyle(fontSize: 12)),
|
label: Text(sceneName, style: const TextStyle(fontSize: 12)),
|
||||||
backgroundColor: Colors.white10,
|
backgroundColor: Colors.white10,
|
||||||
onPressed: () => ref
|
onPressed: () => ref
|
||||||
.read(groupsProvider.notifier)
|
.read(groupsProvider.notifier)
|
||||||
.setScene(groupId, sceneId.toString()),
|
.setScene(widget.groupId, sceneId),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user