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 'package:geolocator/geolocator.dart';
|
||||||
import '../app/error_message.dart';
|
import '../app/error_message.dart';
|
||||||
import '../app/load_state.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_device.dart';
|
||||||
import '../models/ignis_group.dart';
|
import '../models/ignis_group.dart';
|
||||||
import '../models/ignis_scene.dart';
|
import '../models/ignis_scene.dart';
|
||||||
import '../models/home_config.dart';
|
import '../models/home_config.dart';
|
||||||
|
import '../models/schedule_task.dart';
|
||||||
import '../services/api_client.dart';
|
import '../services/api_client.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../services/geofence_worker.dart';
|
import '../services/geofence_worker.dart';
|
||||||
@@ -624,32 +627,22 @@ class ScenesNotifier extends Notifier<LoadState<List<IgnisScene>>> {
|
|||||||
|
|
||||||
// ─── Расписания ──────────────────────────────────────────────
|
// ─── Расписания ──────────────────────────────────────────────
|
||||||
|
|
||||||
final tasksProvider = NotifierProvider<TasksNotifier, LoadState<List<dynamic>>>(
|
final tasksProvider =
|
||||||
|
NotifierProvider<TasksNotifier, LoadState<List<ScheduleTask>>>(
|
||||||
() => TasksNotifier(),
|
() => TasksNotifier(),
|
||||||
);
|
);
|
||||||
|
|
||||||
class TasksNotifier extends Notifier<LoadState<List<dynamic>>> {
|
class TasksNotifier extends Notifier<LoadState<List<ScheduleTask>>> {
|
||||||
@override
|
@override
|
||||||
LoadState<List<dynamic>> build() => const LoadState.idle(<dynamic>[]);
|
LoadState<List<ScheduleTask>> build() =>
|
||||||
|
const LoadState.idle(<ScheduleTask>[]);
|
||||||
|
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
state = LoadState.loading(state.data);
|
state = LoadState.loading(state.data);
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiProvider);
|
final api = ref.read(apiProvider);
|
||||||
final res = await api.getTasks();
|
final res = await api.getTasks();
|
||||||
final data = res.data;
|
final tasks = ScheduleTask.listFromApi(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 должен быть списком расписаний');
|
|
||||||
}
|
|
||||||
|
|
||||||
state = tasks.isEmpty ? LoadState.empty(tasks) : LoadState.data(tasks);
|
state = tasks.isEmpty ? LoadState.empty(tasks) : LoadState.data(tasks);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -774,32 +767,20 @@ class EventLogNotifier extends Notifier<LoadState<List<dynamic>>> {
|
|||||||
// ─── API-ключи ───────────────────────────────────────────────
|
// ─── API-ключи ───────────────────────────────────────────────
|
||||||
|
|
||||||
final apiKeysProvider =
|
final apiKeysProvider =
|
||||||
NotifierProvider<ApiKeysNotifier, LoadState<List<dynamic>>>(
|
NotifierProvider<ApiKeysNotifier, LoadState<List<ApiKeyInfo>>>(
|
||||||
() => ApiKeysNotifier(),
|
() => ApiKeysNotifier(),
|
||||||
);
|
);
|
||||||
|
|
||||||
class ApiKeysNotifier extends Notifier<LoadState<List<dynamic>>> {
|
class ApiKeysNotifier extends Notifier<LoadState<List<ApiKeyInfo>>> {
|
||||||
@override
|
@override
|
||||||
LoadState<List<dynamic>> build() => const LoadState.idle(<dynamic>[]);
|
LoadState<List<ApiKeyInfo>> build() => const LoadState.idle(<ApiKeyInfo>[]);
|
||||||
|
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
state = LoadState.loading(state.data);
|
state = LoadState.loading(state.data);
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiProvider);
|
final api = ref.read(apiProvider);
|
||||||
final res = await api.getApiKeys();
|
final res = await api.getApiKeys();
|
||||||
final data = res.data;
|
final keys = ApiKeyInfo.listFromApi(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 должен быть списком ключей');
|
|
||||||
}
|
|
||||||
|
|
||||||
state = keys.isEmpty ? LoadState.empty(keys) : LoadState.data(keys);
|
state = keys.isEmpty ? LoadState.empty(keys) : LoadState.data(keys);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -833,29 +814,26 @@ class ApiKeysNotifier extends Notifier<LoadState<List<dynamic>>> {
|
|||||||
|
|
||||||
// ─── Информация об авторизации ────────────────────────────────
|
// ─── Информация об авторизации ────────────────────────────────
|
||||||
|
|
||||||
final authInfoProvider =
|
final authInfoProvider = NotifierProvider<AuthInfoNotifier, AuthInfo?>(
|
||||||
NotifierProvider<AuthInfoNotifier, Map<String, dynamic>?>(
|
|
||||||
() => AuthInfoNotifier(),
|
() => AuthInfoNotifier(),
|
||||||
);
|
);
|
||||||
|
|
||||||
class AuthInfoNotifier extends Notifier<Map<String, dynamic>?> {
|
class AuthInfoNotifier extends Notifier<AuthInfo?> {
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic>? build() => null;
|
AuthInfo? build() => null;
|
||||||
|
|
||||||
Future<void> load({bool failOnError = false}) async {
|
Future<void> load({bool failOnError = false}) async {
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiProvider);
|
final api = ref.read(apiProvider);
|
||||||
final res = await api.getAuthMe();
|
final res = await api.getAuthMe();
|
||||||
if (res.data is Map) {
|
state = AuthInfo.fromApi(res.data);
|
||||||
state = Map<String, dynamic>.from(res.data);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("Ошибка загрузки auth/me: $e");
|
debugPrint("Ошибка загрузки auth/me: $e");
|
||||||
if (failOnError) rethrow;
|
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 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../app/error_message.dart';
|
import '../app/error_message.dart';
|
||||||
import '../app/load_state.dart';
|
import '../app/load_state.dart';
|
||||||
|
import '../models/api_key_info.dart';
|
||||||
import '../providers/providers.dart';
|
import '../providers/providers.dart';
|
||||||
import '../widgets/load_error_view.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) {
|
if ((keysState.isIdle || keysState.isLoading) && keys.isEmpty) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: CircularProgressIndicator(color: Colors.deepOrange),
|
child: CircularProgressIndicator(color: Colors.deepOrange),
|
||||||
@@ -103,13 +107,10 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
|
|||||||
|
|
||||||
final keyIndex = index - statusHeaderCount;
|
final keyIndex = index - statusHeaderCount;
|
||||||
final key = keys[keyIndex];
|
final key = keys[keyIndex];
|
||||||
final map = key is Map
|
|
||||||
? Map<String, dynamic>.from(key)
|
|
||||||
: <String, dynamic>{};
|
|
||||||
return _ApiKeyCard(
|
return _ApiKeyCard(
|
||||||
data: map,
|
data: key,
|
||||||
onRevoke: () => _revokeKey(map),
|
onRevoke: () => _revokeKey(key),
|
||||||
onActivate: () => _activateKey(map),
|
onActivate: () => _activateKey(key),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -185,14 +186,12 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _revokeKey(Map<String, dynamic> data) async {
|
Future<void> _revokeKey(ApiKeyInfo data) async {
|
||||||
final key = (data['key'] ?? data['token'] ?? '').toString();
|
|
||||||
final name = (data['name'] ?? '').toString();
|
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Отозвать ключ?'),
|
title: const Text('Отозвать ключ?'),
|
||||||
content: Text('Отозвать "$name"?'),
|
content: Text('Отозвать "${data.name}"?'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(ctx).pop(false),
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
@@ -210,7 +209,7 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
|
|||||||
);
|
);
|
||||||
if (confirmed == true) {
|
if (confirmed == true) {
|
||||||
try {
|
try {
|
||||||
await ref.read(apiKeysProvider.notifier).revoke(key);
|
await ref.read(apiKeysProvider.notifier).revoke(data.key);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -222,10 +221,9 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _activateKey(Map<String, dynamic> data) async {
|
Future<void> _activateKey(ApiKeyInfo data) async {
|
||||||
final key = (data['key'] ?? data['token'] ?? '').toString();
|
|
||||||
try {
|
try {
|
||||||
await ref.read(apiKeysProvider.notifier).activate(key);
|
await ref.read(apiKeysProvider.notifier).activate(data.key);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@@ -308,7 +306,7 @@ class _LastCreatedKeyBanner extends StatelessWidget {
|
|||||||
|
|
||||||
/// Карточка одного API-ключа
|
/// Карточка одного API-ключа
|
||||||
class _ApiKeyCard extends StatelessWidget {
|
class _ApiKeyCard extends StatelessWidget {
|
||||||
final Map<String, dynamic> data;
|
final ApiKeyInfo data;
|
||||||
final VoidCallback onRevoke;
|
final VoidCallback onRevoke;
|
||||||
final VoidCallback onActivate;
|
final VoidCallback onActivate;
|
||||||
|
|
||||||
@@ -320,30 +318,25 @@ class _ApiKeyCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Card(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
Icons.vpn_key,
|
Icons.vpn_key,
|
||||||
color: isActive
|
color: data.isActive
|
||||||
? (isAdmin ? Colors.amber : Colors.deepOrange)
|
? (data.isAdmin ? Colors.amber : Colors.deepOrange)
|
||||||
: Colors.white24,
|
: Colors.white24,
|
||||||
),
|
),
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
name,
|
data.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
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),
|
const SizedBox(width: 8),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
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),
|
const SizedBox(width: 8),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
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(
|
? Text(
|
||||||
'Создан: ${_formatDate(createdAt.toString())}',
|
'Создан: ${data.formattedCreatedAt}',
|
||||||
style: const TextStyle(fontSize: 11, color: Colors.white30),
|
style: const TextStyle(fontSize: 11, color: Colors.white30),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
trailing: isActive
|
trailing: data.isActive
|
||||||
? IconButton(
|
? IconButton(
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.block,
|
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 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../models/home_config.dart';
|
import '../models/home_config.dart';
|
||||||
import '../providers/providers.dart';
|
import '../providers/providers.dart';
|
||||||
|
import '../widgets/build_info_text.dart';
|
||||||
import 'home_edit_screen.dart';
|
import 'home_edit_screen.dart';
|
||||||
import 'remote_screen.dart';
|
import 'remote_screen.dart';
|
||||||
|
|
||||||
@@ -41,33 +42,17 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
|||||||
title: const Text('ДОМА'),
|
title: const Text('ДОМА'),
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
),
|
),
|
||||||
body: homes.isEmpty
|
body: Column(
|
||||||
? Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.home_outlined, size: 64, color: Colors.white24),
|
Expanded(
|
||||||
const SizedBox(height: 16),
|
child: homes.isEmpty
|
||||||
const Text(
|
? const _EmptyHomesView()
|
||||||
'Нет добавленных домов',
|
|
||||||
style: TextStyle(color: Colors.white54, fontSize: 16),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const Text(
|
|
||||||
'Добавьте сервер Ignis',
|
|
||||||
style: TextStyle(color: Colors.white38, fontSize: 14),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
itemCount: homes.length,
|
itemCount: homes.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final home = homes[index];
|
final home = homes[index];
|
||||||
final isActive = currentHome?.id == home.id;
|
final isActive = currentHome?.id == home.id;
|
||||||
|
|
||||||
// Расстояние до дома (null если нет координат или геолокации)
|
|
||||||
final distKm = location.distanceToKm(
|
final distKm = location.distanceToKm(
|
||||||
home.latitude,
|
home.latitude,
|
||||||
home.longitude,
|
home.longitude,
|
||||||
@@ -78,7 +63,9 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
|||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
Icons.home,
|
Icons.home,
|
||||||
color: isActive ? Colors.deepOrange : Colors.white38,
|
color: isActive
|
||||||
|
? Colors.deepOrange
|
||||||
|
: Colors.white38,
|
||||||
size: 28,
|
size: 28,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
@@ -87,65 +74,19 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
|||||||
fontWeight: isActive
|
fontWeight: isActive
|
||||||
? FontWeight.bold
|
? FontWeight.bold
|
||||||
: FontWeight.normal,
|
: FontWeight.normal,
|
||||||
color: isActive ? Colors.deepOrange : Colors.white,
|
color: isActive
|
||||||
|
? Colors.deepOrange
|
||||||
|
: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle: Column(
|
subtitle: _HomeSubtitle(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
home: home,
|
||||||
children: [
|
location: location,
|
||||||
Text(
|
distKm: distKm,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
trailing: Row(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Кнопка редактирования
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.edit,
|
Icons.edit,
|
||||||
@@ -154,7 +95,6 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
|||||||
),
|
),
|
||||||
onPressed: () => _editHome(context, home),
|
onPressed: () => _editHome(context, home),
|
||||||
),
|
),
|
||||||
// Кнопка удаления
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.delete_outline,
|
Icons.delete_outline,
|
||||||
@@ -170,6 +110,13 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 10),
|
||||||
|
child: BuildInfoText(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
backgroundColor: Colors.deepOrange,
|
backgroundColor: Colors.deepOrange,
|
||||||
onPressed: () => _addHome(context),
|
onPressed: () => _addHome(context),
|
||||||
@@ -178,7 +125,6 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Выбрать дом и перейти на пульт
|
|
||||||
void _selectHome(BuildContext context, HomeConfig home) async {
|
void _selectHome(BuildContext context, HomeConfig home) async {
|
||||||
try {
|
try {
|
||||||
await ref.read(currentHomeProvider.notifier).switchTo(home);
|
await ref.read(currentHomeProvider.notifier).switchTo(home);
|
||||||
@@ -197,21 +143,18 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Добавить новый дом
|
|
||||||
void _addHome(BuildContext context) {
|
void _addHome(BuildContext context) {
|
||||||
Navigator.of(
|
Navigator.of(
|
||||||
context,
|
context,
|
||||||
).push(MaterialPageRoute(builder: (_) => const HomeEditScreen()));
|
).push(MaterialPageRoute(builder: (_) => const HomeEditScreen()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Редактировать дом
|
|
||||||
void _editHome(BuildContext context, HomeConfig home) {
|
void _editHome(BuildContext context, HomeConfig home) {
|
||||||
Navigator.of(
|
Navigator.of(
|
||||||
context,
|
context,
|
||||||
).push(MaterialPageRoute(builder: (_) => HomeEditScreen(home: home)));
|
).push(MaterialPageRoute(builder: (_) => HomeEditScreen(home: home)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Подтвердить удаление
|
|
||||||
void _confirmDelete(BuildContext context, HomeConfig home) {
|
void _confirmDelete(BuildContext context, HomeConfig home) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -227,7 +170,6 @@ class _HomesScreenState extends ConsumerState<HomesScreen> {
|
|||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
Navigator.of(ctx).pop();
|
Navigator.of(ctx).pop();
|
||||||
await ref.read(homesProvider.notifier).remove(home.id);
|
await ref.read(homesProvider.notifier).remove(home.id);
|
||||||
// Синхронизировать фоновый таск (мог быть удалён дом с геофенсом)
|
|
||||||
await syncGeofenceTask(ref.read(homesProvider));
|
await syncGeofenceTask(ref.read(homesProvider));
|
||||||
},
|
},
|
||||||
child: const Text(
|
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 '../app/error_message.dart';
|
||||||
import '../models/ignis_group.dart';
|
import '../models/ignis_group.dart';
|
||||||
import '../providers/providers.dart';
|
import '../providers/providers.dart';
|
||||||
|
import '../widgets/build_info_text.dart';
|
||||||
import '../widgets/group_card.dart';
|
import '../widgets/group_card.dart';
|
||||||
import 'homes_screen.dart';
|
import 'homes_screen.dart';
|
||||||
import 'group_edit_screen.dart';
|
import 'group_edit_screen.dart';
|
||||||
@@ -39,7 +40,7 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
|||||||
final groupsLoadState = ref.watch(groupsLoadStateProvider);
|
final groupsLoadState = ref.watch(groupsLoadStateProvider);
|
||||||
final currentHome = ref.watch(currentHomeProvider);
|
final currentHome = ref.watch(currentHomeProvider);
|
||||||
final authInfo = ref.watch(authInfoProvider);
|
final authInfo = ref.watch(authInfoProvider);
|
||||||
final isAdmin = authInfo?['is_admin'] == true;
|
final isAdmin = authInfo?.isAdmin == true;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -121,6 +122,13 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
|
|||||||
contentPadding: EdgeInsets.zero,
|
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 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../app/error_message.dart';
|
import '../app/error_message.dart';
|
||||||
import '../app/load_state.dart';
|
import '../app/load_state.dart';
|
||||||
|
import '../models/schedule_task.dart';
|
||||||
import '../providers/providers.dart';
|
import '../providers/providers.dart';
|
||||||
import '../widgets/load_error_view.dart';
|
import '../widgets/load_error_view.dart';
|
||||||
|
|
||||||
@@ -55,8 +56,8 @@ class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildContent(
|
Widget _buildContent(
|
||||||
LoadState<List<dynamic>> tasksState,
|
LoadState<List<ScheduleTask>> tasksState,
|
||||||
List<dynamic> tasks,
|
List<ScheduleTask> tasks,
|
||||||
) {
|
) {
|
||||||
if ((tasksState.isIdle || tasksState.isLoading) && tasks.isEmpty) {
|
if ((tasksState.isIdle || tasksState.isLoading) && tasks.isEmpty) {
|
||||||
return const Center(
|
return const Center(
|
||||||
@@ -125,56 +126,30 @@ class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
|
|||||||
|
|
||||||
/// Карточка одной задачи расписания
|
/// Карточка одной задачи расписания
|
||||||
class _TaskCard extends ConsumerWidget {
|
class _TaskCard extends ConsumerWidget {
|
||||||
final dynamic task;
|
final ScheduleTask task;
|
||||||
|
|
||||||
const _TaskCard({required this.task});
|
const _TaskCard({required this.task});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
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(
|
return Card(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
type == 'cron' ? Icons.repeat : Icons.timer,
|
task.isCron ? Icons.repeat : Icons.timer,
|
||||||
color: Colors.deepOrange,
|
color: Colors.deepOrange,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
'$stateStr - $targetId',
|
task.title,
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
subtitle,
|
task.subtitle,
|
||||||
style: const TextStyle(fontSize: 12, color: Colors.white54),
|
style: const TextStyle(fontSize: 12, color: Colors.white54),
|
||||||
),
|
),
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,12 +15,14 @@ class FakeIgnisApi extends IgnisApi {
|
|||||||
Object? statsData;
|
Object? statsData;
|
||||||
Object? eventLogData;
|
Object? eventLogData;
|
||||||
Object? apiKeysData;
|
Object? apiKeysData;
|
||||||
|
Object? authData;
|
||||||
Object? devicesError;
|
Object? devicesError;
|
||||||
Object? scenesError;
|
Object? scenesError;
|
||||||
Object? tasksError;
|
Object? tasksError;
|
||||||
Object? statsError;
|
Object? statsError;
|
||||||
Object? eventLogError;
|
Object? eventLogError;
|
||||||
Object? apiKeysError;
|
Object? apiKeysError;
|
||||||
|
Object? authError;
|
||||||
Object? groupsError;
|
Object? groupsError;
|
||||||
Object? groupStatusError;
|
Object? groupStatusError;
|
||||||
Object? controlGroupError;
|
Object? controlGroupError;
|
||||||
@@ -42,8 +44,19 @@ class FakeIgnisApi extends IgnisApi {
|
|||||||
this.statsData,
|
this.statsData,
|
||||||
this.eventLogData,
|
this.eventLogData,
|
||||||
this.apiKeysData,
|
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
|
@override
|
||||||
Future<Response> getDevices() async {
|
Future<Response> getDevices() async {
|
||||||
final error = devicesError;
|
final error = devicesError;
|
||||||
@@ -278,6 +291,8 @@ void main() {
|
|||||||
final state = container.read(tasksProvider);
|
final state = container.read(tasksProvider);
|
||||||
expect(state.status, LoadStatus.data);
|
expect(state.status, LoadStatus.data);
|
||||||
expect(state.data, hasLength(1));
|
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 {
|
test('tasks load exposes empty state', () async {
|
||||||
@@ -418,6 +433,20 @@ void main() {
|
|||||||
final state = container.read(apiKeysProvider);
|
final state = container.read(apiKeysProvider);
|
||||||
expect(state.status, LoadStatus.data);
|
expect(state.status, LoadStatus.data);
|
||||||
expect(state.data, hasLength(1));
|
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 {
|
test('api keys load exposes empty state', () async {
|
||||||
@@ -434,7 +463,7 @@ void main() {
|
|||||||
test('api keys load error exposes message', () async {
|
test('api keys load error exposes message', () async {
|
||||||
final api = FakeIgnisApi(
|
final api = FakeIgnisApi(
|
||||||
apiKeysData: [
|
apiKeysData: [
|
||||||
{'name': 'guest'},
|
{'name': 'guest', 'key': 'secret'},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
final container = containerWith(api);
|
final container = containerWith(api);
|
||||||
|
|||||||
Reference in New Issue
Block a user