feat: secure home credentials
This commit is contained in:
@@ -32,9 +32,7 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
|
||||
final keys = ref.watch(apiKeysProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('API-КЛЮЧИ'),
|
||||
),
|
||||
appBar: AppBar(title: const Text('API-КЛЮЧИ')),
|
||||
body: _loading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(color: Colors.deepOrange),
|
||||
@@ -83,7 +81,8 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
|
||||
icon: const Icon(Icons.copy, size: 20),
|
||||
onPressed: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: _lastCreatedKey!));
|
||||
ClipboardData(text: _lastCreatedKey!),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Ключ скопирован'),
|
||||
@@ -210,8 +209,10 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child:
|
||||
const Text('Отозвать', style: TextStyle(color: Colors.redAccent)),
|
||||
child: const Text(
|
||||
'Отозвать',
|
||||
style: TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -310,13 +311,20 @@ class _ApiKeyCard extends StatelessWidget {
|
||||
: null,
|
||||
trailing: isActive
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.block, size: 20, color: Colors.redAccent),
|
||||
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),
|
||||
icon: const Icon(
|
||||
Icons.check_circle_outline,
|
||||
size: 20,
|
||||
color: Colors.green,
|
||||
),
|
||||
tooltip: 'Активировать',
|
||||
onPressed: onActivate,
|
||||
),
|
||||
|
||||
@@ -52,28 +52,26 @@ class _EventLogScreenState extends ConsumerState<EventLogScreen> {
|
||||
child: CircularProgressIndicator(color: Colors.deepOrange),
|
||||
)
|
||||
: events.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'Нет событий',
|
||||
style: TextStyle(color: Colors.white54),
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
color: Colors.deepOrange,
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: events.length,
|
||||
itemBuilder: (context, index) {
|
||||
final event = events[index];
|
||||
return _EventRow(
|
||||
event: event is Map
|
||||
? Map<String, dynamic>.from(event)
|
||||
: {},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
? const Center(
|
||||
child: Text(
|
||||
'Нет событий',
|
||||
style: TextStyle(color: Colors.white54),
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
color: Colors.deepOrange,
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: events.length,
|
||||
itemBuilder: (context, index) {
|
||||
final event = events[index];
|
||||
return _EventRow(
|
||||
event: event is Map ? Map<String, dynamic>.from(event) : {},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -85,9 +83,11 @@ class _EventRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final timestamp = event['timestamp'] ?? event['time'] ?? event['created_at'] ?? '';
|
||||
final timestamp =
|
||||
event['timestamp'] ?? event['time'] ?? event['created_at'] ?? '';
|
||||
final action = event['action'] ?? event['command'] ?? event['type'] ?? '';
|
||||
final targetId = event['target_id'] ?? event['target'] ?? event['group_id'] ?? '';
|
||||
final targetId =
|
||||
event['target_id'] ?? event['target'] ?? event['group_id'] ?? '';
|
||||
final params = event['params'] ?? event['details'] ?? '';
|
||||
final actor = event['actor'] ?? event['user'] ?? event['key_name'] ?? '';
|
||||
|
||||
|
||||
@@ -47,9 +47,9 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
||||
await ref.read(devicesProvider.notifier).load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Ошибка сканирования: $e')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Ошибка сканирования: $e')));
|
||||
}
|
||||
}
|
||||
if (mounted) setState(() => _rescanning = false);
|
||||
@@ -78,7 +78,9 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator(color: Colors.deepOrange))
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(color: Colors.deepOrange),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -166,7 +168,9 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
||||
subtitle: Text(
|
||||
'$mac${ip != null ? ' - $ip' : ''}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: Colors.white38),
|
||||
fontSize: 11,
|
||||
color: Colors.white38,
|
||||
),
|
||||
),
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
@@ -201,7 +205,9 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text('СОЗДАТЬ ГРУППУ'),
|
||||
),
|
||||
@@ -216,7 +222,8 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
||||
/// Извлечь MAC-адрес из объекта устройства
|
||||
String? _extractMac(dynamic device) {
|
||||
if (device is Map) {
|
||||
return (device['mac'] ?? device['id'] ?? device['mac_address'])?.toString();
|
||||
return (device['mac'] ?? device['id'] ?? device['mac_address'])
|
||||
?.toString();
|
||||
}
|
||||
return device?.toString();
|
||||
}
|
||||
@@ -243,9 +250,9 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
||||
final name = _nameCtrl.text.trim();
|
||||
|
||||
if (id.isEmpty || name.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Укажите ID и название')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Укажите ID и название')));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -265,9 +272,9 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Ошибка создания: $e')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Ошибка создания: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/home_config.dart';
|
||||
import '../providers/providers.dart';
|
||||
import '../services/api_client.dart';
|
||||
|
||||
/// Экран создания или редактирования "дома" (сервера Ignis).
|
||||
class HomeEditScreen extends ConsumerStatefulWidget {
|
||||
@@ -34,7 +35,6 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
if (_isEdit) {
|
||||
_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();
|
||||
}
|
||||
@@ -42,6 +42,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
_lonCtrl.text = widget.home!.longitude.toString();
|
||||
}
|
||||
_geofenceEnabled = widget.home!.geofenceEnabled;
|
||||
_loadApiKey();
|
||||
}
|
||||
|
||||
// Следим за полями координат чтобы обновлять доступность Switch
|
||||
@@ -49,6 +50,15 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
_lonCtrl.addListener(_onCoordsChanged);
|
||||
}
|
||||
|
||||
Future<void> _loadApiKey() async {
|
||||
final apiKey = await ref
|
||||
.read(settingsServiceProvider)
|
||||
.getHomeApiKey(widget.home!.id);
|
||||
if (mounted && apiKey != null) {
|
||||
_keyCtrl.text = apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
void _onCoordsChanged() {
|
||||
// Если координаты очистили -- выключаем геофенс
|
||||
if (!_hasCoordinates && _geofenceEnabled) {
|
||||
@@ -73,9 +83,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_isEdit ? 'РЕДАКТИРОВАТЬ ДОМ' : 'НОВЫЙ ДОМ'),
|
||||
),
|
||||
appBar: AppBar(title: Text(_isEdit ? 'РЕДАКТИРОВАТЬ ДОМ' : 'НОВЫЙ ДОМ')),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
@@ -136,7 +144,9 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
hintText: '51.128',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true, signed: true),
|
||||
decimal: true,
|
||||
signed: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -149,7 +159,9 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
hintText: '71.430',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true, signed: true),
|
||||
decimal: true,
|
||||
signed: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -224,20 +236,41 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
|
||||
Future<void> _save() async {
|
||||
final name = _nameCtrl.text.trim();
|
||||
final url = _urlCtrl.text.trim();
|
||||
final rawUrl = _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) {
|
||||
if (name.isEmpty || rawUrl.isEmpty || key.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Заполните все обязательные поля')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
late final String url;
|
||||
try {
|
||||
url = IgnisApi.normalizeBaseUrl(rawUrl);
|
||||
final parsed = Uri.parse(url);
|
||||
if ((parsed.scheme != 'http' && parsed.scheme != 'https') ||
|
||||
parsed.host.isEmpty) {
|
||||
throw const FormatException();
|
||||
}
|
||||
} catch (_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Некорректный адрес сервера')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
double? lat;
|
||||
double? lon;
|
||||
if (latText.isEmpty != lonText.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Введите обе координаты')));
|
||||
return;
|
||||
}
|
||||
if (latText.isNotEmpty && lonText.isNotEmpty) {
|
||||
lat = double.tryParse(latText);
|
||||
lon = double.tryParse(lonText);
|
||||
@@ -247,6 +280,12 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Координаты вне допустимого диапазона')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _saving = true);
|
||||
@@ -257,7 +296,6 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
? widget.home!.copyWith(
|
||||
name: name,
|
||||
url: url,
|
||||
apiKey: key,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
geofenceEnabled: clearCoords ? false : _geofenceEnabled,
|
||||
@@ -267,22 +305,38 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
name: name,
|
||||
url: url,
|
||||
apiKey: key,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
geofenceEnabled: _geofenceEnabled,
|
||||
);
|
||||
|
||||
if (_isEdit) {
|
||||
await ref.read(homesProvider.notifier).update(home);
|
||||
} else {
|
||||
await ref.read(homesProvider.notifier).add(home);
|
||||
try {
|
||||
await ref.read(apiProvider).validateCredentials(url, key);
|
||||
|
||||
if (_isEdit) {
|
||||
await ref.read(homesProvider.notifier).update(home, apiKey: key);
|
||||
} else {
|
||||
await ref.read(homesProvider.notifier).add(home, apiKey: key);
|
||||
}
|
||||
|
||||
final currentHome = ref.read(currentHomeProvider);
|
||||
if (currentHome?.id == home.id) {
|
||||
await ref.read(currentHomeProvider.notifier).switchTo(home);
|
||||
}
|
||||
|
||||
// Синхронизировать фоновый таск с новыми настройками
|
||||
final allHomes = ref.read(homesProvider);
|
||||
await syncGeofenceTask(allHomes);
|
||||
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Не удалось проверить дом: $e')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
|
||||
// Синхронизировать фоновый таск с новыми настройками
|
||||
final allHomes = ref.read(homesProvider);
|
||||
await syncGeofenceTask(allHomes);
|
||||
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,8 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
||||
|
||||
// Расстояние до дома (null если нет координат или геолокации)
|
||||
final distKm = location.distanceToKm(
|
||||
home.latitude, home.longitude,
|
||||
home.latitude,
|
||||
home.longitude,
|
||||
);
|
||||
|
||||
return Card(
|
||||
@@ -83,7 +84,9 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
||||
title: Text(
|
||||
home.name,
|
||||
style: TextStyle(
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
fontWeight: isActive
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
color: isActive ? Colors.deepOrange : Colors.white,
|
||||
),
|
||||
),
|
||||
@@ -92,14 +95,21 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
||||
children: [
|
||||
Text(
|
||||
home.url,
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 12),
|
||||
style: const TextStyle(
|
||||
color: Colors.white38,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (distKm != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.near_me, size: 11, color: Colors.white30),
|
||||
const Icon(
|
||||
Icons.near_me,
|
||||
size: 11,
|
||||
color: Colors.white30,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'~${formatDistance(distKm)}',
|
||||
@@ -115,11 +125,18 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
||||
// Координаты заданы, но геолокация недоступна
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.location_on, size: 12, color: Colors.white24),
|
||||
const Icon(
|
||||
Icons.location_on,
|
||||
size: 12,
|
||||
color: Colors.white24,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
location.error ?? 'Координаты заданы',
|
||||
style: const TextStyle(color: Colors.white24, fontSize: 11),
|
||||
style: const TextStyle(
|
||||
color: Colors.white24,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -130,12 +147,20 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
||||
children: [
|
||||
// Кнопка редактирования
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, size: 20, color: Colors.white38),
|
||||
icon: const Icon(
|
||||
Icons.edit,
|
||||
size: 20,
|
||||
color: Colors.white38,
|
||||
),
|
||||
onPressed: () => _editHome(context, home),
|
||||
),
|
||||
// Кнопка удаления
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, size: 20, color: Colors.redAccent),
|
||||
icon: const Icon(
|
||||
Icons.delete_outline,
|
||||
size: 20,
|
||||
color: Colors.redAccent,
|
||||
),
|
||||
onPressed: () => _confirmDelete(context, home),
|
||||
),
|
||||
],
|
||||
@@ -166,16 +191,16 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
||||
|
||||
/// Добавить новый дом
|
||||
void _addHome(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const HomeEditScreen()),
|
||||
);
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => const HomeEditScreen()));
|
||||
}
|
||||
|
||||
/// Редактировать дом
|
||||
void _editHome(BuildContext context, HomeConfig home) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => HomeEditScreen(home: home)),
|
||||
);
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => HomeEditScreen(home: home)));
|
||||
}
|
||||
|
||||
/// Подтвердить удаление
|
||||
@@ -197,7 +222,10 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
||||
// Синхронизировать фоновый таск (мог быть удалён дом с геофенсом)
|
||||
await syncGeofenceTask(ref.read(homesProvider));
|
||||
},
|
||||
child: const Text('Удалить', style: TextStyle(color: Colors.redAccent)),
|
||||
child: const Text(
|
||||
'Удалить',
|
||||
style: TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -55,9 +55,9 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
tooltip: 'Создать группу',
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const GroupEditScreen()),
|
||||
),
|
||||
onPressed: () => Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => const GroupEditScreen())),
|
||||
),
|
||||
// Меню
|
||||
PopupMenuButton<String>(
|
||||
@@ -139,64 +139,72 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
||||
),
|
||||
)
|
||||
: 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()),
|
||||
),
|
||||
),
|
||||
],
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lightbulb_outline,
|
||||
size: 64,
|
||||
color: Colors.white24,
|
||||
),
|
||||
)
|
||||
: 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.withValues(alpha: 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),
|
||||
);
|
||||
},
|
||||
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.withValues(alpha: 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 {
|
||||
BuildContext context,
|
||||
Map<String, dynamic> g,
|
||||
) async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
@@ -209,8 +217,10 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Удалить',
|
||||
style: TextStyle(color: Colors.redAccent)),
|
||||
child: const Text(
|
||||
'Удалить',
|
||||
style: TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -224,9 +234,9 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
||||
await ref.read(groupsProvider.notifier).refresh();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Ошибка удаления: $e')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Ошибка удаления: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,37 +30,37 @@ class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
|
||||
final tasks = ref.watch(tasksProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('РАСПИСАНИЯ'),
|
||||
),
|
||||
appBar: AppBar(title: const Text('РАСПИСАНИЯ')),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator(color: Colors.deepOrange))
|
||||
? 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),
|
||||
),
|
||||
],
|
||||
? 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);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: 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),
|
||||
@@ -91,7 +91,9 @@ class _TaskCard extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final map = task is Map ? Map<String, dynamic>.from(task) : <String, dynamic>{};
|
||||
final map = task is Map
|
||||
? Map<String, dynamic>.from(task)
|
||||
: <String, dynamic>{};
|
||||
final jobId = (map['id'] ?? map['job_id'] ?? '').toString();
|
||||
final targetId = (map['target_id'] ?? map['target'] ?? '').toString();
|
||||
final state = map['state'];
|
||||
@@ -102,8 +104,8 @@ class _TaskCard extends ConsumerWidget {
|
||||
final stateStr = state == true
|
||||
? 'Включить'
|
||||
: state == false
|
||||
? 'Выключить'
|
||||
: '?';
|
||||
? 'Выключить'
|
||||
: '?';
|
||||
|
||||
String subtitle = 'Цель: $targetId';
|
||||
if (runAt != null) subtitle += '\nЗапуск: $runAt';
|
||||
@@ -286,7 +288,9 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> {
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _hourCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Час (0-23)'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Час (0-23)',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
@@ -294,7 +298,9 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> {
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _minuteCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Минута (0-59)'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Минута (0-59)',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
@@ -336,13 +342,17 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> {
|
||||
|
||||
try {
|
||||
if (_type == 'once') {
|
||||
await ref.read(tasksProvider.notifier).addOnce(
|
||||
await ref
|
||||
.read(tasksProvider.notifier)
|
||||
.addOnce(
|
||||
targetId: _selectedGroupId!,
|
||||
targetState: _targetState,
|
||||
hoursFromNow: _hoursFromNow,
|
||||
);
|
||||
} else {
|
||||
await ref.read(tasksProvider.notifier).addCron(
|
||||
await ref
|
||||
.read(tasksProvider.notifier)
|
||||
.addCron(
|
||||
targetId: _selectedGroupId!,
|
||||
hour: _hourCtrl.text.trim(),
|
||||
minute: _minuteCtrl.text.trim(),
|
||||
@@ -353,9 +363,9 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> {
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Ошибка: $e')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Ошибка: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,9 +33,7 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
|
||||
final groups = (stats['groups'] as List<dynamic>?) ?? [];
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('СТАТИСТИКА'),
|
||||
),
|
||||
appBar: AppBar(title: const Text('СТАТИСТИКА')),
|
||||
body: Column(
|
||||
children: [
|
||||
// ─── Переключатель периода ───
|
||||
@@ -70,26 +68,26 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
|
||||
child: CircularProgressIndicator(color: Colors.deepOrange),
|
||||
)
|
||||
: groups.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'Нет данных',
|
||||
style: TextStyle(color: Colors.white54),
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
color: Colors.deepOrange,
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: groups.length,
|
||||
itemBuilder: (context, index) {
|
||||
final g = groups[index];
|
||||
return _StatsCard(data: g is Map
|
||||
? Map<String, dynamic>.from(g)
|
||||
: {});
|
||||
},
|
||||
),
|
||||
),
|
||||
? const Center(
|
||||
child: Text(
|
||||
'Нет данных',
|
||||
style: TextStyle(color: Colors.white54),
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
color: Colors.deepOrange,
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: groups.length,
|
||||
itemBuilder: (context, index) {
|
||||
final g = groups[index];
|
||||
return _StatsCard(
|
||||
data: g is Map ? Map<String, dynamic>.from(g) : {},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -105,7 +103,8 @@ class _StatsCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final targetId = (data['target_id'] ?? data['group_id'] ?? data['id'] ?? '').toString();
|
||||
final targetId = (data['target_id'] ?? data['group_id'] ?? data['id'] ?? '')
|
||||
.toString();
|
||||
final name = (data['name'] ?? targetId).toString();
|
||||
final totalCommands = data['total_commands'] ?? 0;
|
||||
final togglesOn = data['toggles_on'] ?? 0;
|
||||
@@ -121,10 +120,7 @@ class _StatsCard extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_StatRow(
|
||||
|
||||
Reference in New Issue
Block a user