diff --git a/lib/main.dart b/lib/main.dart index fb7c097..739b0f4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -70,6 +70,8 @@ class _MainGateState extends ConsumerState { if (home != null) { // Есть дом -- идём на пульт управления await ref.read(groupsProvider.notifier).initAndRefresh(); + // Загружаем info об авторизации (admin / не admin) + await ref.read(authInfoProvider.notifier).load(); if (mounted) { Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (_) => const RemoteScreen()), diff --git a/lib/models/home_config.dart b/lib/models/home_config.dart index 56aa4ca..45bc459 100644 --- a/lib/models/home_config.dart +++ b/lib/models/home_config.dart @@ -5,20 +5,29 @@ class HomeConfig { final String name; // человекочитаемое название ("Квартира", "Дача") final String url; // адрес сервера (например ignis.akokos.ru) final String apiKey; // ключ авторизации + final double? latitude; // GPS-широта дома (для гео-автоматизации) + final double? longitude; // GPS-долгота дома (для гео-автоматизации) HomeConfig({ required this.id, required this.name, required this.url, required this.apiKey, + this.latitude, + this.longitude, }); + /// Есть ли координаты у дома + bool get hasCoordinates => latitude != null && longitude != null; + /// Сериализация в JSON для хранения в SharedPreferences Map toJson() => { 'id': id, 'name': name, 'url': url, 'apiKey': apiKey, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, }; factory HomeConfig.fromJson(Map json) => HomeConfig( @@ -26,13 +35,25 @@ class HomeConfig { name: json['name'] as String, url: json['url'] as String, apiKey: json['apiKey'] as String, + latitude: (json['latitude'] as num?)?.toDouble(), + longitude: (json['longitude'] as num?)?.toDouble(), ); /// Копирование с изменениями - 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, name: name ?? this.name, url: url ?? this.url, apiKey: apiKey ?? this.apiKey, + latitude: clearCoordinates ? null : (latitude ?? this.latitude), + longitude: clearCoordinates ? null : (longitude ?? this.longitude), ); } diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart index 47429cd..65bcecc 100644 --- a/lib/providers/providers.dart +++ b/lib/providers/providers.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/home_config.dart'; @@ -90,9 +91,17 @@ class GroupsNotifier extends Notifier> { /// чтобы UI не прыгал пока лампа ещё не ответила. final Map _lockUntil = {}; + /// Debounce-таймеры для слайдеров (яркость, темп, цвет) + final Map _debounceTimers = {}; + @override List build() { - ref.onDispose(() => _timer?.cancel()); + ref.onDispose(() { + _timer?.cancel(); + for (final t in _debounceTimers.values) { + t.cancel(); + } + }); return []; } @@ -140,16 +149,24 @@ class GroupsNotifier extends Notifier> { if (resStatus.data != null && resStatus.data['results'] is List && resStatus.data['results'].isNotEmpty) { - final data = resStatus.data['results'][0]['status']; - map['last_state'] = { - 'state': data['state'] == true, - 'brightness': (data['dimming'] ?? 100).toInt(), - 'temp': (data['temp'] ?? 4000).toInt(), - 'r': (data['r'] ?? 0).toInt(), - 'g': (data['g'] ?? 0).toInt(), - 'b': (data['b'] ?? 0).toInt(), - 'scene': data['scene'], - }; + // Берём первый результат без ошибки, или просто первый + final results = resStatus.data['results'] as List; + final validResult = results.firstWhere( + (r) => r['status'] != null && r['error'] == null, + orElse: () => results.first, + ); + final data = validResult['status']; + if (data != null) { + map['last_state'] = { + 'state': data['state'] == true, + 'brightness': (data['dimming'] ?? 100).toInt(), + 'temp': (data['temp'] ?? 4000).toInt(), + 'r': (data['r'] ?? 0).toInt(), + 'g': (data['g'] ?? 0).toInt(), + 'b': (data['b'] ?? 0).toInt(), + 'scene': data['scene'], + }; + } } } catch (e) { // При ошибке опроса -- сохраняем предыдущее состояние @@ -189,7 +206,30 @@ class GroupsNotifier extends Notifier> { ]; } - /// Включить/выключить группу + /// Debounce: отправить API-запрос с задержкой, но UI обновить сразу. + /// Если значение меняется быстро (слайдер тянут), отправляется только + /// последнее значение после паузы. + void _debouncedControl(String id, String key, Map localPatch, + Map 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 toggleGroup(String id, bool on) async { _setLock(id); _updateLocal(id, {'state': on}); @@ -201,32 +241,31 @@ class GroupsNotifier extends Notifier> { } } - /// Установить яркость (0-100) - Future setBrightness(String id, int value) async { - _setLock(id); - _updateLocal(id, {'brightness': value}); - await _api.controlGroup(id, {'brightness': value}); + /// Установить яркость (0-100) -- с debounce + void setBrightness(String id, int value) { + _debouncedControl(id, 'brightness', {'brightness': value}, {'brightness': value}); } - /// Установить цветовую температуру (2700-6500K) - Future setTemperature(String id, int value) async { - _setLock(id); - _updateLocal(id, {'temp': value}); - await _api.controlGroup(id, {'temp': value}); + /// Установить цветовую температуру (2700-6500K) -- с debounce + void setTemperature(String id, int value) { + _debouncedControl(id, 'temp', {'temp': value}, {'temp': value}); } - /// Установить RGB-цвет - Future setColor(String id, int r, int g, int b) async { - _setLock(id); - _updateLocal(id, {'r': r, 'g': g, 'b': b}); - await _api.controlGroup(id, {'r': r, 'g': g, 'b': b}); + /// Установить RGB-цвет -- с debounce + void setColor(String id, int r, int g, int b) { + _debouncedControl(id, 'color', {'r': r, 'g': g, 'b': b}, {'r': r, 'g': g, 'b': b}); } - /// Установить сцену + /// Установить сцену (без debounce) Future setScene(String id, String scene) async { _setLock(id); _updateLocal(id, {'scene': scene}); - await _api.controlGroup(id, {'scene': scene}); + try { + await _api.controlGroup(id, {'scene': scene}); + } catch (e) { + _lockUntil.remove(id); + refresh(); + } } /// Таймер: включить на 4 часа @@ -279,10 +318,22 @@ class ScenesNotifier extends Notifier> { try { final api = ref.read(apiProvider); final res = await api.getScenes(); - if (res.data is List) { - state = res.data; - } else if (res.data is Map) { - state = res.data['data'] ?? res.data['scenes'] ?? res.data.values.toList(); + final data = res.data; + if (data is List) { + state = data; + } 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) { debugPrint("Ошибка загрузки сцен: $e"); @@ -303,10 +354,11 @@ class TasksNotifier extends Notifier> { try { final api = ref.read(apiProvider); final res = await api.getTasks(); - if (res.data is List) { - state = res.data; - } else if (res.data is Map) { - state = res.data['data'] ?? res.data['tasks'] ?? res.data.values.toList(); + final data = res.data; + if (data is List) { + state = data; + } else if (data is Map) { + state = data['tasks'] ?? data['data'] ?? data.values.toList(); } } catch (e) { debugPrint("Ошибка загрузки расписаний: $e"); @@ -361,3 +413,162 @@ class TasksNotifier extends Notifier> { await load(); } } + +// ─── Статистика ────────────────────────────────────────────── + +final statsProvider = + NotifierProvider>(() => StatsNotifier()); + +class StatsNotifier extends Notifier> { + @override + Map build() => {}; + + Future 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.from(data); + } + } catch (e) { + debugPrint("Ошибка загрузки статистики: $e"); + } + } +} + +// ─── Лог событий ───────────────────────────────────────────── + +final eventLogProvider = + NotifierProvider>(() => EventLogNotifier()); + +class EventLogNotifier extends Notifier> { + @override + List build() => []; + + Future 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()); + +class ApiKeysNotifier extends Notifier> { + @override + List build() => []; + + Future 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 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 revoke(String key) async { + try { + await ref.read(apiProvider).revokeApiKey(key); + await load(); + } catch (e) { + debugPrint("Ошибка отзыва ключа: $e"); + } + } + + Future activate(String key) async { + try { + await ref.read(apiProvider).activateApiKey(key); + await load(); + } catch (e) { + debugPrint("Ошибка активации ключа: $e"); + } + } +} + +// ─── Информация об авторизации ──────────────────────────────── + +final authInfoProvider = + NotifierProvider?>(() => AuthInfoNotifier()); + +class AuthInfoNotifier extends Notifier?> { + @override + Map? build() => null; + + Future load() async { + try { + final api = ref.read(apiProvider); + final res = await api.getAuthMe(); + if (res.data is Map) { + state = Map.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()} км'; + } +} diff --git a/lib/screens/api_keys_screen.dart b/lib/screens/api_keys_screen.dart new file mode 100644 index 0000000..0f9a2c3 --- /dev/null +++ b/lib/screens/api_keys_screen.dart @@ -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 createState() => _ApiKeysScreenState(); +} + +class _ApiKeysScreenState extends ConsumerState { + bool _loading = true; + String? _lastCreatedKey; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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.from(k) + : {}; + 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 _revokeKey(Map data) async { + final key = (data['key'] ?? data['token'] ?? '').toString(); + final name = (data['name'] ?? '').toString(); + final confirmed = await showDialog( + 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 _activateKey(Map 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 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; + } + } +} diff --git a/lib/screens/event_log_screen.dart b/lib/screens/event_log_screen.dart new file mode 100644 index 0000000..83f1f20 --- /dev/null +++ b/lib/screens/event_log_screen.dart @@ -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 createState() => _EventLogScreenState(); +} + +class _EventLogScreenState extends ConsumerState { + bool _loading = true; + int _limit = 100; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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( + 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.from(event) + : {}, + ); + }, + ), + ), + ); + } +} + +class _EventRow extends StatelessWidget { + final Map 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; + } + } +} diff --git a/lib/screens/group_edit_screen.dart b/lib/screens/group_edit_screen.dart index 8038017..945286c 100644 --- a/lib/screens/group_edit_screen.dart +++ b/lib/screens/group_edit_screen.dart @@ -164,7 +164,7 @@ class _GroupEditScreenState extends ConsumerState { activeColor: Colors.deepOrange, title: Text(name), subtitle: Text( - '${mac}${ip != null ? ' - $ip' : ''}', + '$mac${ip != null ? ' - $ip' : ''}', style: const TextStyle( fontSize: 11, color: Colors.white38), ), @@ -183,23 +183,28 @@ class _GroupEditScreenState extends ConsumerState { ), // Кнопка сохранения - SizedBox( - width: double.infinity, - height: 48, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.deepOrange, - foregroundColor: Colors.white, + Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom + 8, + ), + child: SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.deepOrange, + foregroundColor: Colors.white, + ), + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Text('СОЗДАТЬ ГРУППУ'), ), - onPressed: _saving ? null : _save, - child: _saving - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white), - ) - : const Text('СОЗДАТЬ ГРУППУ'), ), ), ], diff --git a/lib/screens/home_edit_screen.dart b/lib/screens/home_edit_screen.dart index 4075067..c993c36 100644 --- a/lib/screens/home_edit_screen.dart +++ b/lib/screens/home_edit_screen.dart @@ -17,6 +17,8 @@ class _HomeEditScreenState extends ConsumerState { final _nameCtrl = TextEditingController(); final _urlCtrl = TextEditingController(); final _keyCtrl = TextEditingController(); + final _latCtrl = TextEditingController(); + final _lonCtrl = TextEditingController(); bool _saving = false; bool get _isEdit => widget.home != null; @@ -28,6 +30,12 @@ class _HomeEditScreenState extends ConsumerState { _nameCtrl.text = widget.home!.name; _urlCtrl.text = widget.home!.url; _keyCtrl.text = widget.home!.apiKey; + if (widget.home!.latitude != null) { + _latCtrl.text = widget.home!.latitude.toString(); + } + if (widget.home!.longitude != null) { + _lonCtrl.text = widget.home!.longitude.toString(); + } } } @@ -36,6 +44,8 @@ class _HomeEditScreenState extends ConsumerState { _nameCtrl.dispose(); _urlCtrl.dispose(); _keyCtrl.dispose(); + _latCtrl.dispose(); + _lonCtrl.dispose(); super.dispose(); } @@ -45,9 +55,10 @@ class _HomeEditScreenState extends ConsumerState { appBar: AppBar( title: Text(_isEdit ? 'РЕДАКТИРОВАТЬ ДОМ' : 'НОВЫЙ ДОМ'), ), - body: Padding( + body: SingleChildScrollView( padding: const EdgeInsets.all(20), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: _nameCtrl, @@ -75,6 +86,54 @@ class _HomeEditScreenState extends ConsumerState { ), 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), SizedBox( width: double.infinity, @@ -97,6 +156,8 @@ class _HomeEditScreenState extends ConsumerState { : Text(_isEdit ? 'СОХРАНИТЬ' : 'ДОБАВИТЬ'), ), ), + // Отступ внизу для системных кнопок + SizedBox(height: MediaQuery.of(context).padding.bottom + 16), ], ), ), @@ -107,23 +168,47 @@ class _HomeEditScreenState extends ConsumerState { final name = _nameCtrl.text.trim(); final url = _urlCtrl.text.trim(); final key = _keyCtrl.text.trim(); + final latText = _latCtrl.text.trim(); + final lonText = _lonCtrl.text.trim(); if (name.isEmpty || url.isEmpty || key.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Заполните все поля')), + const SnackBar(content: Text('Заполните все обязательные поля')), ); 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); 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( id: DateTime.now().millisecondsSinceEpoch.toString(), name: name, url: url, apiKey: key, + latitude: lat, + longitude: lon, ); if (_isEdit) { diff --git a/lib/screens/homes_screen.dart b/lib/screens/homes_screen.dart index 907f42b..8cd471a 100644 --- a/lib/screens/homes_screen.dart +++ b/lib/screens/homes_screen.dart @@ -61,9 +61,25 @@ class HomesScreen extends ConsumerWidget { color: isActive ? Colors.deepOrange : Colors.white, ), ), - subtitle: Text( - home.url, - style: const TextStyle(color: Colors.white38, fontSize: 12), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + home.url, + 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( mainAxisSize: MainAxisSize.min, @@ -96,6 +112,7 @@ class HomesScreen extends ConsumerWidget { /// Выбрать дом и перейти на пульт void _selectHome(BuildContext context, WidgetRef ref, HomeConfig home) async { await ref.read(currentHomeProvider.notifier).switchTo(home); + await ref.read(authInfoProvider.notifier).load(); if (context.mounted) { Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (_) => const RemoteScreen()), diff --git a/lib/screens/remote_screen.dart b/lib/screens/remote_screen.dart index f01eb16..56e8a63 100644 --- a/lib/screens/remote_screen.dart +++ b/lib/screens/remote_screen.dart @@ -5,6 +5,9 @@ import '../widgets/group_card.dart'; import 'homes_screen.dart'; import 'group_edit_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 { Future _bootstrap() async { await ref.read(groupsProvider.notifier).initAndRefresh(); + await ref.read(authInfoProvider.notifier).load(); if (mounted) setState(() => _loading = false); } @@ -33,6 +37,8 @@ class _RemoteScreenState extends ConsumerState { Widget build(BuildContext context) { final groups = ref.watch(groupsProvider); final currentHome = ref.watch(currentHomeProvider); + final authInfo = ref.watch(authInfoProvider); + final isAdmin = authInfo?['is_admin'] == true; return Scaffold( appBar: AppBar( @@ -53,13 +59,68 @@ class _RemoteScreenState extends ConsumerState { MaterialPageRoute(builder: (_) => const GroupEditScreen()), ), ), - // Расписания - IconButton( - icon: const Icon(Icons.schedule), - tooltip: 'Расписания', - onPressed: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const SchedulesScreen()), - ), + // Меню + PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (value) { + switch (value) { + case 'schedules': + Navigator.of(context).push( + 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, + ), + ), + ], ), ], ), diff --git a/lib/screens/schedules_screen.dart b/lib/screens/schedules_screen.dart index 46b1b56..bd967b9 100644 --- a/lib/screens/schedules_screen.dart +++ b/lib/screens/schedules_screen.dart @@ -78,7 +78,7 @@ class _SchedulesScreenState extends ConsumerState { shape: const RoundedRectangleBorder( 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 { + const _AddScheduleSheet(); + @override ConsumerState<_AddScheduleSheet> createState() => _AddScheduleSheetState(); } @@ -194,13 +196,15 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> { @override Widget build(BuildContext context) { final groups = ref.watch(groupsProvider); + final bottomPadding = MediaQuery.of(context).viewInsets.bottom; + final systemPadding = MediaQuery.of(context).padding.bottom; return Padding( padding: EdgeInsets.only( left: 20, right: 20, top: 20, - bottom: MediaQuery.of(context).viewInsets.bottom + 20, + bottom: bottomPadding + systemPadding + 20, ), child: SingleChildScrollView( child: Column( diff --git a/lib/screens/stats_screen.dart b/lib/screens/stats_screen.dart new file mode 100644 index 0000000..e29c756 --- /dev/null +++ b/lib/screens/stats_screen.dart @@ -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 createState() => _StatsScreenState(); +} + +class _StatsScreenState extends ConsumerState { + bool _loading = true; + int _days = 7; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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?) ?? []; + + 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.from(g) + : {}); + }, + ), + ), + ), + ], + ), + ); + } +} + +/// Карточка статистики одной группы +class _StatsCard extends StatelessWidget { + final Map 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, + ), + ), + ], + ); + } +} diff --git a/lib/services/api_client.dart b/lib/services/api_client.dart index 6e57a6c..83401b6 100644 --- a/lib/services/api_client.dart +++ b/lib/services/api_client.dart @@ -22,6 +22,11 @@ class IgnisApi { _dio.options.receiveTimeout = const Duration(seconds: 15); } + // ─── Авторизация ─────────────────────────────────────────── + + /// Проверка текущего ключа: возвращает {is_admin, name} + Future getAuthMe() => _dio.get('/auth/me'); + // ─── Устройства и группы ─────────────────────────────────── /// Все устройства (лампы) @@ -82,4 +87,34 @@ class IgnisApi { /// Отменить задачу расписания Future cancelTask(String jobId) => _dio.delete('/schedules/$jobId'); + + // ─── API-ключи ───────────────────────────────────────────── + + /// Список всех гостевых ключей + Future getApiKeys() => _dio.get('/api-keys'); + + /// Создать гостевой ключ + Future createApiKey(String name, {bool isAdmin = false}) => + _dio.post('/api-keys', queryParameters: { + 'name': name, + 'is_admin': isAdmin, + }); + + /// Отозвать ключ (body: {key: ...}) + Future revokeApiKey(String key) => + _dio.post('/api-keys/revoke', data: {'key': key}); + + /// Активировать ключ (body: {key: ...}) + Future activateApiKey(String key) => + _dio.post('/api-keys/activate', data: {'key': key}); + + // ─── Статистика ──────────────────────────────────────────── + + /// Сводная статистика за N дней + Future getStatsSummary({int days = 7}) => + _dio.get('/stats/summary', queryParameters: {'days': days}); + + /// Лог последних N событий + Future getStatsLog({int limit = 100}) => + _dio.get('/stats/log', queryParameters: {'limit': limit}); } diff --git a/lib/widgets/color_picker.dart b/lib/widgets/color_picker.dart index 6d68a72..327d391 100644 --- a/lib/widgets/color_picker.dart +++ b/lib/widgets/color_picker.dart @@ -1,16 +1,21 @@ import 'dart:math'; import 'package:flutter/material.dart'; -/// Простой цветовой пикер в виде кольца HSV. +/// Простой цветовой пикер в виде HSV-слайдеров. /// Возвращает RGB через callback. +/// +/// [onColorChanged] -- вызывается при каждом движении (для превью UI). +/// [onColorChangeEnd] -- вызывается при отпускании слайдера (для отправки на сервер). class SimpleColorPicker extends StatefulWidget { final Color initialColor; final ValueChanged onColorChanged; + final ValueChanged? onColorChangeEnd; const SimpleColorPicker({ super.key, this.initialColor = Colors.red, required this.onColorChanged, + this.onColorChangeEnd, }); @override @@ -34,6 +39,10 @@ class _SimpleColorPickerState extends State { Color get _currentColor => HSVColor.fromAHSV(1.0, _hue, _saturation, _value).toColor(); + void _notifyEnd() { + (widget.onColorChangeEnd ?? widget.onColorChanged)(_currentColor); + } + @override Widget build(BuildContext context) { return Column( @@ -63,6 +72,7 @@ class _SimpleColorPickerState extends State { setState(() => _hue = v); widget.onColorChanged(_currentColor); }, + onChangeEnd: (_) => _notifyEnd(), ), // Saturation -- насыщенность (0-1) @@ -77,6 +87,7 @@ class _SimpleColorPickerState extends State { setState(() => _saturation = v); widget.onColorChanged(_currentColor); }, + onChangeEnd: (_) => _notifyEnd(), ), // Value -- яркость (0-1) @@ -91,6 +102,7 @@ class _SimpleColorPickerState extends State { setState(() => _value = v); widget.onColorChanged(_currentColor); }, + onChangeEnd: (_) => _notifyEnd(), ), ], ); @@ -104,6 +116,7 @@ class _SimpleColorPickerState extends State { required int divisions, required Color activeColor, required ValueChanged onChanged, + ValueChanged? onChangeEnd, }) { return Row( children: [ @@ -119,6 +132,7 @@ class _SimpleColorPickerState extends State { divisions: divisions, activeColor: activeColor, onChanged: onChanged, + onChangeEnd: onChangeEnd, ), ), ], diff --git a/lib/widgets/group_card.dart b/lib/widgets/group_card.dart index 13e3ded..9e56f85 100644 --- a/lib/widgets/group_card.dart +++ b/lib/widgets/group_card.dart @@ -17,7 +17,11 @@ class GroupCard extends ConsumerStatefulWidget { class _GroupCardState extends ConsumerState { /// Текущий режим управления: temp (температура) или color (RGB) String _mode = 'temp'; - bool _showColorPicker = false; + + // Локальные значения слайдеров -- обновляются мгновенно, + // а на сервер отправляются через debounce в провайдере. + double? _localBrightness; + double? _localTemp; @override Widget build(BuildContext context) { @@ -31,10 +35,14 @@ class _GroupCardState extends ConsumerState { final int gVal = g['last_state']?['g'] ?? 200; 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 ? (_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)) : Colors.white12; @@ -90,15 +98,19 @@ class _GroupCardState extends ConsumerState { // Яркость _SliderRow( icon: Icons.sunny, - value: bri.toDouble().clamp(10, 100), + value: briValue, min: 10, max: 100, divisions: 9, - label: "$bri%", + label: "${briValue.toInt()}%", activeColor: Colors.amber, - onChanged: (v) => ref - .read(groupsProvider.notifier) - .setBrightness(id, v.toInt()), + onChanged: (v) { + setState(() => _localBrightness = v); + ref.read(groupsProvider.notifier).setBrightness(id, v.toInt()); + }, + onChangeEnd: (v) { + setState(() => _localBrightness = null); + }, ), // Переключатель режима: температура / цвет / сцена @@ -108,30 +120,21 @@ class _GroupCardState extends ConsumerState { label: 'Темп.', icon: Icons.wb_twilight, selected: _mode == 'temp', - onTap: () => setState(() { - _mode = 'temp'; - _showColorPicker = false; - }), + onTap: () => setState(() => _mode = 'temp'), ), const SizedBox(width: 8), _ModeChip( label: 'Цвет', icon: Icons.palette, selected: _mode == 'color', - onTap: () => setState(() { - _mode = 'color'; - _showColorPicker = true; - }), + onTap: () => setState(() => _mode = 'color'), ), const SizedBox(width: 8), _ModeChip( label: 'Сцена', icon: Icons.auto_awesome, selected: _mode == 'scene', - onTap: () => setState(() { - _mode = 'scene'; - _showColorPicker = false; - }), + onTap: () => setState(() => _mode = 'scene'), ), ], ), @@ -141,25 +144,30 @@ class _GroupCardState extends ConsumerState { if (_mode == 'temp') _SliderRow( icon: Icons.wb_twilight, - value: temp.toDouble().clamp(2700, 6500), + value: tempValue, min: 2700, max: 6500, divisions: 38, // шаг 100K - label: "${temp}K", + label: "${tempValue.toInt()}K", activeColor: Color.lerp( - Colors.orange, Colors.blueAccent, (temp - 2700) / 3800), - onChanged: (v) => ref - .read(groupsProvider.notifier) - .setTemperature(id, v.toInt()), + Colors.orange, Colors.blueAccent, (tempValue - 2700) / 3800), + onChanged: (v) { + setState(() => _localTemp = v); + ref.read(groupsProvider.notifier).setTemperature(id, v.toInt()); + }, + onChangeEnd: (v) { + setState(() => _localTemp = null); + }, ), // ─── Режим: цвет ─── if (_mode == 'color') SimpleColorPicker( initialColor: Color.fromARGB(255, r, gVal, b), - onColorChanged: (c) => ref - .read(groupsProvider.notifier) - .setColor(id, c.red, c.green, c.blue), + onColorChanged: (c) { + // Обновление UI-превью -- через debounce отправляется на сервер + ref.read(groupsProvider.notifier).setColor(id, c.red, c.green, c.blue); + }, ), // ─── Режим: сцена ─── @@ -183,6 +191,7 @@ class _SliderRow extends StatelessWidget { final String label; final Color? activeColor; final ValueChanged onChanged; + final ValueChanged? onChangeEnd; const _SliderRow({ required this.icon, @@ -193,6 +202,7 @@ class _SliderRow extends StatelessWidget { required this.label, this.activeColor, required this.onChanged, + this.onChangeEnd, }); @override @@ -209,6 +219,7 @@ class _SliderRow extends StatelessWidget { label: label, activeColor: activeColor, onChanged: onChanged, + onChangeEnd: onChangeEnd, ), ), SizedBox( @@ -272,17 +283,25 @@ class _ModeChip extends StatelessWidget { } /// Выбор сцены из списка, загруженного с сервера -class _SceneSelector extends ConsumerWidget { +class _SceneSelector extends ConsumerStatefulWidget { final String groupId; const _SceneSelector({required this.groupId}); @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); - if (scenes.isEmpty) { + if (scenes.isEmpty && !_loadStarted) { // Загрузить сцены при первом показе + _loadStarted = true; Future.microtask(() => ref.read(scenesProvider.notifier).load()); return const Padding( 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( spacing: 8, runSpacing: 4, children: scenes.map((scene) { - // Сцена может быть строкой или Map с полем 'name'/'id' - final sceneName = scene is String - ? scene - : (scene['name'] ?? scene['id'] ?? scene.toString()); - final sceneId = scene is String - ? scene - : (scene['id'] ?? scene['name'] ?? scene.toString()); + String sceneName; + String sceneId; + + if (scene is String) { + sceneName = scene; + sceneId = scene; + } else if (scene is Map) { + sceneName = (scene['name'] ?? scene['id'] ?? scene.toString()).toString(); + sceneId = (scene['id'] ?? scene['name'] ?? scene.toString()).toString(); + } else { + sceneName = scene.toString(); + sceneId = scene.toString(); + } return ActionChip( - label: Text(sceneName.toString(), style: const TextStyle(fontSize: 12)), + label: Text(sceneName, style: const TextStyle(fontSize: 12)), backgroundColor: Colors.white10, onPressed: () => ref .read(groupsProvider.notifier) - .setScene(groupId, sceneId.toString()), + .setScene(widget.groupId, sceneId), ); }).toList(), );