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

@@ -22,8 +22,6 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
final _keyCtrl = TextEditingController();
final _latCtrl = TextEditingController();
final _lonCtrl = TextEditingController();
final _radiusCtrl = TextEditingController();
bool _geofenceEnabled = false;
bool _saving = false;
bool get _isEdit => widget.home != null;
@@ -44,14 +42,10 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
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);
}
@@ -66,12 +60,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
}
void _onCoordsChanged() {
// Если координаты очистили -- выключаем геофенс
if (!_hasCoordinates && _geofenceEnabled) {
setState(() => _geofenceEnabled = false);
} else {
setState(() {}); // перерисовать Switch enabled/disabled
}
setState(() {});
}
@override
@@ -83,7 +72,6 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
_keyCtrl.dispose();
_latCtrl.dispose();
_lonCtrl.dispose();
_radiusCtrl.dispose();
super.dispose();
}
@@ -229,64 +217,17 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
),
],
),
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)
if (_hasCoordinates)
const Padding(
padding: EdgeInsets.only(left: 40, bottom: 4),
padding: EdgeInsets.only(bottom: 24),
child: Text(
'Работает только для текущего активного дома.\n'
'Использует системный Android geofence, а не polling.\n'
'Нужны фоновые разрешения на геолокацию.',
style: TextStyle(fontSize: 11, color: Colors.white24),
'Geofence и радиус настраиваются отдельно на экране настроек.',
style: TextStyle(fontSize: 12, color: Colors.white38),
),
),
const SizedBox(height: 24),
)
else
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 48,
@@ -327,9 +268,8 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
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) {
if (name.isEmpty || rawUrl.isEmpty || key.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Заполните все обязательные поля')),
);
@@ -376,14 +316,6 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
}
}
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;
@@ -394,8 +326,6 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
url: url,
latitude: lat,
longitude: lon,
geofenceEnabled: clearCoords ? false : _geofenceEnabled,
geofenceRadiusMeters: radiusMeters,
clearCoordinates: clearCoords,
)
: HomeConfig(
@@ -404,8 +334,6 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
url: url,
latitude: lat,
longitude: lon,
geofenceEnabled: _geofenceEnabled,
geofenceRadiusMeters: radiusMeters,
);
try {

View File

@@ -4,9 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/error_message.dart';
import '../models/home_config.dart';
import '../providers/providers.dart';
import '../widgets/build_info_text.dart';
import 'home_edit_screen.dart';
import 'remote_screen.dart';
import 'settings_screen.dart';
/// Экран "Дома" -- список серверов Ignis.
/// Пользователь может добавить, удалить, переключить активный дом.
@@ -61,108 +61,100 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
appBar: AppBar(
title: const Text('ДОМА'),
automaticallyImplyLeading: false,
),
body: Column(
children: [
Expanded(
child: homes.isEmpty
? const _EmptyHomesView()
: RefreshIndicator(
color: Colors.deepOrange,
onRefresh: _refreshEnvironmentState,
child: ListView(
padding: const EdgeInsets.all(12),
children: [
...homes.map((home) {
final isActive = currentHome?.id == home.id;
final isSwitching = _switchingHomeId == home.id;
final isDeleting = _deletingHomeId == home.id;
final isBusy = isSwitching || isDeleting;
final distKm = location.distanceToKm(
home.latitude,
home.longitude,
);
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
enabled: !isBusy,
leading: Icon(
Icons.home,
color: isActive
? Colors.deepOrange
: Colors.white38,
size: 28,
),
title: Text(
home.name,
style: TextStyle(
fontWeight: isActive
? FontWeight.bold
: FontWeight.normal,
color: isActive
? Colors.deepOrange
: Colors.white,
),
),
subtitle: _HomeSubtitle(
home: home,
location: location,
distKm: distKm,
isActive: isActive,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isBusy)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
else ...[
IconButton(
icon: const Icon(
Icons.edit,
size: 20,
color: Colors.white38,
),
onPressed: () => _editHome(context, home),
),
IconButton(
icon: const Icon(
Icons.delete_outline,
size: 20,
color: Colors.redAccent,
),
onPressed: () =>
_confirmDelete(context, home),
),
],
],
),
onTap: isBusy
? null
: () => _selectHome(context, home),
),
);
}),
],
),
),
),
const SafeArea(
top: false,
minimum: EdgeInsets.only(bottom: 10),
child: Padding(
padding: EdgeInsets.only(bottom: 6),
child: BuildInfoText(),
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: 'Настройки',
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
const SettingsScreen(entryPoint: SettingsEntryPoint.homes),
),
),
),
],
),
body: homes.isEmpty
? const _EmptyHomesView()
: RefreshIndicator(
color: Colors.deepOrange,
onRefresh: _refreshEnvironmentState,
child: ListView(
padding: const EdgeInsets.all(12),
children: [
...homes.map((home) {
final isActive = currentHome?.id == home.id;
final isSwitching = _switchingHomeId == home.id;
final isDeleting = _deletingHomeId == home.id;
final isBusy = isSwitching || isDeleting;
final distKm = location.distanceToKm(
home.latitude,
home.longitude,
);
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
enabled: !isBusy,
leading: Icon(
Icons.home,
color: isActive ? Colors.deepOrange : Colors.white38,
size: 28,
),
title: Text(
home.name,
style: TextStyle(
fontWeight: isActive
? FontWeight.bold
: FontWeight.normal,
color: isActive ? Colors.deepOrange : Colors.white,
),
),
subtitle: _HomeSubtitle(
home: home,
location: location,
distKm: distKm,
isActive: isActive,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isBusy)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
else ...[
IconButton(
icon: const Icon(
Icons.edit,
size: 20,
color: Colors.white38,
),
onPressed: () => _editHome(context, home),
),
IconButton(
icon: const Icon(
Icons.delete_outline,
size: 20,
color: Colors.redAccent,
),
onPressed: () => _confirmDelete(context, home),
),
],
],
),
onTap: isBusy ? null : () => _selectHome(context, home),
),
);
}),
SizedBox(height: MediaQuery.of(context).padding.bottom + 80),
],
),
),
floatingActionButton: FloatingActionButton(
backgroundColor: Colors.deepOrange,
onPressed: () => _addHome(context),
@@ -282,7 +274,9 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
}
Future<void> _syncLocationWatching() async {
final shouldWatch = ref.read(homesProvider).any((home) => home.hasCoordinates);
final shouldWatch = ref
.read(homesProvider)
.any((home) => home.hasCoordinates);
if (shouldWatch == _isWatchingLocation) {
return;
}

View File

@@ -3,14 +3,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/error_message.dart';
import '../models/ignis_group.dart';
import '../providers/providers.dart';
import '../widgets/build_info_text.dart';
import '../widgets/group_card.dart';
import 'homes_screen.dart';
import 'group_edit_screen.dart';
import 'schedules_screen.dart';
import 'stats_screen.dart';
import 'event_log_screen.dart';
import 'api_keys_screen.dart';
import 'event_log_screen.dart';
import 'group_edit_screen.dart';
import 'homes_screen.dart';
import 'schedules_screen.dart';
import 'settings_screen.dart';
import 'stats_screen.dart';
/// Основной экран пульта управления.
/// Показывает группы текущего дома с управлением.
@@ -91,6 +91,15 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
MaterialPageRoute(builder: (_) => const ApiKeysScreen()),
);
break;
case 'settings':
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const SettingsScreen(
entryPoint: SettingsEntryPoint.remote,
),
),
);
break;
}
},
itemBuilder: (context) => [
@@ -118,6 +127,14 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuItem(
value: 'settings',
child: ListTile(
leading: Icon(Icons.settings_outlined),
title: Text('Настройки'),
contentPadding: EdgeInsets.zero,
),
),
if (isAdmin)
const PopupMenuItem(
value: 'api_keys',
@@ -127,13 +144,6 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuItem(
enabled: false,
child: Padding(
padding: EdgeInsets.symmetric(vertical: 4),
child: BuildInfoText(compact: false, alignStart: true),
),
),
],
),
],

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)),
],
),
),
],
);
}
}