Extract settings and harden geofence automation

This commit is contained in:
Artem Kokos
2026-05-15 10:18:46 +07:00
parent 1963488479
commit d796537917
21 changed files with 1392 additions and 278 deletions

View File

@@ -0,0 +1,713 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/build_info.dart';
import '../app/error_message.dart';
import '../app/load_state.dart';
import '../features/settings/models/app_theme_preset.dart';
import '../features/settings/models/geofence_system_state.dart';
import '../features/settings/providers/settings_providers.dart';
import '../models/home_config.dart';
import '../providers/providers.dart';
import 'home_edit_screen.dart';
import 'homes_screen.dart';
enum SettingsEntryPoint { homes, remote }
class SettingsScreen extends ConsumerStatefulWidget {
final SettingsEntryPoint entryPoint;
const SettingsScreen({super.key, required this.entryPoint});
@override
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends ConsumerState<SettingsScreen>
with WidgetsBindingObserver {
bool _savingGeofence = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
ref.invalidate(geofenceSystemStatusProvider);
}
}
@override
Widget build(BuildContext context) {
final currentHome = ref.watch(currentHomeProvider);
final authState = ref.watch(authInfoProvider);
final themePreset = ref.watch(appThemeProvider);
final geofenceStatus = ref.watch(geofenceSystemStatusProvider);
final geofenceState = geofenceStatus.asData?.value;
return Scaffold(
appBar: AppBar(title: const Text('НАСТРОЙКИ')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_SectionTitle(
title: 'Дом и подключение',
subtitle: 'К какому backend сейчас привязано приложение',
),
_SectionCard(
children: [
if (currentHome == null)
const _EmptySectionState(
icon: Icons.home_outlined,
title: 'Активный дом не выбран',
message: 'Выберите или добавьте дом, чтобы управлять светом.',
)
else ...[
_InfoRow(label: 'Дом', value: currentHome.name),
_InfoRow(label: 'Backend', value: currentHome.url),
_InfoRow(
label: 'Доступ',
value: _authSummary(authState),
muted: authState.status == LoadStatus.idle,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.tonalIcon(
onPressed: () => _openHomeEditor(context, currentHome),
icon: const Icon(Icons.edit_location_alt_outlined),
label: const Text('Редактировать дом'),
),
OutlinedButton.icon(
onPressed: () => _openHomes(context),
icon: const Icon(Icons.home_work_outlined),
label: const Text('Дома'),
),
],
),
],
],
),
const SizedBox(height: 20),
_SectionTitle(
title: 'Гео-автоматизация',
subtitle: 'Автовыключение света при уходе из дома',
),
_SectionCard(
children: [
if (currentHome == null)
const _EmptySectionState(
icon: Icons.location_off_outlined,
title: 'Сначала нужен активный дом',
message: 'Без выбранного дома geofence настраивать нечего.',
)
else ...[
SwitchListTile(
contentPadding: EdgeInsets.zero,
secondary: Icon(
Icons.directions_walk,
color: currentHome.hasCoordinates
? Theme.of(context).colorScheme.primary
: Theme.of(context).disabledColor,
),
title: const Text('Выключать свет при уходе'),
subtitle: Text(
currentHome.hasCoordinates
? 'Работает только для текущего активного дома'
: 'Сначала задайте координаты дома',
),
value: currentHome.geofenceEnabled,
onChanged: currentHome.hasCoordinates && !_savingGeofence
? (enabled) => _setGeofenceEnabled(currentHome, enabled)
: null,
),
const Divider(height: 24),
_StatusTile(
icon: _statusIcon(geofenceState, currentHome),
color: _statusColor(context, geofenceState, currentHome),
title: _statusTitle(geofenceState, currentHome),
subtitle: _statusSubtitle(geofenceState, currentHome),
),
if (_savingGeofence)
const Padding(
padding: EdgeInsets.only(top: 12),
child: LinearProgressIndicator(minHeight: 3),
),
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.radar_outlined),
title: const Text('Радиус срабатывания'),
subtitle: Text('${currentHome.geofenceRadiusMeters} м'),
trailing: const Icon(Icons.chevron_right),
enabled: currentHome.hasCoordinates && !_savingGeofence,
onTap: currentHome.hasCoordinates && !_savingGeofence
? () => _editGeofenceRadius(context, currentHome)
: null,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _buildGeofenceActions(
context: context,
home: currentHome,
systemState: geofenceState,
),
),
],
],
),
const SizedBox(height: 20),
_SectionTitle(
title: 'Внешний вид',
subtitle: 'Фиксированные темы без лишней кастомизации',
),
_SectionCard(
children: [
RadioGroup<AppThemePreset>(
groupValue: themePreset,
onChanged: (value) {
if (value != null) {
ref.read(appThemeProvider.notifier).setTheme(value);
}
},
child: Column(
children: [
for (final preset in AppThemePreset.values)
RadioListTile<AppThemePreset>(
value: preset,
activeColor: preset.accentColor,
secondary: CircleAvatar(
radius: 12,
backgroundColor: preset.accentColor,
),
title: Text(preset.title),
subtitle: Text(preset.subtitle),
),
],
),
),
],
),
const SizedBox(height: 20),
_SectionTitle(
title: 'О приложении',
subtitle: 'Короткая техническая сводка без маркетинговой воды',
),
_SectionCard(
children: [
const _InfoRow(label: 'Приложение', value: 'Ignis App'),
_InfoRow(label: 'Сборка', value: BuildInfo.label),
_InfoRow(label: 'Тема', value: themePreset.title),
_InfoRow(
label: 'Backend',
value: currentHome?.url ?? 'не выбран',
muted: currentHome == null,
),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: () => _copyDiagnosticsSummary(
currentHome,
authState,
themePreset,
),
icon: const Icon(Icons.content_copy_outlined),
label: const Text('Скопировать сводку'),
),
],
),
],
),
);
}
List<Widget> _buildGeofenceActions({
required BuildContext context,
required HomeConfig home,
required GeofenceSystemState? systemState,
}) {
final locationNotifier = ref.read(userLocationProvider.notifier);
final issue = systemState?.issue;
if (!home.hasCoordinates) {
return [
FilledButton.tonalIcon(
onPressed: () => _openHomeEditor(context, home),
icon: const Icon(Icons.place_outlined),
label: const Text('Задать координаты'),
),
];
}
return switch (issue) {
GeofenceSystemIssue.locationServicesDisabled => [
FilledButton.tonalIcon(
onPressed: () async {
await locationNotifier.openLocationSettings();
ref.invalidate(geofenceSystemStatusProvider);
},
icon: const Icon(Icons.location_searching_outlined),
label: const Text('Включить геолокацию'),
),
],
GeofenceSystemIssue.permissionDenied => [
FilledButton.tonalIcon(
onPressed: () async {
await locationNotifier.requestPermission();
ref.invalidate(geofenceSystemStatusProvider);
},
icon: const Icon(Icons.verified_user_outlined),
label: const Text('Запросить доступ'),
),
],
GeofenceSystemIssue.permissionDeniedForever ||
GeofenceSystemIssue.backgroundPermissionRequired => [
FilledButton.tonalIcon(
onPressed: () async {
await locationNotifier.openAppSettings();
ref.invalidate(geofenceSystemStatusProvider);
},
icon: const Icon(Icons.settings_outlined),
label: const Text('Открыть настройки Android'),
),
],
_ => [
OutlinedButton.icon(
onPressed: () => _openHomeEditor(context, home),
icon: const Icon(Icons.edit_outlined),
label: const Text('Координаты дома'),
),
],
};
}
Future<void> _setGeofenceEnabled(HomeConfig home, bool enabled) async {
await _saveCurrentHome(
home.copyWith(geofenceEnabled: enabled),
successMessage: enabled
? 'Автовыключение включено'
: 'Автовыключение выключено',
);
}
Future<void> _saveCurrentHome(
HomeConfig updatedHome, {
String? successMessage,
}) async {
setState(() => _savingGeofence = true);
try {
await ref.read(homesProvider.notifier).update(updatedHome);
await ref.read(currentHomeProvider.notifier).switchTo(updatedHome);
ref.invalidate(geofenceSystemStatusProvider);
if (mounted && successMessage != null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(successMessage)));
}
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Не удалось сохранить настройки: ${describeLoadError(error)}',
),
),
);
}
} finally {
if (mounted) {
setState(() => _savingGeofence = false);
}
}
}
Future<void> _editGeofenceRadius(
BuildContext context,
HomeConfig home,
) async {
var draft = home.geofenceRadiusMeters.toDouble();
final saved = await showModalBottomSheet<int>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
return StatefulBuilder(
builder: (context, setModalState) {
final roundedDraft = (draft / 50).round() * 50;
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Радиус geofence',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'$roundedDraft м',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Slider(
value: draft.clamp(100, 5000),
min: 100,
max: 5000,
divisions: 98,
label: '$roundedDraft м',
onChanged: (value) {
setModalState(() => draft = value);
},
),
const SizedBox(height: 4),
const Text(
'Чем больше радиус, тем меньше шанс ложного срабатывания далеко от дома.',
),
const SizedBox(height: 16),
Row(
children: [
TextButton(
onPressed: () => Navigator.of(sheetContext).pop(),
child: const Text('Отмена'),
),
const Spacer(),
FilledButton(
onPressed: () =>
Navigator.of(sheetContext).pop(roundedDraft),
child: const Text('Сохранить'),
),
],
),
],
),
),
);
},
);
},
);
if (saved == null || saved == home.geofenceRadiusMeters) {
return;
}
await _saveCurrentHome(
home.copyWith(geofenceRadiusMeters: saved),
successMessage: 'Радиус geofence обновлён',
);
}
Future<void> _copyDiagnosticsSummary(
HomeConfig? currentHome,
LoadState authState,
AppThemePreset themePreset,
) async {
final lines = [
'app=Ignis App',
'build=${BuildInfo.label}',
'theme=${themePreset.storageValue}',
'home=${currentHome?.name ?? 'none'}',
'backend=${currentHome?.url ?? 'none'}',
'auth=${_authSummary(authState)}',
'geofence_enabled=${currentHome?.geofenceEnabled ?? false}',
'geofence_radius=${currentHome?.geofenceRadiusMeters ?? 0}',
'has_coordinates=${currentHome?.hasCoordinates == true}',
];
await Clipboard.setData(ClipboardData(text: lines.join('\n')));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Сводка скопирована в буфер обмена')),
);
}
String _authSummary(LoadState authState) {
final auth = authState.data;
if (auth == null) {
if (authState.hasError) {
return authState.errorMessage ?? 'ошибка проверки доступа';
}
return 'не загружено';
}
final role = auth.isAdmin ? 'admin' : 'guest';
if (auth.name == null || auth.name!.trim().isEmpty) {
return role;
}
return '${auth.name} · $role';
}
IconData _statusIcon(GeofenceSystemState? state, HomeConfig home) {
if (!home.geofenceEnabled) {
return Icons.pause_circle_outline;
}
return switch (state?.issue) {
GeofenceSystemIssue.ready => Icons.verified_outlined,
GeofenceSystemIssue.locationServicesDisabled => Icons.location_disabled,
GeofenceSystemIssue.permissionDenied ||
GeofenceSystemIssue.permissionDeniedForever ||
GeofenceSystemIssue.backgroundPermissionRequired =>
Icons.gpp_bad_outlined,
GeofenceSystemIssue.missingCoordinates => Icons.place_outlined,
_ => Icons.info_outline,
};
}
Color _statusColor(
BuildContext context,
GeofenceSystemState? state,
HomeConfig home,
) {
if (!home.geofenceEnabled) {
return Theme.of(context).colorScheme.secondary;
}
return switch (state?.issue) {
GeofenceSystemIssue.ready => Colors.green,
GeofenceSystemIssue.locationServicesDisabled ||
GeofenceSystemIssue.permissionDenied ||
GeofenceSystemIssue.permissionDeniedForever ||
GeofenceSystemIssue.backgroundPermissionRequired ||
GeofenceSystemIssue.missingCoordinates => Theme.of(
context,
).colorScheme.error,
_ => Theme.of(context).colorScheme.primary,
};
}
String _statusTitle(GeofenceSystemState? state, HomeConfig home) {
if (!home.geofenceEnabled) {
return 'Автовыключение выключено';
}
return switch (state?.issue) {
GeofenceSystemIssue.ready => 'Geofence готов',
GeofenceSystemIssue.missingCoordinates => 'Нужны координаты дома',
GeofenceSystemIssue.locationServicesDisabled => 'Геолокация выключена',
GeofenceSystemIssue.permissionDenied => 'Нужен доступ к геолокации',
GeofenceSystemIssue.permissionDeniedForever => 'Доступ запрещён навсегда',
GeofenceSystemIssue.backgroundPermissionRequired =>
'Нужен доступ «Всегда»',
GeofenceSystemIssue.noActiveHome => 'Нет активного дома',
null => 'Проверяем системные условия',
};
}
String _statusSubtitle(GeofenceSystemState? state, HomeConfig home) {
if (!home.geofenceEnabled) {
return home.hasCoordinates
? 'Можно заранее настроить радиус и включить позже.'
: 'Сначала задайте координаты дома, потом включайте geofence.';
}
return switch (state?.issue) {
GeofenceSystemIssue.ready =>
'Android сможет отслеживать выход из зоны и запускать автовыключение.',
GeofenceSystemIssue.missingCoordinates =>
'Без координат дома geofence физически некуда поставить.',
GeofenceSystemIssue.locationServicesDisabled =>
'Пока системная геолокация выключена, фоновое срабатывание не поднимется.',
GeofenceSystemIssue.permissionDenied =>
'Нужно хотя бы базовое разрешение на геолокацию.',
GeofenceSystemIssue.permissionDeniedForever =>
'Разрешение нужно вернуть вручную в настройках Android.',
GeofenceSystemIssue.backgroundPermissionRequired =>
'Для фонового geofence нужен доступ к геолокации в режиме «Всегда».',
GeofenceSystemIssue.noActiveHome =>
'Сначала выберите активный дом, для которого будет работать автоматика.',
null => 'Собираем информацию о системных ограничениях.',
};
}
Future<void> _openHomeEditor(BuildContext context, HomeConfig home) async {
await Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => HomeEditScreen(home: home)));
await ref.read(homesProvider.notifier).load();
await ref.read(currentHomeProvider.notifier).load();
ref.invalidate(geofenceSystemStatusProvider);
}
Future<void> _openHomes(BuildContext context) async {
if (widget.entryPoint == SettingsEntryPoint.homes) {
Navigator.of(context).pop();
return;
}
Navigator.of(
context,
).pushReplacement(MaterialPageRoute(builder: (_) => const HomesScreen()));
}
}
class _SectionTitle extends StatelessWidget {
final String title;
final String subtitle;
const _SectionTitle({required this.title, required this.subtitle});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 2),
Text(
subtitle,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.white54),
),
],
),
);
}
}
class _SectionCard extends StatelessWidget {
final List<Widget> children;
const _SectionCard({required this.children});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),
),
);
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;
final bool muted;
const _InfoRow({
required this.label,
required this.value,
this.muted = false,
});
@override
Widget build(BuildContext context) {
final valueStyle = Theme.of(context).textTheme.bodyMedium?.copyWith(
color: muted ? Colors.white54 : null,
fontWeight: FontWeight.w600,
);
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.white54),
),
const SizedBox(height: 2),
Text(value, style: valueStyle),
],
),
);
}
}
class _StatusTile extends StatelessWidget {
final IconData icon;
final Color color;
final String title;
final String subtitle;
const _StatusTile({
required this.icon,
required this.color,
required this.title,
required this.subtitle,
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: color),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
const SizedBox(height: 2),
Text(subtitle, style: const TextStyle(color: Colors.white54)),
],
),
),
],
);
}
}
class _EmptySectionState extends StatelessWidget {
final IconData icon;
final String title;
final String message;
const _EmptySectionState({
required this.icon,
required this.title,
required this.message,
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: Colors.white38),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
const SizedBox(height: 2),
Text(message, style: const TextStyle(color: Colors.white54)),
],
),
),
],
);
}
}