feat: polish phase 7 forms and schedules
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user