feat: ignis app v1.0.0 -- управление WiZ лампами
This commit is contained in:
111
lib/main.dart
Normal file
111
lib/main.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'providers/providers.dart';
|
||||
import 'screens/homes_screen.dart';
|
||||
import 'screens/remote_screen.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const ProviderScope(child: IgnisApp()));
|
||||
}
|
||||
|
||||
class IgnisApp extends StatelessWidget {
|
||||
const IgnisApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Ignis',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData.dark(useMaterial3: true).copyWith(
|
||||
scaffoldBackgroundColor: const Color(0xFF0E0E0E),
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.deepOrange,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Color(0xFF1A1A1A),
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: const Color(0xFF1E1E1E),
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
sliderTheme: const SliderThemeData(
|
||||
trackHeight: 4,
|
||||
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 8),
|
||||
),
|
||||
),
|
||||
home: const MainGate(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Стартовый экран -- проверяет наличие домов и перенаправляет
|
||||
class MainGate extends ConsumerStatefulWidget {
|
||||
const MainGate({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<MainGate> createState() => _MainGateState();
|
||||
}
|
||||
|
||||
class _MainGateState extends ConsumerState<MainGate> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bootstrap();
|
||||
}
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
try {
|
||||
// Загружаем список домов
|
||||
await ref.read(homesProvider.notifier).load();
|
||||
await ref.read(currentHomeProvider.notifier).load();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final home = ref.read(currentHomeProvider);
|
||||
|
||||
if (home != null) {
|
||||
// Есть дом -- идём на пульт управления
|
||||
await ref.read(groupsProvider.notifier).initAndRefresh();
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const RemoteScreen()),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Нет домов -- на экран управления домами
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const HomesScreen()),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Ошибка бутстрапа: $e");
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const HomesScreen()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(color: Colors.deepOrange),
|
||||
SizedBox(height: 20),
|
||||
Text("Загрузка...", style: TextStyle(color: Colors.white54)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
38
lib/models/home_config.dart
Normal file
38
lib/models/home_config.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
/// Модель "дома" -- один физический сервер Ignis.
|
||||
/// Каждый дом имеет свой URL и API-ключ.
|
||||
class HomeConfig {
|
||||
final String id; // уникальный идентификатор (uuid или timestamp)
|
||||
final String name; // человекочитаемое название ("Квартира", "Дача")
|
||||
final String url; // адрес сервера (например ignis.akokos.ru)
|
||||
final String apiKey; // ключ авторизации
|
||||
|
||||
HomeConfig({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.url,
|
||||
required this.apiKey,
|
||||
});
|
||||
|
||||
/// Сериализация в JSON для хранения в SharedPreferences
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'url': url,
|
||||
'apiKey': apiKey,
|
||||
};
|
||||
|
||||
factory HomeConfig.fromJson(Map<String, dynamic> json) => HomeConfig(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
url: json['url'] as String,
|
||||
apiKey: json['apiKey'] as String,
|
||||
);
|
||||
|
||||
/// Копирование с изменениями
|
||||
HomeConfig copyWith({String? name, String? url, String? apiKey}) => HomeConfig(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
url: url ?? this.url,
|
||||
apiKey: apiKey ?? this.apiKey,
|
||||
);
|
||||
}
|
||||
365
lib/providers/providers.dart
Normal file
365
lib/providers/providers.dart
Normal file
@@ -0,0 +1,365 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/home_config.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../services/settings_service.dart';
|
||||
|
||||
// ─── Сервисы ─────────────────────────────────────────────────
|
||||
|
||||
/// Синглтон сервиса настроек
|
||||
final settingsServiceProvider = Provider((ref) => SettingsService());
|
||||
|
||||
/// API-клиент -- пересоздаётся при смене дома
|
||||
final apiProvider = Provider((ref) => IgnisApi());
|
||||
|
||||
// ─── Текущий дом ─────────────────────────────────────────────
|
||||
|
||||
/// Текущий выбранный дом (null если ни одного нет)
|
||||
final currentHomeProvider =
|
||||
NotifierProvider<CurrentHomeNotifier, HomeConfig?>(() => CurrentHomeNotifier());
|
||||
|
||||
class CurrentHomeNotifier extends Notifier<HomeConfig?> {
|
||||
@override
|
||||
HomeConfig? build() => null;
|
||||
|
||||
/// Загрузить текущий дом из SharedPreferences
|
||||
Future<void> load() async {
|
||||
final svc = ref.read(settingsServiceProvider);
|
||||
state = await svc.getCurrentHome();
|
||||
if (state != null) {
|
||||
_initApi(state!);
|
||||
}
|
||||
}
|
||||
|
||||
/// Переключиться на другой дом
|
||||
Future<void> switchTo(HomeConfig home) async {
|
||||
final svc = ref.read(settingsServiceProvider);
|
||||
await svc.setCurrentHomeId(home.id);
|
||||
state = home;
|
||||
_initApi(home);
|
||||
// Перезагрузить группы для нового дома
|
||||
await ref.read(groupsProvider.notifier).initAndRefresh();
|
||||
}
|
||||
|
||||
/// Инициализировать API-клиент текущим домом
|
||||
void _initApi(HomeConfig home) {
|
||||
ref.read(apiProvider).init(home.url, home.apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Список домов ────────────────────────────────────────────
|
||||
|
||||
final homesProvider =
|
||||
NotifierProvider<HomesNotifier, List<HomeConfig>>(() => HomesNotifier());
|
||||
|
||||
class HomesNotifier extends Notifier<List<HomeConfig>> {
|
||||
@override
|
||||
List<HomeConfig> build() => [];
|
||||
|
||||
Future<void> load() async {
|
||||
state = await ref.read(settingsServiceProvider).getHomes();
|
||||
}
|
||||
|
||||
Future<void> add(HomeConfig home) async {
|
||||
await ref.read(settingsServiceProvider).upsertHome(home);
|
||||
await load();
|
||||
}
|
||||
|
||||
Future<void> remove(String id) async {
|
||||
await ref.read(settingsServiceProvider).deleteHome(id);
|
||||
await load();
|
||||
}
|
||||
|
||||
Future<void> update(HomeConfig home) async {
|
||||
await ref.read(settingsServiceProvider).upsertHome(home);
|
||||
await load();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Группы текущего дома ────────────────────────────────────
|
||||
|
||||
final groupsProvider =
|
||||
NotifierProvider<GroupsNotifier, List<dynamic>>(() => GroupsNotifier());
|
||||
|
||||
class GroupsNotifier extends Notifier<List<dynamic>> {
|
||||
IgnisApi get _api => ref.read(apiProvider);
|
||||
Timer? _timer;
|
||||
|
||||
/// Блокировка обновления для группы после управления --
|
||||
/// чтобы UI не прыгал пока лампа ещё не ответила.
|
||||
final Map<String, DateTime> _lockUntil = {};
|
||||
|
||||
@override
|
||||
List<dynamic> build() {
|
||||
ref.onDispose(() => _timer?.cancel());
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Инициализация: настроить API и начать периодический опрос
|
||||
Future<void> initAndRefresh() async {
|
||||
final home = ref.read(currentHomeProvider);
|
||||
if (home == null) return;
|
||||
_api.init(home.url, home.apiKey);
|
||||
await refresh();
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh());
|
||||
}
|
||||
|
||||
/// Полный опрос: загрузить группы + статус каждой
|
||||
Future<void> refresh() async {
|
||||
try {
|
||||
final resGroups = await _api.getGroups();
|
||||
List<dynamic> rawList = [];
|
||||
|
||||
// Бэкенд может вернуть и Map, и List -- обрабатываем оба варианта
|
||||
if (resGroups.data is Map) {
|
||||
rawList = resGroups.data['data'] ??
|
||||
resGroups.data['groups'] ??
|
||||
resGroups.data.values.toList();
|
||||
} else if (resGroups.data is List) {
|
||||
rawList = resGroups.data;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
|
||||
// Параллельный опрос статусов всех групп
|
||||
final updatedList = await Future.wait(rawList.map((g) async {
|
||||
final map = Map<String, dynamic>.from(g);
|
||||
final id = map['id'].toString();
|
||||
|
||||
// Если группа залочена (недавно управляли) -- берём локальное состояние
|
||||
if (_lockUntil.containsKey(id) && _lockUntil[id]!.isAfter(now)) {
|
||||
final existing = state.cast<dynamic?>().firstWhere(
|
||||
(old) => old?['id'].toString() == id,
|
||||
orElse: () => null);
|
||||
return existing ?? map;
|
||||
}
|
||||
|
||||
try {
|
||||
final resStatus = await _api.getGroupStatus(id);
|
||||
// Формат ответа: { results: [ { status: { state, dimming, temp, ... } } ] }
|
||||
if (resStatus.data != null &&
|
||||
resStatus.data['results'] is List &&
|
||||
resStatus.data['results'].isNotEmpty) {
|
||||
final 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'],
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// При ошибке опроса -- сохраняем предыдущее состояние
|
||||
final existing = state.cast<dynamic?>().firstWhere(
|
||||
(s) => s?['id'].toString() == id,
|
||||
orElse: () => null);
|
||||
map['last_state'] = existing?['last_state'] ??
|
||||
{'state': false, 'brightness': 100, 'temp': 4000};
|
||||
}
|
||||
return map;
|
||||
}));
|
||||
|
||||
state = updatedList;
|
||||
} catch (e) {
|
||||
debugPrint("Ошибка глобального опроса: $e");
|
||||
}
|
||||
}
|
||||
|
||||
/// Установить блокировку на 5 секунд (чтобы UI не перетирал значения)
|
||||
void _setLock(String id) =>
|
||||
_lockUntil[id] = DateTime.now().add(const Duration(seconds: 5));
|
||||
|
||||
/// Обновить локальное состояние группы (оптимистичный UI)
|
||||
void _updateLocal(String id, Map<String, dynamic> patch) {
|
||||
state = [
|
||||
for (final g in state)
|
||||
if (g['id'].toString() == id)
|
||||
{
|
||||
...g,
|
||||
'last_state': {
|
||||
...Map<String, dynamic>.from(g['last_state'] ?? {}),
|
||||
...patch
|
||||
}
|
||||
}
|
||||
else
|
||||
g
|
||||
];
|
||||
}
|
||||
|
||||
/// Включить/выключить группу
|
||||
Future<void> toggleGroup(String id, bool on) async {
|
||||
_setLock(id);
|
||||
_updateLocal(id, {'state': on});
|
||||
try {
|
||||
await _api.controlGroup(id, {'state': on});
|
||||
} catch (e) {
|
||||
_lockUntil.remove(id);
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/// Установить яркость (0-100)
|
||||
Future<void> setBrightness(String id, int value) async {
|
||||
_setLock(id);
|
||||
_updateLocal(id, {'brightness': value});
|
||||
await _api.controlGroup(id, {'brightness': value});
|
||||
}
|
||||
|
||||
/// Установить цветовую температуру (2700-6500K)
|
||||
Future<void> setTemperature(String id, int value) async {
|
||||
_setLock(id);
|
||||
_updateLocal(id, {'temp': value});
|
||||
await _api.controlGroup(id, {'temp': value});
|
||||
}
|
||||
|
||||
/// Установить RGB-цвет
|
||||
Future<void> 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});
|
||||
}
|
||||
|
||||
/// Установить сцену
|
||||
Future<void> setScene(String id, String scene) async {
|
||||
_setLock(id);
|
||||
_updateLocal(id, {'scene': scene});
|
||||
await _api.controlGroup(id, {'scene': scene});
|
||||
}
|
||||
|
||||
/// Таймер: включить на 4 часа
|
||||
Future<void> setTimer4h(String id) async {
|
||||
await toggleGroup(id, true);
|
||||
await _api.scheduleOnce({
|
||||
'target_id': id,
|
||||
'state': false,
|
||||
'hours_from_now': 4,
|
||||
'is_group': true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Устройства (для создания групп) ─────────────────────────
|
||||
|
||||
final devicesProvider =
|
||||
NotifierProvider<DevicesNotifier, List<dynamic>>(() => DevicesNotifier());
|
||||
|
||||
class DevicesNotifier extends Notifier<List<dynamic>> {
|
||||
@override
|
||||
List<dynamic> build() => [];
|
||||
|
||||
/// Загрузить список устройств из текущего дома
|
||||
Future<void> load() async {
|
||||
try {
|
||||
final api = ref.read(apiProvider);
|
||||
final res = await api.getDevices();
|
||||
if (res.data is List) {
|
||||
state = res.data;
|
||||
} else if (res.data is Map) {
|
||||
state = res.data['data'] ?? res.data['devices'] ?? res.data.values.toList();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Ошибка загрузки устройств: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Сцены ───────────────────────────────────────────────────
|
||||
|
||||
final scenesProvider =
|
||||
NotifierProvider<ScenesNotifier, List<dynamic>>(() => ScenesNotifier());
|
||||
|
||||
class ScenesNotifier extends Notifier<List<dynamic>> {
|
||||
@override
|
||||
List<dynamic> build() => [];
|
||||
|
||||
Future<void> load() async {
|
||||
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();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Ошибка загрузки сцен: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Расписания ──────────────────────────────────────────────
|
||||
|
||||
final tasksProvider =
|
||||
NotifierProvider<TasksNotifier, List<dynamic>>(() => TasksNotifier());
|
||||
|
||||
class TasksNotifier extends Notifier<List<dynamic>> {
|
||||
@override
|
||||
List<dynamic> build() => [];
|
||||
|
||||
Future<void> load() async {
|
||||
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();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Ошибка загрузки расписаний: $e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancel(String jobId) async {
|
||||
try {
|
||||
await ref.read(apiProvider).cancelTask(jobId);
|
||||
await load();
|
||||
} catch (e) {
|
||||
debugPrint("Ошибка отмены задачи: $e");
|
||||
}
|
||||
}
|
||||
|
||||
/// Создать одноразовый таймер
|
||||
Future<void> addOnce({
|
||||
required String targetId,
|
||||
required bool targetState,
|
||||
int? hoursFromNow,
|
||||
String? runAt,
|
||||
bool isGroup = true,
|
||||
}) async {
|
||||
final params = <String, dynamic>{
|
||||
'target_id': targetId,
|
||||
'state': targetState,
|
||||
'is_group': isGroup,
|
||||
};
|
||||
if (hoursFromNow != null) params['hours_from_now'] = hoursFromNow;
|
||||
if (runAt != null) params['run_at'] = runAt;
|
||||
await ref.read(apiProvider).scheduleOnce(params);
|
||||
await load();
|
||||
}
|
||||
|
||||
/// Создать cron-задачу
|
||||
Future<void> addCron({
|
||||
required String targetId,
|
||||
required String hour,
|
||||
required String minute,
|
||||
String dayOfWeek = '*',
|
||||
bool isGroup = true,
|
||||
bool targetState = true,
|
||||
}) async {
|
||||
await ref.read(apiProvider).scheduleCron({
|
||||
'target_id': targetId,
|
||||
'hour': hour,
|
||||
'minute': minute,
|
||||
'day_of_week': dayOfWeek,
|
||||
'is_group': isGroup,
|
||||
'state': targetState,
|
||||
});
|
||||
await load();
|
||||
}
|
||||
}
|
||||
271
lib/screens/group_edit_screen.dart
Normal file
271
lib/screens/group_edit_screen.dart
Normal file
@@ -0,0 +1,271 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/providers.dart';
|
||||
|
||||
/// Экран создания новой группы ламп.
|
||||
/// Загружает список устройств, позволяет выбрать нужные.
|
||||
class GroupEditScreen extends ConsumerStatefulWidget {
|
||||
const GroupEditScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<GroupEditScreen> createState() => _GroupEditScreenState();
|
||||
}
|
||||
|
||||
class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
||||
final _idCtrl = TextEditingController();
|
||||
final _nameCtrl = TextEditingController();
|
||||
final Set<String> _selectedMacs = {};
|
||||
bool _loading = true;
|
||||
bool _saving = false;
|
||||
bool _rescanning = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDevices();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_idCtrl.dispose();
|
||||
_nameCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadDevices() async {
|
||||
await ref.read(devicesProvider.notifier).load();
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
|
||||
/// Пересканировать сеть и перезагрузить устройства
|
||||
Future<void> _rescan() async {
|
||||
setState(() => _rescanning = true);
|
||||
try {
|
||||
await ref.read(apiProvider).rescanNetwork();
|
||||
// Подождать немного -- сканирование асинхронное
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
await ref.read(devicesProvider.notifier).load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Ошибка сканирования: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (mounted) setState(() => _rescanning = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final devices = ref.watch(devicesProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('НОВАЯ ГРУППА'),
|
||||
actions: [
|
||||
// Кнопка ресканирования сети
|
||||
IconButton(
|
||||
icon: _rescanning
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
tooltip: 'Пересканировать сеть',
|
||||
onPressed: _rescanning ? null : _rescan,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator(color: Colors.deepOrange))
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ID группы
|
||||
TextField(
|
||||
controller: _idCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'ID группы (например "bedroom")',
|
||||
prefixIcon: Icon(Icons.tag),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Название группы
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Название (например "Спальня")',
|
||||
prefixIcon: Icon(Icons.label),
|
||||
),
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Заголовок списка устройств
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Устройства (${_selectedMacs.length} выбрано)',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (_selectedMacs.length == devices.length) {
|
||||
_selectedMacs.clear();
|
||||
} else {
|
||||
for (final d in devices) {
|
||||
final mac = _extractMac(d);
|
||||
if (mac != null) _selectedMacs.add(mac);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
_selectedMacs.length == devices.length
|
||||
? 'Снять все'
|
||||
: 'Выбрать все',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Список устройств
|
||||
Expanded(
|
||||
child: devices.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'Устройства не найдены.\nПопробуйте пересканировать сеть.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.white38),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: devices.length,
|
||||
itemBuilder: (context, index) {
|
||||
final d = devices[index];
|
||||
final mac = _extractMac(d) ?? '';
|
||||
final name = _extractName(d);
|
||||
final ip = _extractIp(d);
|
||||
final selected = _selectedMacs.contains(mac);
|
||||
|
||||
return CheckboxListTile(
|
||||
value: selected,
|
||||
activeColor: Colors.deepOrange,
|
||||
title: Text(name),
|
||||
subtitle: Text(
|
||||
'${mac}${ip != null ? ' - $ip' : ''}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: Colors.white38),
|
||||
),
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
if (v == true) {
|
||||
_selectedMacs.add(mac);
|
||||
} else {
|
||||
_selectedMacs.remove(mac);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Кнопка сохранения
|
||||
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('СОЗДАТЬ ГРУППУ'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Извлечь MAC-адрес из объекта устройства
|
||||
String? _extractMac(dynamic device) {
|
||||
if (device is Map) {
|
||||
return (device['mac'] ?? device['id'] ?? device['mac_address'])?.toString();
|
||||
}
|
||||
return device?.toString();
|
||||
}
|
||||
|
||||
/// Извлечь имя устройства
|
||||
String _extractName(dynamic device) {
|
||||
if (device is Map) {
|
||||
return (device['name'] ?? device['model'] ?? device['mac'] ?? 'Лампа')
|
||||
.toString();
|
||||
}
|
||||
return device?.toString() ?? 'Лампа';
|
||||
}
|
||||
|
||||
/// Извлечь IP-адрес
|
||||
String? _extractIp(dynamic device) {
|
||||
if (device is Map) {
|
||||
return (device['ip'] ?? device['address'])?.toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final id = _idCtrl.text.trim();
|
||||
final name = _nameCtrl.text.trim();
|
||||
|
||||
if (id.isEmpty || name.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Укажите ID и название')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selectedMacs.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Выберите хотя бы одно устройство')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _saving = true);
|
||||
|
||||
try {
|
||||
await ref.read(apiProvider).createGroup(id, name, _selectedMacs.toList());
|
||||
// Обновить список групп
|
||||
await ref.read(groupsProvider.notifier).refresh();
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Ошибка создания: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
137
lib/screens/home_edit_screen.dart
Normal file
137
lib/screens/home_edit_screen.dart
Normal file
@@ -0,0 +1,137 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/home_config.dart';
|
||||
import '../providers/providers.dart';
|
||||
|
||||
/// Экран создания или редактирования "дома" (сервера Ignis).
|
||||
class HomeEditScreen extends ConsumerStatefulWidget {
|
||||
final HomeConfig? home; // null -- создание, иначе редактирование
|
||||
|
||||
const HomeEditScreen({super.key, this.home});
|
||||
|
||||
@override
|
||||
ConsumerState<HomeEditScreen> createState() => _HomeEditScreenState();
|
||||
}
|
||||
|
||||
class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _urlCtrl = TextEditingController();
|
||||
final _keyCtrl = TextEditingController();
|
||||
bool _saving = false;
|
||||
|
||||
bool get _isEdit => widget.home != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (_isEdit) {
|
||||
_nameCtrl.text = widget.home!.name;
|
||||
_urlCtrl.text = widget.home!.url;
|
||||
_keyCtrl.text = widget.home!.apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_urlCtrl.dispose();
|
||||
_keyCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_isEdit ? 'РЕДАКТИРОВАТЬ ДОМ' : 'НОВЫЙ ДОМ'),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Название (например "Квартира")',
|
||||
prefixIcon: Icon(Icons.home),
|
||||
),
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _urlCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Адрес сервера (например ignis.akokos.ru)',
|
||||
prefixIcon: Icon(Icons.dns),
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _keyCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'API Key',
|
||||
prefixIcon: Icon(Icons.key),
|
||||
),
|
||||
obscureText: true,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
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,
|
||||
),
|
||||
)
|
||||
: Text(_isEdit ? 'СОХРАНИТЬ' : 'ДОБАВИТЬ'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final name = _nameCtrl.text.trim();
|
||||
final url = _urlCtrl.text.trim();
|
||||
final key = _keyCtrl.text.trim();
|
||||
|
||||
if (name.isEmpty || url.isEmpty || key.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Заполните все поля')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _saving = true);
|
||||
|
||||
final home = _isEdit
|
||||
? widget.home!.copyWith(name: name, url: url, apiKey: key)
|
||||
: HomeConfig(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
name: name,
|
||||
url: url,
|
||||
apiKey: key,
|
||||
);
|
||||
|
||||
if (_isEdit) {
|
||||
await ref.read(homesProvider.notifier).update(home);
|
||||
} else {
|
||||
await ref.read(homesProvider.notifier).add(home);
|
||||
}
|
||||
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
143
lib/screens/homes_screen.dart
Normal file
143
lib/screens/homes_screen.dart
Normal file
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/home_config.dart';
|
||||
import '../providers/providers.dart';
|
||||
import 'home_edit_screen.dart';
|
||||
import 'remote_screen.dart';
|
||||
|
||||
/// Экран "Дома" -- список серверов Ignis.
|
||||
/// Пользователь может добавить, удалить, переключить активный дом.
|
||||
class HomesScreen extends ConsumerWidget {
|
||||
const HomesScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final homes = ref.watch(homesProvider);
|
||||
final currentHome = ref.watch(currentHomeProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('ДОМА'),
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
body: homes.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.home_outlined, size: 64, color: Colors.white24),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Нет добавленных домов',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Добавьте сервер Ignis',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: homes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final home = homes[index];
|
||||
final isActive = currentHome?.id == home.id;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
Icons.home,
|
||||
color: isActive ? Colors.deepOrange : Colors.white38,
|
||||
size: 28,
|
||||
),
|
||||
title: Text(
|
||||
home.name,
|
||||
style: TextStyle(
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
color: isActive ? Colors.deepOrange : Colors.white,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
home.url,
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 12),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Кнопка редактирования
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, size: 20, color: Colors.white38),
|
||||
onPressed: () => _editHome(context, ref, home),
|
||||
),
|
||||
// Кнопка удаления
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, size: 20, color: Colors.redAccent),
|
||||
onPressed: () => _confirmDelete(context, ref, home),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => _selectHome(context, ref, home),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
backgroundColor: Colors.deepOrange,
|
||||
onPressed: () => _addHome(context),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Выбрать дом и перейти на пульт
|
||||
void _selectHome(BuildContext context, WidgetRef ref, HomeConfig home) async {
|
||||
await ref.read(currentHomeProvider.notifier).switchTo(home);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const RemoteScreen()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Добавить новый дом
|
||||
void _addHome(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const HomeEditScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Редактировать дом
|
||||
void _editHome(BuildContext context, WidgetRef ref, HomeConfig home) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => HomeEditScreen(home: home)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Подтвердить удаление
|
||||
void _confirmDelete(BuildContext context, WidgetRef ref, HomeConfig home) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Удалить дом?'),
|
||||
content: Text('Удалить "${home.name}" (${home.url})?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(ctx).pop();
|
||||
await ref.read(homesProvider.notifier).remove(home.id);
|
||||
},
|
||||
child: const Text('Удалить', style: TextStyle(color: Colors.redAccent)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
172
lib/screens/remote_screen.dart
Normal file
172
lib/screens/remote_screen.dart
Normal file
@@ -0,0 +1,172 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/providers.dart';
|
||||
import '../widgets/group_card.dart';
|
||||
import 'homes_screen.dart';
|
||||
import 'group_edit_screen.dart';
|
||||
import 'schedules_screen.dart';
|
||||
|
||||
/// Основной экран пульта управления.
|
||||
/// Показывает группы текущего дома с управлением.
|
||||
class RemoteScreen extends ConsumerStatefulWidget {
|
||||
const RemoteScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RemoteScreen> createState() => _RemoteScreenState();
|
||||
}
|
||||
|
||||
class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bootstrap();
|
||||
}
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
await ref.read(groupsProvider.notifier).initAndRefresh();
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final groups = ref.watch(groupsProvider);
|
||||
final currentHome = ref.watch(currentHomeProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(currentHome?.name ?? 'IGNIS'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.home),
|
||||
tooltip: 'Дома',
|
||||
onPressed: () => Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const HomesScreen()),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
// Кнопка добавления группы
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
tooltip: 'Создать группу',
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const GroupEditScreen()),
|
||||
),
|
||||
),
|
||||
// Расписания
|
||||
IconButton(
|
||||
icon: const Icon(Icons.schedule),
|
||||
tooltip: 'Расписания',
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SchedulesScreen()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(color: Colors.deepOrange),
|
||||
SizedBox(height: 20),
|
||||
Text(
|
||||
"Опрос ламп (это долго)...",
|
||||
style: TextStyle(color: Colors.white54),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: groups.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.lightbulb_outline, size: 64, color: Colors.white24),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Нет групп',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Создать группу'),
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const GroupEditScreen()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
color: Colors.deepOrange,
|
||||
onRefresh: () =>
|
||||
ref.read(groupsProvider.notifier).refresh(),
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 80),
|
||||
itemCount: groups.length,
|
||||
itemBuilder: (context, index) {
|
||||
final g = Map<String, dynamic>.from(groups[index]);
|
||||
return Dismissible(
|
||||
key: Key(g['id'].toString()),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.redAccent.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Icon(Icons.delete, color: Colors.redAccent),
|
||||
),
|
||||
confirmDismiss: (_) => _confirmDeleteGroup(context, g),
|
||||
onDismissed: (_) => _deleteGroup(g['id'].toString()),
|
||||
child: GroupCard(group: g),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Подтверждение удаления группы свайпом
|
||||
Future<bool> _confirmDeleteGroup(
|
||||
BuildContext context, Map<String, dynamic> g) async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Удалить группу?'),
|
||||
content: Text('Удалить "${g['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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
Future<void> _deleteGroup(String id) async {
|
||||
try {
|
||||
await ref.read(apiProvider).deleteGroup(id);
|
||||
await ref.read(groupsProvider.notifier).refresh();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Ошибка удаления: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
358
lib/screens/schedules_screen.dart
Normal file
358
lib/screens/schedules_screen.dart
Normal file
@@ -0,0 +1,358 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/providers.dart';
|
||||
|
||||
/// Экран управления расписаниями.
|
||||
/// Показывает все задачи (one-shot и cron), позволяет создавать и удалять.
|
||||
class SchedulesScreen extends ConsumerStatefulWidget {
|
||||
const SchedulesScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SchedulesScreen> createState() => _SchedulesScreenState();
|
||||
}
|
||||
|
||||
class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
await ref.read(tasksProvider.notifier).load();
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tasks = ref.watch(tasksProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('РАСПИСАНИЯ'),
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator(color: Colors.deepOrange))
|
||||
: tasks.isEmpty
|
||||
? const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.schedule, size: 64, color: Colors.white24),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Нет активных расписаний',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
color: Colors.deepOrange,
|
||||
onRefresh: () => ref.read(tasksProvider.notifier).load(),
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: tasks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final task = tasks[index];
|
||||
return _TaskCard(task: task);
|
||||
},
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
backgroundColor: Colors.deepOrange,
|
||||
onPressed: () => _showAddDialog(context),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Диалог создания расписания
|
||||
void _showAddDialog(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: const Color(0xFF1E1E1E),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => _AddScheduleSheet(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Карточка одной задачи расписания
|
||||
class _TaskCard extends ConsumerWidget {
|
||||
final dynamic task;
|
||||
|
||||
const _TaskCard({required this.task});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final map = task is Map ? Map<String, dynamic>.from(task) : <String, dynamic>{};
|
||||
final jobId = (map['id'] ?? map['job_id'] ?? '').toString();
|
||||
final targetId = (map['target_id'] ?? map['target'] ?? '').toString();
|
||||
final state = map['state'];
|
||||
final runAt = map['run_at'] ?? map['next_run'] ?? map['next_run_time'];
|
||||
final type = map['type'] ?? (map['cron'] != null ? 'cron' : 'once');
|
||||
|
||||
// Формирование описания
|
||||
final stateStr = state == true
|
||||
? 'Включить'
|
||||
: state == false
|
||||
? 'Выключить'
|
||||
: '?';
|
||||
|
||||
String subtitle = 'Цель: $targetId';
|
||||
if (runAt != null) subtitle += '\nЗапуск: $runAt';
|
||||
if (map['cron'] != null) subtitle += '\nCron: ${map['cron']}';
|
||||
if (map['hour'] != null && map['minute'] != null) {
|
||||
subtitle += '\nВремя: ${map['hour']}:${map['minute']}';
|
||||
}
|
||||
if (map['day_of_week'] != null && map['day_of_week'] != '*') {
|
||||
subtitle += ' (дни: ${map['day_of_week']})';
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
type == 'cron' ? Icons.repeat : Icons.timer,
|
||||
color: Colors.deepOrange,
|
||||
),
|
||||
title: Text(
|
||||
'$stateStr - $targetId',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.white54),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
|
||||
onPressed: () => _confirmCancel(context, ref, jobId),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmCancel(BuildContext context, WidgetRef ref, String jobId) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Отменить задачу?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Нет'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
ref.read(tasksProvider.notifier).cancel(jobId);
|
||||
},
|
||||
child: const Text('Да', style: TextStyle(color: Colors.redAccent)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Нижний лист для создания расписания
|
||||
class _AddScheduleSheet extends ConsumerStatefulWidget {
|
||||
@override
|
||||
ConsumerState<_AddScheduleSheet> createState() => _AddScheduleSheetState();
|
||||
}
|
||||
|
||||
class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> {
|
||||
/// Тип: 'once' или 'cron'
|
||||
String _type = 'once';
|
||||
|
||||
/// Общие поля
|
||||
String? _selectedGroupId;
|
||||
bool _targetState = false; // включить или выключить
|
||||
|
||||
/// Поля для once
|
||||
int _hoursFromNow = 4;
|
||||
|
||||
/// Поля для cron
|
||||
final _hourCtrl = TextEditingController(text: '22');
|
||||
final _minuteCtrl = TextEditingController(text: '00');
|
||||
final _dowCtrl = TextEditingController(text: '*');
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hourCtrl.dispose();
|
||||
_minuteCtrl.dispose();
|
||||
_dowCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final groups = ref.watch(groupsProvider);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 20,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Заголовок
|
||||
const Text(
|
||||
'Новое расписание',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Тип расписания
|
||||
Row(
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('Таймер'),
|
||||
selected: _type == 'once',
|
||||
selectedColor: Colors.deepOrange,
|
||||
onSelected: (_) => setState(() => _type = 'once'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ChoiceChip(
|
||||
label: const Text('Cron (повтор)'),
|
||||
selected: _type == 'cron',
|
||||
selectedColor: Colors.deepOrange,
|
||||
onSelected: (_) => setState(() => _type = 'cron'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Выбор группы
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedGroupId,
|
||||
decoration: const InputDecoration(labelText: 'Группа'),
|
||||
items: groups.map((g) {
|
||||
final id = g['id'].toString();
|
||||
final name = g['name']?.toString() ?? id;
|
||||
return DropdownMenuItem(value: id, child: Text(name));
|
||||
}).toList(),
|
||||
onChanged: (v) => setState(() => _selectedGroupId = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Действие: включить / выключить
|
||||
SwitchListTile(
|
||||
title: Text(_targetState ? 'Включить' : 'Выключить'),
|
||||
subtitle: const Text('Действие при срабатывании'),
|
||||
value: _targetState,
|
||||
activeColor: Colors.deepOrange,
|
||||
onChanged: (v) => setState(() => _targetState = v),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ─── Once: через сколько часов ───
|
||||
if (_type == 'once') ...[
|
||||
Text(
|
||||
'Через $_hoursFromNow ч.',
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
Slider(
|
||||
value: _hoursFromNow.toDouble(),
|
||||
min: 1,
|
||||
max: 24,
|
||||
divisions: 23,
|
||||
label: '$_hoursFromNow ч.',
|
||||
activeColor: Colors.deepOrange,
|
||||
onChanged: (v) => setState(() => _hoursFromNow = v.toInt()),
|
||||
),
|
||||
],
|
||||
|
||||
// ─── Cron: час, минута, дни недели ───
|
||||
if (_type == 'cron') ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _hourCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Час (0-23)'),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _minuteCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Минута (0-59)'),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _dowCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Дни недели (* = каждый, 0-6 = вс-сб)',
|
||||
helperText: 'Например: 1-5 (пн-пт), 0,6 (выходные)',
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Кнопка создания
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.deepOrange,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: _selectedGroupId == null ? null : _save,
|
||||
child: const Text('СОЗДАТЬ'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_selectedGroupId == null) return;
|
||||
|
||||
try {
|
||||
if (_type == 'once') {
|
||||
await ref.read(tasksProvider.notifier).addOnce(
|
||||
targetId: _selectedGroupId!,
|
||||
targetState: _targetState,
|
||||
hoursFromNow: _hoursFromNow,
|
||||
);
|
||||
} else {
|
||||
await ref.read(tasksProvider.notifier).addCron(
|
||||
targetId: _selectedGroupId!,
|
||||
hour: _hourCtrl.text.trim(),
|
||||
minute: _minuteCtrl.text.trim(),
|
||||
dayOfWeek: _dowCtrl.text.trim(),
|
||||
targetState: _targetState,
|
||||
);
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Ошибка: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
85
lib/services/api_client.dart
Normal file
85
lib/services/api_client.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// HTTP-клиент для одного сервера Ignis.
|
||||
/// Покрывает все эндпоинты из openapi.json.
|
||||
class IgnisApi {
|
||||
final Dio _dio = Dio();
|
||||
Dio get dioInstance => _dio;
|
||||
|
||||
/// Инициализация базового URL и API-ключа
|
||||
void init(String baseUrl, String apiKey) {
|
||||
String url = baseUrl.trim();
|
||||
if (!url.startsWith('http')) {
|
||||
url = 'https://$url';
|
||||
}
|
||||
// Убираем trailing slash
|
||||
if (url.endsWith('/')) url = url.substring(0, url.length - 1);
|
||||
|
||||
_dio.options.baseUrl = url;
|
||||
_dio.options.headers['X-API-Key'] = apiKey;
|
||||
// Бэкенд WiZ ламп тормозит -- даём запас
|
||||
_dio.options.connectTimeout = const Duration(seconds: 15);
|
||||
_dio.options.receiveTimeout = const Duration(seconds: 15);
|
||||
}
|
||||
|
||||
// ─── Устройства и группы ───────────────────────────────────
|
||||
|
||||
/// Все устройства (лампы)
|
||||
Future<Response> getDevices() => _dio.get('/devices');
|
||||
|
||||
/// Все группы
|
||||
Future<Response> getGroups() => _dio.get('/devices/groups');
|
||||
|
||||
/// Все доступные сцены
|
||||
Future<Response> getScenes() => _dio.get('/devices/scenes');
|
||||
|
||||
/// Создать группу
|
||||
Future<Response> createGroup(String id, String name, List<String> macs) =>
|
||||
_dio.post('/devices/groups', data: {'id': id, 'name': name, 'macs': macs});
|
||||
|
||||
/// Удалить группу
|
||||
Future<Response> deleteGroup(String groupId) =>
|
||||
_dio.delete('/devices/groups/$groupId');
|
||||
|
||||
/// Пересканировать сеть (найти новые лампы)
|
||||
Future<Response> rescanNetwork() => _dio.post('/devices/rescan');
|
||||
|
||||
// ─── Управление ────────────────────────────────────────────
|
||||
|
||||
/// Управление группой: state, brightness, temp, scene, r/g/b
|
||||
Future<Response> controlGroup(String id, Map<String, dynamic> params) =>
|
||||
_dio.post('/control/group/$id', queryParameters: params);
|
||||
|
||||
/// Управление одной лампой
|
||||
Future<Response> controlDevice(String id, Map<String, dynamic> params) =>
|
||||
_dio.post('/control/device/$id', queryParameters: params);
|
||||
|
||||
/// Мигнуть лампой (для идентификации)
|
||||
Future<Response> blinkDevice(String id) =>
|
||||
_dio.post('/control/device/$id/blink');
|
||||
|
||||
/// Статус группы (реальный опрос ламп)
|
||||
Future<Response> getGroupStatus(String id) =>
|
||||
_dio.get('/control/group/$id/status');
|
||||
|
||||
/// Статус одной лампы
|
||||
Future<Response> getDeviceStatus(String id) =>
|
||||
_dio.get('/control/device/$id/status');
|
||||
|
||||
// ─── Расписания ────────────────────────────────────────────
|
||||
|
||||
/// Одноразовое расписание (таймер)
|
||||
Future<Response> scheduleOnce(Map<String, dynamic> params) =>
|
||||
_dio.post('/schedules/once', queryParameters: params);
|
||||
|
||||
/// Cron-расписание (повторяющееся)
|
||||
Future<Response> scheduleCron(Map<String, dynamic> params) =>
|
||||
_dio.post('/schedules/cron', queryParameters: params);
|
||||
|
||||
/// Все активные задачи расписания
|
||||
Future<Response> getTasks() => _dio.get('/schedules/tasks');
|
||||
|
||||
/// Отменить задачу расписания
|
||||
Future<Response> cancelTask(String jobId) =>
|
||||
_dio.delete('/schedules/$jobId');
|
||||
}
|
||||
78
lib/services/settings_service.dart
Normal file
78
lib/services/settings_service.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/home_config.dart';
|
||||
|
||||
/// Сервис для хранения списка "домов" и текущего выбранного.
|
||||
/// Данные лежат в SharedPreferences как JSON-массив.
|
||||
class SettingsService {
|
||||
static const String _homesKey = 'ignis_homes';
|
||||
static const String _currentHomeKey = 'ignis_current_home_id';
|
||||
|
||||
/// Загрузить все дома
|
||||
Future<List<HomeConfig>> getHomes() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_homesKey);
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
final list = jsonDecode(raw) as List<dynamic>;
|
||||
return list.map((e) => HomeConfig.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
/// Сохранить весь список домов
|
||||
Future<void> saveHomes(List<HomeConfig> homes) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_homesKey, jsonEncode(homes.map((h) => h.toJson()).toList()));
|
||||
}
|
||||
|
||||
/// Добавить или обновить дом
|
||||
Future<void> upsertHome(HomeConfig home) async {
|
||||
final homes = await getHomes();
|
||||
final idx = homes.indexWhere((h) => h.id == home.id);
|
||||
if (idx >= 0) {
|
||||
homes[idx] = home;
|
||||
} else {
|
||||
homes.add(home);
|
||||
}
|
||||
await saveHomes(homes);
|
||||
}
|
||||
|
||||
/// Удалить дом по id
|
||||
Future<void> deleteHome(String id) async {
|
||||
final homes = await getHomes();
|
||||
homes.removeWhere((h) => h.id == id);
|
||||
await saveHomes(homes);
|
||||
|
||||
// Если удалили текущий -- сбрасываем выбор
|
||||
final currentId = await getCurrentHomeId();
|
||||
if (currentId == id) {
|
||||
await setCurrentHomeId(homes.isNotEmpty ? homes.first.id : null);
|
||||
}
|
||||
}
|
||||
|
||||
/// Получить id текущего активного дома
|
||||
Future<String?> getCurrentHomeId() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_currentHomeKey);
|
||||
}
|
||||
|
||||
/// Установить текущий дом
|
||||
Future<void> setCurrentHomeId(String? id) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (id == null) {
|
||||
await prefs.remove(_currentHomeKey);
|
||||
} else {
|
||||
await prefs.setString(_currentHomeKey, id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Удобный метод: получить текущий HomeConfig или null
|
||||
Future<HomeConfig?> getCurrentHome() async {
|
||||
final homes = await getHomes();
|
||||
final id = await getCurrentHomeId();
|
||||
if (id == null && homes.isNotEmpty) return homes.first;
|
||||
try {
|
||||
return homes.firstWhere((h) => h.id == id);
|
||||
} catch (_) {
|
||||
return homes.isNotEmpty ? homes.first : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
127
lib/widgets/color_picker.dart
Normal file
127
lib/widgets/color_picker.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Простой цветовой пикер в виде кольца HSV.
|
||||
/// Возвращает RGB через callback.
|
||||
class SimpleColorPicker extends StatefulWidget {
|
||||
final Color initialColor;
|
||||
final ValueChanged<Color> onColorChanged;
|
||||
|
||||
const SimpleColorPicker({
|
||||
super.key,
|
||||
this.initialColor = Colors.red,
|
||||
required this.onColorChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SimpleColorPicker> createState() => _SimpleColorPickerState();
|
||||
}
|
||||
|
||||
class _SimpleColorPickerState extends State<SimpleColorPicker> {
|
||||
late double _hue;
|
||||
late double _saturation;
|
||||
late double _value;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final hsv = HSVColor.fromColor(widget.initialColor);
|
||||
_hue = hsv.hue;
|
||||
_saturation = hsv.saturation;
|
||||
_value = hsv.value;
|
||||
}
|
||||
|
||||
Color get _currentColor =>
|
||||
HSVColor.fromAHSV(1.0, _hue, _saturation, _value).toColor();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Превью текущего цвета
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: _currentColor,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white24, width: 2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Hue -- оттенок (0-360)
|
||||
_buildSliderRow(
|
||||
label: 'Оттенок',
|
||||
value: _hue,
|
||||
min: 0,
|
||||
max: 360,
|
||||
divisions: 36,
|
||||
activeColor: HSVColor.fromAHSV(1, _hue, 1, 1).toColor(),
|
||||
onChanged: (v) {
|
||||
setState(() => _hue = v);
|
||||
widget.onColorChanged(_currentColor);
|
||||
},
|
||||
),
|
||||
|
||||
// Saturation -- насыщенность (0-1)
|
||||
_buildSliderRow(
|
||||
label: 'Насыщ.',
|
||||
value: _saturation,
|
||||
min: 0,
|
||||
max: 1,
|
||||
divisions: 10,
|
||||
activeColor: _currentColor,
|
||||
onChanged: (v) {
|
||||
setState(() => _saturation = v);
|
||||
widget.onColorChanged(_currentColor);
|
||||
},
|
||||
),
|
||||
|
||||
// Value -- яркость (0-1)
|
||||
_buildSliderRow(
|
||||
label: 'Яркость',
|
||||
value: _value,
|
||||
min: 0,
|
||||
max: 1,
|
||||
divisions: 10,
|
||||
activeColor: _currentColor,
|
||||
onChanged: (v) {
|
||||
setState(() => _value = v);
|
||||
widget.onColorChanged(_currentColor);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSliderRow({
|
||||
required String label,
|
||||
required double value,
|
||||
required double min,
|
||||
required double max,
|
||||
required int divisions,
|
||||
required Color activeColor,
|
||||
required ValueChanged<double> onChanged,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 60,
|
||||
child: Text(label, style: const TextStyle(fontSize: 12, color: Colors.white54)),
|
||||
),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: value.clamp(min, max),
|
||||
min: min,
|
||||
max: max,
|
||||
divisions: divisions,
|
||||
activeColor: activeColor,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
321
lib/widgets/group_card.dart
Normal file
321
lib/widgets/group_card.dart
Normal file
@@ -0,0 +1,321 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/providers.dart';
|
||||
import 'color_picker.dart';
|
||||
|
||||
/// Карточка одной группы ламп с управлением:
|
||||
/// вкл/выкл, яркость, температура, цвет, сцена.
|
||||
class GroupCard extends ConsumerStatefulWidget {
|
||||
final Map<String, dynamic> group;
|
||||
|
||||
const GroupCard({super.key, required this.group});
|
||||
|
||||
@override
|
||||
ConsumerState<GroupCard> createState() => _GroupCardState();
|
||||
}
|
||||
|
||||
class _GroupCardState extends ConsumerState<GroupCard> {
|
||||
/// Текущий режим управления: temp (температура) или color (RGB)
|
||||
String _mode = 'temp';
|
||||
bool _showColorPicker = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final g = widget.group;
|
||||
final id = g['id'].toString();
|
||||
final name = g['name'] ?? 'Без имени';
|
||||
final bool isOn = g['last_state']?['state'] ?? false;
|
||||
final int bri = g['last_state']?['brightness'] ?? 100;
|
||||
final int temp = g['last_state']?['temp'] ?? 4000;
|
||||
final int r = g['last_state']?['r'] ?? 255;
|
||||
final int gVal = g['last_state']?['g'] ?? 200;
|
||||
final int b = g['last_state']?['b'] ?? 100;
|
||||
|
||||
// Цвет подсветки карточки зависит от режима и состояния
|
||||
final cardAccent = isOn
|
||||
? (_mode == 'temp'
|
||||
? Color.lerp(Colors.orange, Colors.blueAccent, (temp - 2700) / 3800)!
|
||||
: Color.fromARGB(255, r, gVal, b))
|
||||
: Colors.white12;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: isOn
|
||||
? Border.all(color: cardAccent.withOpacity(0.3), width: 1)
|
||||
: null,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ─── Заголовок + переключатель ───
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isOn ? Colors.white : Colors.white54,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Кнопка "таймер на 4 часа"
|
||||
if (isOn)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.timer, size: 20, color: Colors.white38),
|
||||
tooltip: 'Включить на 4 часа',
|
||||
onPressed: () => ref.read(groupsProvider.notifier).setTimer4h(id),
|
||||
),
|
||||
Switch(
|
||||
value: isOn,
|
||||
activeColor: Colors.deepOrange,
|
||||
onChanged: (v) =>
|
||||
ref.read(groupsProvider.notifier).toggleGroup(id, v),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// ─── Управление (когда включено) ───
|
||||
if (isOn) ...[
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Яркость
|
||||
_SliderRow(
|
||||
icon: Icons.sunny,
|
||||
value: bri.toDouble().clamp(10, 100),
|
||||
min: 10,
|
||||
max: 100,
|
||||
divisions: 9,
|
||||
label: "$bri%",
|
||||
activeColor: Colors.amber,
|
||||
onChanged: (v) => ref
|
||||
.read(groupsProvider.notifier)
|
||||
.setBrightness(id, v.toInt()),
|
||||
),
|
||||
|
||||
// Переключатель режима: температура / цвет / сцена
|
||||
Row(
|
||||
children: [
|
||||
_ModeChip(
|
||||
label: 'Темп.',
|
||||
icon: Icons.wb_twilight,
|
||||
selected: _mode == 'temp',
|
||||
onTap: () => setState(() {
|
||||
_mode = 'temp';
|
||||
_showColorPicker = false;
|
||||
}),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ModeChip(
|
||||
label: 'Цвет',
|
||||
icon: Icons.palette,
|
||||
selected: _mode == 'color',
|
||||
onTap: () => setState(() {
|
||||
_mode = 'color';
|
||||
_showColorPicker = true;
|
||||
}),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ModeChip(
|
||||
label: 'Сцена',
|
||||
icon: Icons.auto_awesome,
|
||||
selected: _mode == 'scene',
|
||||
onTap: () => setState(() {
|
||||
_mode = 'scene';
|
||||
_showColorPicker = false;
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// ─── Режим: температура ───
|
||||
if (_mode == 'temp')
|
||||
_SliderRow(
|
||||
icon: Icons.wb_twilight,
|
||||
value: temp.toDouble().clamp(2700, 6500),
|
||||
min: 2700,
|
||||
max: 6500,
|
||||
divisions: 38, // шаг 100K
|
||||
label: "${temp}K",
|
||||
activeColor: Color.lerp(
|
||||
Colors.orange, Colors.blueAccent, (temp - 2700) / 3800),
|
||||
onChanged: (v) => ref
|
||||
.read(groupsProvider.notifier)
|
||||
.setTemperature(id, v.toInt()),
|
||||
),
|
||||
|
||||
// ─── Режим: цвет ───
|
||||
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),
|
||||
),
|
||||
|
||||
// ─── Режим: сцена ───
|
||||
if (_mode == 'scene') _SceneSelector(groupId: id),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Слайдер с иконкой и подписью
|
||||
class _SliderRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final double value;
|
||||
final double min;
|
||||
final double max;
|
||||
final int divisions;
|
||||
final String label;
|
||||
final Color? activeColor;
|
||||
final ValueChanged<double> onChanged;
|
||||
|
||||
const _SliderRow({
|
||||
required this.icon,
|
||||
required this.value,
|
||||
required this.min,
|
||||
required this.max,
|
||||
required this.divisions,
|
||||
required this.label,
|
||||
this.activeColor,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: Colors.white54),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: value,
|
||||
min: min,
|
||||
max: max,
|
||||
divisions: divisions,
|
||||
label: label,
|
||||
activeColor: activeColor,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 50,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.white54),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Чип переключения режима
|
||||
class _ModeChip extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ModeChip({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? Colors.deepOrange.withOpacity(0.2) : Colors.white10,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: selected
|
||||
? Border.all(color: Colors.deepOrange, width: 1)
|
||||
: Border.all(color: Colors.transparent),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: selected ? Colors.deepOrange : Colors.white54),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: selected ? Colors.deepOrange : Colors.white54,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Выбор сцены из списка, загруженного с сервера
|
||||
class _SceneSelector extends ConsumerWidget {
|
||||
final String groupId;
|
||||
|
||||
const _SceneSelector({required this.groupId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scenes = ref.watch(scenesProvider);
|
||||
|
||||
if (scenes.isEmpty) {
|
||||
// Загрузить сцены при первом показе
|
||||
Future.microtask(() => ref.read(scenesProvider.notifier).load());
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
return ActionChip(
|
||||
label: Text(sceneName.toString(), style: const TextStyle(fontSize: 12)),
|
||||
backgroundColor: Colors.white10,
|
||||
onPressed: () => ref
|
||||
.read(groupsProvider.notifier)
|
||||
.setScene(groupId, sceneId.toString()),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user