feat: type schedule and auth models
This commit is contained in:
12
lib/app/build_info.dart
Normal file
12
lib/app/build_info.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
class BuildInfo {
|
||||
static const String date = String.fromEnvironment(
|
||||
'IGNIS_BUILD_DATE',
|
||||
defaultValue: 'dev',
|
||||
);
|
||||
static const String gitSha = String.fromEnvironment(
|
||||
'IGNIS_GIT_SHA',
|
||||
defaultValue: 'local',
|
||||
);
|
||||
|
||||
static String get label => 'build $date - $gitSha';
|
||||
}
|
||||
105
lib/models/api_key_info.dart
Normal file
105
lib/models/api_key_info.dart
Normal file
@@ -0,0 +1,105 @@
|
||||
class ApiKeyInfo {
|
||||
final String key;
|
||||
final String name;
|
||||
final bool isAdmin;
|
||||
final bool isActive;
|
||||
final DateTime? createdAt;
|
||||
|
||||
const ApiKeyInfo({
|
||||
required this.key,
|
||||
required this.name,
|
||||
required this.isAdmin,
|
||||
required this.isActive,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
String get formattedCreatedAt {
|
||||
final value = createdAt;
|
||||
if (value == null) return '';
|
||||
String pad(int n) => n.toString().padLeft(2, '0');
|
||||
return '${pad(value.day)}.${pad(value.month)}.${value.year}';
|
||||
}
|
||||
|
||||
static ApiKeyInfo fromApi(Object? data, {String? fallbackKey}) {
|
||||
if (data is! Map) {
|
||||
final key = data?.toString() ?? fallbackKey;
|
||||
if (key == null || key.isEmpty) {
|
||||
throw const FormatException('api key должен быть объектом или токеном');
|
||||
}
|
||||
return ApiKeyInfo(key: key, name: key, isAdmin: false, isActive: true);
|
||||
}
|
||||
|
||||
final map = Map<String, dynamic>.from(data);
|
||||
final key =
|
||||
_stringValue(map, const ['key', 'token', 'api_key']) ?? fallbackKey;
|
||||
if (key == null || key.isEmpty) {
|
||||
throw const FormatException('api key не содержит токен');
|
||||
}
|
||||
|
||||
return ApiKeyInfo(
|
||||
key: key,
|
||||
name: _stringValue(map, const ['name', 'label']) ?? 'Без имени',
|
||||
isAdmin: _boolValue(map['is_admin']),
|
||||
isActive: _boolValue(map['is_active'] ?? map['active'], fallback: true),
|
||||
createdAt: DateTime.tryParse(map['created_at']?.toString() ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
static List<ApiKeyInfo> listFromApi(Object? data) {
|
||||
final values = _collectionValues(data, const ['data', 'keys']);
|
||||
return values.map((value) {
|
||||
if (value.entryKey == null) return ApiKeyInfo.fromApi(value.value);
|
||||
return ApiKeyInfo.fromApi(value.value, fallbackKey: value.entryKey);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
bool _boolValue(Object? value, {bool fallback = false}) {
|
||||
if (value is bool) return value;
|
||||
if (value is num) return value != 0;
|
||||
if (value is String) {
|
||||
final normalized = value.trim().toLowerCase();
|
||||
if (normalized == 'true' || normalized == '1') return true;
|
||||
if (normalized == 'false' || normalized == '0') return false;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
String? _stringValue(Map<String, dynamic> map, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
final value = map[key];
|
||||
if (value != null && value.toString().isNotEmpty) {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<_CollectionValue> _collectionValues(Object? data, List<String> wrappers) {
|
||||
if (data is List) {
|
||||
return data.map((value) => _CollectionValue(value)).toList();
|
||||
}
|
||||
|
||||
if (data is Map) {
|
||||
final map = Map<String, dynamic>.from(data);
|
||||
for (final wrapper in wrappers) {
|
||||
final value = map[wrapper];
|
||||
if (value is List) {
|
||||
return value.map((item) => _CollectionValue(item)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
return map.entries
|
||||
.map((entry) => _CollectionValue(entry.value, entryKey: entry.key))
|
||||
.toList();
|
||||
}
|
||||
|
||||
throw const FormatException('ожидался список или объект');
|
||||
}
|
||||
|
||||
class _CollectionValue {
|
||||
final Object? value;
|
||||
final String? entryKey;
|
||||
|
||||
const _CollectionValue(this.value, {this.entryKey});
|
||||
}
|
||||
28
lib/models/auth_info.dart
Normal file
28
lib/models/auth_info.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
class AuthInfo {
|
||||
final bool isAdmin;
|
||||
final String? name;
|
||||
|
||||
const AuthInfo({required this.isAdmin, this.name});
|
||||
|
||||
static AuthInfo fromApi(Object? data) {
|
||||
if (data is! Map) {
|
||||
throw const FormatException('auth/me должен быть объектом');
|
||||
}
|
||||
|
||||
final map = Map<String, dynamic>.from(data);
|
||||
return AuthInfo(
|
||||
isAdmin: _boolValue(map['is_admin']),
|
||||
name: map['name']?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _boolValue(Object? value) {
|
||||
if (value is bool) return value;
|
||||
if (value is num) return value != 0;
|
||||
if (value is String) {
|
||||
final normalized = value.trim().toLowerCase();
|
||||
return normalized == 'true' || normalized == '1';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
136
lib/models/schedule_task.dart
Normal file
136
lib/models/schedule_task.dart
Normal file
@@ -0,0 +1,136 @@
|
||||
class ScheduleTask {
|
||||
final String jobId;
|
||||
final String targetId;
|
||||
final bool? targetState;
|
||||
final String type;
|
||||
final String? runAt;
|
||||
final String? cron;
|
||||
final String? hour;
|
||||
final String? minute;
|
||||
final String? dayOfWeek;
|
||||
|
||||
const ScheduleTask({
|
||||
required this.jobId,
|
||||
required this.targetId,
|
||||
required this.type,
|
||||
this.targetState,
|
||||
this.runAt,
|
||||
this.cron,
|
||||
this.hour,
|
||||
this.minute,
|
||||
this.dayOfWeek,
|
||||
});
|
||||
|
||||
bool get isCron => type == 'cron' || cron != null;
|
||||
|
||||
String get actionText {
|
||||
return targetState == true
|
||||
? 'Включить'
|
||||
: targetState == false
|
||||
? 'Выключить'
|
||||
: '?';
|
||||
}
|
||||
|
||||
String get title => '$actionText - $targetId';
|
||||
|
||||
String get subtitle {
|
||||
final lines = <String>['Цель: $targetId'];
|
||||
if (runAt != null && runAt!.isNotEmpty) lines.add('Запуск: $runAt');
|
||||
if (cron != null && cron!.isNotEmpty) lines.add('Cron: $cron');
|
||||
if (hour != null && minute != null) lines.add('Время: $hour:$minute');
|
||||
if (dayOfWeek != null && dayOfWeek != '*') {
|
||||
lines.add('Дни: $dayOfWeek');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
static ScheduleTask fromApi(Object? data, {String? fallbackId}) {
|
||||
if (data is! Map) {
|
||||
final id = data?.toString() ?? fallbackId;
|
||||
if (id == null || id.isEmpty) {
|
||||
throw const FormatException('schedule task должен быть объектом');
|
||||
}
|
||||
return ScheduleTask(jobId: id, targetId: '', type: 'once');
|
||||
}
|
||||
|
||||
final map = Map<String, dynamic>.from(data);
|
||||
final jobId = _stringValue(map, const ['id', 'job_id']) ?? fallbackId;
|
||||
if (jobId == null || jobId.isEmpty) {
|
||||
throw const FormatException('schedule task не содержит id/job_id');
|
||||
}
|
||||
|
||||
final cron = map['cron']?.toString();
|
||||
final type =
|
||||
_stringValue(map, const ['type']) ?? (cron != null ? 'cron' : 'once');
|
||||
|
||||
return ScheduleTask(
|
||||
jobId: jobId,
|
||||
targetId: _stringValue(map, const ['target_id', 'target']) ?? '',
|
||||
targetState: _boolValue(map['state']),
|
||||
type: type,
|
||||
runAt: _stringValue(map, const ['run_at', 'next_run', 'next_run_time']),
|
||||
cron: cron,
|
||||
hour: map['hour']?.toString(),
|
||||
minute: map['minute']?.toString(),
|
||||
dayOfWeek: map['day_of_week']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
static List<ScheduleTask> listFromApi(Object? data) {
|
||||
final values = _collectionValues(data, const ['tasks', 'data']);
|
||||
return values.map((value) {
|
||||
if (value.entryKey == null) return ScheduleTask.fromApi(value.value);
|
||||
return ScheduleTask.fromApi(value.value, fallbackId: value.entryKey);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
bool? _boolValue(Object? value) {
|
||||
if (value is bool) return value;
|
||||
if (value is num) return value != 0;
|
||||
if (value is String) {
|
||||
final normalized = value.trim().toLowerCase();
|
||||
if (normalized == 'true' || normalized == '1') return true;
|
||||
if (normalized == 'false' || normalized == '0') return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _stringValue(Map<String, dynamic> map, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
final value = map[key];
|
||||
if (value != null && value.toString().isNotEmpty) {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<_CollectionValue> _collectionValues(Object? data, List<String> wrappers) {
|
||||
if (data is List) {
|
||||
return data.map((value) => _CollectionValue(value)).toList();
|
||||
}
|
||||
|
||||
if (data is Map) {
|
||||
final map = Map<String, dynamic>.from(data);
|
||||
for (final wrapper in wrappers) {
|
||||
final value = map[wrapper];
|
||||
if (value is List) {
|
||||
return value.map((item) => _CollectionValue(item)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
return map.entries
|
||||
.map((entry) => _CollectionValue(entry.value, entryKey: entry.key))
|
||||
.toList();
|
||||
}
|
||||
|
||||
throw const FormatException('ожидался список или объект');
|
||||
}
|
||||
|
||||
class _CollectionValue {
|
||||
final Object? value;
|
||||
final String? entryKey;
|
||||
|
||||
const _CollectionValue(this.value, {this.entryKey});
|
||||
}
|
||||
@@ -5,10 +5,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import '../app/error_message.dart';
|
||||
import '../app/load_state.dart';
|
||||
import '../models/api_key_info.dart';
|
||||
import '../models/auth_info.dart';
|
||||
import '../models/ignis_device.dart';
|
||||
import '../models/ignis_group.dart';
|
||||
import '../models/ignis_scene.dart';
|
||||
import '../models/home_config.dart';
|
||||
import '../models/schedule_task.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/geofence_worker.dart';
|
||||
@@ -624,32 +627,22 @@ class ScenesNotifier extends Notifier<LoadState<List<IgnisScene>>> {
|
||||
|
||||
// ─── Расписания ──────────────────────────────────────────────
|
||||
|
||||
final tasksProvider = NotifierProvider<TasksNotifier, LoadState<List<dynamic>>>(
|
||||
() => TasksNotifier(),
|
||||
);
|
||||
final tasksProvider =
|
||||
NotifierProvider<TasksNotifier, LoadState<List<ScheduleTask>>>(
|
||||
() => TasksNotifier(),
|
||||
);
|
||||
|
||||
class TasksNotifier extends Notifier<LoadState<List<dynamic>>> {
|
||||
class TasksNotifier extends Notifier<LoadState<List<ScheduleTask>>> {
|
||||
@override
|
||||
LoadState<List<dynamic>> build() => const LoadState.idle(<dynamic>[]);
|
||||
LoadState<List<ScheduleTask>> build() =>
|
||||
const LoadState.idle(<ScheduleTask>[]);
|
||||
|
||||
Future<void> load() async {
|
||||
state = LoadState.loading(state.data);
|
||||
try {
|
||||
final api = ref.read(apiProvider);
|
||||
final res = await api.getTasks();
|
||||
final data = res.data;
|
||||
late final List<dynamic> tasks;
|
||||
if (data is List) {
|
||||
tasks = List<dynamic>.from(data);
|
||||
} else if (data is Map) {
|
||||
final value = data['tasks'] ?? data['data'] ?? data.values.toList();
|
||||
if (value is! List) {
|
||||
throw FormatException('tasks должен быть списком расписаний');
|
||||
}
|
||||
tasks = List<dynamic>.from(value);
|
||||
} else {
|
||||
throw FormatException('tasks должен быть списком расписаний');
|
||||
}
|
||||
final tasks = ScheduleTask.listFromApi(res.data);
|
||||
|
||||
state = tasks.isEmpty ? LoadState.empty(tasks) : LoadState.data(tasks);
|
||||
} catch (e) {
|
||||
@@ -774,32 +767,20 @@ class EventLogNotifier extends Notifier<LoadState<List<dynamic>>> {
|
||||
// ─── API-ключи ───────────────────────────────────────────────
|
||||
|
||||
final apiKeysProvider =
|
||||
NotifierProvider<ApiKeysNotifier, LoadState<List<dynamic>>>(
|
||||
NotifierProvider<ApiKeysNotifier, LoadState<List<ApiKeyInfo>>>(
|
||||
() => ApiKeysNotifier(),
|
||||
);
|
||||
|
||||
class ApiKeysNotifier extends Notifier<LoadState<List<dynamic>>> {
|
||||
class ApiKeysNotifier extends Notifier<LoadState<List<ApiKeyInfo>>> {
|
||||
@override
|
||||
LoadState<List<dynamic>> build() => const LoadState.idle(<dynamic>[]);
|
||||
LoadState<List<ApiKeyInfo>> build() => const LoadState.idle(<ApiKeyInfo>[]);
|
||||
|
||||
Future<void> load() async {
|
||||
state = LoadState.loading(state.data);
|
||||
try {
|
||||
final api = ref.read(apiProvider);
|
||||
final res = await api.getApiKeys();
|
||||
final data = res.data;
|
||||
late final List<dynamic> keys;
|
||||
if (data is List) {
|
||||
keys = List<dynamic>.from(data);
|
||||
} else if (data is Map) {
|
||||
final value = data['data'] ?? data['keys'] ?? data.values.toList();
|
||||
if (value is! List) {
|
||||
throw FormatException('api-keys должен быть списком ключей');
|
||||
}
|
||||
keys = List<dynamic>.from(value);
|
||||
} else {
|
||||
throw FormatException('api-keys должен быть списком ключей');
|
||||
}
|
||||
final keys = ApiKeyInfo.listFromApi(res.data);
|
||||
|
||||
state = keys.isEmpty ? LoadState.empty(keys) : LoadState.data(keys);
|
||||
} catch (e) {
|
||||
@@ -833,29 +814,26 @@ class ApiKeysNotifier extends Notifier<LoadState<List<dynamic>>> {
|
||||
|
||||
// ─── Информация об авторизации ────────────────────────────────
|
||||
|
||||
final authInfoProvider =
|
||||
NotifierProvider<AuthInfoNotifier, Map<String, dynamic>?>(
|
||||
() => AuthInfoNotifier(),
|
||||
);
|
||||
final authInfoProvider = NotifierProvider<AuthInfoNotifier, AuthInfo?>(
|
||||
() => AuthInfoNotifier(),
|
||||
);
|
||||
|
||||
class AuthInfoNotifier extends Notifier<Map<String, dynamic>?> {
|
||||
class AuthInfoNotifier extends Notifier<AuthInfo?> {
|
||||
@override
|
||||
Map<String, dynamic>? build() => null;
|
||||
AuthInfo? build() => null;
|
||||
|
||||
Future<void> load({bool failOnError = false}) async {
|
||||
try {
|
||||
final api = ref.read(apiProvider);
|
||||
final res = await api.getAuthMe();
|
||||
if (res.data is Map) {
|
||||
state = Map<String, dynamic>.from(res.data);
|
||||
}
|
||||
state = AuthInfo.fromApi(res.data);
|
||||
} catch (e) {
|
||||
debugPrint("Ошибка загрузки auth/me: $e");
|
||||
if (failOnError) rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
bool get isAdmin => state?['is_admin'] == true;
|
||||
bool get isAdmin => state?.isAdmin == true;
|
||||
}
|
||||
|
||||
// ─── Геофенс: управление фоновым таском ─────────────────────
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
16
lib/widgets/build_info_text.dart
Normal file
16
lib/widgets/build_info_text.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/build_info.dart';
|
||||
|
||||
class BuildInfoText extends StatelessWidget {
|
||||
const BuildInfoText({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
BuildInfo.label,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white24, fontSize: 10),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user