feat: polish phase 7 forms and schedules

This commit is contained in:
Artem Kokos
2026-05-01 09:47:08 +07:00
parent 91a494adf5
commit 2fa89f6be0
9 changed files with 1583 additions and 599 deletions

View File

@@ -16,6 +16,7 @@ class HomeEditScreen extends ConsumerStatefulWidget {
}
class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
final _formKey = GlobalKey<FormState>();
final _nameCtrl = TextEditingController();
final _urlCtrl = TextEditingController();
final _keyCtrl = TextEditingController();
@@ -85,158 +86,215 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
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,
),
),
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),
),
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,
),
),
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),
),
],
),
const SizedBox(height: 16),
// ─── Геофенс ───
SwitchListTile(
title: const Text('Выключать свет при уходе'),
subtitle: Text(
_hasCoordinates
? 'Автовыключение при удалении на 500 м'
: 'Задайте координаты для активации',
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(
fontSize: 12,
color: _hasCoordinates ? Colors.white38 : Colors.white24,
color: Colors.white54,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
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,
const SizedBox(height: 4),
const Text(
'Нужны только для расстояния и автовыключения по геолокации',
style: TextStyle(color: Colors.white30, fontSize: 12),
),
),
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: 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,
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepOrange,
foregroundColor: Colors.white,
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 ? 'СОХРАНИТЬ' : 'ДОБАВИТЬ'),
),
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),
],
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();