Waaaay big enchancements
This commit is contained in:
334
lib/screens/api_keys_screen.dart
Normal file
334
lib/screens/api_keys_screen.dart
Normal file
@@ -0,0 +1,334 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/providers.dart';
|
||||
|
||||
/// Экран управления гостевыми API-ключами.
|
||||
/// Доступен только администраторам.
|
||||
class ApiKeysScreen extends ConsumerStatefulWidget {
|
||||
const ApiKeysScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ApiKeysScreen> createState() => _ApiKeysScreenState();
|
||||
}
|
||||
|
||||
class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
|
||||
bool _loading = true;
|
||||
String? _lastCreatedKey;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
await ref.read(apiKeysProvider.notifier).load();
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final keys = ref.watch(apiKeysProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('API-КЛЮЧИ'),
|
||||
),
|
||||
body: _loading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(color: Colors.deepOrange),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
// ─── Последний созданный ключ (для копирования) ───
|
||||
if (_lastCreatedKey != null)
|
||||
Container(
|
||||
margin: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.deepOrange.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.deepOrange.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Новый ключ создан! Скопируйте его сейчас:',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.deepOrange,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_lastCreatedKey!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
color: Colors.white70,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 20),
|
||||
onPressed: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: _lastCreatedKey!));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Ключ скопирован'),
|
||||
duration: Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ─── Список ключей ───
|
||||
Expanded(
|
||||
child: keys.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'Нет гостевых ключей',
|
||||
style: TextStyle(color: Colors.white54),
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
color: Colors.deepOrange,
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: keys.length,
|
||||
itemBuilder: (context, index) {
|
||||
final k = keys[index];
|
||||
final map = k is Map
|
||||
? Map<String, dynamic>.from(k)
|
||||
: <String, dynamic>{};
|
||||
return _ApiKeyCard(
|
||||
data: map,
|
||||
onRevoke: () => _revokeKey(map),
|
||||
onActivate: () => _activateKey(map),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
backgroundColor: Colors.deepOrange,
|
||||
onPressed: () => _showCreateDialog(context),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showCreateDialog(BuildContext context) {
|
||||
final nameCtrl = TextEditingController();
|
||||
bool isAdmin = false;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
title: const Text('Новый API-ключ'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Имя ключа',
|
||||
hintText: 'Например: "Гость"',
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
title: const Text('Администратор'),
|
||||
value: isAdmin,
|
||||
activeColor: Colors.deepOrange,
|
||||
onChanged: (v) => setDialogState(() => isAdmin = v),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.deepOrange,
|
||||
),
|
||||
onPressed: () async {
|
||||
final name = nameCtrl.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
Navigator.of(ctx).pop();
|
||||
final key = await ref
|
||||
.read(apiKeysProvider.notifier)
|
||||
.create(name, isAdmin: isAdmin);
|
||||
if (key != null && mounted) {
|
||||
setState(() => _lastCreatedKey = key);
|
||||
}
|
||||
},
|
||||
child: const Text('Создать'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _revokeKey(Map<String, dynamic> data) async {
|
||||
final key = (data['key'] ?? data['token'] ?? '').toString();
|
||||
final name = (data['name'] ?? '').toString();
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Отозвать ключ?'),
|
||||
content: Text('Отозвать "$name"?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child:
|
||||
const Text('Отозвать', style: TextStyle(color: Colors.redAccent)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await ref.read(apiKeysProvider.notifier).revoke(key);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _activateKey(Map<String, dynamic> data) async {
|
||||
final key = (data['key'] ?? data['token'] ?? '').toString();
|
||||
await ref.read(apiKeysProvider.notifier).activate(key);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Ключ активирован'),
|
||||
duration: Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Карточка одного API-ключа
|
||||
class _ApiKeyCard extends StatelessWidget {
|
||||
final Map<String, dynamic> data;
|
||||
final VoidCallback onRevoke;
|
||||
final VoidCallback onActivate;
|
||||
|
||||
const _ApiKeyCard({
|
||||
required this.data,
|
||||
required this.onRevoke,
|
||||
required this.onActivate,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final name = (data['name'] ?? 'Без имени').toString();
|
||||
final isAdmin = data['is_admin'] == true;
|
||||
final isActive = data['is_active'] ?? data['active'] ?? true;
|
||||
final createdAt = data['created_at'] ?? '';
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
Icons.vpn_key,
|
||||
color: isActive
|
||||
? (isAdmin ? Colors.amber : Colors.deepOrange)
|
||||
: Colors.white24,
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isActive ? Colors.white : Colors.white38,
|
||||
),
|
||||
),
|
||||
if (isAdmin) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'admin',
|
||||
style: TextStyle(fontSize: 10, color: Colors.amber),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (!isActive) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.redAccent.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'отозван',
|
||||
style: TextStyle(fontSize: 10, color: Colors.redAccent),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
subtitle: createdAt.toString().isNotEmpty
|
||||
? Text(
|
||||
'Создан: ${_formatDate(createdAt.toString())}',
|
||||
style: const TextStyle(fontSize: 11, color: Colors.white30),
|
||||
)
|
||||
: null,
|
||||
trailing: isActive
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.block, size: 20, color: Colors.redAccent),
|
||||
tooltip: 'Отозвать',
|
||||
onPressed: onRevoke,
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.check_circle_outline,
|
||||
size: 20, color: Colors.green),
|
||||
tooltip: 'Активировать',
|
||||
onPressed: onActivate,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(String iso) {
|
||||
try {
|
||||
final d = DateTime.parse(iso);
|
||||
final pad = (int n) => n.toString().padLeft(2, '0');
|
||||
return '${pad(d.day)}.${pad(d.month)}.${d.year}';
|
||||
} catch (_) {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
}
|
||||
165
lib/screens/event_log_screen.dart
Normal file
165
lib/screens/event_log_screen.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/providers.dart';
|
||||
|
||||
/// Экран просмотра лога событий.
|
||||
class EventLogScreen extends ConsumerStatefulWidget {
|
||||
const EventLogScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<EventLogScreen> createState() => _EventLogScreenState();
|
||||
}
|
||||
|
||||
class _EventLogScreenState extends ConsumerState<EventLogScreen> {
|
||||
bool _loading = true;
|
||||
int _limit = 100;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _loading = true);
|
||||
await ref.read(eventLogProvider.notifier).load(limit: _limit);
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final events = ref.watch(eventLogProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('ЛОГ СОБЫТИЙ'),
|
||||
actions: [
|
||||
PopupMenuButton<int>(
|
||||
icon: const Icon(Icons.filter_list),
|
||||
tooltip: 'Количество записей',
|
||||
onSelected: (v) {
|
||||
_limit = v;
|
||||
_load();
|
||||
},
|
||||
itemBuilder: (_) => [50, 100, 200, 500]
|
||||
.map((n) => PopupMenuItem(value: n, child: Text('$n записей')))
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(color: Colors.deepOrange),
|
||||
)
|
||||
: events.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'Нет событий',
|
||||
style: TextStyle(color: Colors.white54),
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
color: Colors.deepOrange,
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: events.length,
|
||||
itemBuilder: (context, index) {
|
||||
final event = events[index];
|
||||
return _EventRow(
|
||||
event: event is Map
|
||||
? Map<String, dynamic>.from(event)
|
||||
: {},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EventRow extends StatelessWidget {
|
||||
final Map<String, dynamic> event;
|
||||
|
||||
const _EventRow({required this.event});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final timestamp = event['timestamp'] ?? event['time'] ?? event['created_at'] ?? '';
|
||||
final action = event['action'] ?? event['command'] ?? event['type'] ?? '';
|
||||
final targetId = event['target_id'] ?? event['target'] ?? event['group_id'] ?? '';
|
||||
final params = event['params'] ?? event['details'] ?? '';
|
||||
final actor = event['actor'] ?? event['user'] ?? event['key_name'] ?? '';
|
||||
|
||||
final formattedTime = _formatTime(timestamp.toString());
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Время
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
formattedTime,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.white38,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Контент
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'$action - $targetId',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (params.toString().isNotEmpty)
|
||||
Text(
|
||||
params.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.white38,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (actor.toString().isNotEmpty)
|
||||
Text(
|
||||
actor.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white24,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(String iso) {
|
||||
if (iso.isEmpty) return '';
|
||||
try {
|
||||
final d = DateTime.parse(iso);
|
||||
final pad = (int n) => n.toString().padLeft(2, '0');
|
||||
return '${pad(d.day)}.${pad(d.month)} ${pad(d.hour)}:${pad(d.minute)}:${pad(d.second)}';
|
||||
} catch (_) {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,7 +164,7 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
||||
activeColor: Colors.deepOrange,
|
||||
title: Text(name),
|
||||
subtitle: Text(
|
||||
'${mac}${ip != null ? ' - $ip' : ''}',
|
||||
'$mac${ip != null ? ' - $ip' : ''}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: Colors.white38),
|
||||
),
|
||||
@@ -183,23 +183,28 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
||||
),
|
||||
|
||||
// Кнопка сохранения
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.deepOrange,
|
||||
foregroundColor: Colors.white,
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.deepOrange,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Text('СОЗДАТЬ ГРУППУ'),
|
||||
),
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Text('СОЗДАТЬ ГРУППУ'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -17,6 +17,8 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _urlCtrl = TextEditingController();
|
||||
final _keyCtrl = TextEditingController();
|
||||
final _latCtrl = TextEditingController();
|
||||
final _lonCtrl = TextEditingController();
|
||||
bool _saving = false;
|
||||
|
||||
bool get _isEdit => widget.home != null;
|
||||
@@ -28,6 +30,12 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
_nameCtrl.text = widget.home!.name;
|
||||
_urlCtrl.text = widget.home!.url;
|
||||
_keyCtrl.text = widget.home!.apiKey;
|
||||
if (widget.home!.latitude != null) {
|
||||
_latCtrl.text = widget.home!.latitude.toString();
|
||||
}
|
||||
if (widget.home!.longitude != null) {
|
||||
_lonCtrl.text = widget.home!.longitude.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +44,8 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
_nameCtrl.dispose();
|
||||
_urlCtrl.dispose();
|
||||
_keyCtrl.dispose();
|
||||
_latCtrl.dispose();
|
||||
_lonCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -45,9 +55,10 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
appBar: AppBar(
|
||||
title: Text(_isEdit ? 'РЕДАКТИРОВАТЬ ДОМ' : 'НОВЫЙ ДОМ'),
|
||||
),
|
||||
body: Padding(
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
@@ -75,6 +86,54 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
),
|
||||
obscureText: true,
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ─── GPS-координаты (опционально) ───
|
||||
const Text(
|
||||
'Координаты дома (опционально)',
|
||||
style: TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Для автоматизации по геолокации',
|
||||
style: TextStyle(color: Colors.white30, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _latCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Широта',
|
||||
prefixIcon: Icon(Icons.location_on, size: 20),
|
||||
hintText: '51.128',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true, signed: true),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _lonCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Долгота',
|
||||
prefixIcon: Icon(Icons.location_on, size: 20),
|
||||
hintText: '71.430',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true, signed: true),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -97,6 +156,8 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
: Text(_isEdit ? 'СОХРАНИТЬ' : 'ДОБАВИТЬ'),
|
||||
),
|
||||
),
|
||||
// Отступ внизу для системных кнопок
|
||||
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -107,23 +168,47 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
final name = _nameCtrl.text.trim();
|
||||
final url = _urlCtrl.text.trim();
|
||||
final key = _keyCtrl.text.trim();
|
||||
final latText = _latCtrl.text.trim();
|
||||
final lonText = _lonCtrl.text.trim();
|
||||
|
||||
if (name.isEmpty || url.isEmpty || key.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Заполните все поля')),
|
||||
const SnackBar(content: Text('Заполните все обязательные поля')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
double? lat;
|
||||
double? lon;
|
||||
if (latText.isNotEmpty && lonText.isNotEmpty) {
|
||||
lat = double.tryParse(latText);
|
||||
lon = double.tryParse(lonText);
|
||||
if (lat == null || lon == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Некорректные координаты')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _saving = true);
|
||||
|
||||
final home = _isEdit
|
||||
? widget.home!.copyWith(name: name, url: url, apiKey: key)
|
||||
? widget.home!.copyWith(
|
||||
name: name,
|
||||
url: url,
|
||||
apiKey: key,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
clearCoordinates: latText.isEmpty && lonText.isEmpty,
|
||||
)
|
||||
: HomeConfig(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
name: name,
|
||||
url: url,
|
||||
apiKey: key,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
);
|
||||
|
||||
if (_isEdit) {
|
||||
|
||||
@@ -61,9 +61,25 @@ class HomesScreen extends ConsumerWidget {
|
||||
color: isActive ? Colors.deepOrange : Colors.white,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
home.url,
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 12),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
home.url,
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 12),
|
||||
),
|
||||
if (home.hasCoordinates)
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.location_on, size: 12, color: Colors.white24),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Координаты заданы',
|
||||
style: const TextStyle(color: Colors.white24, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -96,6 +112,7 @@ class HomesScreen extends ConsumerWidget {
|
||||
/// Выбрать дом и перейти на пульт
|
||||
void _selectHome(BuildContext context, WidgetRef ref, HomeConfig home) async {
|
||||
await ref.read(currentHomeProvider.notifier).switchTo(home);
|
||||
await ref.read(authInfoProvider.notifier).load();
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const RemoteScreen()),
|
||||
|
||||
@@ -5,6 +5,9 @@ import '../widgets/group_card.dart';
|
||||
import 'homes_screen.dart';
|
||||
import 'group_edit_screen.dart';
|
||||
import 'schedules_screen.dart';
|
||||
import 'stats_screen.dart';
|
||||
import 'event_log_screen.dart';
|
||||
import 'api_keys_screen.dart';
|
||||
|
||||
/// Основной экран пульта управления.
|
||||
/// Показывает группы текущего дома с управлением.
|
||||
@@ -26,6 +29,7 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
await ref.read(groupsProvider.notifier).initAndRefresh();
|
||||
await ref.read(authInfoProvider.notifier).load();
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
|
||||
@@ -33,6 +37,8 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final groups = ref.watch(groupsProvider);
|
||||
final currentHome = ref.watch(currentHomeProvider);
|
||||
final authInfo = ref.watch(authInfoProvider);
|
||||
final isAdmin = authInfo?['is_admin'] == true;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -53,13 +59,68 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
||||
MaterialPageRoute(builder: (_) => const GroupEditScreen()),
|
||||
),
|
||||
),
|
||||
// Расписания
|
||||
IconButton(
|
||||
icon: const Icon(Icons.schedule),
|
||||
tooltip: 'Расписания',
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SchedulesScreen()),
|
||||
),
|
||||
// Меню
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'schedules':
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SchedulesScreen()),
|
||||
);
|
||||
break;
|
||||
case 'stats':
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const StatsScreen()),
|
||||
);
|
||||
break;
|
||||
case 'log':
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const EventLogScreen()),
|
||||
);
|
||||
break;
|
||||
case 'api_keys':
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const ApiKeysScreen()),
|
||||
);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'schedules',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.schedule),
|
||||
title: Text('Расписания'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'stats',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.bar_chart),
|
||||
title: Text('Статистика'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'log',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.list_alt),
|
||||
title: Text('Лог событий'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
if (isAdmin)
|
||||
const PopupMenuItem(
|
||||
value: 'api_keys',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.vpn_key),
|
||||
title: Text('API-ключи'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -78,7 +78,7 @@ class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => _AddScheduleSheet(),
|
||||
builder: (ctx) => const _AddScheduleSheet(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,8 @@ class _TaskCard extends ConsumerWidget {
|
||||
|
||||
/// Нижний лист для создания расписания
|
||||
class _AddScheduleSheet extends ConsumerStatefulWidget {
|
||||
const _AddScheduleSheet();
|
||||
|
||||
@override
|
||||
ConsumerState<_AddScheduleSheet> createState() => _AddScheduleSheetState();
|
||||
}
|
||||
@@ -194,13 +196,15 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final groups = ref.watch(groupsProvider);
|
||||
final bottomPadding = MediaQuery.of(context).viewInsets.bottom;
|
||||
final systemPadding = MediaQuery.of(context).padding.bottom;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 20,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
||||
bottom: bottomPadding + systemPadding + 20,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
|
||||
208
lib/screens/stats_screen.dart
Normal file
208
lib/screens/stats_screen.dart
Normal file
@@ -0,0 +1,208 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/providers.dart';
|
||||
|
||||
/// Экран просмотра статистики.
|
||||
/// Показывает сводку по группам за выбранный период.
|
||||
class StatsScreen extends ConsumerStatefulWidget {
|
||||
const StatsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<StatsScreen> createState() => _StatsScreenState();
|
||||
}
|
||||
|
||||
class _StatsScreenState extends ConsumerState<StatsScreen> {
|
||||
bool _loading = true;
|
||||
int _days = 7;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _loading = true);
|
||||
await ref.read(statsProvider.notifier).load(days: _days);
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final stats = ref.watch(statsProvider);
|
||||
final groups = (stats['groups'] as List<dynamic>?) ?? [];
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('СТАТИСТИКА'),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// ─── Переключатель периода ───
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('Период:', style: TextStyle(color: Colors.white54)),
|
||||
const SizedBox(width: 12),
|
||||
...[1, 7, 14, 30].map(
|
||||
(d) => Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text('$d д.'),
|
||||
selected: _days == d,
|
||||
selectedColor: Colors.deepOrange,
|
||||
onSelected: (_) {
|
||||
setState(() => _days = d);
|
||||
_load();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ─── Содержимое ───
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(color: Colors.deepOrange),
|
||||
)
|
||||
: groups.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'Нет данных',
|
||||
style: TextStyle(color: Colors.white54),
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
color: Colors.deepOrange,
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: groups.length,
|
||||
itemBuilder: (context, index) {
|
||||
final g = groups[index];
|
||||
return _StatsCard(data: g is Map
|
||||
? Map<String, dynamic>.from(g)
|
||||
: {});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Карточка статистики одной группы
|
||||
class _StatsCard extends StatelessWidget {
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
const _StatsCard({required this.data});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final targetId = (data['target_id'] ?? data['group_id'] ?? data['id'] ?? '').toString();
|
||||
final name = (data['name'] ?? targetId).toString();
|
||||
final totalCommands = data['total_commands'] ?? 0;
|
||||
final togglesOn = data['toggles_on'] ?? 0;
|
||||
final togglesOff = data['toggles_off'] ?? 0;
|
||||
final estimatedHours = data['estimated_hours'];
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_StatRow(
|
||||
icon: Icons.touch_app,
|
||||
label: 'Всего команд',
|
||||
value: totalCommands.toString(),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_StatRow(
|
||||
icon: Icons.power_settings_new,
|
||||
label: 'Включений',
|
||||
value: togglesOn.toString(),
|
||||
color: Colors.green,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_StatRow(
|
||||
icon: Icons.power_off,
|
||||
label: 'Выключений',
|
||||
value: togglesOff.toString(),
|
||||
color: Colors.redAccent,
|
||||
),
|
||||
if (estimatedHours != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
_StatRow(
|
||||
icon: Icons.access_time,
|
||||
label: 'Примерное время работы',
|
||||
value: _formatHours(estimatedHours),
|
||||
color: Colors.amber,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatHours(dynamic hours) {
|
||||
final h = (hours is num) ? hours.toDouble() : 0.0;
|
||||
if (h < 1) return '${(h * 60).round()} мин';
|
||||
return '${h.toStringAsFixed(1)} ч';
|
||||
}
|
||||
}
|
||||
|
||||
/// Строка с иконкой, меткой и значением
|
||||
class _StatRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final Color? color;
|
||||
|
||||
const _StatRow({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color ?? Colors.white38),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 13, color: Colors.white54),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color ?? Colors.white70,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user