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

12
lib/app/build_info.dart Normal file
View 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';
}

View 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
View 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;
}

View 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});
}

View File

@@ -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;
}
// ─── Геофенс: управление фоновым таском ─────────────────────

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),
),
),
);

View 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),
);
}
}

View File

@@ -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<Response> getAuthMe() async {
final error = authError;
if (error != null) throw error;
return Response(
requestOptions: RequestOptions(path: '/auth/me'),
data: authData ?? <String, dynamic>{'is_admin': false},
);
}
@override
Future<Response> 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);