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

@@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application <application
android:label="ignis_app" android:label="ignis_app"

View File

@@ -6,6 +6,7 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.location.Location
import android.os.Build import android.os.Build
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.work.BackoffPolicy import androidx.work.BackoffPolicy
@@ -22,6 +23,7 @@ import java.util.concurrent.TimeUnit
data class StoredGeofenceConfig( data class StoredGeofenceConfig(
val homeId: String, val homeId: String,
val homeName: String,
val baseUrl: String, val baseUrl: String,
val apiKey: String, val apiKey: String,
val latitude: Double, val latitude: Double,
@@ -45,6 +47,7 @@ class GeofenceNativeStore(context: Context) {
val json = JSONObject(raw) val json = JSONObject(raw)
StoredGeofenceConfig( StoredGeofenceConfig(
homeId = json.getString("homeId"), homeId = json.getString("homeId"),
homeName = json.optString("homeName"),
baseUrl = json.getString("baseUrl"), baseUrl = json.getString("baseUrl"),
apiKey = json.getString("apiKey"), apiKey = json.getString("apiKey"),
latitude = json.getDouble("latitude"), latitude = json.getDouble("latitude"),
@@ -59,6 +62,7 @@ class GeofenceNativeStore(context: Context) {
val json = val json =
JSONObject() JSONObject()
.put("homeId", config.homeId) .put("homeId", config.homeId)
.put("homeName", config.homeName)
.put("baseUrl", config.baseUrl) .put("baseUrl", config.baseUrl)
.put("apiKey", config.apiKey) .put("apiKey", config.apiKey)
.put("latitude", config.latitude) .put("latitude", config.latitude)
@@ -110,10 +114,12 @@ object GeofenceAutomationManager {
val client = LocationServices.getGeofencingClient(context) val client = LocationServices.getGeofencingClient(context)
val pendingIntent = buildPendingIntent(context) val pendingIntent = buildPendingIntent(context)
seedPresenceState(context, config) {
client.removeGeofences(pendingIntent).addOnCompleteListener { client.removeGeofences(pendingIntent).addOnCompleteListener {
registerGeofence(context, client, pendingIntent, config, onComplete) registerGeofence(context, client, pendingIntent, config, onComplete)
} }
} }
}
fun disarm(context: Context, onComplete: (() -> Unit)? = null) { fun disarm(context: Context, onComplete: (() -> Unit)? = null) {
WorkManager.getInstance(context).cancelUniqueWork(exitWorkName) WorkManager.getInstance(context).cancelUniqueWork(exitWorkName)
@@ -140,12 +146,18 @@ object GeofenceAutomationManager {
if (requestId != geofenceRequestId) return if (requestId != geofenceRequestId) return
val store = GeofenceNativeStore(context) val store = GeofenceNativeStore(context)
if (store.getState() != GeofencePresenceState.INSIDE) { when (store.getState()) {
return GeofencePresenceState.OUTSIDE,
GeofencePresenceState.TRIGGERED,
-> return
GeofencePresenceState.UNKNOWN,
GeofencePresenceState.INSIDE,
-> Unit
} }
val config = store.loadConfig() ?: return
store.setState(GeofencePresenceState.OUTSIDE) store.setState(GeofencePresenceState.OUTSIDE)
scheduleExitWorker(context, store.loadConfig()?.homeId ?: return) scheduleExitWorker(context, config.homeId)
} }
fun markTriggered(context: Context) { 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") @SuppressLint("MissingPermission")
private fun registerGeofence( private fun registerGeofence(
context: Context, context: Context,

View File

@@ -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)
}
}

View File

@@ -33,6 +33,11 @@ class GeofenceExitWorker(
} }
GeofenceAutomationManager.markTriggered(applicationContext) GeofenceAutomationManager.markTriggered(applicationContext)
GeofenceAutomationNotifier.showExitProcessed(
context = applicationContext,
homeName = config.homeName,
turnedOffGroups = activeGroupIds.size,
)
Result.success() Result.success()
} }
.getOrElse { Result.retry() } .getOrElse { Result.retry() }

View File

@@ -15,6 +15,7 @@ class MainActivity : FlutterActivity() {
when (call.method) { when (call.method) {
"armGeofence" -> { "armGeofence" -> {
val homeId = call.argument<String>("homeId") val homeId = call.argument<String>("homeId")
val homeName = call.argument<String>("homeName")
val baseUrl = call.argument<String>("baseUrl") val baseUrl = call.argument<String>("baseUrl")
val apiKey = call.argument<String>("apiKey") val apiKey = call.argument<String>("apiKey")
val latitude = call.argument<Double>("latitude") val latitude = call.argument<Double>("latitude")
@@ -42,6 +43,7 @@ class MainActivity : FlutterActivity() {
config = config =
StoredGeofenceConfig( StoredGeofenceConfig(
homeId = homeId, homeId = homeId,
homeName = homeName ?: "",
baseUrl = baseUrl, baseUrl = baseUrl,
apiKey = apiKey, apiKey = apiKey,
latitude = latitude, latitude = latitude,

View File

@@ -20,6 +20,7 @@ class GeofenceAutomationService {
final apiKey = await _settingsService.requireHomeApiKey(home.id); final apiKey = await _settingsService.requireHomeApiKey(home.id);
await _invoke('armGeofence', { await _invoke('armGeofence', {
'homeId': home.id, 'homeId': home.id,
'homeName': home.name,
'baseUrl': home.url, 'baseUrl': home.url,
'apiKey': apiKey, 'apiKey': apiKey,
'latitude': home.latitude, 'latitude': home.latitude,

View File

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

View File

@@ -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;
}

View File

@@ -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<AppThemePreset>(
(ref) => AppThemePreset.fallback,
);
final appThemeProvider = NotifierProvider<AppThemeNotifier, AppThemePreset>(
AppThemeNotifier.new,
);
class AppThemeNotifier extends Notifier<AppThemePreset> {
@override
AppThemePreset build() => ref.read(initialAppThemePresetProvider);
Future<void> setTheme(AppThemePreset preset) async {
if (state == preset) {
return;
}
state = preset;
await ref.read(settingsServiceProvider).setAppThemePreset(preset);
}
}
abstract class GeofenceSystemStatusService {
Future<GeofenceSystemState> inspect({
required bool hasActiveHome,
required bool hasCoordinates,
});
}
class DeviceGeofenceSystemStatusService implements GeofenceSystemStatusService {
@override
Future<GeofenceSystemState> 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<GeofenceSystemStatusService>(
(ref) => DeviceGeofenceSystemStatusService(),
);
final geofenceSystemStatusProvider = FutureProvider<GeofenceSystemState>((
ref,
) async {
final currentHome = ref.watch(currentHomeProvider);
return ref
.watch(geofenceSystemStatusServiceProvider)
.inspect(
hasActiveHome: currentHome != null,
hasCoordinates: currentHome?.hasCoordinates == true,
);
});

View File

@@ -1,45 +1,38 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app/app_bootstrap.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/homes_screen.dart';
import 'screens/remote_screen.dart'; import 'screens/remote_screen.dart';
import 'services/settings_service.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); 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}); const IgnisApp({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final themePreset = ref.watch(appThemeProvider);
return MaterialApp( return MaterialApp(
title: 'Ignis', title: 'Ignis',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData.dark(useMaterial3: true).copyWith( theme: themePreset.themeData,
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),
),
),
home: const MainGate(), home: const MainGate(),
); );
} }

View File

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

View File

@@ -4,9 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/error_message.dart'; import '../app/error_message.dart';
import '../models/home_config.dart'; import '../models/home_config.dart';
import '../providers/providers.dart'; import '../providers/providers.dart';
import '../widgets/build_info_text.dart';
import 'home_edit_screen.dart'; import 'home_edit_screen.dart';
import 'remote_screen.dart'; import 'remote_screen.dart';
import 'settings_screen.dart';
/// Экран "Дома" -- список серверов Ignis. /// Экран "Дома" -- список серверов Ignis.
/// Пользователь может добавить, удалить, переключить активный дом. /// Пользователь может добавить, удалить, переключить активный дом.
@@ -61,11 +61,20 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
appBar: AppBar( appBar: AppBar(
title: const Text('ДОМА'), title: const Text('ДОМА'),
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: 'Настройки',
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
const SettingsScreen(entryPoint: SettingsEntryPoint.homes),
), ),
body: Column( ),
children: [ ),
Expanded( ],
child: homes.isEmpty ),
body: homes.isEmpty
? const _EmptyHomesView() ? const _EmptyHomesView()
: RefreshIndicator( : RefreshIndicator(
color: Colors.deepOrange, color: Colors.deepOrange,
@@ -89,9 +98,7 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
enabled: !isBusy, enabled: !isBusy,
leading: Icon( leading: Icon(
Icons.home, Icons.home,
color: isActive color: isActive ? Colors.deepOrange : Colors.white38,
? Colors.deepOrange
: Colors.white38,
size: 28, size: 28,
), ),
title: Text( title: Text(
@@ -100,9 +107,7 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
fontWeight: isActive fontWeight: isActive
? FontWeight.bold ? FontWeight.bold
: FontWeight.normal, : FontWeight.normal,
color: isActive color: isActive ? Colors.deepOrange : Colors.white,
? Colors.deepOrange
: Colors.white,
), ),
), ),
subtitle: _HomeSubtitle( subtitle: _HomeSubtitle(
@@ -137,32 +142,19 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
size: 20, size: 20,
color: Colors.redAccent, color: Colors.redAccent,
), ),
onPressed: () => onPressed: () => _confirmDelete(context, home),
_confirmDelete(context, home),
), ),
], ],
], ],
), ),
onTap: isBusy onTap: isBusy ? null : () => _selectHome(context, home),
? null
: () => _selectHome(context, home),
), ),
); );
}), }),
SizedBox(height: MediaQuery.of(context).padding.bottom + 80),
], ],
), ),
), ),
),
const SafeArea(
top: false,
minimum: EdgeInsets.only(bottom: 10),
child: Padding(
padding: EdgeInsets.only(bottom: 6),
child: BuildInfoText(),
),
),
],
),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
backgroundColor: Colors.deepOrange, backgroundColor: Colors.deepOrange,
onPressed: () => _addHome(context), onPressed: () => _addHome(context),
@@ -282,7 +274,9 @@ class _HomesScreenState extends ConsumerState<HomesScreen>
} }
Future<void> _syncLocationWatching() async { 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) { if (shouldWatch == _isWatchingLocation) {
return; return;
} }

View File

@@ -3,14 +3,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/error_message.dart'; import '../app/error_message.dart';
import '../models/ignis_group.dart'; import '../models/ignis_group.dart';
import '../providers/providers.dart'; import '../providers/providers.dart';
import '../widgets/build_info_text.dart';
import '../widgets/group_card.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 '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()), MaterialPageRoute(builder: (_) => const ApiKeysScreen()),
); );
break; break;
case 'settings':
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const SettingsScreen(
entryPoint: SettingsEntryPoint.remote,
),
),
);
break;
} }
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
@@ -118,6 +127,14 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
), ),
), ),
const PopupMenuItem(
value: 'settings',
child: ListTile(
leading: Icon(Icons.settings_outlined),
title: Text('Настройки'),
contentPadding: EdgeInsets.zero,
),
),
if (isAdmin) if (isAdmin)
const PopupMenuItem( const PopupMenuItem(
value: 'api_keys', value: 'api_keys',
@@ -127,13 +144,6 @@ class _RemoteScreenState extends ConsumerState<RemoteScreen> {
contentPadding: EdgeInsets.zero, 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)),
],
),
),
],
);
}
}

View File

@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../features/settings/models/app_theme_preset.dart';
import '../models/home_config.dart'; import '../models/home_config.dart';
import 'credentials_storage.dart'; import 'credentials_storage.dart';
@@ -8,6 +9,7 @@ import 'credentials_storage.dart';
class SettingsService { class SettingsService {
static const String _homesKey = 'ignis_homes'; static const String _homesKey = 'ignis_homes';
static const String _currentHomeKey = 'ignis_current_home_id'; static const String _currentHomeKey = 'ignis_current_home_id';
static const String _themeKey = 'ignis_theme_preset';
final CredentialsStorage _credentialsStorage; final CredentialsStorage _credentialsStorage;
@@ -107,6 +109,16 @@ class SettingsService {
Future<void> deleteHomeApiKey(String homeId) => Future<void> deleteHomeApiKey(String homeId) =>
_credentialsStorage.deleteApiKey(homeId); _credentialsStorage.deleteApiKey(homeId);
Future<AppThemePreset> getAppThemePreset() async {
final prefs = await SharedPreferences.getInstance();
return AppThemePreset.fromStorageValue(prefs.getString(_themeKey));
}
Future<void> setAppThemePreset(AppThemePreset preset) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_themeKey, preset.storageValue);
}
Future<List<Map<String, dynamic>>> _migrateApiKeysIfNeeded( Future<List<Map<String, dynamic>>> _migrateApiKeysIfNeeded(
SharedPreferences prefs, SharedPreferences prefs,
List<dynamic> rawList, List<dynamic> rawList,

View File

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

View File

@@ -85,6 +85,7 @@ void main() {
expect(calls.single.method, 'armGeofence'); expect(calls.single.method, 'armGeofence');
expect(calls.single.arguments, <String, Object?>{ expect(calls.single.arguments, <String, Object?>{
'homeId': 'home-1', 'homeId': 'home-1',
'homeName': 'Home 1',
'baseUrl': 'https://one.example', 'baseUrl': 'https://one.example',
'apiKey': 'secret-key', 'apiKey': 'secret-key',
'latitude': 55.75, 'latitude': 55.75,

View File

@@ -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<GeofenceSystemState> inspect({
required bool hasActiveHome,
required bool hasCoordinates,
}) async {
lastHasActiveHome = hasActiveHome;
lastHasCoordinates = hasCoordinates;
return result;
}
}

View File

@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter_test/flutter_test.dart'; 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/models/home_config.dart';
import 'package:ignis_app/services/credentials_storage.dart'; import 'package:ignis_app/services/credentials_storage.dart';
import 'package:ignis_app/services/settings_service.dart'; import 'package:ignis_app/services/settings_service.dart';
@@ -79,4 +80,18 @@ void main() {
jsonDecode(prefs.getString('ignis_homes')!) as List<dynamic>; jsonDecode(prefs.getString('ignis_homes')!) as List<dynamic>;
expect(storedHomes.single, isNot(contains('apiKey'))); 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);
});
} }

View File

@@ -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);
});
}

View File

@@ -2,6 +2,8 @@ import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.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/providers/providers.dart';
import 'package:ignis_app/services/api_client.dart'; import 'package:ignis_app/services/api_client.dart';
import 'package:ignis_app/services/credentials_storage.dart'; import 'package:ignis_app/services/credentials_storage.dart';
@@ -336,14 +338,18 @@ ProviderContainer createTestContainer(
FakeIgnisApi api, { FakeIgnisApi api, {
SettingsService? settingsService, SettingsService? settingsService,
bool remotePollingEnabled = true, bool remotePollingEnabled = true,
AppThemePreset initialThemePreset = AppThemePreset.fallback,
List extraOverrides = const [],
}) { }) {
final overrides = [ final overrides = [
apiProvider.overrideWithValue(api), apiProvider.overrideWithValue(api),
remotePollingEnabledProvider.overrideWithValue(remotePollingEnabled), remotePollingEnabledProvider.overrideWithValue(remotePollingEnabled),
initialAppThemePresetProvider.overrideWithValue(initialThemePreset),
]; ];
if (settingsService != null) { if (settingsService != null) {
overrides.add(settingsServiceProvider.overrideWithValue(settingsService)); overrides.add(settingsServiceProvider.overrideWithValue(settingsService));
} }
overrides.addAll(extraOverrides.cast());
final container = ProviderContainer(overrides: overrides); final container = ProviderContainer(overrides: overrides);
addTearDown(container.dispose); addTearDown(container.dispose);
@@ -356,11 +362,15 @@ Future<ProviderContainer> pumpTestApp(
FakeIgnisApi? api, FakeIgnisApi? api,
SettingsService? settingsService, SettingsService? settingsService,
bool remotePollingEnabled = true, bool remotePollingEnabled = true,
AppThemePreset initialThemePreset = AppThemePreset.fallback,
List extraOverrides = const [],
}) async { }) async {
final container = createTestContainer( final container = createTestContainer(
api ?? FakeIgnisApi(), api ?? FakeIgnisApi(),
settingsService: settingsService, settingsService: settingsService,
remotePollingEnabled: remotePollingEnabled, remotePollingEnabled: remotePollingEnabled,
initialThemePreset: initialThemePreset,
extraOverrides: extraOverrides,
); );
await tester.pumpWidget( await tester.pumpWidget(