diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index b85799d..61b7b08 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
+
return
+ GeofencePresenceState.UNKNOWN,
+ GeofencePresenceState.INSIDE,
+ -> Unit
}
+ val config = store.loadConfig() ?: return
store.setState(GeofencePresenceState.OUTSIDE)
- scheduleExitWorker(context, store.loadConfig()?.homeId ?: return)
+ scheduleExitWorker(context, config.homeId)
}
fun markTriggered(context: Context) {
@@ -181,6 +193,49 @@ object GeofenceAutomationManager {
)
}
+ @SuppressLint("MissingPermission")
+ private fun seedPresenceState(
+ context: Context,
+ config: StoredGeofenceConfig,
+ onComplete: () -> Unit,
+ ) {
+ if (!hasRequiredLocationPermission(context)) {
+ GeofenceNativeStore(context).setState(GeofencePresenceState.UNKNOWN)
+ onComplete()
+ return
+ }
+
+ LocationServices.getFusedLocationProviderClient(context).lastLocation
+ .addOnSuccessListener { location ->
+ val state =
+ when {
+ location == null -> GeofencePresenceState.UNKNOWN
+ isInsideFence(location, config) -> GeofencePresenceState.INSIDE
+ else -> GeofencePresenceState.OUTSIDE
+ }
+ GeofenceNativeStore(context).setState(state)
+ onComplete()
+ }.addOnFailureListener {
+ GeofenceNativeStore(context).setState(GeofencePresenceState.UNKNOWN)
+ onComplete()
+ }
+ }
+
+ private fun isInsideFence(
+ location: Location,
+ config: StoredGeofenceConfig,
+ ): Boolean {
+ val distance = FloatArray(1)
+ Location.distanceBetween(
+ location.latitude,
+ location.longitude,
+ config.latitude,
+ config.longitude,
+ distance,
+ )
+ return distance[0] <= config.radiusMeters
+ }
+
@SuppressLint("MissingPermission")
private fun registerGeofence(
context: Context,
diff --git a/android/app/src/main/kotlin/ru/akokos/ignis_app/GeofenceAutomationNotifier.kt b/android/app/src/main/kotlin/ru/akokos/ignis_app/GeofenceAutomationNotifier.kt
new file mode 100644
index 0000000..195c393
--- /dev/null
+++ b/android/app/src/main/kotlin/ru/akokos/ignis_app/GeofenceAutomationNotifier.kt
@@ -0,0 +1,111 @@
+package ru.akokos.ignis_app
+
+import android.Manifest
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+
+object GeofenceAutomationNotifier {
+ private const val channelId = "ignis_geofence_automation"
+ private const val channelName = "Geofence automation"
+ private const val notificationId = 4101
+
+ fun showExitProcessed(
+ context: Context,
+ homeName: String?,
+ turnedOffGroups: Int,
+ ) {
+ val title = if (turnedOffGroups > 0) "Ignis выключил свет" else "Ignis проверил свет"
+ val body =
+ if (turnedOffGroups > 0) {
+ buildString {
+ append("Geofence подтвердил уход из дома")
+ homeName?.takeIf { it.isNotBlank() }?.let { append(" \"$it\"") }
+ append(" и выключил ")
+ append(turnedOffGroups)
+ append(if (turnedOffGroups == 1) " группу." else " групп(ы).")
+ }
+ } else {
+ buildString {
+ append("Geofence подтвердил уход из дома")
+ homeName?.takeIf { it.isNotBlank() }?.let { append(" \"$it\"") }
+ append(", но включённых групп не нашёл.")
+ }
+ }
+
+ show(context, title, body)
+ }
+
+ private fun show(
+ context: Context,
+ title: String,
+ body: String,
+ ) {
+ if (!canNotify(context)) {
+ return
+ }
+
+ ensureChannel(context)
+
+ val launchIntent =
+ context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ }
+
+ val notification =
+ NotificationCompat.Builder(context, channelId)
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
+ .setContentTitle(title)
+ .setContentText(body)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(body))
+ .setAutoCancel(true)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setContentIntent(
+ launchIntent?.let {
+ PendingIntent.getActivity(
+ context,
+ 0,
+ it,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+ },
+ )
+ .build()
+
+ NotificationManagerCompat.from(context).notify(notificationId, notification)
+ }
+
+ private fun canNotify(context: Context): Boolean {
+ val permissionGranted =
+ Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.POST_NOTIFICATIONS,
+ ) == PackageManager.PERMISSION_GRANTED
+ return permissionGranted && NotificationManagerCompat.from(context).areNotificationsEnabled()
+ }
+
+ private fun ensureChannel(context: Context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ return
+ }
+
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel =
+ NotificationChannel(
+ channelId,
+ channelName,
+ NotificationManager.IMPORTANCE_DEFAULT,
+ ).apply {
+ description = "Уведомления о срабатывании geofence-автоматизации Ignis"
+ }
+ manager.createNotificationChannel(channel)
+ }
+}
diff --git a/android/app/src/main/kotlin/ru/akokos/ignis_app/GeofenceExitWorker.kt b/android/app/src/main/kotlin/ru/akokos/ignis_app/GeofenceExitWorker.kt
index 444c2c8..173ca78 100644
--- a/android/app/src/main/kotlin/ru/akokos/ignis_app/GeofenceExitWorker.kt
+++ b/android/app/src/main/kotlin/ru/akokos/ignis_app/GeofenceExitWorker.kt
@@ -33,6 +33,11 @@ class GeofenceExitWorker(
}
GeofenceAutomationManager.markTriggered(applicationContext)
+ GeofenceAutomationNotifier.showExitProcessed(
+ context = applicationContext,
+ homeName = config.homeName,
+ turnedOffGroups = activeGroupIds.size,
+ )
Result.success()
}
.getOrElse { Result.retry() }
diff --git a/android/app/src/main/kotlin/ru/akokos/ignis_app/MainActivity.kt b/android/app/src/main/kotlin/ru/akokos/ignis_app/MainActivity.kt
index a599a90..25c6c3d 100644
--- a/android/app/src/main/kotlin/ru/akokos/ignis_app/MainActivity.kt
+++ b/android/app/src/main/kotlin/ru/akokos/ignis_app/MainActivity.kt
@@ -15,6 +15,7 @@ class MainActivity : FlutterActivity() {
when (call.method) {
"armGeofence" -> {
val homeId = call.argument("homeId")
+ val homeName = call.argument("homeName")
val baseUrl = call.argument("baseUrl")
val apiKey = call.argument("apiKey")
val latitude = call.argument("latitude")
@@ -42,6 +43,7 @@ class MainActivity : FlutterActivity() {
config =
StoredGeofenceConfig(
homeId = homeId,
+ homeName = homeName ?: "",
baseUrl = baseUrl,
apiKey = apiKey,
latitude = latitude,
diff --git a/lib/features/homes/services/geofence_automation_service.dart b/lib/features/homes/services/geofence_automation_service.dart
index a173ff7..1f9894a 100644
--- a/lib/features/homes/services/geofence_automation_service.dart
+++ b/lib/features/homes/services/geofence_automation_service.dart
@@ -20,6 +20,7 @@ class GeofenceAutomationService {
final apiKey = await _settingsService.requireHomeApiKey(home.id);
await _invoke('armGeofence', {
'homeId': home.id,
+ 'homeName': home.name,
'baseUrl': home.url,
'apiKey': apiKey,
'latitude': home.latitude,
diff --git a/lib/features/settings/models/app_theme_preset.dart b/lib/features/settings/models/app_theme_preset.dart
new file mode 100644
index 0000000..7d6c465
--- /dev/null
+++ b/lib/features/settings/models/app_theme_preset.dart
@@ -0,0 +1,110 @@
+import 'package:flutter/material.dart';
+
+enum AppThemePreset {
+ ember,
+ graphite,
+ moss;
+
+ static const AppThemePreset fallback = AppThemePreset.ember;
+
+ String get storageValue => switch (this) {
+ AppThemePreset.ember => 'ember',
+ AppThemePreset.graphite => 'graphite',
+ AppThemePreset.moss => 'moss',
+ };
+
+ String get title => switch (this) {
+ AppThemePreset.ember => 'Ember',
+ AppThemePreset.graphite => 'Graphite',
+ AppThemePreset.moss => 'Moss',
+ };
+
+ String get subtitle => switch (this) {
+ AppThemePreset.ember => 'Тёплая тёмная тема по умолчанию',
+ AppThemePreset.graphite => 'Холодная тёмная тема для пульта',
+ AppThemePreset.moss => 'Тёмная зелёная тема для спокойного режима',
+ };
+
+ Color get accentColor => switch (this) {
+ AppThemePreset.ember => const Color(0xFFFF6B2C),
+ AppThemePreset.graphite => const Color(0xFF5BC0BE),
+ AppThemePreset.moss => const Color(0xFF8AA05A),
+ };
+
+ ThemeData get themeData => switch (this) {
+ AppThemePreset.ember => _buildDarkTheme(
+ seedColor: const Color(0xFFFF6B2C),
+ scaffoldBackgroundColor: const Color(0xFF0E0E0E),
+ surfaceColor: const Color(0xFF1C1A18),
+ appBarColor: const Color(0xFF191715),
+ ),
+ AppThemePreset.graphite => _buildDarkTheme(
+ seedColor: const Color(0xFF5BC0BE),
+ scaffoldBackgroundColor: const Color(0xFF0C1217),
+ surfaceColor: const Color(0xFF151D24),
+ appBarColor: const Color(0xFF121920),
+ ),
+ AppThemePreset.moss => _buildDarkTheme(
+ seedColor: const Color(0xFF8AA05A),
+ scaffoldBackgroundColor: const Color(0xFF10140F),
+ surfaceColor: const Color(0xFF1A2118),
+ appBarColor: const Color(0xFF161C15),
+ ),
+ };
+
+ static AppThemePreset fromStorageValue(String? value) {
+ for (final preset in AppThemePreset.values) {
+ if (preset.storageValue == value) {
+ return preset;
+ }
+ }
+ return fallback;
+ }
+}
+
+ThemeData _buildDarkTheme({
+ required Color seedColor,
+ required Color scaffoldBackgroundColor,
+ required Color surfaceColor,
+ required Color appBarColor,
+}) {
+ final scheme = ColorScheme.fromSeed(
+ seedColor: seedColor,
+ brightness: Brightness.dark,
+ );
+ return ThemeData(
+ useMaterial3: true,
+ colorScheme: scheme,
+ scaffoldBackgroundColor: scaffoldBackgroundColor,
+ appBarTheme: AppBarTheme(
+ backgroundColor: appBarColor,
+ foregroundColor: Colors.white,
+ elevation: 0,
+ centerTitle: true,
+ ),
+ cardTheme: CardThemeData(
+ color: surfaceColor,
+ elevation: 1.5,
+ margin: EdgeInsets.zero,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
+ ),
+ listTileTheme: const ListTileThemeData(iconColor: Colors.white70),
+ inputDecorationTheme: InputDecorationTheme(
+ filled: true,
+ fillColor: surfaceColor,
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(14)),
+ enabledBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(14),
+ borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.08)),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(14),
+ borderSide: BorderSide(color: scheme.primary, width: 1.4),
+ ),
+ ),
+ sliderTheme: const SliderThemeData(
+ trackHeight: 4,
+ thumbShape: RoundSliderThumbShape(enabledThumbRadius: 8),
+ ),
+ );
+}
diff --git a/lib/features/settings/models/geofence_system_state.dart b/lib/features/settings/models/geofence_system_state.dart
new file mode 100644
index 0000000..71d5b93
--- /dev/null
+++ b/lib/features/settings/models/geofence_system_state.dart
@@ -0,0 +1,17 @@
+enum GeofenceSystemIssue {
+ noActiveHome,
+ missingCoordinates,
+ locationServicesDisabled,
+ permissionDenied,
+ permissionDeniedForever,
+ backgroundPermissionRequired,
+ ready,
+}
+
+class GeofenceSystemState {
+ final GeofenceSystemIssue issue;
+
+ const GeofenceSystemState(this.issue);
+
+ bool get isReady => issue == GeofenceSystemIssue.ready;
+}
diff --git a/lib/features/settings/providers/settings_providers.dart b/lib/features/settings/providers/settings_providers.dart
new file mode 100644
index 0000000..149d3a0
--- /dev/null
+++ b/lib/features/settings/providers/settings_providers.dart
@@ -0,0 +1,89 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:geolocator/geolocator.dart';
+
+import '../../homes/providers/homes_providers.dart';
+import '../../shared/providers/core_providers.dart';
+import '../models/app_theme_preset.dart';
+import '../models/geofence_system_state.dart';
+
+final initialAppThemePresetProvider = Provider(
+ (ref) => AppThemePreset.fallback,
+);
+
+final appThemeProvider = NotifierProvider(
+ AppThemeNotifier.new,
+);
+
+class AppThemeNotifier extends Notifier {
+ @override
+ AppThemePreset build() => ref.read(initialAppThemePresetProvider);
+
+ Future setTheme(AppThemePreset preset) async {
+ if (state == preset) {
+ return;
+ }
+ state = preset;
+ await ref.read(settingsServiceProvider).setAppThemePreset(preset);
+ }
+}
+
+abstract class GeofenceSystemStatusService {
+ Future inspect({
+ required bool hasActiveHome,
+ required bool hasCoordinates,
+ });
+}
+
+class DeviceGeofenceSystemStatusService implements GeofenceSystemStatusService {
+ @override
+ Future inspect({
+ required bool hasActiveHome,
+ required bool hasCoordinates,
+ }) async {
+ if (!hasActiveHome) {
+ return const GeofenceSystemState(GeofenceSystemIssue.noActiveHome);
+ }
+ if (!hasCoordinates) {
+ return const GeofenceSystemState(GeofenceSystemIssue.missingCoordinates);
+ }
+ if (!await Geolocator.isLocationServiceEnabled()) {
+ return const GeofenceSystemState(
+ GeofenceSystemIssue.locationServicesDisabled,
+ );
+ }
+
+ final permission = await Geolocator.checkPermission();
+ return switch (permission) {
+ LocationPermission.denied => const GeofenceSystemState(
+ GeofenceSystemIssue.permissionDenied,
+ ),
+ LocationPermission.deniedForever => const GeofenceSystemState(
+ GeofenceSystemIssue.permissionDeniedForever,
+ ),
+ LocationPermission.whileInUse => const GeofenceSystemState(
+ GeofenceSystemIssue.backgroundPermissionRequired,
+ ),
+ LocationPermission.always => const GeofenceSystemState(
+ GeofenceSystemIssue.ready,
+ ),
+ _ => const GeofenceSystemState(GeofenceSystemIssue.permissionDenied),
+ };
+ }
+}
+
+final geofenceSystemStatusServiceProvider =
+ Provider(
+ (ref) => DeviceGeofenceSystemStatusService(),
+ );
+
+final geofenceSystemStatusProvider = FutureProvider((
+ ref,
+) async {
+ final currentHome = ref.watch(currentHomeProvider);
+ return ref
+ .watch(geofenceSystemStatusServiceProvider)
+ .inspect(
+ hasActiveHome: currentHome != null,
+ hasCoordinates: currentHome?.hasCoordinates == true,
+ );
+});
diff --git a/lib/main.dart b/lib/main.dart
index f1c6c49..4c4b6d6 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -1,45 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app/app_bootstrap.dart';
+import 'features/settings/providers/settings_providers.dart';
+import 'features/shared/providers/core_providers.dart';
import 'screens/homes_screen.dart';
import 'screens/remote_screen.dart';
+import 'services/settings_service.dart';
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
- runApp(const ProviderScope(child: IgnisApp()));
+ final settingsService = SettingsService();
+ final initialTheme = await settingsService.getAppThemePreset();
+
+ runApp(
+ ProviderScope(
+ overrides: [
+ settingsServiceProvider.overrideWithValue(settingsService),
+ initialAppThemePresetProvider.overrideWithValue(initialTheme),
+ ],
+ child: const IgnisApp(),
+ ),
+ );
}
-class IgnisApp extends StatelessWidget {
+class IgnisApp extends ConsumerWidget {
const IgnisApp({super.key});
@override
- Widget build(BuildContext context) {
+ Widget build(BuildContext context, WidgetRef ref) {
+ final themePreset = ref.watch(appThemeProvider);
return MaterialApp(
title: 'Ignis',
debugShowCheckedModeBanner: false,
- theme: ThemeData.dark(useMaterial3: true).copyWith(
- scaffoldBackgroundColor: const Color(0xFF0E0E0E),
- colorScheme: ColorScheme.fromSeed(
- seedColor: Colors.deepOrange,
- brightness: Brightness.dark,
- ),
- appBarTheme: const AppBarTheme(
- backgroundColor: Color(0xFF1A1A1A),
- elevation: 0,
- centerTitle: true,
- ),
- cardTheme: CardThemeData(
- color: const Color(0xFF1E1E1E),
- elevation: 2,
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(16),
- ),
- ),
- sliderTheme: const SliderThemeData(
- trackHeight: 4,
- thumbShape: RoundSliderThumbShape(enabledThumbRadius: 8),
- ),
- ),
+ theme: themePreset.themeData,
home: const MainGate(),
);
}
diff --git a/lib/screens/home_edit_screen.dart b/lib/screens/home_edit_screen.dart
index 6c612fa..83a9c78 100644
--- a/lib/screens/home_edit_screen.dart
+++ b/lib/screens/home_edit_screen.dart
@@ -22,8 +22,6 @@ class _HomeEditScreenState extends ConsumerState {
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 {
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 {
}
void _onCoordsChanged() {
- // Если координаты очистили -- выключаем геофенс
- if (!_hasCoordinates && _geofenceEnabled) {
- setState(() => _geofenceEnabled = false);
- } else {
- setState(() {}); // перерисовать Switch enabled/disabled
- }
+ setState(() {});
}
@override
@@ -83,7 +72,6 @@ class _HomeEditScreenState extends ConsumerState {
_keyCtrl.dispose();
_latCtrl.dispose();
_lonCtrl.dispose();
- _radiusCtrl.dispose();
super.dispose();
}
@@ -229,64 +217,17 @@ class _HomeEditScreenState extends ConsumerState {
),
],
),
- 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 {
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 {
}
}
- 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 {
url: url,
latitude: lat,
longitude: lon,
- geofenceEnabled: clearCoords ? false : _geofenceEnabled,
- geofenceRadiusMeters: radiusMeters,
clearCoordinates: clearCoords,
)
: HomeConfig(
@@ -404,8 +334,6 @@ class _HomeEditScreenState extends ConsumerState {
url: url,
latitude: lat,
longitude: lon,
- geofenceEnabled: _geofenceEnabled,
- geofenceRadiusMeters: radiusMeters,
);
try {
diff --git a/lib/screens/homes_screen.dart b/lib/screens/homes_screen.dart
index f674080..996d56c 100644
--- a/lib/screens/homes_screen.dart
+++ b/lib/screens/homes_screen.dart
@@ -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
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
}
Future _syncLocationWatching() async {
- final shouldWatch = ref.read(homesProvider).any((home) => home.hasCoordinates);
+ final shouldWatch = ref
+ .read(homesProvider)
+ .any((home) => home.hasCoordinates);
if (shouldWatch == _isWatchingLocation) {
return;
}
diff --git a/lib/screens/remote_screen.dart b/lib/screens/remote_screen.dart
index f367edc..f70e384 100644
--- a/lib/screens/remote_screen.dart
+++ b/lib/screens/remote_screen.dart
@@ -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 {
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 {
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 {
contentPadding: EdgeInsets.zero,
),
),
- const PopupMenuItem(
- enabled: false,
- child: Padding(
- padding: EdgeInsets.symmetric(vertical: 4),
- child: BuildInfoText(compact: false, alignStart: true),
- ),
- ),
],
),
],
diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart
new file mode 100644
index 0000000..8ec0817
--- /dev/null
+++ b/lib/screens/settings_screen.dart
@@ -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 createState() => _SettingsScreenState();
+}
+
+class _SettingsScreenState extends ConsumerState
+ 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(
+ groupValue: themePreset,
+ onChanged: (value) {
+ if (value != null) {
+ ref.read(appThemeProvider.notifier).setTheme(value);
+ }
+ },
+ child: Column(
+ children: [
+ for (final preset in AppThemePreset.values)
+ RadioListTile(
+ 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 _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 _setGeofenceEnabled(HomeConfig home, bool enabled) async {
+ await _saveCurrentHome(
+ home.copyWith(geofenceEnabled: enabled),
+ successMessage: enabled
+ ? 'Автовыключение включено'
+ : 'Автовыключение выключено',
+ );
+ }
+
+ Future _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 _editGeofenceRadius(
+ BuildContext context,
+ HomeConfig home,
+ ) async {
+ var draft = home.geofenceRadiusMeters.toDouble();
+ final saved = await showModalBottomSheet(
+ 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 _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 _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 _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 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)),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart
index 7abc0c5..42e33fa 100644
--- a/lib/services/settings_service.dart
+++ b/lib/services/settings_service.dart
@@ -1,5 +1,6 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
+import '../features/settings/models/app_theme_preset.dart';
import '../models/home_config.dart';
import 'credentials_storage.dart';
@@ -8,6 +9,7 @@ import 'credentials_storage.dart';
class SettingsService {
static const String _homesKey = 'ignis_homes';
static const String _currentHomeKey = 'ignis_current_home_id';
+ static const String _themeKey = 'ignis_theme_preset';
final CredentialsStorage _credentialsStorage;
@@ -107,6 +109,16 @@ class SettingsService {
Future deleteHomeApiKey(String homeId) =>
_credentialsStorage.deleteApiKey(homeId);
+ Future getAppThemePreset() async {
+ final prefs = await SharedPreferences.getInstance();
+ return AppThemePreset.fromStorageValue(prefs.getString(_themeKey));
+ }
+
+ Future setAppThemePreset(AppThemePreset preset) async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setString(_themeKey, preset.storageValue);
+ }
+
Future>> _migrateApiKeysIfNeeded(
SharedPreferences prefs,
List rawList,
diff --git a/lib/widgets/build_info_text.dart b/lib/widgets/build_info_text.dart
deleted file mode 100644
index e2f24a7..0000000
--- a/lib/widgets/build_info_text.dart
+++ /dev/null
@@ -1,52 +0,0 @@
-import 'package:flutter/material.dart';
-
-import '../app/build_info.dart';
-
-class BuildInfoText extends StatelessWidget {
- final bool compact;
- final bool alignStart;
-
- const BuildInfoText({
- super.key,
- this.compact = true,
- this.alignStart = false,
- });
-
- @override
- Widget build(BuildContext context) {
- final alignment = alignStart
- ? CrossAxisAlignment.start
- : CrossAxisAlignment.center;
- final textAlign = alignStart ? TextAlign.left : TextAlign.center;
-
- if (compact) {
- return Text(
- BuildInfo.label,
- textAlign: textAlign,
- style: const TextStyle(color: Colors.white24, fontSize: 10),
- );
- }
-
- return Column(
- crossAxisAlignment: alignment,
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(
- BuildInfo.hasMetadata ? BuildInfo.shortSha : 'build info unavailable',
- textAlign: textAlign,
- style: const TextStyle(
- color: Colors.white54,
- fontSize: 11,
- fontWeight: FontWeight.w600,
- ),
- ),
- const SizedBox(height: 2),
- Text(
- BuildInfo.hasMetadata ? BuildInfo.formattedDate : '',
- textAlign: textAlign,
- style: const TextStyle(color: Colors.white24, fontSize: 10),
- ),
- ],
- );
- }
-}
diff --git a/test/geofence_automation_service_test.dart b/test/geofence_automation_service_test.dart
index 8fefee3..8a01cd4 100644
--- a/test/geofence_automation_service_test.dart
+++ b/test/geofence_automation_service_test.dart
@@ -85,6 +85,7 @@ void main() {
expect(calls.single.method, 'armGeofence');
expect(calls.single.arguments, {
'homeId': 'home-1',
+ 'homeName': 'Home 1',
'baseUrl': 'https://one.example',
'apiKey': 'secret-key',
'latitude': 55.75,
diff --git a/test/geofence_system_status_provider_test.dart b/test/geofence_system_status_provider_test.dart
new file mode 100644
index 0000000..2d02c8e
--- /dev/null
+++ b/test/geofence_system_status_provider_test.dart
@@ -0,0 +1,65 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:ignis_app/features/settings/models/geofence_system_state.dart';
+import 'package:ignis_app/features/settings/providers/settings_providers.dart';
+import 'package:ignis_app/providers/providers.dart';
+import 'package:ignis_app/services/settings_service.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+import 'test_support.dart';
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ test(
+ 'geofence system status provider inspects current home prerequisites',
+ () async {
+ SharedPreferences.setMockInitialValues({
+ 'ignis_homes':
+ '[{"id":"home-1","name":"Дом","url":"https://one.example","latitude":55.75,"longitude":37.61,"geofenceEnabled":true}]',
+ 'ignis_current_home_id': 'home-1',
+ });
+
+ final settingsService = SettingsService(
+ credentialsStorage: InMemoryCredentialsStorage(),
+ );
+ await settingsService.setHomeApiKey('home-1', 'key-1');
+ final fakeService = _RecordingGeofenceSystemStatusService(
+ const GeofenceSystemState(GeofenceSystemIssue.ready),
+ );
+ final container = createTestContainer(
+ FakeIgnisApi(),
+ settingsService: settingsService,
+ extraOverrides: [
+ geofenceSystemStatusServiceProvider.overrideWithValue(fakeService),
+ ],
+ );
+
+ await container.read(currentHomeProvider.notifier).load();
+ final state = await container.read(geofenceSystemStatusProvider.future);
+
+ expect(state.issue, GeofenceSystemIssue.ready);
+ expect(fakeService.lastHasActiveHome, isTrue);
+ expect(fakeService.lastHasCoordinates, isTrue);
+ },
+ );
+}
+
+class _RecordingGeofenceSystemStatusService
+ implements GeofenceSystemStatusService {
+ final GeofenceSystemState result;
+
+ _RecordingGeofenceSystemStatusService(this.result);
+
+ bool? lastHasActiveHome;
+ bool? lastHasCoordinates;
+
+ @override
+ Future inspect({
+ required bool hasActiveHome,
+ required bool hasCoordinates,
+ }) async {
+ lastHasActiveHome = hasActiveHome;
+ lastHasCoordinates = hasCoordinates;
+ return result;
+ }
+}
diff --git a/test/settings_service_test.dart b/test/settings_service_test.dart
index 37cd03b..059c64c 100644
--- a/test/settings_service_test.dart
+++ b/test/settings_service_test.dart
@@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
+import 'package:ignis_app/features/settings/models/app_theme_preset.dart';
import 'package:ignis_app/models/home_config.dart';
import 'package:ignis_app/services/credentials_storage.dart';
import 'package:ignis_app/services/settings_service.dart';
@@ -79,4 +80,18 @@ void main() {
jsonDecode(prefs.getString('ignis_homes')!) as List;
expect(storedHomes.single, isNot(contains('apiKey')));
});
+
+ test('stores and restores app theme preset', () async {
+ SharedPreferences.setMockInitialValues({});
+
+ final service = SettingsService(
+ credentialsStorage: InMemoryCredentialsStorage(),
+ );
+
+ expect(await service.getAppThemePreset(), AppThemePreset.fallback);
+
+ await service.setAppThemePreset(AppThemePreset.graphite);
+
+ expect(await service.getAppThemePreset(), AppThemePreset.graphite);
+ });
}
diff --git a/test/settings_theme_provider_test.dart b/test/settings_theme_provider_test.dart
new file mode 100644
index 0000000..72121f5
--- /dev/null
+++ b/test/settings_theme_provider_test.dart
@@ -0,0 +1,34 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:ignis_app/features/settings/models/app_theme_preset.dart';
+import 'package:ignis_app/features/settings/providers/settings_providers.dart';
+import 'package:ignis_app/features/shared/providers/core_providers.dart';
+import 'package:ignis_app/services/settings_service.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+import 'test_support.dart';
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ test('app theme notifier updates state and persists selection', () async {
+ SharedPreferences.setMockInitialValues({});
+ final settingsService = SettingsService(
+ credentialsStorage: InMemoryCredentialsStorage(),
+ );
+ final container = ProviderContainer(
+ overrides: [
+ settingsServiceProvider.overrideWithValue(settingsService),
+ initialAppThemePresetProvider.overrideWithValue(AppThemePreset.ember),
+ ],
+ );
+ addTearDown(container.dispose);
+
+ await container
+ .read(appThemeProvider.notifier)
+ .setTheme(AppThemePreset.graphite);
+
+ expect(container.read(appThemeProvider), AppThemePreset.graphite);
+ expect(await settingsService.getAppThemePreset(), AppThemePreset.graphite);
+ });
+}
diff --git a/test/test_support.dart b/test/test_support.dart
index 79531a9..daeffdf 100644
--- a/test/test_support.dart
+++ b/test/test_support.dart
@@ -2,6 +2,8 @@ import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
+import 'package:ignis_app/features/settings/models/app_theme_preset.dart';
+import 'package:ignis_app/features/settings/providers/settings_providers.dart';
import 'package:ignis_app/providers/providers.dart';
import 'package:ignis_app/services/api_client.dart';
import 'package:ignis_app/services/credentials_storage.dart';
@@ -336,14 +338,18 @@ ProviderContainer createTestContainer(
FakeIgnisApi api, {
SettingsService? settingsService,
bool remotePollingEnabled = true,
+ AppThemePreset initialThemePreset = AppThemePreset.fallback,
+ List extraOverrides = const [],
}) {
final overrides = [
apiProvider.overrideWithValue(api),
remotePollingEnabledProvider.overrideWithValue(remotePollingEnabled),
+ initialAppThemePresetProvider.overrideWithValue(initialThemePreset),
];
if (settingsService != null) {
overrides.add(settingsServiceProvider.overrideWithValue(settingsService));
}
+ overrides.addAll(extraOverrides.cast());
final container = ProviderContainer(overrides: overrides);
addTearDown(container.dispose);
@@ -356,11 +362,15 @@ Future pumpTestApp(
FakeIgnisApi? api,
SettingsService? settingsService,
bool remotePollingEnabled = true,
+ AppThemePreset initialThemePreset = AppThemePreset.fallback,
+ List extraOverrides = const [],
}) async {
final container = createTestContainer(
api ?? FakeIgnisApi(),
settingsService: settingsService,
remotePollingEnabled: remotePollingEnabled,
+ initialThemePreset: initialThemePreset,
+ extraOverrides: extraOverrides,
);
await tester.pumpWidget(