Files
ignis_app/lib/screens/home_edit_screen.dart
2026-05-16 11:22:02 +07:00

413 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 '../features/homes/services/home_connection_change.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 _saving = false;
bool _loadingApiKey = false;
bool _hasStoredApiKey = false;
String _originalApiKey = '';
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();
}
_loadingApiKey = true;
_loadApiKey();
}
// Следим за полями координат, чтобы обновлять подсказки экрана.
_latCtrl.addListener(_onCoordsChanged);
_lonCtrl.addListener(_onCoordsChanged);
}
Future<void> _loadApiKey() async {
try {
final apiKey = await ref
.read(settingsServiceProvider)
.getHomeApiKey(widget.home!.id);
_originalApiKey = apiKey?.trim() ?? '';
_hasStoredApiKey = _originalApiKey.isNotEmpty;
if (!mounted) {
return;
}
} finally {
if (mounted) {
setState(() => _loadingApiKey = false);
}
}
}
void _onCoordsChanged() {
setState(() {});
}
@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: InputDecoration(
labelText: 'API Key',
hintText: _hasStoredApiKey
? 'Оставьте пустым, чтобы сохранить текущий ключ'
: null,
helperText: _loadingApiKey
? 'Загружаем сохранённый ключ...'
: _hasStoredApiKey
? 'Сохранённый ключ хранится отдельно и не показывается в поле'
: 'Ключ хранится отдельно в защищённом хранилище',
prefixIcon: const Icon(Icons.key),
),
obscureText: true,
validator: (value) {
final enteredKey = value?.trim() ?? '';
if (enteredKey.isNotEmpty || _hasStoredApiKey) {
return null;
}
return 'Укажите API key';
},
),
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),
if (_hasCoordinates)
const Padding(
padding: EdgeInsets.only(bottom: 24),
child: Text(
'Geofence и радиус настраиваются отдельно на экране настроек.',
style: TextStyle(fontSize: 12, color: Colors.white38),
),
)
else
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepOrange,
foregroundColor: Colors.white,
),
onPressed: (_saving || _loadingApiKey) ? 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 (_loadingApiKey) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Подождите, API key ещё загружается')),
);
return;
}
if (!_formKey.currentState!.validate()) {
return;
}
final name = _nameCtrl.text.trim();
final rawUrl = _urlCtrl.text.trim();
final enteredKey = _keyCtrl.text.trim();
final key = enteredKey.isNotEmpty ? enteredKey : _originalApiKey;
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 credentialsChanged = hasHomeConnectionChanges(
originalHome: widget.home,
normalizedUrl: url,
apiKey: key,
originalApiKey: _originalApiKey,
);
final home = _isEdit
? widget.home!.copyWith(
name: name,
url: url,
latitude: lat,
longitude: lon,
clearCoordinates: clearCoords,
)
: HomeConfig(
id: DateTime.now().millisecondsSinceEpoch.toString(),
name: name,
url: url,
latitude: lat,
longitude: lon,
);
try {
if (credentialsChanged) {
await ref.read(apiProvider).validateCredentials(url, key);
}
if (_isEdit) {
await ref
.read(homesProvider.notifier)
.update(home, apiKey: enteredKey.isNotEmpty ? key : null);
} else {
await ref.read(homesProvider.notifier).add(home, apiKey: key);
}
final currentHome = ref.read(currentHomeProvider);
if (currentHome?.id == home.id) {
if (credentialsChanged) {
await ref.read(currentHomeProvider.notifier).select(home);
} else {
await ref.read(currentHomeProvider.notifier).switchTo(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);
}
}
}