350 lines
12 KiB
Dart
350 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../app/error_message.dart';
|
||
import '../models/home_config.dart';
|
||
import '../providers/providers.dart';
|
||
import '../services/api_client.dart';
|
||
|
||
/// Экран создания или редактирования "дома" (сервера Ignis).
|
||
class HomeEditScreen extends ConsumerStatefulWidget {
|
||
final HomeConfig? home; // null -- создание, иначе редактирование
|
||
|
||
const HomeEditScreen({super.key, this.home});
|
||
|
||
@override
|
||
ConsumerState<HomeEditScreen> createState() => _HomeEditScreenState();
|
||
}
|
||
|
||
class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||
final _nameCtrl = TextEditingController();
|
||
final _urlCtrl = TextEditingController();
|
||
final _keyCtrl = TextEditingController();
|
||
final _latCtrl = TextEditingController();
|
||
final _lonCtrl = TextEditingController();
|
||
bool _geofenceEnabled = false;
|
||
bool _saving = false;
|
||
|
||
bool get _isEdit => widget.home != null;
|
||
|
||
/// Координаты заполнены (оба поля непустые)
|
||
bool get _hasCoordinates =>
|
||
_latCtrl.text.trim().isNotEmpty && _lonCtrl.text.trim().isNotEmpty;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
if (_isEdit) {
|
||
_nameCtrl.text = widget.home!.name;
|
||
_urlCtrl.text = widget.home!.url;
|
||
if (widget.home!.latitude != null) {
|
||
_latCtrl.text = widget.home!.latitude.toString();
|
||
}
|
||
if (widget.home!.longitude != null) {
|
||
_lonCtrl.text = widget.home!.longitude.toString();
|
||
}
|
||
_geofenceEnabled = widget.home!.geofenceEnabled;
|
||
_loadApiKey();
|
||
}
|
||
|
||
// Следим за полями координат чтобы обновлять доступность Switch
|
||
_latCtrl.addListener(_onCoordsChanged);
|
||
_lonCtrl.addListener(_onCoordsChanged);
|
||
}
|
||
|
||
Future<void> _loadApiKey() async {
|
||
final apiKey = await ref
|
||
.read(settingsServiceProvider)
|
||
.getHomeApiKey(widget.home!.id);
|
||
if (mounted && apiKey != null) {
|
||
_keyCtrl.text = apiKey;
|
||
}
|
||
}
|
||
|
||
void _onCoordsChanged() {
|
||
// Если координаты очистили -- выключаем геофенс
|
||
if (!_hasCoordinates && _geofenceEnabled) {
|
||
setState(() => _geofenceEnabled = false);
|
||
} else {
|
||
setState(() {}); // перерисовать Switch enabled/disabled
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_latCtrl.removeListener(_onCoordsChanged);
|
||
_lonCtrl.removeListener(_onCoordsChanged);
|
||
_nameCtrl.dispose();
|
||
_urlCtrl.dispose();
|
||
_keyCtrl.dispose();
|
||
_latCtrl.dispose();
|
||
_lonCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
appBar: AppBar(title: Text(_isEdit ? 'РЕДАКТИРОВАТЬ ДОМ' : 'НОВЫЙ ДОМ')),
|
||
body: SingleChildScrollView(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
TextField(
|
||
controller: _nameCtrl,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Название (например "Квартира")',
|
||
prefixIcon: Icon(Icons.home),
|
||
),
|
||
textCapitalization: TextCapitalization.sentences,
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextField(
|
||
controller: _urlCtrl,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Адрес сервера (например ignis.akokos.ru)',
|
||
prefixIcon: Icon(Icons.dns),
|
||
),
|
||
keyboardType: TextInputType.url,
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextField(
|
||
controller: _keyCtrl,
|
||
decoration: const InputDecoration(
|
||
labelText: 'API Key',
|
||
prefixIcon: Icon(Icons.key),
|
||
),
|
||
obscureText: true,
|
||
),
|
||
|
||
const SizedBox(height: 24),
|
||
|
||
// ─── GPS-координаты (опционально) ───
|
||
const Text(
|
||
'Координаты дома (опционально)',
|
||
style: TextStyle(
|
||
color: Colors.white54,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
const Text(
|
||
'Для автоматизации по геолокации',
|
||
style: TextStyle(color: Colors.white30, fontSize: 12),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: TextField(
|
||
controller: _latCtrl,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Широта',
|
||
prefixIcon: Icon(Icons.location_on, size: 20),
|
||
hintText: '51.128',
|
||
),
|
||
keyboardType: const TextInputType.numberWithOptions(
|
||
decimal: true,
|
||
signed: true,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: TextField(
|
||
controller: _lonCtrl,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Долгота',
|
||
prefixIcon: Icon(Icons.location_on, size: 20),
|
||
hintText: '71.430',
|
||
),
|
||
keyboardType: const TextInputType.numberWithOptions(
|
||
decimal: true,
|
||
signed: true,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
|
||
const SizedBox(height: 16),
|
||
|
||
// ─── Геофенс ───
|
||
SwitchListTile(
|
||
title: const Text('Выключать свет при уходе'),
|
||
subtitle: Text(
|
||
_hasCoordinates
|
||
? 'Автовыключение при удалении на 500 м'
|
||
: 'Задайте координаты для активации',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: _hasCoordinates ? Colors.white38 : Colors.white24,
|
||
),
|
||
),
|
||
value: _geofenceEnabled,
|
||
activeThumbColor: Colors.deepOrange,
|
||
onChanged: _hasCoordinates
|
||
? (v) => setState(() => _geofenceEnabled = v)
|
||
: null,
|
||
contentPadding: EdgeInsets.zero,
|
||
secondary: Icon(
|
||
Icons.directions_walk,
|
||
color: _geofenceEnabled && _hasCoordinates
|
||
? Colors.deepOrange
|
||
: Colors.white24,
|
||
),
|
||
),
|
||
if (_geofenceEnabled && _hasCoordinates)
|
||
const Padding(
|
||
padding: EdgeInsets.only(left: 40, bottom: 4),
|
||
child: Text(
|
||
'Проверка раз в ~15 мин (ограничение Android).\n'
|
||
'Работает только для текущего активного дома.\n'
|
||
'Нужны фоновые разрешения на геолокацию и уведомления.',
|
||
style: TextStyle(fontSize: 11, color: Colors.white24),
|
||
),
|
||
),
|
||
|
||
const SizedBox(height: 24),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
height: 48,
|
||
child: ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: Colors.deepOrange,
|
||
foregroundColor: Colors.white,
|
||
),
|
||
onPressed: _saving ? null : _save,
|
||
child: _saving
|
||
? const SizedBox(
|
||
width: 20,
|
||
height: 20,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: Colors.white,
|
||
),
|
||
)
|
||
: Text(_isEdit ? 'СОХРАНИТЬ' : 'ДОБАВИТЬ'),
|
||
),
|
||
),
|
||
// Отступ внизу для системных кнопок
|
||
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _save() async {
|
||
final name = _nameCtrl.text.trim();
|
||
final rawUrl = _urlCtrl.text.trim();
|
||
final key = _keyCtrl.text.trim();
|
||
final latText = _latCtrl.text.trim();
|
||
final lonText = _lonCtrl.text.trim();
|
||
|
||
if (name.isEmpty || rawUrl.isEmpty || key.isEmpty) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Заполните все обязательные поля')),
|
||
);
|
||
return;
|
||
}
|
||
|
||
late final String url;
|
||
try {
|
||
url = IgnisApi.normalizeBaseUrl(rawUrl);
|
||
final parsed = Uri.parse(url);
|
||
if ((parsed.scheme != 'http' && parsed.scheme != 'https') ||
|
||
parsed.host.isEmpty) {
|
||
throw const FormatException();
|
||
}
|
||
} catch (_) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Некорректный адрес сервера')),
|
||
);
|
||
return;
|
||
}
|
||
|
||
double? lat;
|
||
double? lon;
|
||
if (latText.isEmpty != lonText.isEmpty) {
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(const SnackBar(content: Text('Введите обе координаты')));
|
||
return;
|
||
}
|
||
if (latText.isNotEmpty && lonText.isNotEmpty) {
|
||
lat = double.tryParse(latText);
|
||
lon = double.tryParse(lonText);
|
||
if (lat == null || lon == null) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Некорректные координаты')),
|
||
);
|
||
return;
|
||
}
|
||
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Координаты вне допустимого диапазона')),
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
|
||
setState(() => _saving = true);
|
||
|
||
final clearCoords = latText.isEmpty && lonText.isEmpty;
|
||
|
||
final home = _isEdit
|
||
? widget.home!.copyWith(
|
||
name: name,
|
||
url: url,
|
||
latitude: lat,
|
||
longitude: lon,
|
||
geofenceEnabled: clearCoords ? false : _geofenceEnabled,
|
||
clearCoordinates: clearCoords,
|
||
)
|
||
: HomeConfig(
|
||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||
name: name,
|
||
url: url,
|
||
latitude: lat,
|
||
longitude: lon,
|
||
geofenceEnabled: _geofenceEnabled,
|
||
);
|
||
|
||
try {
|
||
await ref.read(apiProvider).validateCredentials(url, key);
|
||
|
||
if (_isEdit) {
|
||
await ref.read(homesProvider.notifier).update(home, apiKey: key);
|
||
} else {
|
||
await ref.read(homesProvider.notifier).add(home, apiKey: key);
|
||
}
|
||
|
||
final currentHome = ref.read(currentHomeProvider);
|
||
if (currentHome?.id == home.id) {
|
||
await ref.read(currentHomeProvider.notifier).select(home);
|
||
}
|
||
|
||
// Синхронизировать фоновый таск с новыми настройками
|
||
final allHomes = ref.read(homesProvider);
|
||
await syncGeofenceTask(
|
||
allHomes,
|
||
currentHome: ref.read(currentHomeProvider),
|
||
);
|
||
|
||
if (mounted) Navigator.of(context).pop();
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('Не удалось сохранить дом: ${describeLoadError(e)}'),
|
||
),
|
||
);
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _saving = false);
|
||
}
|
||
}
|
||
}
|