From 0fdaf0bac412b4d03cf1b930679fcb5bf6d00ce9 Mon Sep 17 00:00:00 2001 From: Artem Kokos Date: Thu, 23 Apr 2026 20:57:15 +0700 Subject: [PATCH] feat: type schedule and auth models --- lib/app/build_info.dart | 12 ++ lib/models/api_key_info.dart | 105 +++++++++++ lib/models/auth_info.dart | 28 +++ lib/models/schedule_task.dart | 136 ++++++++++++++ lib/providers/providers.dart | 66 +++---- lib/screens/api_keys_screen.dart | 63 +++---- lib/screens/homes_screen.dart | 266 +++++++++++++++------------- lib/screens/remote_screen.dart | 10 +- lib/screens/schedules_screen.dart | 41 +---- lib/widgets/build_info_text.dart | 16 ++ test/read_only_load_state_test.dart | 31 +++- 11 files changed, 531 insertions(+), 243 deletions(-) create mode 100644 lib/app/build_info.dart create mode 100644 lib/models/api_key_info.dart create mode 100644 lib/models/auth_info.dart create mode 100644 lib/models/schedule_task.dart create mode 100644 lib/widgets/build_info_text.dart diff --git a/lib/app/build_info.dart b/lib/app/build_info.dart new file mode 100644 index 0000000..c206d1f --- /dev/null +++ b/lib/app/build_info.dart @@ -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'; +} diff --git a/lib/models/api_key_info.dart b/lib/models/api_key_info.dart new file mode 100644 index 0000000..df40906 --- /dev/null +++ b/lib/models/api_key_info.dart @@ -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.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 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 map, List 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 wrappers) { + if (data is List) { + return data.map((value) => _CollectionValue(value)).toList(); + } + + if (data is Map) { + final map = Map.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}); +} diff --git a/lib/models/auth_info.dart b/lib/models/auth_info.dart new file mode 100644 index 0000000..e2ecc9e --- /dev/null +++ b/lib/models/auth_info.dart @@ -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.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; +} diff --git a/lib/models/schedule_task.dart b/lib/models/schedule_task.dart new file mode 100644 index 0000000..f8b1a01 --- /dev/null +++ b/lib/models/schedule_task.dart @@ -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 = ['Цель: $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.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 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 map, List 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 wrappers) { + if (data is List) { + return data.map((value) => _CollectionValue(value)).toList(); + } + + if (data is Map) { + final map = Map.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}); +} diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart index ab07996..1f8cef5 100644 --- a/lib/providers/providers.dart +++ b/lib/providers/providers.dart @@ -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>> { // ─── Расписания ────────────────────────────────────────────── -final tasksProvider = NotifierProvider>>( - () => TasksNotifier(), -); +final tasksProvider = + NotifierProvider>>( + () => TasksNotifier(), + ); -class TasksNotifier extends Notifier>> { +class TasksNotifier extends Notifier>> { @override - LoadState> build() => const LoadState.idle([]); + LoadState> build() => + const LoadState.idle([]); Future 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 tasks; - if (data is List) { - tasks = List.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.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>> { // ─── API-ключи ─────────────────────────────────────────────── final apiKeysProvider = - NotifierProvider>>( + NotifierProvider>>( () => ApiKeysNotifier(), ); -class ApiKeysNotifier extends Notifier>> { +class ApiKeysNotifier extends Notifier>> { @override - LoadState> build() => const LoadState.idle([]); + LoadState> build() => const LoadState.idle([]); Future 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 keys; - if (data is List) { - keys = List.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.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>> { // ─── Информация об авторизации ──────────────────────────────── -final authInfoProvider = - NotifierProvider?>( - () => AuthInfoNotifier(), - ); +final authInfoProvider = NotifierProvider( + () => AuthInfoNotifier(), +); -class AuthInfoNotifier extends Notifier?> { +class AuthInfoNotifier extends Notifier { @override - Map? build() => null; + AuthInfo? build() => null; Future load({bool failOnError = false}) async { try { final api = ref.read(apiProvider); final res = await api.getAuthMe(); - if (res.data is Map) { - state = Map.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; } // ─── Геофенс: управление фоновым таском ───────────────────── diff --git a/lib/screens/api_keys_screen.dart b/lib/screens/api_keys_screen.dart index be9cb9d..00bd2ab 100644 --- a/lib/screens/api_keys_screen.dart +++ b/lib/screens/api_keys_screen.dart @@ -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 { ); } - Widget _buildContent(LoadState> keysState, List keys) { + Widget _buildContent( + LoadState> keysState, + List keys, + ) { if ((keysState.isIdle || keysState.isLoading) && keys.isEmpty) { return const Center( child: CircularProgressIndicator(color: Colors.deepOrange), @@ -103,13 +107,10 @@ class _ApiKeysScreenState extends ConsumerState { final keyIndex = index - statusHeaderCount; final key = keys[keyIndex]; - final map = key is Map - ? Map.from(key) - : {}; 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 { ); } - Future _revokeKey(Map data) async { - final key = (data['key'] ?? data['token'] ?? '').toString(); - final name = (data['name'] ?? '').toString(); + Future _revokeKey(ApiKeyInfo data) async { final confirmed = await showDialog( 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 { ); 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 { } } - Future _activateKey(Map data) async { - final key = (data['key'] ?? data['token'] ?? '').toString(); + Future _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 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; - } - } } diff --git a/lib/screens/homes_screen.dart b/lib/screens/homes_screen.dart index 10c5b15..c9f7e80 100644 --- a/lib/screens/homes_screen.dart +++ b/lib/screens/homes_screen.dart @@ -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 { 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 { ); } - /// Выбрать дом и перейти на пульт void _selectHome(BuildContext context, HomeConfig home) async { try { await ref.read(currentHomeProvider.notifier).switchTo(home); @@ -197,21 +143,18 @@ class _HomesScreenState extends ConsumerState { } } - /// Добавить новый дом 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 { 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 { ); } } + +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), + ), + ], + ), + ], + ); + } +} diff --git a/lib/screens/remote_screen.dart b/lib/screens/remote_screen.dart index 95de4a0..21d0f23 100644 --- a/lib/screens/remote_screen.dart +++ b/lib/screens/remote_screen.dart @@ -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 { 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 { contentPadding: EdgeInsets.zero, ), ), + const PopupMenuItem( + enabled: false, + child: Padding( + padding: EdgeInsets.symmetric(vertical: 4), + child: BuildInfoText(), + ), + ), ], ), ], diff --git a/lib/screens/schedules_screen.dart b/lib/screens/schedules_screen.dart index be79279..79b0727 100644 --- a/lib/screens/schedules_screen.dart +++ b/lib/screens/schedules_screen.dart @@ -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 { } Widget _buildContent( - LoadState> tasksState, - List tasks, + LoadState> tasksState, + List tasks, ) { if ((tasksState.isIdle || tasksState.isLoading) && tasks.isEmpty) { return const Center( @@ -125,56 +126,30 @@ class _SchedulesScreenState extends ConsumerState { /// Карточка одной задачи расписания 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.from(task) - : {}; - 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), ), ), ); diff --git a/lib/widgets/build_info_text.dart b/lib/widgets/build_info_text.dart new file mode 100644 index 0000000..d85d025 --- /dev/null +++ b/lib/widgets/build_info_text.dart @@ -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), + ); + } +} diff --git a/test/read_only_load_state_test.dart b/test/read_only_load_state_test.dart index 5848570..189d154 100644 --- a/test/read_only_load_state_test.dart +++ b/test/read_only_load_state_test.dart @@ -15,12 +15,14 @@ class FakeIgnisApi extends IgnisApi { Object? statsData; Object? eventLogData; Object? apiKeysData; + Object? authData; Object? devicesError; Object? scenesError; Object? tasksError; Object? statsError; Object? eventLogError; Object? apiKeysError; + Object? authError; Object? groupsError; Object? groupStatusError; Object? controlGroupError; @@ -42,8 +44,19 @@ class FakeIgnisApi extends IgnisApi { this.statsData, this.eventLogData, this.apiKeysData, + this.authData, }); + @override + Future getAuthMe() async { + final error = authError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/auth/me'), + data: authData ?? {'is_admin': false}, + ); + } + @override Future getDevices() async { final error = devicesError; @@ -278,6 +291,8 @@ void main() { final state = container.read(tasksProvider); expect(state.status, LoadStatus.data); expect(state.data, hasLength(1)); + expect(state.data.single.jobId, 'job-1'); + expect(state.data.single.targetId, 'kitchen'); }); test('tasks load exposes empty state', () async { @@ -418,6 +433,20 @@ void main() { final state = container.read(apiKeysProvider); expect(state.status, LoadStatus.data); expect(state.data, hasLength(1)); + expect(state.data.single.key, 'secret'); + expect(state.data.single.name, 'guest'); + }); + + test('auth info load maps admin flag', () async { + final api = FakeIgnisApi(authData: {'is_admin': 'true', 'name': 'owner'}); + final container = containerWith(api); + + await container.read(authInfoProvider.notifier).load(); + + final state = container.read(authInfoProvider); + expect(state?.isAdmin, isTrue); + expect(state?.name, 'owner'); + expect(container.read(authInfoProvider.notifier).isAdmin, isTrue); }); test('api keys load exposes empty state', () async { @@ -434,7 +463,7 @@ void main() { test('api keys load error exposes message', () async { final api = FakeIgnisApi( apiKeysData: [ - {'name': 'guest'}, + {'name': 'guest', 'key': 'secret'}, ], ); final container = containerWith(api);