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(