From 287de9f86a86ac99e7644401dd127cb7f6573f7e Mon Sep 17 00:00:00 2001 From: Andrew Dean Date: Sun, 9 Aug 2026 16:36:22 +1000 Subject: [PATCH 1/2] Add iCloud shared album sync, web settings UI, and photo frame improvements Features: - iCloud shared album sync: two-step API (webstream + webasseturls), handles 330 redirects, picks best derivative by pixel dimensions (handles iPhone 15 Pro key '2049'), filters videos, checksum sidecar files for upgrade detection - Embedded web settings server on port 8080 with full settings form and sync controls; web UI save now also writes to SharedPreferences so BootReceiver picks up changes without requiring an app restart - Geocoding now shows city only (not City, State, Country); cache key bumped to v2 to invalidate old entries - Photo info overlay: added XSmall (22px) text size option (default); for bottom positions city is shown above date; for top positions date stays on top - Slide duration change now takes effect immediately without waiting for the current timer to expire - Boot autostart: BootReceiver uses AlarmManager (setExactAndAllowWhileIdle) to schedule MainActivity launch 3s after boot; logs upgraded to Log.i so they appear in release builds --- android/app/build.gradle.kts | 4 + android/app/src/main/AndroidManifest.xml | 7 + .../micw/openphotoframe/BootLaunchService.kt | 66 ++ .../micw/openphotoframe/BootReceiver.kt | 55 +- android/gradle.properties | 4 + assets/config.json | 3 + .../services/geocoding_service.dart | 29 +- .../services/icloud_album_source_config.dart | 34 + .../services/icloud_album_sync_service.dart | 390 ++++++++ .../services/photo_service.dart | 1 + .../services/web_server_service.dart | 852 ++++++++++++++++++ lib/l10n/app_de.arb | 14 + lib/l10n/app_en.arb | 14 + lib/l10n/app_localizations.dart | 36 + lib/l10n/app_localizations_de.dart | 22 + lib/l10n/app_localizations_en.dart | 21 + lib/main.dart | 26 + lib/ui/screens/settings_screen.dart | 155 +++- lib/ui/screens/slideshow_screen.dart | 13 +- lib/ui/widgets/photo_info_overlay.dart | 26 +- pubspec.lock | 8 +- 21 files changed, 1698 insertions(+), 82 deletions(-) create mode 100644 android/app/src/main/kotlin/io/github/micw/openphotoframe/BootLaunchService.kt create mode 100644 lib/infrastructure/services/icloud_album_source_config.dart create mode 100644 lib/infrastructure/services/icloud_album_sync_service.dart create mode 100644 lib/infrastructure/services/web_server_service.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d93a49d..f46052e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -36,6 +36,10 @@ android { jvmTarget = JavaVersion.VERSION_17.toString() } + lint { + disable += "Instantiatable" + } + defaultConfig { applicationId = "io.github.micw.openphotoframe" // You can update the following values to match your application needs. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 703aebf..6e2f4ee 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -89,6 +89,13 @@ + + + = Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Boot launch", + NotificationManager.IMPORTANCE_LOW + ) + getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel) + } + } +} diff --git a/android/app/src/main/kotlin/io/github/micw/openphotoframe/BootReceiver.kt b/android/app/src/main/kotlin/io/github/micw/openphotoframe/BootReceiver.kt index 815b19d..f50cc0d 100644 --- a/android/app/src/main/kotlin/io/github/micw/openphotoframe/BootReceiver.kt +++ b/android/app/src/main/kotlin/io/github/micw/openphotoframe/BootReceiver.kt @@ -1,42 +1,59 @@ package io.github.micw.openphotoframe +import android.app.AlarmManager +import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.SharedPreferences +import android.os.SystemClock import android.util.Log /** * BroadcastReceiver that starts the app when the device boots. * Only starts if autostart is enabled in app settings. + * + * Android 11+ blocks activity starts from background broadcast receivers + * (and even from foreground services started by them). Using AlarmManager + * with a PendingIntent works around this: the alarm fires via the system + * process, which is whitelisted for background activity starts. */ class BootReceiver : BroadcastReceiver() { companion object { private const val TAG = "BootReceiver" private const val PREFS_NAME = "FlutterSharedPreferences" private const val AUTOSTART_KEY = "flutter.autostart_on_boot" + private const val BOOT_ALARM_REQUEST_CODE = 9001 } override fun onReceive(context: Context, intent: Intent) { - if (intent.action == Intent.ACTION_BOOT_COMPLETED || - intent.action == "android.intent.action.QUICKBOOT_POWERON") { - - Log.d(TAG, "Boot completed received") - - // Check if autostart is enabled in shared preferences - val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - val autostartEnabled = prefs.getBoolean(AUTOSTART_KEY, false) - - Log.d(TAG, "Autostart enabled: $autostartEnabled") - - if (autostartEnabled) { - Log.d(TAG, "Starting MainActivity") - val startIntent = Intent(context, MainActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - } - context.startActivity(startIntent) - } + if (intent.action != Intent.ACTION_BOOT_COMPLETED && + intent.action != "android.intent.action.QUICKBOOT_POWERON") return + + Log.i(TAG, "Boot completed received") + + val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val autostartEnabled = prefs.getBoolean(AUTOSTART_KEY, false) + Log.i(TAG, "Autostart enabled: $autostartEnabled") + if (!autostartEnabled) return + + // Schedule MainActivity to start in ~3 seconds via AlarmManager. + // The alarm fires through the system process, bypassing Android 11's + // background activity start restriction (isBgStartWhitelisted). + val activityIntent = Intent(context, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) } + val pendingIntent = PendingIntent.getActivity( + context, + BOOT_ALARM_REQUEST_CODE, + activityIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val alarmManager = context.getSystemService(AlarmManager::class.java) + val triggerAt = SystemClock.elapsedRealtime() + 3_000L + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.ELAPSED_REALTIME_WAKEUP, triggerAt, pendingIntent + ) + Log.i(TAG, "Scheduled MainActivity launch via AlarmManager in 3s") } } diff --git a/android/gradle.properties b/android/gradle.properties index fbee1d8..d5da727 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,2 +1,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/assets/config.json b/assets/config.json index 2a575a9..7ac21cb 100644 --- a/assets/config.json +++ b/assets/config.json @@ -10,6 +10,9 @@ "url": "", "folder_sync_mode": "all", "selected_folders": [] + }, + "icloud_album": { + "album_url": "" } } } diff --git a/lib/infrastructure/services/geocoding_service.dart b/lib/infrastructure/services/geocoding_service.dart index f754a62..cc90cb7 100644 --- a/lib/infrastructure/services/geocoding_service.dart +++ b/lib/infrastructure/services/geocoding_service.dart @@ -18,9 +18,9 @@ class GeocodingService { /// User-Agent required by Nominatim usage policy static const String _userAgent = 'OpenPhotoFrame/1.0'; - /// Prefix for SharedPreferences keys - static const String _prefsPrefix = 'geocache_'; - static const String _prefsTsPrefix = 'geocache_ts_'; + /// Prefix for SharedPreferences keys (v2 = city-only format) + static const String _prefsPrefix = 'geocache_v2_'; + static const String _prefsTsPrefix = 'geocache_v2_ts_'; /// Maximum age for cache entries (3 months) static const Duration _maxCacheAge = Duration(days: 90); @@ -108,26 +108,13 @@ class GeocodingService { return null; } - // Build location string: City, State, Country - final parts = []; - - // City (try multiple fields) - final city = address['city'] ?? - address['town'] ?? - address['village'] ?? + // Build location string: city only + final city = address['city'] ?? + address['town'] ?? + address['village'] ?? address['municipality'] ?? address['county']; - if (city != null) parts.add(city.toString()); - - // State/Region - final state = address['state']; - if (state != null) parts.add(state.toString()); - - // Country - final country = address['country']; - if (country != null) parts.add(country.toString()); - - final result = parts.isNotEmpty ? parts.join(', ') : null; + final result = city?.toString(); await _cacheResult(cacheKey, result); _log.fine('Geocoded ($latitude, $longitude) → $result'); diff --git a/lib/infrastructure/services/icloud_album_source_config.dart b/lib/infrastructure/services/icloud_album_source_config.dart new file mode 100644 index 0000000..25570ce --- /dev/null +++ b/lib/infrastructure/services/icloud_album_source_config.dart @@ -0,0 +1,34 @@ +class ICloudAlbumSourceConfig { + final String albumUrl; + + const ICloudAlbumSourceConfig({this.albumUrl = ''}); + + /// Extracts the share token. + /// Handles both URL styles: + /// https://www.icloud.com/sharedalbum/#TOKEN (token in fragment) + /// https://www.icloud.com/photos/TOKEN (token in last path segment) + String get token { + final trimmed = albumUrl.trim(); + if (trimmed.isEmpty) return ''; + final uri = Uri.tryParse(trimmed); + if (uri == null) return ''; + if (uri.fragment.isNotEmpty) return uri.fragment; + final segments = uri.pathSegments.where((s) => s.isNotEmpty).toList(); + return segments.isEmpty ? '' : segments.last; + } + + bool get isValid { + if (albumUrl.trim().isEmpty) return false; + final uri = Uri.tryParse(albumUrl.trim()); + if (uri == null) return false; + return uri.host.contains('icloud.com') && token.isNotEmpty; + } + + factory ICloudAlbumSourceConfig.fromMap(Map config) { + return ICloudAlbumSourceConfig( + albumUrl: (config['album_url'] as String? ?? '').trim(), + ); + } + + Map toMap() => {'album_url': albumUrl.trim()}; +} diff --git a/lib/infrastructure/services/icloud_album_sync_service.dart b/lib/infrastructure/services/icloud_album_sync_service.dart new file mode 100644 index 0000000..8909d90 --- /dev/null +++ b/lib/infrastructure/services/icloud_album_sync_service.dart @@ -0,0 +1,390 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:logging/logging.dart'; + +import '../../domain/interfaces/sync_provider.dart'; +import '../../domain/interfaces/storage_provider.dart'; +import 'icloud_album_source_config.dart'; + +class ICloudAlbumSyncException implements Exception { + ICloudAlbumSyncException(this.message, {this.cause}); + final String message; + final Object? cause; + + @override + String toString() => 'iCloud sync failed: $message${cause != null ? ' ($cause)' : ''}'; +} + +class ICloudAlbumSyncService implements SyncProvider { + static const Duration _requestTimeout = Duration(seconds: 30); + static const Duration _downloadIdleTimeout = Duration(minutes: 15); + + ICloudAlbumSyncService({ + required ICloudAlbumSourceConfig config, + required StorageProvider storageProvider, + }) : _config = config, + _storageProvider = storageProvider; + + final ICloudAlbumSourceConfig _config; + final StorageProvider _storageProvider; + final _log = Logger('ICloudAlbumSyncService'); + + @override + String get id => 'icloud_album'; + + @override + Future sync({ + bool deleteOrphanedFiles = false, + SyncProgressCallback? onProgress, + }) async { + _log.info('Starting iCloud album sync (token: ${_config.token})'); + + final localDir = await _storageProvider.getPhotoDirectory(); + await localDir.create(recursive: true); + + // Step 1: get photo metadata + final shard host + final (photos, host) = await _fetchPhotoList(); + _log.info('Found ${photos.length} photos in iCloud album'); + + if (photos.isEmpty) return; + + // Step 2: build guid list and checksum→guid mapping for best derivative. + // webasseturls response is keyed by derivative checksum, NOT photoGuid. + final guids = []; + final checksumToGuid = {}; + final guidToChecksum = {}; + + for (final photo in photos) { + final guid = photo['photoGuid'] as String?; + if (guid == null || guid.isEmpty) continue; + guids.add(guid); + final derivatives = photo['derivatives']; + if (derivatives is! Map) continue; + + // Iterate ALL derivative keys and pick the one with the largest max dimension. + // iCloud uses both standard keys ('342', '2048') and exact-pixel keys ('1537', etc.) + String? bestChecksum; + String? bestKey; + int bestMaxDim = 0; + int? bestW, bestH; + + for (final derivEntry in (derivatives as Map).entries) { + final deriv = derivEntry.value; + if (deriv is! Map) continue; + final checksum = deriv['checksum'] as String?; + if (checksum == null || checksum.isEmpty) continue; + final w = int.tryParse(deriv['width']?.toString() ?? '') ?? 0; + final h = int.tryParse(deriv['height']?.toString() ?? '') ?? 0; + final maxDim = w > h ? w : h; + if (maxDim > bestMaxDim) { + bestMaxDim = maxDim; + bestChecksum = checksum; + bestKey = derivEntry.key.toString(); + bestW = w; + bestH = h; + } + } + + if (bestChecksum != null) { + checksumToGuid[bestChecksum] = guid; + guidToChecksum[guid] = bestChecksum; + _log.info('Photo ${guid.substring(0, 8)}: best derivative key=$bestKey (${bestW}x${bestH})'); + } + } + + // Step 3: get download URLs — response keys are derivative checksums + final rawUrls = await _fetchAssetUrls(guids: guids, host: host); + _log.info('Got ${rawUrls.length} raw asset URLs for ${guids.length} photos'); + + // Map checksum keys back to photoGuids for file naming + final guidToUrl = {}; + for (final entry in rawUrls.entries) { + final guid = checksumToGuid[entry.key]; + if (guid != null) guidToUrl.putIfAbsent(guid, () => entry.value); + } + _log.info('Matched ${guidToUrl.length} photos with download URLs'); + + // Step 4: determine which files need downloading. + // A .key sidecar file records the derivative checksum last downloaded. + // Re-download if the file is missing OR the checksum changed (better derivative available). + final pending = >[]; + for (final entry in guidToUrl.entries) { + final guid = entry.key; + final localFile = File('${localDir.path}/$guid.jpg'); + final keyFile = File('${localDir.path}/$guid.jpg.key'); + if (await localFile.exists() && await keyFile.exists()) { + final storedChecksum = await keyFile.readAsString(); + if (storedChecksum == guidToChecksum[guid]) continue; // already have best version + _log.info('Re-downloading upgraded derivative: $guid'); + await localFile.delete(); + } + pending.add(entry); + } + + _log.info('${pending.length} new photos to download'); + + // Step 5: download missing photos + final dio = Dio(); + for (var i = 0; i < pending.length; i++) { + final guid = pending[i].key; + final url = pending[i].value; + + onProgress?.call(SyncProgress( + completedFiles: i, + totalFiles: pending.length, + currentFileLabel: guid, + )); + + final partFile = File('${localDir.path}/$guid.jpg.part'); + final destFile = File('${localDir.path}/$guid.jpg'); + + _log.info('Downloading ${i + 1}/${pending.length}: $guid'); + try { + await dio.download( + url, + partFile.path, + options: Options(receiveTimeout: _downloadIdleTimeout), + ); + await partFile.setLastModified(DateTime.now()); + await partFile.rename(destFile.path); + // Record which derivative checksum we downloaded for future upgrade checks + final keyFile = File('${localDir.path}/$guid.jpg.key'); + await keyFile.writeAsString(guidToChecksum[guid] ?? ''); + } catch (e) { + try { await partFile.delete(); } catch (_) {} + _log.warning('Failed to download $guid: $e'); + continue; + } + + onProgress?.call(SyncProgress( + completedFiles: i + 1, + totalFiles: pending.length, + currentFileLabel: guid, + )); + } + + // Step 6: delete orphaned files if requested (compare by photoGuid) + if (deleteOrphanedFiles) { + await _deleteOrphans(localDir, guids.toSet()); + } + + _log.info('iCloud album sync complete'); + } + + // --------------------------------------------------------------------------- + // API: webstream — returns photo metadata + the final shard host + // --------------------------------------------------------------------------- + + Future<(List>, String)> _fetchPhotoList() async { + final token = _config.token; + final dio = Dio(); + var host = 'sharedstreams.icloud.com'; + + for (var attempt = 0; attempt < 2; attempt++) { + final url = 'https://$host/$token/sharedstreams/webstream'; + Response> response; + + try { + response = await dio.post>( + url, + data: '{"streamCtag":null}', + options: Options( + contentType: 'application/json', + receiveTimeout: _requestTimeout, + followRedirects: false, + validateStatus: (s) => s != null, + ), + ); + } on DioException catch (e) { + throw ICloudAlbumSyncException('Request failed', cause: e); + } + + if (response.statusCode == 330) { + final newHost = _extractRedirectHost(response); + if (newHost == null) { + throw ICloudAlbumSyncException('Got 330 redirect but no host in response'); + } + _log.info('iCloud redirect: $host → $newHost'); + host = newHost; + continue; + } + + if (response.statusCode != 200) { + throw ICloudAlbumSyncException('Unexpected HTTP ${response.statusCode}'); + } + + final data = response.data; + if (data == null) return (>[], host); + final photos = data['photos']; + if (photos is! List) return (>[], host); + + final all = photos.whereType>().toList(); + + // Log the first non-image item type we encounter so we can see the field + for (final p in all) { + final t = p['mediaAssetType'] ?? p['type'] ?? p['assetType']; + if (t != null && t.toString().toLowerCase() != 'image') { + _log.info('Skipping non-image asset: type=$t guid=${p['photoGuid']}'); + } + } + + // Filter to images only — videos and live photo components are excluded + final images = all.where((p) { + final t = (p['mediaAssetType'] ?? p['type'] ?? p['assetType']) + ?.toString() + .toLowerCase(); + return t == null || t == 'image'; + }).toList(); + + if (images.length < all.length) { + _log.info('Filtered ${all.length - images.length} non-image assets'); + } + + return (images, host); + } + + throw ICloudAlbumSyncException('Too many redirects'); + } + + // --------------------------------------------------------------------------- + // API: webasseturls — returns {photoGuid: downloadUrl} for the best derivative + // --------------------------------------------------------------------------- + + Future> _fetchAssetUrls({ + required List guids, + required String host, + }) async { + final token = _config.token; + final dio = Dio(); + final url = 'https://$host/$token/sharedstreams/webasseturls'; + + Response> response; + try { + response = await dio.post>( + url, + data: jsonEncode({'photoGuids': guids}), + options: Options( + contentType: 'application/json', + receiveTimeout: _requestTimeout, + validateStatus: (s) => s != null, + ), + ); + } on DioException catch (e) { + throw ICloudAlbumSyncException('webasseturls request failed', cause: e); + } + + if (response.statusCode != 200) { + throw ICloudAlbumSyncException( + 'webasseturls returned HTTP ${response.statusCode}'); + } + + final data = response.data; + if (data == null) return {}; + + // Log structure once so we can see what fields Apple returns + final items = data['items']; + if (items is Map && items.isNotEmpty) { + final firstItem = items.values.firstOrNull; + if (firstItem is Map) { + _log.info('webasseturls item keys: ${firstItem.keys.toList()}'); + final firstValue = firstItem.values.firstOrNull; + if (firstValue is Map) { + _log.info('webasseturls nested keys: ${firstValue.keys.toList()}'); + } + } + } + + return _extractAssetUrls(data); + } + + Map _extractAssetUrls(Map data) { + final result = {}; + final items = data['items']; + if (items is! Map) return result; + + for (final entry in items.entries) { + final guid = entry.key as String; + final item = entry.value; + if (item is! Map) continue; + + String? url; + + // Format A: items[guid] = {"2048": {"url": "...", ...}} (per-derivative map) + for (final key in ['2048', '1024', '512', '342', '256']) { + final deriv = item[key]; + if (deriv is Map) { + url = _buildUrl(deriv); + if (url != null) break; + } + } + + // Format B: items[guid] = {"url_location": "host", "url_path": "/path?sig=...", ...} + // Apple CDN splits host and signed path into separate fields + url ??= _buildUrl(item); + + if (url != null) result[guid] = url; + } + + return result; + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + /// Builds a full HTTPS URL from an API item map. + /// Handles three formats: + /// 1. {"url": "https://..."} — already full URL + /// 2. {"url_location": "host", "url_path": "/path?sig=..."} — Apple CDN split + /// 3. {"downloadURL": "..."} — alternate key name + String? _buildUrl(Map item) { + // Full URL in a single field + for (final key in ['url', 'downloadURL', 'download_url']) { + final v = item[key] as String?; + if (v != null && v.isNotEmpty) { + return v.startsWith('http') ? v : 'https://$v'; + } + } + // Apple split: url_location (host) + url_path (signed path) + final loc = item['url_location'] as String?; + final path = item['url_path'] as String?; + if (loc != null && loc.isNotEmpty && path != null && path.isNotEmpty) { + final host = loc.startsWith('http') ? loc : 'https://$loc'; + return '$host$path'; + } + return null; + } + + String? _extractRedirectHost(Response> response) { + final body = response.data; + if (body != null) { + final h = body['X-Apple-MMe-Host'] as String?; + if (h != null && h.isNotEmpty) return h; + } + return response.headers.map['x-apple-mme-host']?.firstOrNull; + } + + Future _deleteOrphans(Directory dir, Set remoteGuids) async { + final expectedJpg = remoteGuids.map((g) => '$g.jpg').toSet(); + final expectedKey = remoteGuids.map((g) => '$g.jpg.key').toSet(); + await for (final entity in dir.list(recursive: true, followLinks: false)) { + if (entity is! File) continue; + final name = entity.path.split('/').last; + if (name.endsWith('.part')) continue; + if (name.endsWith('.jpg.key')) { + if (expectedKey.contains(name)) continue; + } else if (name.endsWith('.jpg') || name.endsWith('.jpeg')) { + if (expectedJpg.contains(name)) continue; + } else { + continue; + } + _log.info('Deleting orphaned file: $name'); + try { await entity.delete(); } catch (e) { + _log.warning('Failed to delete orphan $name: $e'); + } + } + } +} diff --git a/lib/infrastructure/services/photo_service.dart b/lib/infrastructure/services/photo_service.dart index 32c9cc7..87db24b 100644 --- a/lib/infrastructure/services/photo_service.dart +++ b/lib/infrastructure/services/photo_service.dart @@ -76,6 +76,7 @@ class PhotoService extends ChangeNotifier { bool get isSyncing => _isSyncing; SyncProgress? get syncProgress => _syncProgress; SyncStatus? get syncStatus => _syncStatus; + int get photoCount => _repository.photos.length; Future initialize() async { if (_isInitialized) return; diff --git a/lib/infrastructure/services/web_server_service.dart b/lib/infrastructure/services/web_server_service.dart new file mode 100644 index 0000000..2ce6f0c --- /dev/null +++ b/lib/infrastructure/services/web_server_service.dart @@ -0,0 +1,852 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:logging/logging.dart'; + +import '../../domain/interfaces/config_provider.dart'; +import '../../domain/interfaces/storage_provider.dart'; +import 'android_runtime_settings_sync.dart'; +import 'photo_service.dart'; + +class WebServerService { + static const int port = 8080; + + WebServerService({ + required ConfigProvider configProvider, + required PhotoService photoService, + required StorageProvider storageProvider, + }) : _config = configProvider, + _photoService = photoService, + _storageProvider = storageProvider; + + final ConfigProvider _config; + final PhotoService _photoService; + final StorageProvider _storageProvider; + final _log = Logger('WebServerService'); + + HttpServer? _server; + String? _lanIp; + StreamSubscription? _logSub; + + static const int _maxLogEntries = 500; + final List> _logBuffer = []; + + String? get lanIp => _lanIp; + String? get serverUrl => _lanIp != null ? 'http://$_lanIp:$port' : null; + + Future start() async { + // Capture all app log records into a rolling buffer + _logSub = Logger.root.onRecord.listen((r) { + final entry = { + 't': r.time.toIso8601String(), + 'l': r.level.name, + 'm': r.message, + }; + if (r.error != null) entry['e'] = r.error.toString(); + _logBuffer.add(entry); + if (_logBuffer.length > _maxLogEntries) _logBuffer.removeAt(0); + }); + + try { + _lanIp = await _findLanIp(); + _server = await HttpServer.bind(InternetAddress.anyIPv4, port); + _log.info('Web settings server on port $port (LAN: $_lanIp)'); + _handleRequests(); + } catch (e) { + _log.severe('Failed to start web server on port $port: $e'); + } + } + + Future stop() async { + await _logSub?.cancel(); + _logSub = null; + await _server?.close(force: true); + _server = null; + } + + void _handleRequests() { + _server?.listen((HttpRequest request) async { + try { + await _route(request); + } catch (e, st) { + _log.warning( + 'Error handling ${request.method} ${request.uri.path}', e, st); + _sendError(request, 500, 'Internal server error'); + } + }); + } + + Future _route(HttpRequest request) async { + final method = request.method; + final path = request.uri.path; + + request.response.headers + ..add('Access-Control-Allow-Origin', '*') + ..add('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + ..add('Access-Control-Allow-Headers', 'Content-Type'); + + if (method == 'OPTIONS') { + request.response.statusCode = 204; + await request.response.close(); + return; + } + + if (method == 'GET' && path == '/') { + _sendHtml(request, _settingsPage); + } else if (method == 'GET' && path == '/api/config') { + _sendJson(request, _buildConfigMap()); + } else if (method == 'POST' && path == '/api/config') { + await _handleSaveConfig(request); + } else if (method == 'GET' && path == '/api/status') { + _sendJson(request, _buildStatusMap()); + } else if (method == 'POST' && path == '/api/sync') { + _handleTriggerSync(request); + } else if (method == 'POST' && path == '/api/photos') { + await _handleUploadPhoto(request); + } else if (method == 'GET' && path == '/api/log') { + _sendJson(request, {'entries': List>.from(_logBuffer.reversed)}); + } else { + _sendError(request, 404, 'Not found'); + } + } + + // --------------------------------------------------------------------------- + // Handlers + // --------------------------------------------------------------------------- + + Map _buildConfigMap() => { + // Source + 'active_source': _config.activeSourceType, + 'icloud_album': _config.getSourceConfig('icloud_album'), + 'nextcloud_link': _config.getSourceConfig('nextcloud_link'), + // Slideshow + 'slide_duration_seconds': _config.slideDurationSeconds, + 'transition_duration_ms': _config.transitionDurationMs, + 'blur_borders': _config.blurBorders, + // Sync + 'sync_interval_minutes': _config.syncIntervalMinutes, + 'delete_orphaned_files': _config.deleteOrphanedFiles, + // Clock + 'show_clock': _config.showClock, + 'clock_size': _config.clockSize, + 'clock_position': _config.clockPosition, + // Photo info + 'show_photo_info': _config.showPhotoInfo, + 'photo_info_position': _config.photoInfoPosition, + 'photo_info_size': _config.photoInfoSize, + 'use_script_font': _config.useScriptFontForMetadata, + 'geocoding_enabled': _config.geocodingEnabled, + // Schedule + 'schedule_enabled': _config.scheduleEnabled, + 'day_start_hour': _config.dayStartHour, + 'day_start_minute': _config.dayStartMinute, + 'night_start_hour': _config.nightStartHour, + 'night_start_minute': _config.nightStartMinute, + 'fri_sat_night_start_hour': _config.fridaySaturdayNightStartHour, + 'fri_sat_night_start_minute': _config.fridaySaturdayNightStartMinute, + // Display + 'screen_orientation': _config.screenOrientation, + 'use_native_screen_off': _config.useNativeScreenOff, + // Android + 'autostart_on_boot': _config.autostartOnBoot, + 'keep_alive_enabled': _config.keepAliveEnabled, + 'auto_update_enabled': _config.autoUpdateEnabled, + }; + + Future _handleSaveConfig(HttpRequest request) async { + final body = await utf8.decodeStream(request); + Map u; + try { + u = jsonDecode(body) as Map; + } catch (_) { + _sendError(request, 400, 'Invalid JSON'); + return; + } + + void setBool(String key, void Function(bool) setter) { + if (u[key] is bool) setter(u[key] as bool); + } + + void setInt(String key, void Function(int) setter) { + if (u[key] is int) setter(u[key] as int); + } + + void setString(String key, void Function(String) setter) { + if (u[key] is String) setter(u[key] as String); + } + + // Source + setString('active_source', (v) => _config.activeSourceType = v); + if (u['icloud_album'] is Map) { + _config.setSourceConfig( + 'icloud_album', Map.from(u['icloud_album'] as Map)); + } + if (u['nextcloud_link'] is Map) { + _config.setSourceConfig( + 'nextcloud_link', Map.from(u['nextcloud_link'] as Map)); + } + // Slideshow + setInt('slide_duration_seconds', (v) => _config.slideDurationSeconds = v); + setInt('transition_duration_ms', (v) => _config.transitionDurationMs = v); + setBool('blur_borders', (v) => _config.blurBorders = v); + // Sync + setInt('sync_interval_minutes', (v) => _config.syncIntervalMinutes = v); + setBool('delete_orphaned_files', (v) => _config.deleteOrphanedFiles = v); + // Clock + setBool('show_clock', (v) => _config.showClock = v); + setString('clock_size', (v) => _config.clockSize = v); + setString('clock_position', (v) => _config.clockPosition = v); + // Photo info + setBool('show_photo_info', (v) => _config.showPhotoInfo = v); + setString('photo_info_position', (v) => _config.photoInfoPosition = v); + setString('photo_info_size', (v) => _config.photoInfoSize = v); + setBool('use_script_font', (v) => _config.useScriptFontForMetadata = v); + setBool('geocoding_enabled', (v) => _config.geocodingEnabled = v); + // Schedule + setBool('schedule_enabled', (v) => _config.scheduleEnabled = v); + setInt('day_start_hour', (v) => _config.dayStartHour = v); + setInt('day_start_minute', (v) => _config.dayStartMinute = v); + setInt('night_start_hour', (v) => _config.nightStartHour = v); + setInt('night_start_minute', (v) => _config.nightStartMinute = v); + if (u.containsKey('fri_sat_night_start_hour')) { + _config.fridaySaturdayNightStartHour = + u['fri_sat_night_start_hour'] as int?; + } + if (u.containsKey('fri_sat_night_start_minute')) { + _config.fridaySaturdayNightStartMinute = + u['fri_sat_night_start_minute'] as int?; + } + // Display + setString('screen_orientation', (v) => _config.screenOrientation = v); + setBool('use_native_screen_off', (v) => _config.useNativeScreenOff = v); + // Android + setBool('autostart_on_boot', (v) => _config.autostartOnBoot = v); + setBool('keep_alive_enabled', (v) => _config.keepAliveEnabled = v); + setBool('auto_update_enabled', (v) => _config.autoUpdateEnabled = v); + + await _config.save(); + // Sync Android runtime settings (SharedPreferences) so BootReceiver and + // KeepAliveService pick up changes without requiring an app restart. + await AndroidRuntimeSettingsSync().syncFromConfig(_config); + _sendJson(request, {'ok': true}); + } + + Map _buildStatusMap() { + final lastSync = _config.lastSuccessfulSync; + return { + 'photo_count': _photoService.photoCount, + 'is_syncing': _photoService.isSyncing, + 'last_sync_iso': lastSync?.toIso8601String(), + }; + } + + void _handleTriggerSync(HttpRequest request) { + _photoService.triggerSync().catchError((e) { + _log.warning('Web-triggered sync error: $e'); + }); + _sendJson(request, {'ok': true, 'message': 'Sync started'}); + } + + Future _handleUploadPhoto(HttpRequest request) async { + final filename = request.uri.queryParameters['filename'] ?? ''; + if (filename.isEmpty || filename.contains('/') || filename.contains('..')) { + _sendError(request, 400, 'Missing or invalid filename parameter'); + return; + } + + final dir = await _storageProvider.getPhotoDirectory(); + await dir.create(recursive: true); + + final bytes = await request.fold>( + [], + (acc, chunk) => acc..addAll(chunk), + ); + + await File('${dir.path}/$filename').writeAsBytes(bytes); + _log.info('Uploaded photo: $filename (${bytes.length} bytes)'); + _sendJson(request, {'ok': true, 'filename': filename}); + } + + // --------------------------------------------------------------------------- + // Response helpers + // --------------------------------------------------------------------------- + + void _sendJson(HttpRequest req, Map data) { + req.response + ..statusCode = 200 + ..headers.contentType = ContentType.json + ..write(jsonEncode(data)); + req.response.close(); + } + + void _sendHtml(HttpRequest req, String html) { + req.response + ..statusCode = 200 + ..headers.contentType = ContentType.html + ..write(html); + req.response.close(); + } + + void _sendError(HttpRequest req, int code, String message) { + req.response + ..statusCode = code + ..headers.contentType = ContentType.json + ..write(jsonEncode({'error': message})); + req.response.close(); + } + + // --------------------------------------------------------------------------- + // Network + // --------------------------------------------------------------------------- + + Future _findLanIp() async { + try { + final interfaces = await NetworkInterface.list( + includeLinkLocal: false, + type: InternetAddressType.IPv4, + ); + for (final iface in interfaces) { + for (final addr in iface.addresses) { + final ip = addr.address; + if (ip.startsWith('192.168.') || + ip.startsWith('10.') || + ip.startsWith('172.')) { + return ip; + } + } + } + } catch (e) { + _log.warning('Could not determine LAN IP: $e'); + } + return null; + } + + // --------------------------------------------------------------------------- + // Embedded HTML settings page + // --------------------------------------------------------------------------- + + static const String _settingsPage = r''' + + + + +Open Photo Frame — Settings + + + +

Open Photo Frame

+ +
+ Photos: + Last sync: + +
+ + +
+

Photo Source

+
+ + + +
+
+ +
+
+ +
+
+ + +
+

Slideshow

+
+ + + +
+
+ + + +
+
+ Blur bordersFill screen edges with blurred image + +
+
+ + +
+

Clock

+
+ Show clock + +
+ +
+ + +
+

Photo Information

+
+ Show photo infoDate and location overlay on slideshow + +
+ +
+ + +
+

Display Schedule

+
+ Day / Night scheduleTurn off display at night + +
+ +
+ + +
+

Screen

+ +
+ Native screen offUse Device Admin to fully turn off screen at night + +
+
+ + +
+

Sync

+
+ + + +
+
+ Delete photos removed from sourceRemove local files no longer on server + +
+
+ + +
+

Android

+
+ Start on bootAutomatically launch when device boots + +
+
+ Keep app runningPrevent app being stopped on low memory + +
+
+ Automatic updatesCheck GitHub for new versions + +
+
+ + +
+

Actions

+
+ + +
+
+ + +
+

App Log

+
+ + + +
+
+
+ + +
+

Upload Photos

+
+ Drop photos here, or click to select files +
+ +
+
+ +
+ + +'''; +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 9e33cda..0c80774 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -34,6 +34,20 @@ "devicePhotosSubtitle": "Fotos vom Gerät anzeigen", "localFolder": "Lokaler Ordner", "localFolderSubtitle": "Fotos aus einem lokalen Ordner verwenden", + "icloudAlbum": "iCloud Geteiltes Album", + "icloudAlbumSubtitle": "Von Apple Photos geteiltem Album synchronisieren", + "icloudAlbumUrl": "iCloud geteiltes Album URL", + "icloudAlbumUrlHint": "https://www.icloud.com/photos/…", + "icloudAlbumUrlInvalid": "Bitte eine gültige icloud.com/photos-URL eingeben", + "webSettingsAddress": "Web-Einstellungen verfügbar unter {url}", + "@webSettingsAddress": { + "placeholders": { + "url": { + "type": "String" + } + } + }, + "nextcloud": "Nextcloud", "nextcloudSubtitle": "Von Nextcloud öffentlichem Link synchronisieren", "loading": "Lädt...", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e69db4a..3ac09a5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -34,6 +34,20 @@ "devicePhotosSubtitle": "Show photos from your device", "localFolder": "Local Folder", "localFolderSubtitle": "Use photos from a local folder", + "icloudAlbum": "iCloud Shared Album", + "icloudAlbumSubtitle": "Sync from Apple Photos shared album", + "icloudAlbumUrl": "iCloud Shared Album URL", + "icloudAlbumUrlHint": "https://www.icloud.com/photos/…", + "icloudAlbumUrlInvalid": "Enter a valid icloud.com/photos shared album URL", + "webSettingsAddress": "Web settings available at {url}", + "@webSettingsAddress": { + "placeholders": { + "url": { + "type": "String" + } + } + }, + "nextcloud": "Nextcloud", "nextcloudSubtitle": "Sync from Nextcloud public share link", "loading": "Loading...", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8b5ecc7..4cceb49 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -272,6 +272,42 @@ abstract class AppLocalizations { /// **'Use photos from a local folder'** String get localFolderSubtitle; + /// No description provided for @icloudAlbum. + /// + /// In en, this message translates to: + /// **'iCloud Shared Album'** + String get icloudAlbum; + + /// No description provided for @icloudAlbumSubtitle. + /// + /// In en, this message translates to: + /// **'Sync from Apple Photos shared album'** + String get icloudAlbumSubtitle; + + /// No description provided for @icloudAlbumUrl. + /// + /// In en, this message translates to: + /// **'iCloud Shared Album URL'** + String get icloudAlbumUrl; + + /// No description provided for @icloudAlbumUrlHint. + /// + /// In en, this message translates to: + /// **'https://www.icloud.com/photos/…'** + String get icloudAlbumUrlHint; + + /// No description provided for @icloudAlbumUrlInvalid. + /// + /// In en, this message translates to: + /// **'Enter a valid icloud.com/photos shared album URL'** + String get icloudAlbumUrlInvalid; + + /// No description provided for @webSettingsAddress. + /// + /// In en, this message translates to: + /// **'Web settings available at {url}'** + String webSettingsAddress(String url); + /// No description provided for @nextcloud. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index b63fdeb..feccb17 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -100,6 +100,28 @@ class AppLocalizationsDe extends AppLocalizations { @override String get localFolderSubtitle => 'Fotos aus einem lokalen Ordner verwenden'; + @override + String get icloudAlbum => 'iCloud Geteiltes Album'; + + @override + String get icloudAlbumSubtitle => + 'Von Apple Photos geteiltem Album synchronisieren'; + + @override + String get icloudAlbumUrl => 'iCloud geteiltes Album URL'; + + @override + String get icloudAlbumUrlHint => 'https://www.icloud.com/photos/…'; + + @override + String get icloudAlbumUrlInvalid => + 'Bitte eine gültige icloud.com/photos-URL eingeben'; + + @override + String webSettingsAddress(String url) { + return 'Web-Einstellungen verfügbar unter $url'; + } + @override String get nextcloud => 'Nextcloud'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e617181..7700863 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -99,6 +99,27 @@ class AppLocalizationsEn extends AppLocalizations { @override String get localFolderSubtitle => 'Use photos from a local folder'; + @override + String get icloudAlbum => 'iCloud Shared Album'; + + @override + String get icloudAlbumSubtitle => 'Sync from Apple Photos shared album'; + + @override + String get icloudAlbumUrl => 'iCloud Shared Album URL'; + + @override + String get icloudAlbumUrlHint => 'https://www.icloud.com/photos/…'; + + @override + String get icloudAlbumUrlInvalid => + 'Enter a valid icloud.com/photos shared album URL'; + + @override + String webSettingsAddress(String url) { + return 'Web settings available at $url'; + } + @override String get nextcloud => 'Nextcloud'; diff --git a/lib/main.dart b/lib/main.dart index 77c21fa..1d0f984 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,10 +15,13 @@ import 'domain/interfaces/display_controller.dart'; import 'infrastructure/services/app_initializer.dart'; import 'infrastructure/services/json_config_service.dart'; import 'infrastructure/services/exif_metadata_provider.dart'; +import 'infrastructure/services/icloud_album_source_config.dart'; +import 'infrastructure/services/icloud_album_sync_service.dart'; import 'infrastructure/services/webdav_source_config.dart'; import 'infrastructure/services/webdav_sync_service.dart'; import 'infrastructure/services/noop_sync_service.dart'; import 'infrastructure/services/photo_service.dart'; +import 'infrastructure/services/web_server_service.dart'; import 'infrastructure/services/local_storage_provider.dart'; import 'infrastructure/services/native_display_controller.dart'; import 'infrastructure/services/update_service.dart'; @@ -123,6 +126,14 @@ class OpenPhotoFrameApp extends StatelessWidget { if (webdavConfig.url.isNotEmpty) { return WebDavSyncService.fromConfig(webdavConfig, storage); } + } else if (type == 'icloud_album') { + final icloudConfig = ICloudAlbumSourceConfig.fromMap(sourceConfig); + if (icloudConfig.isValid) { + return ICloudAlbumSyncService( + config: icloudConfig, + storageProvider: storage, + ); + } } return NoOpSyncService(); @@ -138,6 +149,21 @@ class OpenPhotoFrameApp extends StatelessWidget { }, ), + // Web settings UI — accessible from any browser on the LAN at port 8080 + Provider( + lazy: false, + create: (context) { + final service = WebServerService( + configProvider: context.read(), + photoService: context.read(), + storageProvider: context.read(), + ); + service.start(); + return service; + }, + dispose: (_, service) => service.stop(), + ), + // Opt-in GitHub self-updater (no-op unless enabled in settings) ChangeNotifierProvider( lazy: false, diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart index 09e1a82..ed0cea0 100644 --- a/lib/ui/screens/settings_screen.dart +++ b/lib/ui/screens/settings_screen.dart @@ -15,6 +15,8 @@ import '../../infrastructure/repositories/hybrid_photo_repository.dart'; import '../../infrastructure/services/photo_service.dart'; import '../../infrastructure/services/native_updater_service.dart'; import '../../infrastructure/services/update_service.dart'; +import '../../infrastructure/services/icloud_album_source_config.dart'; +import '../../infrastructure/services/web_server_service.dart'; import '../../infrastructure/services/webdav_source_config.dart'; import '../../infrastructure/services/webdav_sync_service.dart'; import '../../infrastructure/services/autostart_service.dart'; @@ -43,7 +45,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse minute: 0, ); - late int _slideDurationMinutes; + late int _slideDurationSeconds; late double _transitionDurationSeconds; late bool _blurBorders; late String _syncType; @@ -52,6 +54,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse late TextEditingController _webdavUserController; late TextEditingController _webdavPasswordController; late bool _webdavAllowInvalidCertificate; + late TextEditingController _icloudAlbumUrlController; late int _syncIntervalMinutes; late bool _deleteOrphanedFiles; late bool _autostartOnBoot; @@ -110,6 +113,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse // Track original values to detect changes late String _originalSyncType; late WebDavSourceConfig _originalWebDavSourceConfig; + late String _originalICloudAlbumUrl; @override void initState() { @@ -126,7 +130,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse SystemChrome.setPreferredOrientations(DeviceOrientation.values); final config = context.read(); - _slideDurationMinutes = (config.slideDurationSeconds / 60).round().clamp(1, 15); + _slideDurationSeconds = config.slideDurationSeconds.clamp(10, 3600); _transitionDurationSeconds = (config.transitionDurationMs / 1000.0).clamp(0.5, 5.0); _blurBorders = config.blurBorders; // Default sync type: app_folder on Android, local_folder on Desktop @@ -214,9 +218,16 @@ class _SettingsScreenState extends State with WidgetsBindingObse ) .toList(growable: false); + final icloudConfig = ICloudAlbumSourceConfig.fromMap( + config.getSourceConfig('icloud_album'), + ); + _icloudAlbumUrlController = TextEditingController(text: icloudConfig.albumUrl) + ..addListener(() => setState(() {})); + // Store original values for comparison on save _originalSyncType = _syncType; _originalWebDavSourceConfig = nextcloudConfig; + _originalICloudAlbumUrl = icloudConfig.albumUrl; // Load saved album selection for device_photos mode final devicePhotosConfig = config.getSourceConfig('device_photos'); @@ -286,6 +297,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse _nextcloudUrlController.dispose(); _webdavUserController.dispose(); _webdavPasswordController.dispose(); + _icloudAlbumUrlController.dispose(); super.dispose(); } @@ -302,19 +314,23 @@ class _SettingsScreenState extends State with WidgetsBindingObse // Detect if sync configuration changed final newNextcloudUrl = _nextcloudUrlController.text.trim(); + final newICloudAlbumUrl = _icloudAlbumUrlController.text.trim(); final newWebDavSourceConfig = _buildWebDavSourceConfig( url: newNextcloudUrl, ); final nextcloudConfigChanged = !_nextcloudConfigsEqual(newWebDavSourceConfig, _originalWebDavSourceConfig); + final icloudConfigChanged = newICloudAlbumUrl != _originalICloudAlbumUrl; final syncConfigChanged = _syncType != _originalSyncType || - (_syncType == 'nextcloud_link' && nextcloudConfigChanged); - final newSyncSourceConfigured = syncConfigChanged && - _syncType == 'nextcloud_link' && - newNextcloudUrl.isNotEmpty; + (_syncType == 'nextcloud_link' && nextcloudConfigChanged) || + (_syncType == 'icloud_album' && icloudConfigChanged); + final newSyncSourceConfigured = syncConfigChanged && ( + (_syncType == 'nextcloud_link' && newNextcloudUrl.isNotEmpty) || + (_syncType == 'icloud_album' && ICloudAlbumSourceConfig(albumUrl: newICloudAlbumUrl).isValid) + ); - config.slideDurationSeconds = _slideDurationMinutes * 60; + config.slideDurationSeconds = _slideDurationSeconds; config.transitionDurationMs = (_transitionDurationSeconds * 1000).round(); config.blurBorders = _blurBorders; // app_folder and local_folder both use empty activeSourceType (no sync) @@ -366,7 +382,13 @@ class _SettingsScreenState extends State with WidgetsBindingObse if (_syncType == 'nextcloud_link') { config.setSourceConfig('nextcloud_link', newWebDavSourceConfig.toMap()); } - + if (_syncType == 'icloud_album') { + config.setSourceConfig( + 'icloud_album', + ICloudAlbumSourceConfig(albumUrl: newICloudAlbumUrl).toMap(), + ); + } + await config.save(); // If a new sync source was configured, trigger an immediate sync @@ -380,7 +402,14 @@ class _SettingsScreenState extends State with WidgetsBindingObse @override Widget build(BuildContext context) { - return Scaffold( + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) async { + if (didPop) return; + await _saveSettings(); + if (mounted) Navigator.of(context).pop(); + }, + child: Scaffold( appBar: AppBar( title: Text(AppLocalizations.of(context)!.settings), leading: IconButton( @@ -405,13 +434,20 @@ class _SettingsScreenState extends State with WidgetsBindingObse _buildSliderSetting( icon: Icons.timer, title: AppLocalizations.of(context)!.slideDuration, - value: _slideDurationMinutes.toDouble(), - min: 1, - max: 15, - divisions: 14, - unit: AppLocalizations.of(context)!.unitMinutes, + value: _slideDurationSeconds.toDouble(), + min: 10, + max: 3600, + divisions: 359, // 10-second steps + unit: '', + formatValue: (v) { + final s = v.round(); + if (s < 60) return '${s}s'; + final m = s ~/ 60; + final rem = s % 60; + return rem > 0 ? '${m}m ${rem}s' : '${m}m'; + }, onChanged: (value) { - setState(() => _slideDurationMinutes = value.round()); + setState(() => _slideDurationSeconds = value.round()); }, ), @@ -534,18 +570,47 @@ class _SettingsScreenState extends State with WidgetsBindingObse // === SYNC SETTINGS === _buildSectionHeader(AppLocalizations.of(context)!.sectionPhotoSource), const SizedBox(height: 8), - + + // Web settings server banner + Builder(builder: (ctx) { + final webServer = ctx.read(); + final url = webServer.serverUrl; + if (url == null) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: ListTile( + leading: const Icon(Icons.open_in_browser), + title: Text( + AppLocalizations.of(ctx)!.webSettingsAddress(url), + style: const TextStyle(fontSize: 13), + ), + dense: true, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide( + color: Theme.of(ctx).colorScheme.outline.withOpacity(0.4)), + ), + ), + ); + }), + // Sync Type Selection (includes inline folder selector for local_folder) _buildSyncTypeSelector(), - + + // iCloud URL field (only visible if iCloud selected) + if (_syncType == 'icloud_album') ...[ + const SizedBox(height: 16), + _buildICloudAlbumSettings(), + ], + // Nextcloud URL (only visible if nextcloud selected) if (_syncType == 'nextcloud_link') ...[ const SizedBox(height: 16), _buildNextcloudSettings(), ], - - // Sync options (only visible if sync enabled - i.e. Nextcloud) - if (_syncType == 'nextcloud_link') ...[ + + // Sync options (iCloud or Nextcloud) + if (_syncType == 'icloud_album' || _syncType == 'nextcloud_link') ...[ const SizedBox(height: 16), // Sync Interval Slider @@ -671,9 +736,10 @@ class _SettingsScreenState extends State with WidgetsBindingObse ), ], ), - ); + ), // end Scaffold (child of PopScope) + ); // end PopScope } - + Widget _buildAutoUpdateSection() { final l10n = AppLocalizations.of(context)!; final hintColor = Theme.of(context).colorScheme.onSurfaceVariant; @@ -841,6 +907,15 @@ class _SettingsScreenState extends State with WidgetsBindingObse if (_syncType == 'local_folder') _buildLocalFolderSelector(), ], + RadioListTile( + title: Text(AppLocalizations.of(context)!.icloudAlbum), + subtitle: Text(AppLocalizations.of(context)!.icloudAlbumSubtitle), + value: 'icloud_album', + groupValue: _syncType, + onChanged: (value) { + setState(() => _syncType = value!); + }, + ), RadioListTile( title: Text(AppLocalizations.of(context)!.nextcloud), subtitle: Text(AppLocalizations.of(context)!.nextcloudSubtitle), @@ -853,6 +928,41 @@ class _SettingsScreenState extends State with WidgetsBindingObse ], ); } + + Widget _buildICloudAlbumSettings() { + final l10n = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.icloudAlbumUrl, + style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 8), + TextField( + controller: _icloudAlbumUrlController, + decoration: InputDecoration( + hintText: l10n.icloudAlbumUrlHint, + border: const OutlineInputBorder(), + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 4), + Builder(builder: (ctx) { + final url = _icloudAlbumUrlController.text.trim(); + final valid = url.isEmpty || + ICloudAlbumSourceConfig(albumUrl: url).isValid; + return valid + ? const SizedBox.shrink() + : Text(l10n.icloudAlbumUrlInvalid, + style: TextStyle( + color: Theme.of(ctx).colorScheme.error, + fontSize: 12)); + }), + ], + ), + ); + } /// Android only: Show app folder path with warning Widget _buildAppFolderInfo() { @@ -2319,6 +2429,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse const Spacer(), SegmentedButton( segments: const [ + ButtonSegment(value: 'xsmall', label: Text('XS')), ButtonSegment(value: 'small', label: Text('S')), ButtonSegment(value: 'medium', label: Text('M')), ButtonSegment(value: 'large', label: Text('L')), diff --git a/lib/ui/screens/slideshow_screen.dart b/lib/ui/screens/slideshow_screen.dart index 2d84a2f..653c407 100644 --- a/lib/ui/screens/slideshow_screen.dart +++ b/lib/ui/screens/slideshow_screen.dart @@ -802,13 +802,16 @@ class _SlideshowScreenState extends State with TickerProviderSt config.addListener(_onConfigChanged); } - /// Handle config changes for Keep Alive service + /// Handle config changes void _onConfigChanged() { + if (!mounted) return; final config = context.read(); - final shouldRun = config.keepAliveEnabled; - - // Start or stop service based on config - if (shouldRun) { + + // Restart timer so any slide duration change takes effect immediately + _startTimer(); + + // Start or stop keep alive service + if (config.keepAliveEnabled) { KeepAliveService.startService(); } else { KeepAliveService.stopService(); diff --git a/lib/ui/widgets/photo_info_overlay.dart b/lib/ui/widgets/photo_info_overlay.dart index af266ae..ee822c8 100644 --- a/lib/ui/widgets/photo_info_overlay.dart +++ b/lib/ui/widgets/photo_info_overlay.dart @@ -72,17 +72,19 @@ class PhotoInfoOverlay extends StatelessWidget { @override Widget build(BuildContext context) { - // Build info lines + final dateStr = photo.captureDate != null ? _formatDate(photo.captureDate!) : null; + final cityStr = (locationName != null && locationName!.isNotEmpty) ? locationName : null; + + // For bottom positions: city on top, date on bottom (reads naturally upward). + // For top positions: date on top, city below. + final bool bottomPosition = position == 'bottomRight' || position == 'bottomLeft'; final List infoLines = []; - - // Add capture date only if available from EXIF (no fallback to file date) - if (photo.captureDate != null) { - infoLines.add(_formatDate(photo.captureDate!)); - } - - // Add location if available - if (locationName != null && locationName!.isNotEmpty) { - infoLines.add(locationName!); + if (bottomPosition) { + if (cityStr != null) infoLines.add(cityStr); + if (dateStr != null) infoLines.add(dateStr); + } else { + if (dateStr != null) infoLines.add(dateStr); + if (cityStr != null) infoLines.add(cityStr); } if (infoLines.isEmpty) { @@ -109,8 +111,10 @@ class PhotoInfoOverlay extends StatelessWidget { case 'medium': return 39; case 'small': - default: return 30; + case 'xsmall': + default: + return 22; } } diff --git a/pubspec.lock b/pubspec.lock index d7c4fb2..cd9cc46 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -337,10 +337,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -702,10 +702,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" typed_data: dependency: transitive description: From 74a3955aaea6d5939c5e78f694c1f35b636810df Mon Sep 17 00:00:00 2001 From: Andrew Dean Date: Thu, 13 Aug 2026 18:11:35 +1000 Subject: [PATCH 2/2] Add sync timeout config, Wi-Fi settings shortcut, and device IP banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sync timeout is now configurable (15–120s, default 60s) in both the in-app settings and web UI; passed through to iCloud sync service so slow connections don't fail with the old 30s hard-coded limit - Auto-sync now fires on every save when a source is configured, not only when the URL changes — so tapping Save always kicks off a sync - Wi-Fi Settings shortcut added to Android section of settings screen (opens system Wi-Fi page via MethodChannel) - Device IP/web UI URL banner shown at top of settings screen - Web UI save now calls syncFromConfig so autostart SharedPreferences are updated immediately without requiring an app restart - BootReceiver logs upgraded to Log.i so they appear in release builds - Add FRAME_SETUP.md with quick ADB commands for setting up a new frame --- FRAME_SETUP.md | 42 ++++++++ .../openphotoframe/ScreenControlHandler.kt | 7 ++ lib/domain/interfaces/config_provider.dart | 5 +- .../services/icloud_album_sync_service.dart | 6 +- .../services/json_config_service.dart | 12 ++- .../native_screen_control_service.dart | 7 +- .../services/web_server_service.dart | 12 +++ lib/main.dart | 1 + lib/ui/screens/settings_screen.dart | 95 +++++++++++++++++-- 9 files changed, 171 insertions(+), 16 deletions(-) create mode 100644 FRAME_SETUP.md diff --git a/FRAME_SETUP.md b/FRAME_SETUP.md new file mode 100644 index 0000000..592fb4b --- /dev/null +++ b/FRAME_SETUP.md @@ -0,0 +1,42 @@ +# Setting up a new photo frame + +## 1. Find the device + +```bash +adb devices -l +``` + +Note the device serial (e.g. `c3d9b8674f4b94f6`). Use `-s ` in all commands below if multiple devices are connected. + +## 2. Install the APK + +```bash +adb install -r build/app/outputs/flutter-apk/app-release.apk +``` + +## 3. Disable the stock frame app (if any) + +```bash +adb shell pm list packages -s # find the stock app package name +adb shell pm disable-user --user 0 net.frameo.frame # replace with actual package +``` + +## 4. Set Open Photo Frame as default home (auto-starts on boot) + +```bash +adb shell cmd package set-home-activity io.github.micw.openphotoframe/.MainActivity +``` + +## 5. Launch now + +```bash +adb shell am start -n io.github.micw.openphotoframe/.MainActivity +``` + +## Build the APK + +```bash +cd android && ./gradlew assembleRelease +``` + +Output: `build/app/outputs/flutter-apk/app-release.apk` diff --git a/android/app/src/main/kotlin/io/github/micw/openphotoframe/ScreenControlHandler.kt b/android/app/src/main/kotlin/io/github/micw/openphotoframe/ScreenControlHandler.kt index 15af24e..9eca93c 100644 --- a/android/app/src/main/kotlin/io/github/micw/openphotoframe/ScreenControlHandler.kt +++ b/android/app/src/main/kotlin/io/github/micw/openphotoframe/ScreenControlHandler.kt @@ -58,6 +58,13 @@ class ScreenControlHandler(private val context: Context) { openDeviceAdminSettings() result.success(null) } + "openWifiSettings" -> { + val intent = Intent(android.provider.Settings.ACTION_WIFI_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + result.success(null) + } "turnScreenOff" -> { val success = turnScreenOff() result.success(success) diff --git a/lib/domain/interfaces/config_provider.dart b/lib/domain/interfaces/config_provider.dart index 5bec632..fd885bb 100644 --- a/lib/domain/interfaces/config_provider.dart +++ b/lib/domain/interfaces/config_provider.dart @@ -23,7 +23,10 @@ abstract class ConfigProvider extends ChangeNotifier { // Sync settings int get syncIntervalMinutes; // 0 = disabled, otherwise interval in minutes set syncIntervalMinutes(int value); - + + int get syncTimeoutSeconds; // Network timeout for sync requests (default 60) + set syncTimeoutSeconds(int value); + bool get deleteOrphanedFiles; // Delete local files not on server set deleteOrphanedFiles(bool value); diff --git a/lib/infrastructure/services/icloud_album_sync_service.dart b/lib/infrastructure/services/icloud_album_sync_service.dart index 8909d90..2e919e3 100644 --- a/lib/infrastructure/services/icloud_album_sync_service.dart +++ b/lib/infrastructure/services/icloud_album_sync_service.dart @@ -19,17 +19,19 @@ class ICloudAlbumSyncException implements Exception { } class ICloudAlbumSyncService implements SyncProvider { - static const Duration _requestTimeout = Duration(seconds: 30); static const Duration _downloadIdleTimeout = Duration(minutes: 15); ICloudAlbumSyncService({ required ICloudAlbumSourceConfig config, required StorageProvider storageProvider, + int timeoutSeconds = 60, }) : _config = config, - _storageProvider = storageProvider; + _storageProvider = storageProvider, + _requestTimeout = Duration(seconds: timeoutSeconds); final ICloudAlbumSourceConfig _config; final StorageProvider _storageProvider; + final Duration _requestTimeout; final _log = Logger('ICloudAlbumSyncService'); @override diff --git a/lib/infrastructure/services/json_config_service.dart b/lib/infrastructure/services/json_config_service.dart index 7b87361..a7b45dd 100644 --- a/lib/infrastructure/services/json_config_service.dart +++ b/lib/infrastructure/services/json_config_service.dart @@ -272,12 +272,20 @@ class JsonConfigService extends ConfigProvider { // Sync settings @override int get syncIntervalMinutes => _config['sync_interval_minutes'] ?? 15; - + @override set syncIntervalMinutes(int value) { _config['sync_interval_minutes'] = value; } - + + @override + int get syncTimeoutSeconds => _config['sync_timeout_seconds'] ?? 60; + + @override + set syncTimeoutSeconds(int value) { + _config['sync_timeout_seconds'] = value; + } + @override bool get deleteOrphanedFiles => _config['delete_orphaned_files'] ?? true; diff --git a/lib/infrastructure/services/native_screen_control_service.dart b/lib/infrastructure/services/native_screen_control_service.dart index 5c899e4..555211a 100644 --- a/lib/infrastructure/services/native_screen_control_service.dart +++ b/lib/infrastructure/services/native_screen_control_service.dart @@ -116,7 +116,7 @@ class NativeScreenControlService { /// Check if the screen is currently on. static Future isScreenOn() async { if (!isSupported) return true; - + try { final result = await _channel.invokeMethod('isScreenOn'); return result ?? true; @@ -125,4 +125,9 @@ class NativeScreenControlService { return true; } } + + static Future openWifiSettings() async { + if (!isSupported) return; + await _channel.invokeMethod('openWifiSettings'); + } } diff --git a/lib/infrastructure/services/web_server_service.dart b/lib/infrastructure/services/web_server_service.dart index 2ce6f0c..10c22af 100644 --- a/lib/infrastructure/services/web_server_service.dart +++ b/lib/infrastructure/services/web_server_service.dart @@ -126,6 +126,7 @@ class WebServerService { 'blur_borders': _config.blurBorders, // Sync 'sync_interval_minutes': _config.syncIntervalMinutes, + 'sync_timeout_seconds': _config.syncTimeoutSeconds, 'delete_orphaned_files': _config.deleteOrphanedFiles, // Clock 'show_clock': _config.showClock, @@ -192,6 +193,7 @@ class WebServerService { setBool('blur_borders', (v) => _config.blurBorders = v); // Sync setInt('sync_interval_minutes', (v) => _config.syncIntervalMinutes = v); + setInt('sync_timeout_seconds', (v) => _config.syncTimeoutSeconds = v); setBool('delete_orphaned_files', (v) => _config.deleteOrphanedFiles = v); // Clock setBool('show_clock', (v) => _config.showClock = v); @@ -544,6 +546,11 @@ button{padding:10px 20px;border:none;border-radius:6px;cursor:pointer;font-size: +
+ + + +
Delete photos removed from sourceRemove local files no longer on server @@ -664,6 +671,8 @@ function applyConfig(c){ // Sync const si=c.sync_interval_minutes??15; setRange('sync-int',si); syncLbl(si); + const st=c.sync_timeout_seconds??60; + setRange('sync-timeout',st); syncTimeoutLbl(st); setCheck('del-orphans', c.delete_orphaned_files??false); // Android @@ -707,9 +716,11 @@ document.getElementById('fri-sat-on').addEventListener('change',e=>{ function durLbl(v){const s=+v;document.getElementById('dur-lbl').textContent=s<60?s+'s':(s/60|0)+'m'+(s%60?(s%60)+'s':'');} function transLbl(v){document.getElementById('trans-lbl').textContent=(+v/1000).toFixed(1)+'s';} function syncLbl(v){const m=+v;document.getElementById('sync-lbl').textContent=m?m+'m':'off';} +function syncTimeoutLbl(v){document.getElementById('sync-timeout-lbl').textContent=v+'s';} document.getElementById('slide-dur').addEventListener('input',e=>durLbl(e.target.value)); document.getElementById('trans-dur').addEventListener('input',e=>transLbl(e.target.value)); document.getElementById('sync-int').addEventListener('input',e=>syncLbl(e.target.value)); +document.getElementById('sync-timeout').addEventListener('input',e=>syncTimeoutLbl(e.target.value)); /* ---- save ---- */ async function saveSettings(){ @@ -729,6 +740,7 @@ async function saveSettings(){ transition_duration_ms:+document.getElementById('trans-dur').value, blur_borders:document.getElementById('blur-borders').checked, sync_interval_minutes:+document.getElementById('sync-int').value, + sync_timeout_seconds:+document.getElementById('sync-timeout').value, delete_orphaned_files:document.getElementById('del-orphans').checked, show_clock:document.getElementById('show-clock').checked, clock_size:document.getElementById('clock-size').value, diff --git a/lib/main.dart b/lib/main.dart index 1d0f984..4f49c4d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -132,6 +132,7 @@ class OpenPhotoFrameApp extends StatelessWidget { return ICloudAlbumSyncService( config: icloudConfig, storageProvider: storage, + timeoutSeconds: config.syncTimeoutSeconds, ); } } diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart index ed0cea0..290b752 100644 --- a/lib/ui/screens/settings_screen.dart +++ b/lib/ui/screens/settings_screen.dart @@ -56,6 +56,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse late bool _webdavAllowInvalidCertificate; late TextEditingController _icloudAlbumUrlController; late int _syncIntervalMinutes; + late int _syncTimeoutSeconds; late bool _deleteOrphanedFiles; late bool _autostartOnBoot; late bool _keepAliveEnabled; @@ -138,6 +139,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse _syncType = config.activeSourceType.isEmpty ? defaultSyncType : config.activeSourceType; _localFolderPath = config.customPhotoPath ?? ''; _syncIntervalMinutes = config.syncIntervalMinutes; + _syncTimeoutSeconds = config.syncTimeoutSeconds; _deleteOrphanedFiles = config.deleteOrphanedFiles; _autostartOnBoot = config.autostartOnBoot; _keepAliveEnabled = config.keepAliveEnabled; @@ -344,6 +346,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse config.customPhotoPath = null; } config.syncIntervalMinutes = _syncIntervalMinutes; + config.syncTimeoutSeconds = _syncTimeoutSeconds; config.deleteOrphanedFiles = _deleteOrphanedFiles; config.autostartOnBoot = _autostartOnBoot; config.keepAliveEnabled = _keepAliveEnabled; @@ -390,13 +393,16 @@ class _SettingsScreenState extends State with WidgetsBindingObse } await config.save(); - - // If a new sync source was configured, trigger an immediate sync - // This runs in the background (fire-and-forget) so the user can continue - if (newSyncSourceConfigured) { + + // Trigger a sync whenever a source is configured, not just on first setup. + // Covers: URL changes, timeout changes, or simply tapping Save to force a retry. + final sourceIsConfigured = + (_syncType == 'nextcloud_link' && newNextcloudUrl.isNotEmpty) || + (_syncType == 'icloud_album' && + ICloudAlbumSourceConfig(albumUrl: newICloudAlbumUrl).isValid); + if (sourceIsConfigured) { final photoService = context.read(); - // Don't await - let it run in the background - photoService.triggerSync(); + photoService.triggerSync(); // fire-and-forget } } @@ -423,9 +429,33 @@ class _SettingsScreenState extends State with WidgetsBindingObse body: ListView( padding: const EdgeInsets.all(16), children: [ + // === DEVICE IP / WEB SETTINGS URL === + if (Platform.isAndroid) + Builder(builder: (ctx) { + final url = ctx.read().serverUrl; + if (url == null) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(ctx).colorScheme.surfaceVariant.withOpacity(0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.wifi, size: 18, color: Theme.of(ctx).colorScheme.primary), + const SizedBox(width: 10), + Text(url, style: TextStyle(fontSize: 13, color: Theme.of(ctx).colorScheme.primary, fontWeight: FontWeight.w500)), + ], + ), + ), + ); + }), + // === DEVICE ADMIN WARNING === if (Platform.isAndroid && _deviceAdminEnabled) ..._buildDeviceAdminWarning(), - + // === SLIDESHOW SETTINGS === _buildSectionHeader(AppLocalizations.of(context)!.sectionSlideshow), const SizedBox(height: 8), @@ -615,9 +645,14 @@ class _SettingsScreenState extends State with WidgetsBindingObse // Sync Interval Slider _buildSyncIntervalSlider(), - + const SizedBox(height: 8), - + + // Sync Timeout Slider + _buildSyncTimeoutSlider(), + + const SizedBox(height: 8), + // Delete orphaned files checkbox SwitchListTile( title: Text(AppLocalizations.of(context)!.deleteOrphanedFiles), @@ -664,7 +699,17 @@ class _SettingsScreenState extends State with WidgetsBindingObse if (Platform.isAndroid) ...[ _buildSectionHeader(AppLocalizations.of(context)!.sectionAndroid), const SizedBox(height: 8), - + + ListTile( + leading: const Icon(Icons.wifi), + title: const Text('Wi-Fi Settings'), + subtitle: const Text('Connect to a network'), + trailing: const Icon(Icons.open_in_new, size: 18), + onTap: () => NativeScreenControlService.openWifiSettings(), + ), + + const SizedBox(height: 8), + SwitchListTile( title: Text(AppLocalizations.of(context)!.startOnBoot), subtitle: Text(AppLocalizations.of(context)!.startOnBootSubtitle), @@ -1715,6 +1760,36 @@ class _SettingsScreenState extends State with WidgetsBindingObse ); } + Widget _buildSyncTimeoutSlider() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.timer_outlined, size: 20), + const SizedBox(width: 12), + const Expanded(child: Text('Sync timeout')), + Text( + '$_syncTimeoutSeconds s', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + Slider( + value: _syncTimeoutSeconds.toDouble(), + min: 15, + max: 120, + divisions: 7, // 15, 30, 45, 60, 75, 90, 105, 120 + onChanged: (value) { + setState(() => _syncTimeoutSeconds = (value / 15).round() * 15); + }, + ), + ], + ); + } + Widget _buildSyncNowButton() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16),