439 lines
16 KiB
Dart
439 lines
16 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 _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);
|
||
}
|
||
}
|
||
}
|