Files
ignis_app/lib/screens/home_edit_screen.dart
2026-05-01 09:47:08 +07:00

408 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 _formKey = GlobalKey<FormState>();
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: Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _nameCtrl,
decoration: const InputDecoration(
labelText: 'Название',
hintText: 'Например: Квартира',
prefixIcon: Icon(Icons.home),
),
textCapitalization: TextCapitalization.sentences,
validator: (value) => (value?.trim().isEmpty ?? true)
? 'Укажите название дома'
: null,
),
const SizedBox(height: 12),
TextFormField(
controller: _urlCtrl,
decoration: const InputDecoration(
labelText: 'Адрес сервера',
hintText: 'Например: ignis.akokos.ru',
helperText: 'Можно без https, приложение подставит его само',
prefixIcon: Icon(Icons.dns),
),
keyboardType: TextInputType.url,
validator: (value) {
final rawUrl = value?.trim() ?? '';
if (rawUrl.isEmpty) {
return 'Укажите адрес сервера';
}
try {
final normalized = IgnisApi.normalizeBaseUrl(rawUrl);
final parsed = Uri.parse(normalized);
if ((parsed.scheme != 'http' && parsed.scheme != 'https') ||
parsed.host.isEmpty) {
return 'Некорректный адрес сервера';
}
} catch (_) {
return 'Некорректный адрес сервера';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _keyCtrl,
decoration: const InputDecoration(
labelText: 'API Key',
helperText: 'Ключ проверяется перед сохранением дома',
prefixIcon: Icon(Icons.key),
),
obscureText: true,
validator: (value) =>
(value?.trim().isEmpty ?? true) ? 'Укажите API key' : null,
),
const SizedBox(height: 24),
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: TextFormField(
controller: _latCtrl,
decoration: const InputDecoration(
labelText: 'Широта',
prefixIcon: Icon(Icons.location_on, size: 20),
hintText: '51.128',
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
validator: (value) {
final latText = value?.trim() ?? '';
final lonText = _lonCtrl.text.trim();
if (latText.isEmpty && lonText.isEmpty) {
return null;
}
if (latText.isEmpty || lonText.isEmpty) {
return 'Введите обе координаты';
}
final lat = double.tryParse(latText);
if (lat == null || lat < -90 || lat > 90) {
return 'От -90 до 90';
}
return null;
},
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _lonCtrl,
decoration: const InputDecoration(
labelText: 'Долгота',
prefixIcon: Icon(Icons.location_on, size: 20),
hintText: '71.430',
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
validator: (value) {
final lonText = value?.trim() ?? '';
final latText = _latCtrl.text.trim();
if (latText.isEmpty && lonText.isEmpty) {
return null;
}
if (latText.isEmpty || lonText.isEmpty) {
return 'Введите обе координаты';
}
final lon = double.tryParse(lonText);
if (lon == null || lon < -180 || lon > 180) {
return 'От -180 до 180';
}
return null;
},
),
),
],
),
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 {
FocusScope.of(context).unfocus();
if (!_formKey.currentState!.validate()) {
return;
}
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);
}
}
}