feat: type schedule and auth models

This commit is contained in:
Artem Kokos
2026-04-23 20:57:15 +07:00
parent fa403bfcce
commit 0fdaf0bac4
11 changed files with 531 additions and 243 deletions

View File

@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/error_message.dart';
import '../app/load_state.dart';
import '../models/api_key_info.dart';
import '../providers/providers.dart';
import '../widgets/load_error_view.dart';
@@ -50,7 +51,10 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
);
}
Widget _buildContent(LoadState<List<dynamic>> keysState, List<dynamic> keys) {
Widget _buildContent(
LoadState<List<ApiKeyInfo>> keysState,
List<ApiKeyInfo> keys,
) {
if ((keysState.isIdle || keysState.isLoading) && keys.isEmpty) {
return const Center(
child: CircularProgressIndicator(color: Colors.deepOrange),
@@ -103,13 +107,10 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
final keyIndex = index - statusHeaderCount;
final key = keys[keyIndex];
final map = key is Map
? Map<String, dynamic>.from(key)
: <String, dynamic>{};
return _ApiKeyCard(
data: map,
onRevoke: () => _revokeKey(map),
onActivate: () => _activateKey(map),
data: key,
onRevoke: () => _revokeKey(key),
onActivate: () => _activateKey(key),
);
},
),
@@ -185,14 +186,12 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
);
}
Future<void> _revokeKey(Map<String, dynamic> data) async {
final key = (data['key'] ?? data['token'] ?? '').toString();
final name = (data['name'] ?? '').toString();
Future<void> _revokeKey(ApiKeyInfo data) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Отозвать ключ?'),
content: Text('Отозвать "$name"?'),
content: Text('Отозвать "${data.name}"?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
@@ -210,7 +209,7 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
);
if (confirmed == true) {
try {
await ref.read(apiKeysProvider.notifier).revoke(key);
await ref.read(apiKeysProvider.notifier).revoke(data.key);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
@@ -222,10 +221,9 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
}
}
Future<void> _activateKey(Map<String, dynamic> data) async {
final key = (data['key'] ?? data['token'] ?? '').toString();
Future<void> _activateKey(ApiKeyInfo data) async {
try {
await ref.read(apiKeysProvider.notifier).activate(key);
await ref.read(apiKeysProvider.notifier).activate(data.key);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@@ -308,7 +306,7 @@ class _LastCreatedKeyBanner extends StatelessWidget {
/// Карточка одного API-ключа
class _ApiKeyCard extends StatelessWidget {
final Map<String, dynamic> data;
final ApiKeyInfo data;
final VoidCallback onRevoke;
final VoidCallback onActivate;
@@ -320,30 +318,25 @@ class _ApiKeyCard extends StatelessWidget {
@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)
color: data.isActive
? (data.isAdmin ? Colors.amber : Colors.deepOrange)
: Colors.white24,
),
title: Row(
children: [
Text(
name,
data.name,
style: TextStyle(
fontWeight: FontWeight.bold,
color: isActive ? Colors.white : Colors.white38,
color: data.isActive ? Colors.white : Colors.white38,
),
),
if (isAdmin) ...[
if (data.isAdmin) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
@@ -357,7 +350,7 @@ class _ApiKeyCard extends StatelessWidget {
),
),
],
if (!isActive) ...[
if (!data.isActive) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
@@ -373,13 +366,13 @@ class _ApiKeyCard extends StatelessWidget {
],
],
),
subtitle: createdAt.toString().isNotEmpty
subtitle: data.formattedCreatedAt.isNotEmpty
? Text(
'Создан: ${_formatDate(createdAt.toString())}',
'Создан: ${data.formattedCreatedAt}',
style: const TextStyle(fontSize: 11, color: Colors.white30),
)
: null,
trailing: isActive
trailing: data.isActive
? IconButton(
icon: const Icon(
Icons.block,
@@ -401,14 +394,4 @@ class _ApiKeyCard extends StatelessWidget {
),
);
}
String _formatDate(String iso) {
try {
final d = DateTime.parse(iso);
String pad(int n) => n.toString().padLeft(2, '0');
return '${pad(d.day)}.${pad(d.month)}.${d.year}';
} catch (_) {
return iso;
}
}
}

View File

@@ -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 '../widgets/build_info_text.dart';
import 'home_edit_screen.dart';
import 'remote_screen.dart';
@@ -41,135 +42,81 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
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;
body: Column(
children: [
Expanded(
child: homes.isEmpty
? const _EmptyHomesView()
: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: homes.length,
itemBuilder: (context, index) {
final home = homes[index];
final isActive = currentHome?.id == home.id;
final distKm = location.distanceToKm(
home.latitude,
home.longitude,
);
// Расстояние до дома (null если нет координат или геолокации)
final distKm = location.distanceToKm(
home.latitude,
home.longitude,
);
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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
home.url,
style: const TextStyle(
color: Colors.white38,
fontSize: 12,
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Icon(
Icons.home,
color: isActive
? Colors.deepOrange
: Colors.white38,
size: 28,
),
),
if (distKm != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Row(
children: [
const Icon(
Icons.near_me,
size: 11,
color: Colors.white30,
),
const SizedBox(width: 4),
Text(
'~${formatDistance(distKm)}',
style: const TextStyle(
color: Colors.white30,
fontSize: 11,
),
),
],
title: Text(
home.name,
style: TextStyle(
fontWeight: isActive
? FontWeight.bold
: FontWeight.normal,
color: isActive
? Colors.deepOrange
: Colors.white,
),
)
else if (home.hasCoordinates && !location.hasPosition)
// Координаты заданы, но геолокация недоступна
Row(
),
subtitle: _HomeSubtitle(
home: home,
location: location,
distKm: distKm,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
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,
IconButton(
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,
),
onPressed: () => _confirmDelete(context, home),
),
],
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Кнопка редактирования
IconButton(
icon: const Icon(
Icons.edit,
size: 20,
color: Colors.white38,
),
onPressed: () => _editHome(context, home),
onTap: () => _selectHome(context, home),
),
// Кнопка удаления
IconButton(
icon: const Icon(
Icons.delete_outline,
size: 20,
color: Colors.redAccent,
),
onPressed: () => _confirmDelete(context, home),
),
],
),
onTap: () => _selectHome(context, home),
);
},
),
);
},
),
),
const Padding(
padding: EdgeInsets.only(bottom: 10),
child: BuildInfoText(),
),
],
),
floatingActionButton: FloatingActionButton(
backgroundColor: Colors.deepOrange,
onPressed: () => _addHome(context),
@@ -178,7 +125,6 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
);
}
/// Выбрать дом и перейти на пульт
void _selectHome(BuildContext context, HomeConfig home) async {
try {
await ref.read(currentHomeProvider.notifier).switchTo(home);
@@ -197,21 +143,18 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
}
}
/// Добавить новый дом
void _addHome(BuildContext context) {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const HomeEditScreen()));
}
/// Редактировать дом
void _editHome(BuildContext context, HomeConfig home) {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => HomeEditScreen(home: home)));
}
/// Подтвердить удаление
void _confirmDelete(BuildContext context, HomeConfig home) {
showDialog(
context: context,
@@ -227,7 +170,6 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
onPressed: () async {
Navigator.of(ctx).pop();
await ref.read(homesProvider.notifier).remove(home.id);
// Синхронизировать фоновый таск (мог быть удалён дом с геофенсом)
await syncGeofenceTask(ref.read(homesProvider));
},
child: const Text(
@@ -240,3 +182,79 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
);
}
}
class _EmptyHomesView extends StatelessWidget {
const _EmptyHomesView();
@override
Widget build(BuildContext context) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.home_outlined, size: 64, color: Colors.white24),
SizedBox(height: 16),
Text(
'Нет добавленных домов',
style: TextStyle(color: Colors.white54, fontSize: 16),
),
SizedBox(height: 8),
Text(
'Добавьте сервер Ignis',
style: TextStyle(color: Colors.white38, fontSize: 14),
),
],
),
);
}
}
class _HomeSubtitle extends StatelessWidget {
final HomeConfig home;
final UserLocation location;
final double? distKm;
const _HomeSubtitle({
required this.home,
required this.location,
required this.distKm,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
home.url,
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 SizedBox(width: 4),
Text(
'~${formatDistance(distKm!)}',
style: const TextStyle(color: Colors.white30, fontSize: 11),
),
],
),
)
else if (home.hasCoordinates && !location.hasPosition)
Row(
children: [
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),
),
],
),
],
);
}
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/error_message.dart';
import '../models/ignis_group.dart';
import '../providers/providers.dart';
import '../widgets/build_info_text.dart';
import '../widgets/group_card.dart';
import 'homes_screen.dart';
import 'group_edit_screen.dart';
@@ -39,7 +40,7 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
final groupsLoadState = ref.watch(groupsLoadStateProvider);
final currentHome = ref.watch(currentHomeProvider);
final authInfo = ref.watch(authInfoProvider);
final isAdmin = authInfo?['is_admin'] == true;
final isAdmin = authInfo?.isAdmin == true;
return Scaffold(
appBar: AppBar(
@@ -121,6 +122,13 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuItem(
enabled: false,
child: Padding(
padding: EdgeInsets.symmetric(vertical: 4),
child: BuildInfoText(),
),
),
],
),
],

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/error_message.dart';
import '../app/load_state.dart';
import '../models/schedule_task.dart';
import '../providers/providers.dart';
import '../widgets/load_error_view.dart';
@@ -55,8 +56,8 @@ class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
}
Widget _buildContent(
LoadState<List<dynamic>> tasksState,
List<dynamic> tasks,
LoadState<List<ScheduleTask>> tasksState,
List<ScheduleTask> tasks,
) {
if ((tasksState.isIdle || tasksState.isLoading) && tasks.isEmpty) {
return const Center(
@@ -125,56 +126,30 @@ class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
/// Карточка одной задачи расписания
class _TaskCard extends ConsumerWidget {
final dynamic task;
final ScheduleTask 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,
task.isCron ? Icons.repeat : Icons.timer,
color: Colors.deepOrange,
),
title: Text(
'$stateStr - $targetId',
task.title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
subtitle,
task.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),
onPressed: () => _confirmCancel(context, ref, task.jobId),
),
),
);