Files
ignis_app/lib/screens/home_edit_screen.dart
2026-05-12 11:27:01 +07:00

439 lines
16 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();
final _radiusCtrl = 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();
}
_radiusCtrl.text = widget.home!.geofenceRadiusMeters.toString();
_geofenceEnabled = widget.home!.geofenceEnabled;
_loadApiKey();
} else {
_radiusCtrl.text = HomeConfig.defaultGeofenceRadiusMeters.toString();
}
// Следим за полями координат чтобы обновлять доступность 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();
_radiusCtrl.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: 12),
TextFormField(
controller: _radiusCtrl,
decoration: const InputDecoration(
labelText: 'Радиус geofence, м',
hintText: '500',
helperText: 'Автовыключение сработает после выхода за этот радиус',
prefixIcon: Icon(Icons.radar),
),
keyboardType: TextInputType.number,
validator: (value) {
final normalized = value?.trim() ?? '';
final radius = int.tryParse(normalized);
if (radius == null) {
return 'Введите радиус в метрах';
}
if (radius < 100 || radius > 5000) {
return 'От 100 до 5000 м';
}
return null;
},
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Выключать свет при уходе'),
subtitle: Text(
_hasCoordinates
? 'Автовыключение после выхода за радиус geofence'
: 'Задайте координаты для активации',
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(
'Работает только для текущего активного дома.\n'
'Использует системный Android geofence, а не polling.\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();
final radiusText = _radiusCtrl.text.trim();
if (name.isEmpty || rawUrl.isEmpty || key.isEmpty || radiusText.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;
}
}
final radiusMeters = int.tryParse(radiusText);
if (radiusMeters == null || radiusMeters < 100 || radiusMeters > 5000) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Радиус geofence должен быть от 100 до 5000 м')),
);
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,
geofenceRadiusMeters: radiusMeters,
clearCoordinates: clearCoords,
)
: HomeConfig(
id: DateTime.now().millisecondsSinceEpoch.toString(),
name: name,
url: url,
latitude: lat,
longitude: lon,
geofenceEnabled: _geofenceEnabled,
geofenceRadiusMeters: radiusMeters,
);
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);
}
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);
}
}
}