Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/12354_nock.json
T

1 line
1.2 MiB
Plaintext

{"repo": "12354/nock", "files": {"app/src/main/java/app/nock/android/ui/settings/SettingsViewModel.kt": {"62435763959e697b61607d4ea76b9ca03d580950": {"t": 1785745725.8056176, "n": 14769, "h": "119d783a703a581c", "c": "package app.nock.android.ui.settings\n\nimport android.content.Context\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport app.nock.android.R\nimport app.nock.android.data.NockRepository\nimport app.nock.android.data.SeedGroupLocaleSync\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.data.dao.ActiveEscalationDao\nimport app.nock.android.data.json.ChainJson\nimport app.nock.android.domain.escalation.EscalationEngine\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.di.ApplicationScope\nimport app.nock.android.sync.DriveSyncClient\nimport app.nock.android.sync.SyncOutcome\nimport app.nock.android.history.AlarmHistoryLogger\nimport app.nock.android.telegram.TelegramSender\nimport app.nock.android.trip.CalendarInfo\nimport app.nock.android.trip.CalendarRepository\nimport app.nock.android.trip.TomTomClient\nimport app.nock.android.trip.TripSyncLog\nimport app.nock.android.trip.TripSyncManager\nimport app.nock.android.domain.trip.TripDefaults\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\ndata class SettingsState(\n val chain: EscalationChain? = null,\n val telegramToken: String = \"\",\n val telegramChat: String = \"\",\n val driveEmail: String? = null,\n val driveLastSyncMs: Long? = null,\n val telegramStatus: String? = null,\n val driveStatus: String? = null,\n val groups: List<Group> = emptyList(),\n val deepSeekApiKey: String = \"\",\n val deepSeekModel: String = SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n val deepSeekBaseUrl: String = SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n val deepSeekContext: String = \"\",\n val tripsEnabled: Boolean = false,\n val tomtomKey: String = \"\",\n val tripHomeAddress: String = \"\",\n val tripBufferMin: Int = (TripDefaults.BUFFER_MS / 60_000L).toInt(),\n val tripStatus: String? = null,\n val tripHasCalendarPermission: Boolean = false,\n val tripCalendars: List<CalendarInfo> = emptyList(),\n val tripSelectedCalendarIds: Set<Long> = emptySet(),\n // null = not set (default notification tone); \"\" = silent; else a sound URI.\n val preAlarmSoundUri: String? = null,\n)\n\n@HiltViewModel\nclass SettingsViewModel @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val settings: SettingsRepository,\n private val repo: NockRepository,\n private val telegram: TelegramSender,\n private val drive: DriveSyncClient,\n private val engine: EscalationEngine,\n private val seedGroupLocaleSync: SeedGroupLocaleSync,\n private val alarmHistory: AlarmHistoryLogger,\n private val activeDao: ActiveEscalationDao,\n private val trips: TripSyncManager,\n private val tripSyncLog: TripSyncLog,\n private val tomtom: TomTomClient,\n private val calendar: CalendarRepository,\n @ApplicationScope private val appScope: CoroutineScope,\n) : ViewModel() {\n\n /** Diagnostic of the latest calendar sync, shown under the Sync button. */\n val tripSyncLogText: StateFlow<String> = tripSyncLog.log\n\n fun clearTripSyncLog() = tripSyncLog.clear()\n\n fun syncSeedGroupNames() {\n viewModelScope.launch { seedGroupLocaleSync.sync() }\n }\n\n fun alarmHistoryDump(): String = alarmHistory.dump()\n fun clearAlarmHistory() = alarmHistory.clear()\n\n /**\n * The history log with a live snapshot of every current alarm and its\n * state appended — used by the Copy button so a pasted report shows both\n * what happened and what is scheduled right now.\n */\n suspend fun alarmHistoryWithCurrentState(): String {\n val now = System.currentTimeMillis()\n val groups = repo.getGroups().associateBy { it.id }\n val states = repo.getAllReminders().map { r ->\n val group = groups[r.groupId]\n val active = activeDao.getByReminderId(r.id)\n val activeState = active?.let { row ->\n val chain = runCatching { ChainJson.decode(row.chainSnapshotJson) }.getOrNull()\n val stageType = chain?.let { it.stage(row.nextStageIndex.coerceIn(0, it.lastIndex)).type }\n stageType?.let { AlarmHistoryLogger.ActiveState(it, row.nextFireAtMs) }\n }\n // The chain currently in flight (snapshotted when it started) if the\n // alarm is mid-escalation, else the chain it would walk next fire:\n // the group's override or the global default.\n val chain = active?.let { runCatching { ChainJson.decode(it.chainSnapshotJson) }.getOrNull() }\n ?: group?.let { repo.effectiveChain(it) }\n ?: settings.getStageChain()\n AlarmHistoryLogger.AlarmState(\n name = r.name,\n group = group?.name,\n schedule = r.schedule,\n nextFireAtMs = r.nextFireAt,\n lastCompletedAt = r.lastCompletedAt,\n active = activeState,\n chain = chain,\n )\n }\n return alarmHistory.dump() + alarmHistory.snapshot(states, now)\n }\n\n private val statusFlow = MutableStateFlow(Pair<String?, String?>(null, null))\n private val tripStatusFlow = MutableStateFlow<String?>(null)\n\n val state: StateFlow<SettingsState> = combine(\n settings.observeAll(),\n repo.observeGroups(),\n statusFlow,\n tripStatusFlow\n ) { kv, groups, status, tripStatus ->\n val chain = (kv[SettingsRepository.KEY_STAGE_CHAIN])?.let {\n runCatching { app.nock.android.data.json.ChainJson.decode(it) }.getOrNull()\n } ?: app.nock.android.domain.model.DefaultChain.CHAIN\n SettingsState(\n chain = chain,\n telegramToken = kv[SettingsRepository.KEY_TELEGRAM_TOKEN].orEmpty(),\n telegramChat = kv[SettingsRepository.KEY_TELEGRAM_CHAT].orEmpty(),\n driveEmail = kv[SettingsRepository.KEY_DRIVE_ACCOUNT],\n driveLastSyncMs = kv[SettingsRepository.KEY_DRIVE_LAST_SYNC_MS]?.toLongOrNull(),\n telegramStatus = status.first,\n driveStatus = status.second,\n groups = groups,\n deepSeekApiKey = kv[SettingsRepository.KEY_DEEPSEEK_API_KEY].orEmpty(),\n deepSeekModel = kv[SettingsRepository.KEY_DEEPSEEK_MODEL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n deepSeekBaseUrl = kv[SettingsRepository.KEY_DEEPSEEK_BASE_URL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n deepSeekContext = kv[SettingsRepository.KEY_DEEPSEEK_CONTEXT].orEmpty(),\n tripsEnabled = kv[SettingsRepository.KEY_TRIPS_ENABLED]?.toBooleanStrictOrNull() == true,\n tomtomKey = kv[SettingsRepository.KEY_TOMTOM_KEY].orEmpty(),\n tripHomeAddress = kv[SettingsRepository.KEY_TRIP_HOME_ADDRESS].orEmpty(),\n tripBufferMin = kv[SettingsRepository.KEY_TRIP_BUFFER_MIN]?.toIntOrNull()\n ?: (TripDefaults.BUFFER_MS / 60_000L).toInt(),\n tripStatus = tripStatus,\n tripHasCalendarPermission = calendar.hasPermission(),\n tripCalendars = if (calendar.hasPermission()) calendar.listCalendars() else emptyList(),\n tripSelectedCalendarIds = kv[SettingsRepository.KEY_TRIP_CALENDAR_IDS]\n ?.split(',')?.mapNotNull { it.trim().toLongOrNull() }?.toSet().orEmpty(),\n preAlarmSoundUri = kv[SettingsRepository.KEY_PREALARM_SOUND],\n )\n }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsState())\n\n // The global chain editor saves on every tweak; mark it dirty so we re-arm the\n // affected (no-override) groups once, when the user leaves the screen.\n @Volatile private var chainEdited = false\n\n fun setChain(chain: EscalationChain) {\n chainEdited = true\n viewModelScope.launch { settings.setStageChain(chain) }\n }\n\n /**\n * Apply a global-chain edit to already-armed reminders, called when the\n * Notifications screen is left. Runs on the application scope (not\n * viewModelScope, which is cancelled as the screen's ViewModel is cleared) so\n * the re-arm always completes. Re-persists the latest chain first so the\n * re-arm can't read a stale value if the final keystroke's write was still in\n * flight (or lost to the same cancellation).\n */\n fun applyChainEditsIfDirty() {\n if (!chainEdited) return\n chainEdited = false\n val chain = state.value.chain ?: return\n appScope.launch {\n settings.setStageChain(chain)\n engine.rearmDefaultChainGroups()\n }\n }\n\n /** Persist the chosen pre-alarm sound: null URI = silent, else the sound URI. */\n fun setPreAlarmSound(uri: String?) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_PREALARM_SOUND, uri ?: \"\")\n }\n }\n\n fun setTelegram(token: String, chat: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_TELEGRAM_TOKEN, token)\n settings.set(SettingsRepository.KEY_TELEGRAM_CHAT, chat)\n }\n }\n\n fun testTelegram() {\n viewModelScope.launch {\n val token = settings.get(SettingsRepository.KEY_TELEGRAM_TOKEN).orEmpty()\n val chat = settings.get(SettingsRepository.KEY_TELEGRAM_CHAT).orEmpty()\n val res = telegram.sendRaw(token, chat, ctx.getString(R.string.telegram_test_message), silent = false)\n val msg = if (res.ok) ctx.getString(R.string.status_telegram_ok)\n else ctx.getString(R.string.status_telegram_error, res.errorDescription.orEmpty())\n statusFlow.value = (msg to statusFlow.value.second)\n }\n }\n\n fun setDriveEmail(email: String?) {\n viewModelScope.launch {\n if (email == null) settings.clear(SettingsRepository.KEY_DRIVE_ACCOUNT)\n else settings.set(SettingsRepository.KEY_DRIVE_ACCOUNT, email)\n }\n }\n\n fun setDriveStatus(message: String?) {\n statusFlow.value = (statusFlow.value.first to message)\n }\n\n fun syncPush() = runDrive { drive.pushSnapshot() }\n fun syncPull() = runDrive { drive.pullIfNewer() }\n\n private fun runDrive(block: suspend () -> SyncOutcome) {\n viewModelScope.launch {\n val r = block()\n val msg = when (r) {\n is SyncOutcome.Ok -> ctx.getString(R.string.status_drive_ok)\n is SyncOutcome.NotSignedIn -> ctx.getString(R.string.status_drive_sign_in_required)\n is SyncOutcome.Error -> ctx.getString(R.string.status_drive_error, r.message)\n }\n statusFlow.value = (statusFlow.value.first to msg)\n }\n }\n\n fun updateGroup(g: Group, sortIndex: Int = 0) {\n viewModelScope.launch { repo.upsertGroup(g, sortIndex) }\n }\n\n fun deleteGroup(g: Group) {\n viewModelScope.launch {\n // Cancel scheduled/ringing alarms for the group's reminders before the\n // FK cascade deletes them, otherwise their OS alarms are orphaned and\n // still fire for a reminder that no longer appears in any list.\n engine.cancelActiveForGroup(g.id)\n repo.deleteGroup(g)\n }\n }\n\n fun setTrips(enabled: Boolean, tomtomKey: String, homeAddress: String, bufferMin: Int) {\n appScope.launch {\n val prevHome = settings.get(SettingsRepository.KEY_TRIP_HOME_ADDRESS).orEmpty()\n settings.set(SettingsRepository.KEY_TRIPS_ENABLED, enabled.toString())\n settings.set(SettingsRepository.KEY_TOMTOM_KEY, tomtomKey.trim())\n settings.set(SettingsRepository.KEY_TRIP_HOME_ADDRESS, homeAddress.trim())\n settings.set(SettingsRepository.KEY_TRIP_BUFFER_MIN, bufferMin.coerceAtLeast(1).toString())\n // The cached home coordinates are only valid for the old address.\n if (homeAddress.trim() != prevHome) {\n settings.clear(SettingsRepository.KEY_TRIP_HOME_LAT)\n settings.clear(SettingsRepository.KEY_TRIP_HOME_LON)\n }\n runCatching { trips.syncNow() }\n }\n }\n\n fun setTripCalendars(ids: Set<Long>) {\n appScope.launch {\n settings.set(SettingsRepository.KEY_TRIP_CALENDAR_IDS, ids.joinToString(\",\"))\n runCatching { trips.syncNow() }\n }\n }\n\n /** Manually re-import calendar events now, reporting the result in the trip status line. */\n fun syncTripsNow() {\n appScope.launch {\n runCatching { trips.syncNow() }\n tripStatusFlow.value = ctx.getString(R.string.trips_status_synced)\n }\n }\n\n /** Called after the calendar permission is granted so trips import immediately. */\n fun onCalendarPermissionResult() {\n appScope.launch { runCatching { trips.syncNow() } }\n // Nudge the state flow so the calendar list / permission flag re-evaluate.\n tripStatusFlow.value = tripStatusFlow.value\n }\n\n /** Validates the TomTom key + home address by geocoding the home address. */\n fun testRoute() {\n viewModelScope.launch {\n val home = settings.get(SettingsRepository.KEY_TRIP_HOME_ADDRESS).orEmpty()\n val key = settings.get(SettingsRepository.KEY_TOMTOM_KEY).orEmpty()\n tripStatusFlow.value = when {\n key.isBlank() -> ctx.getString(R.string.trips_status_no_key)\n home.isBlank() -> ctx.getString(R.string.trips_status_no_home)\n else -> {\n val geo = tomtom.geocode(home)\n if (geo != null) ctx.getString(R.string.trips_status_ok)\n else ctx.getString(R.string.trips_status_error)\n }\n }\n }\n }\n\n fun setDeepSeek(apiKey: String, model: String, baseUrl: String, context: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_DEEPSEEK_API_KEY, apiKey)\n settings.set(SettingsRepository.KEY_DEEPSEEK_MODEL, model.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_MODEL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_BASE_URL, baseUrl.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_CONTEXT, context.trim())\n }\n }\n}\n"}, "b9380aa63a08bb34bbcd2e129dda0f064c6a6923": {"t": 1785755942.9605482, "n": 14769, "h": "119d783a703a581c", "c": "package app.nock.android.ui.settings\n\nimport android.content.Context\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport app.nock.android.R\nimport app.nock.android.data.NockRepository\nimport app.nock.android.data.SeedGroupLocaleSync\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.data.dao.ActiveEscalationDao\nimport app.nock.android.data.json.ChainJson\nimport app.nock.android.domain.escalation.EscalationEngine\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.di.ApplicationScope\nimport app.nock.android.sync.DriveSyncClient\nimport app.nock.android.sync.SyncOutcome\nimport app.nock.android.history.AlarmHistoryLogger\nimport app.nock.android.telegram.TelegramSender\nimport app.nock.android.trip.CalendarInfo\nimport app.nock.android.trip.CalendarRepository\nimport app.nock.android.trip.TomTomClient\nimport app.nock.android.trip.TripSyncLog\nimport app.nock.android.trip.TripSyncManager\nimport app.nock.android.domain.trip.TripDefaults\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\ndata class SettingsState(\n val chain: EscalationChain? = null,\n val telegramToken: String = \"\",\n val telegramChat: String = \"\",\n val driveEmail: String? = null,\n val driveLastSyncMs: Long? = null,\n val telegramStatus: String? = null,\n val driveStatus: String? = null,\n val groups: List<Group> = emptyList(),\n val deepSeekApiKey: String = \"\",\n val deepSeekModel: String = SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n val deepSeekBaseUrl: String = SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n val deepSeekContext: String = \"\",\n val tripsEnabled: Boolean = false,\n val tomtomKey: String = \"\",\n val tripHomeAddress: String = \"\",\n val tripBufferMin: Int = (TripDefaults.BUFFER_MS / 60_000L).toInt(),\n val tripStatus: String? = null,\n val tripHasCalendarPermission: Boolean = false,\n val tripCalendars: List<CalendarInfo> = emptyList(),\n val tripSelectedCalendarIds: Set<Long> = emptySet(),\n // null = not set (default notification tone); \"\" = silent; else a sound URI.\n val preAlarmSoundUri: String? = null,\n)\n\n@HiltViewModel\nclass SettingsViewModel @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val settings: SettingsRepository,\n private val repo: NockRepository,\n private val telegram: TelegramSender,\n private val drive: DriveSyncClient,\n private val engine: EscalationEngine,\n private val seedGroupLocaleSync: SeedGroupLocaleSync,\n private val alarmHistory: AlarmHistoryLogger,\n private val activeDao: ActiveEscalationDao,\n private val trips: TripSyncManager,\n private val tripSyncLog: TripSyncLog,\n private val tomtom: TomTomClient,\n private val calendar: CalendarRepository,\n @ApplicationScope private val appScope: CoroutineScope,\n) : ViewModel() {\n\n /** Diagnostic of the latest calendar sync, shown under the Sync button. */\n val tripSyncLogText: StateFlow<String> = tripSyncLog.log\n\n fun clearTripSyncLog() = tripSyncLog.clear()\n\n fun syncSeedGroupNames() {\n viewModelScope.launch { seedGroupLocaleSync.sync() }\n }\n\n fun alarmHistoryDump(): String = alarmHistory.dump()\n fun clearAlarmHistory() = alarmHistory.clear()\n\n /**\n * The history log with a live snapshot of every current alarm and its\n * state appended — used by the Copy button so a pasted report shows both\n * what happened and what is scheduled right now.\n */\n suspend fun alarmHistoryWithCurrentState(): String {\n val now = System.currentTimeMillis()\n val groups = repo.getGroups().associateBy { it.id }\n val states = repo.getAllReminders().map { r ->\n val group = groups[r.groupId]\n val active = activeDao.getByReminderId(r.id)\n val activeState = active?.let { row ->\n val chain = runCatching { ChainJson.decode(row.chainSnapshotJson) }.getOrNull()\n val stageType = chain?.let { it.stage(row.nextStageIndex.coerceIn(0, it.lastIndex)).type }\n stageType?.let { AlarmHistoryLogger.ActiveState(it, row.nextFireAtMs) }\n }\n // The chain currently in flight (snapshotted when it started) if the\n // alarm is mid-escalation, else the chain it would walk next fire:\n // the group's override or the global default.\n val chain = active?.let { runCatching { ChainJson.decode(it.chainSnapshotJson) }.getOrNull() }\n ?: group?.let { repo.effectiveChain(it) }\n ?: settings.getStageChain()\n AlarmHistoryLogger.AlarmState(\n name = r.name,\n group = group?.name,\n schedule = r.schedule,\n nextFireAtMs = r.nextFireAt,\n lastCompletedAt = r.lastCompletedAt,\n active = activeState,\n chain = chain,\n )\n }\n return alarmHistory.dump() + alarmHistory.snapshot(states, now)\n }\n\n private val statusFlow = MutableStateFlow(Pair<String?, String?>(null, null))\n private val tripStatusFlow = MutableStateFlow<String?>(null)\n\n val state: StateFlow<SettingsState> = combine(\n settings.observeAll(),\n repo.observeGroups(),\n statusFlow,\n tripStatusFlow\n ) { kv, groups, status, tripStatus ->\n val chain = (kv[SettingsRepository.KEY_STAGE_CHAIN])?.let {\n runCatching { app.nock.android.data.json.ChainJson.decode(it) }.getOrNull()\n } ?: app.nock.android.domain.model.DefaultChain.CHAIN\n SettingsState(\n chain = chain,\n telegramToken = kv[SettingsRepository.KEY_TELEGRAM_TOKEN].orEmpty(),\n telegramChat = kv[SettingsRepository.KEY_TELEGRAM_CHAT].orEmpty(),\n driveEmail = kv[SettingsRepository.KEY_DRIVE_ACCOUNT],\n driveLastSyncMs = kv[SettingsRepository.KEY_DRIVE_LAST_SYNC_MS]?.toLongOrNull(),\n telegramStatus = status.first,\n driveStatus = status.second,\n groups = groups,\n deepSeekApiKey = kv[SettingsRepository.KEY_DEEPSEEK_API_KEY].orEmpty(),\n deepSeekModel = kv[SettingsRepository.KEY_DEEPSEEK_MODEL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n deepSeekBaseUrl = kv[SettingsRepository.KEY_DEEPSEEK_BASE_URL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n deepSeekContext = kv[SettingsRepository.KEY_DEEPSEEK_CONTEXT].orEmpty(),\n tripsEnabled = kv[SettingsRepository.KEY_TRIPS_ENABLED]?.toBooleanStrictOrNull() == true,\n tomtomKey = kv[SettingsRepository.KEY_TOMTOM_KEY].orEmpty(),\n tripHomeAddress = kv[SettingsRepository.KEY_TRIP_HOME_ADDRESS].orEmpty(),\n tripBufferMin = kv[SettingsRepository.KEY_TRIP_BUFFER_MIN]?.toIntOrNull()\n ?: (TripDefaults.BUFFER_MS / 60_000L).toInt(),\n tripStatus = tripStatus,\n tripHasCalendarPermission = calendar.hasPermission(),\n tripCalendars = if (calendar.hasPermission()) calendar.listCalendars() else emptyList(),\n tripSelectedCalendarIds = kv[SettingsRepository.KEY_TRIP_CALENDAR_IDS]\n ?.split(',')?.mapNotNull { it.trim().toLongOrNull() }?.toSet().orEmpty(),\n preAlarmSoundUri = kv[SettingsRepository.KEY_PREALARM_SOUND],\n )\n }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsState())\n\n // The global chain editor saves on every tweak; mark it dirty so we re-arm the\n // affected (no-override) groups once, when the user leaves the screen.\n @Volatile private var chainEdited = false\n\n fun setChain(chain: EscalationChain) {\n chainEdited = true\n viewModelScope.launch { settings.setStageChain(chain) }\n }\n\n /**\n * Apply a global-chain edit to already-armed reminders, called when the\n * Notifications screen is left. Runs on the application scope (not\n * viewModelScope, which is cancelled as the screen's ViewModel is cleared) so\n * the re-arm always completes. Re-persists the latest chain first so the\n * re-arm can't read a stale value if the final keystroke's write was still in\n * flight (or lost to the same cancellation).\n */\n fun applyChainEditsIfDirty() {\n if (!chainEdited) return\n chainEdited = false\n val chain = state.value.chain ?: return\n appScope.launch {\n settings.setStageChain(chain)\n engine.rearmDefaultChainGroups()\n }\n }\n\n /** Persist the chosen pre-alarm sound: null URI = silent, else the sound URI. */\n fun setPreAlarmSound(uri: String?) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_PREALARM_SOUND, uri ?: \"\")\n }\n }\n\n fun setTelegram(token: String, chat: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_TELEGRAM_TOKEN, token)\n settings.set(SettingsRepository.KEY_TELEGRAM_CHAT, chat)\n }\n }\n\n fun testTelegram() {\n viewModelScope.launch {\n val token = settings.get(SettingsRepository.KEY_TELEGRAM_TOKEN).orEmpty()\n val chat = settings.get(SettingsRepository.KEY_TELEGRAM_CHAT).orEmpty()\n val res = telegram.sendRaw(token, chat, ctx.getString(R.string.telegram_test_message), silent = false)\n val msg = if (res.ok) ctx.getString(R.string.status_telegram_ok)\n else ctx.getString(R.string.status_telegram_error, res.errorDescription.orEmpty())\n statusFlow.value = (msg to statusFlow.value.second)\n }\n }\n\n fun setDriveEmail(email: String?) {\n viewModelScope.launch {\n if (email == null) settings.clear(SettingsRepository.KEY_DRIVE_ACCOUNT)\n else settings.set(SettingsRepository.KEY_DRIVE_ACCOUNT, email)\n }\n }\n\n fun setDriveStatus(message: String?) {\n statusFlow.value = (statusFlow.value.first to message)\n }\n\n fun syncPush() = runDrive { drive.pushSnapshot() }\n fun syncPull() = runDrive { drive.pullIfNewer() }\n\n private fun runDrive(block: suspend () -> SyncOutcome) {\n viewModelScope.launch {\n val r = block()\n val msg = when (r) {\n is SyncOutcome.Ok -> ctx.getString(R.string.status_drive_ok)\n is SyncOutcome.NotSignedIn -> ctx.getString(R.string.status_drive_sign_in_required)\n is SyncOutcome.Error -> ctx.getString(R.string.status_drive_error, r.message)\n }\n statusFlow.value = (statusFlow.value.first to msg)\n }\n }\n\n fun updateGroup(g: Group, sortIndex: Int = 0) {\n viewModelScope.launch { repo.upsertGroup(g, sortIndex) }\n }\n\n fun deleteGroup(g: Group) {\n viewModelScope.launch {\n // Cancel scheduled/ringing alarms for the group's reminders before the\n // FK cascade deletes them, otherwise their OS alarms are orphaned and\n // still fire for a reminder that no longer appears in any list.\n engine.cancelActiveForGroup(g.id)\n repo.deleteGroup(g)\n }\n }\n\n fun setTrips(enabled: Boolean, tomtomKey: String, homeAddress: String, bufferMin: Int) {\n appScope.launch {\n val prevHome = settings.get(SettingsRepository.KEY_TRIP_HOME_ADDRESS).orEmpty()\n settings.set(SettingsRepository.KEY_TRIPS_ENABLED, enabled.toString())\n settings.set(SettingsRepository.KEY_TOMTOM_KEY, tomtomKey.trim())\n settings.set(SettingsRepository.KEY_TRIP_HOME_ADDRESS, homeAddress.trim())\n settings.set(SettingsRepository.KEY_TRIP_BUFFER_MIN, bufferMin.coerceAtLeast(1).toString())\n // The cached home coordinates are only valid for the old address.\n if (homeAddress.trim() != prevHome) {\n settings.clear(SettingsRepository.KEY_TRIP_HOME_LAT)\n settings.clear(SettingsRepository.KEY_TRIP_HOME_LON)\n }\n runCatching { trips.syncNow() }\n }\n }\n\n fun setTripCalendars(ids: Set<Long>) {\n appScope.launch {\n settings.set(SettingsRepository.KEY_TRIP_CALENDAR_IDS, ids.joinToString(\",\"))\n runCatching { trips.syncNow() }\n }\n }\n\n /** Manually re-import calendar events now, reporting the result in the trip status line. */\n fun syncTripsNow() {\n appScope.launch {\n runCatching { trips.syncNow() }\n tripStatusFlow.value = ctx.getString(R.string.trips_status_synced)\n }\n }\n\n /** Called after the calendar permission is granted so trips import immediately. */\n fun onCalendarPermissionResult() {\n appScope.launch { runCatching { trips.syncNow() } }\n // Nudge the state flow so the calendar list / permission flag re-evaluate.\n tripStatusFlow.value = tripStatusFlow.value\n }\n\n /** Validates the TomTom key + home address by geocoding the home address. */\n fun testRoute() {\n viewModelScope.launch {\n val home = settings.get(SettingsRepository.KEY_TRIP_HOME_ADDRESS).orEmpty()\n val key = settings.get(SettingsRepository.KEY_TOMTOM_KEY).orEmpty()\n tripStatusFlow.value = when {\n key.isBlank() -> ctx.getString(R.string.trips_status_no_key)\n home.isBlank() -> ctx.getString(R.string.trips_status_no_home)\n else -> {\n val geo = tomtom.geocode(home)\n if (geo != null) ctx.getString(R.string.trips_status_ok)\n else ctx.getString(R.string.trips_status_error)\n }\n }\n }\n }\n\n fun setDeepSeek(apiKey: String, model: String, baseUrl: String, context: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_DEEPSEEK_API_KEY, apiKey)\n settings.set(SettingsRepository.KEY_DEEPSEEK_MODEL, model.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_MODEL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_BASE_URL, baseUrl.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_CONTEXT, context.trim())\n }\n }\n}\n"}, "9f2ad25e60416602e9c7d3f6d4c0de4bba57fd44": {"t": 1785755944.597176, "n": 14494, "h": "7d94fd17e85eb73a", "c": "package app.nock.android.ui.settings\n\nimport android.content.Context\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport app.nock.android.R\nimport app.nock.android.data.NockRepository\nimport app.nock.android.data.SeedGroupLocaleSync\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.data.dao.ActiveEscalationDao\nimport app.nock.android.data.json.ChainJson\nimport app.nock.android.domain.escalation.EscalationEngine\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.di.ApplicationScope\nimport app.nock.android.sync.DriveSyncClient\nimport app.nock.android.sync.SyncOutcome\nimport app.nock.android.history.AlarmHistoryLogger\nimport app.nock.android.telegram.TelegramSender\nimport app.nock.android.trip.CalendarInfo\nimport app.nock.android.trip.CalendarRepository\nimport app.nock.android.trip.TomTomClient\nimport app.nock.android.trip.TripSyncManager\nimport app.nock.android.domain.trip.TripDefaults\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\ndata class SettingsState(\n val chain: EscalationChain? = null,\n val telegramToken: String = \"\",\n val telegramChat: String = \"\",\n val driveEmail: String? = null,\n val driveLastSyncMs: Long? = null,\n val telegramStatus: String? = null,\n val driveStatus: String? = null,\n val groups: List<Group> = emptyList(),\n val deepSeekApiKey: String = \"\",\n val deepSeekModel: String = SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n val deepSeekBaseUrl: String = SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n val deepSeekContext: String = \"\",\n val tripsEnabled: Boolean = false,\n val tomtomKey: String = \"\",\n val tripHomeAddress: String = \"\",\n val tripBufferMin: Int = (TripDefaults.BUFFER_MS / 60_000L).toInt(),\n val tripStatus: String? = null,\n val tripHasCalendarPermission: Boolean = false,\n val tripCalendars: List<CalendarInfo> = emptyList(),\n val tripSelectedCalendarIds: Set<Long> = emptySet(),\n // null = not set (default notification tone); \"\" = silent; else a sound URI.\n val preAlarmSoundUri: String? = null,\n)\n\n@HiltViewModel\nclass SettingsViewModel @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val settings: SettingsRepository,\n private val repo: NockRepository,\n private val telegram: TelegramSender,\n private val drive: DriveSyncClient,\n private val engine: EscalationEngine,\n private val seedGroupLocaleSync: SeedGroupLocaleSync,\n private val alarmHistory: AlarmHistoryLogger,\n private val activeDao: ActiveEscalationDao,\n private val trips: TripSyncManager,\n private val tomtom: TomTomClient,\n private val calendar: CalendarRepository,\n @ApplicationScope private val appScope: CoroutineScope,\n) : ViewModel() {\n\n fun syncSeedGroupNames() {\n viewModelScope.launch { seedGroupLocaleSync.sync() }\n }\n\n fun alarmHistoryDump(): String = alarmHistory.dump()\n fun clearAlarmHistory() = alarmHistory.clear()\n\n /**\n * The history log with a live snapshot of every current alarm and its\n * state appended — used by the Copy button so a pasted report shows both\n * what happened and what is scheduled right now.\n */\n suspend fun alarmHistoryWithCurrentState(): String {\n val now = System.currentTimeMillis()\n val groups = repo.getGroups().associateBy { it.id }\n val states = repo.getAllReminders().map { r ->\n val group = groups[r.groupId]\n val active = activeDao.getByReminderId(r.id)\n val activeState = active?.let { row ->\n val chain = runCatching { ChainJson.decode(row.chainSnapshotJson) }.getOrNull()\n val stageType = chain?.let { it.stage(row.nextStageIndex.coerceIn(0, it.lastIndex)).type }\n stageType?.let { AlarmHistoryLogger.ActiveState(it, row.nextFireAtMs) }\n }\n // The chain currently in flight (snapshotted when it started) if the\n // alarm is mid-escalation, else the chain it would walk next fire:\n // the group's override or the global default.\n val chain = active?.let { runCatching { ChainJson.decode(it.chainSnapshotJson) }.getOrNull() }\n ?: group?.let { repo.effectiveChain(it) }\n ?: settings.getStageChain()\n AlarmHistoryLogger.AlarmState(\n name = r.name,\n group = group?.name,\n schedule = r.schedule,\n nextFireAtMs = r.nextFireAt,\n lastCompletedAt = r.lastCompletedAt,\n active = activeState,\n chain = chain,\n )\n }\n return alarmHistory.dump() + alarmHistory.snapshot(states, now)\n }\n\n private val statusFlow = MutableStateFlow(Pair<String?, String?>(null, null))\n private val tripStatusFlow = MutableStateFlow<String?>(null)\n\n val state: StateFlow<SettingsState> = combine(\n settings.observeAll(),\n repo.observeGroups(),\n statusFlow,\n tripStatusFlow\n ) { kv, groups, status, tripStatus ->\n val chain = (kv[SettingsRepository.KEY_STAGE_CHAIN])?.let {\n runCatching { app.nock.android.data.json.ChainJson.decode(it) }.getOrNull()\n } ?: app.nock.android.domain.model.DefaultChain.CHAIN\n SettingsState(\n chain = chain,\n telegramToken = kv[SettingsRepository.KEY_TELEGRAM_TOKEN].orEmpty(),\n telegramChat = kv[SettingsRepository.KEY_TELEGRAM_CHAT].orEmpty(),\n driveEmail = kv[SettingsRepository.KEY_DRIVE_ACCOUNT],\n driveLastSyncMs = kv[SettingsRepository.KEY_DRIVE_LAST_SYNC_MS]?.toLongOrNull(),\n telegramStatus = status.first,\n driveStatus = status.second,\n groups = groups,\n deepSeekApiKey = kv[SettingsRepository.KEY_DEEPSEEK_API_KEY].orEmpty(),\n deepSeekModel = kv[SettingsRepository.KEY_DEEPSEEK_MODEL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n deepSeekBaseUrl = kv[SettingsRepository.KEY_DEEPSEEK_BASE_URL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n deepSeekContext = kv[SettingsRepository.KEY_DEEPSEEK_CONTEXT].orEmpty(),\n tripsEnabled = kv[SettingsRepository.KEY_TRIPS_ENABLED]?.toBooleanStrictOrNull() == true,\n tomtomKey = kv[SettingsRepository.KEY_TOMTOM_KEY].orEmpty(),\n tripHomeAddress = kv[SettingsRepository.KEY_TRIP_HOME_ADDRESS].orEmpty(),\n tripBufferMin = kv[SettingsRepository.KEY_TRIP_BUFFER_MIN]?.toIntOrNull()\n ?: (TripDefaults.BUFFER_MS / 60_000L).toInt(),\n tripStatus = tripStatus,\n tripHasCalendarPermission = calendar.hasPermission(),\n tripCalendars = if (calendar.hasPermission()) calendar.listCalendars() else emptyList(),\n tripSelectedCalendarIds = kv[SettingsRepository.KEY_TRIP_CALENDAR_IDS]\n ?.split(',')?.mapNotNull { it.trim().toLongOrNull() }?.toSet().orEmpty(),\n preAlarmSoundUri = kv[SettingsRepository.KEY_PREALARM_SOUND],\n )\n }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsState())\n\n // The global chain editor saves on every tweak; mark it dirty so we re-arm the\n // affected (no-override) groups once, when the user leaves the screen.\n @Volatile private var chainEdited = false\n\n fun setChain(chain: EscalationChain) {\n chainEdited = true\n viewModelScope.launch { settings.setStageChain(chain) }\n }\n\n /**\n * Apply a global-chain edit to already-armed reminders, called when the\n * Notifications screen is left. Runs on the application scope (not\n * viewModelScope, which is cancelled as the screen's ViewModel is cleared) so\n * the re-arm always completes. Re-persists the latest chain first so the\n * re-arm can't read a stale value if the final keystroke's write was still in\n * flight (or lost to the same cancellation).\n */\n fun applyChainEditsIfDirty() {\n if (!chainEdited) return\n chainEdited = false\n val chain = state.value.chain ?: return\n appScope.launch {\n settings.setStageChain(chain)\n engine.rearmDefaultChainGroups()\n }\n }\n\n /** Persist the chosen pre-alarm sound: null URI = silent, else the sound URI. */\n fun setPreAlarmSound(uri: String?) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_PREALARM_SOUND, uri ?: \"\")\n }\n }\n\n fun setTelegram(token: String, chat: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_TELEGRAM_TOKEN, token)\n settings.set(SettingsRepository.KEY_TELEGRAM_CHAT, chat)\n }\n }\n\n fun testTelegram() {\n viewModelScope.launch {\n val token = settings.get(SettingsRepository.KEY_TELEGRAM_TOKEN).orEmpty()\n val chat = settings.get(SettingsRepository.KEY_TELEGRAM_CHAT).orEmpty()\n val res = telegram.sendRaw(token, chat, ctx.getString(R.string.telegram_test_message), silent = false)\n val msg = if (res.ok) ctx.getString(R.string.status_telegram_ok)\n else ctx.getString(R.string.status_telegram_error, res.errorDescription.orEmpty())\n statusFlow.value = (msg to statusFlow.value.second)\n }\n }\n\n fun setDriveEmail(email: String?) {\n viewModelScope.launch {\n if (email == null) settings.clear(SettingsRepository.KEY_DRIVE_ACCOUNT)\n else settings.set(SettingsRepository.KEY_DRIVE_ACCOUNT, email)\n }\n }\n\n fun setDriveStatus(message: String?) {\n statusFlow.value = (statusFlow.value.first to message)\n }\n\n fun syncPush() = runDrive { drive.pushSnapshot() }\n fun syncPull() = runDrive { drive.pullIfNewer() }\n\n private fun runDrive(block: suspend () -> SyncOutcome) {\n viewModelScope.launch {\n val r = block()\n val msg = when (r) {\n is SyncOutcome.Ok -> ctx.getString(R.string.status_drive_ok)\n is SyncOutcome.NotSignedIn -> ctx.getString(R.string.status_drive_sign_in_required)\n is SyncOutcome.Error -> ctx.getString(R.string.status_drive_error, r.message)\n }\n statusFlow.value = (statusFlow.value.first to msg)\n }\n }\n\n fun updateGroup(g: Group, sortIndex: Int = 0) {\n viewModelScope.launch { repo.upsertGroup(g, sortIndex) }\n }\n\n fun deleteGroup(g: Group) {\n viewModelScope.launch {\n // Cancel scheduled/ringing alarms for the group's reminders before the\n // FK cascade deletes them, otherwise their OS alarms are orphaned and\n // still fire for a reminder that no longer appears in any list.\n engine.cancelActiveForGroup(g.id)\n repo.deleteGroup(g)\n }\n }\n\n fun setTrips(enabled: Boolean, tomtomKey: String, homeAddress: String, bufferMin: Int) {\n appScope.launch {\n val prevHome = settings.get(SettingsRepository.KEY_TRIP_HOME_ADDRESS).orEmpty()\n settings.set(SettingsRepository.KEY_TRIPS_ENABLED, enabled.toString())\n settings.set(SettingsRepository.KEY_TOMTOM_KEY, tomtomKey.trim())\n settings.set(SettingsRepository.KEY_TRIP_HOME_ADDRESS, homeAddress.trim())\n settings.set(SettingsRepository.KEY_TRIP_BUFFER_MIN, bufferMin.coerceAtLeast(1).toString())\n // The cached home coordinates are only valid for the old address.\n if (homeAddress.trim() != prevHome) {\n settings.clear(SettingsRepository.KEY_TRIP_HOME_LAT)\n settings.clear(SettingsRepository.KEY_TRIP_HOME_LON)\n }\n runCatching { trips.syncNow() }\n }\n }\n\n fun setTripCalendars(ids: Set<Long>) {\n appScope.launch {\n settings.set(SettingsRepository.KEY_TRIP_CALENDAR_IDS, ids.joinToString(\",\"))\n runCatching { trips.syncNow() }\n }\n }\n\n /** Manually re-import calendar events now, reporting the result in the trip status line. */\n fun syncTripsNow() {\n appScope.launch {\n runCatching { trips.syncNow() }\n tripStatusFlow.value = ctx.getString(R.string.trips_status_synced)\n }\n }\n\n /** Called after the calendar permission is granted so trips import immediately. */\n fun onCalendarPermissionResult() {\n appScope.launch { runCatching { trips.syncNow() } }\n // Nudge the state flow so the calendar list / permission flag re-evaluate.\n tripStatusFlow.value = tripStatusFlow.value\n }\n\n /** Validates the TomTom key + home address by geocoding the home address. */\n fun testRoute() {\n viewModelScope.launch {\n val home = settings.get(SettingsRepository.KEY_TRIP_HOME_ADDRESS).orEmpty()\n val key = settings.get(SettingsRepository.KEY_TOMTOM_KEY).orEmpty()\n tripStatusFlow.value = when {\n key.isBlank() -> ctx.getString(R.string.trips_status_no_key)\n home.isBlank() -> ctx.getString(R.string.trips_status_no_home)\n else -> {\n val geo = tomtom.geocode(home)\n if (geo != null) ctx.getString(R.string.trips_status_ok)\n else ctx.getString(R.string.trips_status_error)\n }\n }\n }\n }\n\n fun setDeepSeek(apiKey: String, model: String, baseUrl: String, context: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_DEEPSEEK_API_KEY, apiKey)\n settings.set(SettingsRepository.KEY_DEEPSEEK_MODEL, model.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_MODEL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_BASE_URL, baseUrl.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_CONTEXT, context.trim())\n }\n }\n}\n"}, "798d969d6f84b3433aae3f3fab30b95aa2176685": {"t": 1785755945.9588816, "n": 14206, "h": "3cbab28c2dee3c0f", "c": "package app.nock.android.ui.settings\n\nimport android.content.Context\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport app.nock.android.R\nimport app.nock.android.data.NockRepository\nimport app.nock.android.data.SeedGroupLocaleSync\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.data.dao.ActiveEscalationDao\nimport app.nock.android.data.json.ChainJson\nimport app.nock.android.domain.escalation.EscalationEngine\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.di.ApplicationScope\nimport app.nock.android.sync.DriveSyncClient\nimport app.nock.android.sync.SyncOutcome\nimport app.nock.android.history.AlarmHistoryLogger\nimport app.nock.android.telegram.TelegramSender\nimport app.nock.android.trip.CalendarInfo\nimport app.nock.android.trip.CalendarRepository\nimport app.nock.android.trip.TomTomClient\nimport app.nock.android.trip.TripSyncManager\nimport app.nock.android.domain.trip.TripDefaults\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\ndata class SettingsState(\n val chain: EscalationChain? = null,\n val telegramToken: String = \"\",\n val telegramChat: String = \"\",\n val driveEmail: String? = null,\n val driveLastSyncMs: Long? = null,\n val telegramStatus: String? = null,\n val driveStatus: String? = null,\n val groups: List<Group> = emptyList(),\n val deepSeekApiKey: String = \"\",\n val deepSeekModel: String = SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n val deepSeekBaseUrl: String = SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n val deepSeekContext: String = \"\",\n val tripsEnabled: Boolean = false,\n val tomtomKey: String = \"\",\n val tripHomeAddress: String = \"\",\n val tripBufferMin: Int = (TripDefaults.BUFFER_MS / 60_000L).toInt(),\n val tripStatus: String? = null,\n val tripHasCalendarPermission: Boolean = false,\n val tripCalendars: List<CalendarInfo> = emptyList(),\n val tripSelectedCalendarIds: Set<Long> = emptySet(),\n // null = not set (default notification tone); \"\" = silent; else a sound URI.\n val preAlarmSoundUri: String? = null,\n)\n\n@HiltViewModel\nclass SettingsViewModel @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val settings: SettingsRepository,\n private val repo: NockRepository,\n private val telegram: TelegramSender,\n private val drive: DriveSyncClient,\n private val engine: EscalationEngine,\n private val seedGroupLocaleSync: SeedGroupLocaleSync,\n private val alarmHistory: AlarmHistoryLogger,\n private val activeDao: ActiveEscalationDao,\n private val trips: TripSyncManager,\n private val tomtom: TomTomClient,\n private val calendar: CalendarRepository,\n @ApplicationScope private val appScope: CoroutineScope,\n) : ViewModel() {\n\n fun syncSeedGroupNames() {\n viewModelScope.launch { seedGroupLocaleSync.sync() }\n }\n\n fun alarmHistoryDump(): String = alarmHistory.dump()\n fun clearAlarmHistory() = alarmHistory.clear()\n\n /**\n * The history log with a live snapshot of every current alarm and its\n * state appended — used by the Copy button so a pasted report shows both\n * what happened and what is scheduled right now.\n */\n suspend fun alarmHistoryWithCurrentState(): String {\n val now = System.currentTimeMillis()\n val groups = repo.getGroups().associateBy { it.id }\n val states = repo.getAllReminders().map { r ->\n val group = groups[r.groupId]\n val active = activeDao.getByReminderId(r.id)\n val activeState = active?.let { row ->\n val chain = runCatching { ChainJson.decode(row.chainSnapshotJson) }.getOrNull()\n val stageType = chain?.let { it.stage(row.nextStageIndex.coerceIn(0, it.lastIndex)).type }\n stageType?.let { AlarmHistoryLogger.ActiveState(it, row.nextFireAtMs) }\n }\n // The chain currently in flight (snapshotted when it started) if the\n // alarm is mid-escalation, else the chain it would walk next fire:\n // the group's override or the global default.\n val chain = active?.let { runCatching { ChainJson.decode(it.chainSnapshotJson) }.getOrNull() }\n ?: group?.let { repo.effectiveChain(it) }\n ?: settings.getStageChain()\n AlarmHistoryLogger.AlarmState(\n name = r.name,\n group = group?.name,\n schedule = r.schedule,\n nextFireAtMs = r.nextFireAt,\n lastCompletedAt = r.lastCompletedAt,\n active = activeState,\n chain = chain,\n )\n }\n return alarmHistory.dump() + alarmHistory.snapshot(states, now)\n }\n\n private val statusFlow = MutableStateFlow(Pair<String?, String?>(null, null))\n private val tripStatusFlow = MutableStateFlow<String?>(null)\n\n val state: StateFlow<SettingsState> = combine(\n settings.observeAll(),\n repo.observeGroups(),\n statusFlow,\n tripStatusFlow\n ) { kv, groups, status, tripStatus ->\n val chain = (kv[SettingsRepository.KEY_STAGE_CHAIN])?.let {\n runCatching { app.nock.android.data.json.ChainJson.decode(it) }.getOrNull()\n } ?: app.nock.android.domain.model.DefaultChain.CHAIN\n SettingsState(\n chain = chain,\n telegramToken = kv[SettingsRepository.KEY_TELEGRAM_TOKEN].orEmpty(),\n telegramChat = kv[SettingsRepository.KEY_TELEGRAM_CHAT].orEmpty(),\n driveEmail = kv[SettingsRepository.KEY_DRIVE_ACCOUNT],\n driveLastSyncMs = kv[SettingsRepository.KEY_DRIVE_LAST_SYNC_MS]?.toLongOrNull(),\n telegramStatus = status.first,\n driveStatus = status.second,\n groups = groups,\n deepSeekApiKey = kv[SettingsRepository.KEY_DEEPSEEK_API_KEY].orEmpty(),\n deepSeekModel = kv[SettingsRepository.KEY_DEEPSEEK_MODEL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n deepSeekBaseUrl = kv[SettingsRepository.KEY_DEEPSEEK_BASE_URL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n deepSeekContext = kv[SettingsRepository.KEY_DEEPSEEK_CONTEXT].orEmpty(),\n tripsEnabled = kv[SettingsRepository.KEY_TRIPS_ENABLED]?.toBooleanStrictOrNull() == true,\n tomtomKey = kv[SettingsRepository.KEY_TOMTOM_KEY].orEmpty(),\n tripHomeAddress = kv[SettingsRepository.KEY_TRIP_HOME_ADDRESS].orEmpty(),\n tripBufferMin = kv[SettingsRepository.KEY_TRIP_BUFFER_MIN]?.toIntOrNull()\n ?: (TripDefaults.BUFFER_MS / 60_000L).toInt(),\n tripStatus = tripStatus,\n tripHasCalendarPermission = calendar.hasPermission(),\n tripCalendars = if (calendar.hasPermission()) calendar.listCalendars() else emptyList(),\n tripSelectedCalendarIds = kv[SettingsRepository.KEY_TRIP_CALENDAR_IDS]\n ?.split(',')?.mapNotNull { it.trim().toLongOrNull() }?.toSet().orEmpty(),\n preAlarmSoundUri = kv[SettingsRepository.KEY_PREALARM_SOUND],\n )\n }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsState())\n\n // The global chain editor saves on every tweak; mark it dirty so we re-arm the\n // affected (no-override) groups once, when the user leaves the screen.\n @Volatile private var chainEdited = false\n\n fun setChain(chain: EscalationChain) {\n chainEdited = true\n viewModelScope.launch { settings.setStageChain(chain) }\n }\n\n /**\n * Apply a global-chain edit to already-armed reminders, called when the\n * Notifications screen is left. Runs on the application scope (not\n * viewModelScope, which is cancelled as the screen's ViewModel is cleared) so\n * the re-arm always completes. Re-persists the latest chain first so the\n * re-arm can't read a stale value if the final keystroke's write was still in\n * flight (or lost to the same cancellation).\n */\n fun applyChainEditsIfDirty() {\n if (!chainEdited) return\n chainEdited = false\n val chain = state.value.chain ?: return\n appScope.launch {\n settings.setStageChain(chain)\n engine.rearmDefaultChainGroups()\n }\n }\n\n /** Persist the chosen pre-alarm sound: null URI = silent, else the sound URI. */\n fun setPreAlarmSound(uri: String?) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_PREALARM_SOUND, uri ?: \"\")\n }\n }\n\n fun setTelegram(token: String, chat: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_TELEGRAM_TOKEN, token)\n settings.set(SettingsRepository.KEY_TELEGRAM_CHAT, chat)\n }\n }\n\n fun testTelegram() {\n viewModelScope.launch {\n val token = settings.get(SettingsRepository.KEY_TELEGRAM_TOKEN).orEmpty()\n val chat = settings.get(SettingsRepository.KEY_TELEGRAM_CHAT).orEmpty()\n val res = telegram.sendRaw(token, chat, ctx.getString(R.string.telegram_test_message), silent = false)\n val msg = if (res.ok) ctx.getString(R.string.status_telegram_ok)\n else ctx.getString(R.string.status_telegram_error, res.errorDescription.orEmpty())\n statusFlow.value = (msg to statusFlow.value.second)\n }\n }\n\n fun setDriveEmail(email: String?) {\n viewModelScope.launch {\n if (email == null) settings.clear(SettingsRepository.KEY_DRIVE_ACCOUNT)\n else settings.set(SettingsRepository.KEY_DRIVE_ACCOUNT, email)\n }\n }\n\n fun setDriveStatus(message: String?) {\n statusFlow.value = (statusFlow.value.first to message)\n }\n\n fun syncPush() = runDrive { drive.pushSnapshot() }\n fun syncPull() = runDrive { drive.pullIfNewer() }\n\n private fun runDrive(block: suspend () -> SyncOutcome) {\n viewModelScope.launch {\n val r = block()\n val msg = when (r) {\n is SyncOutcome.Ok -> ctx.getString(R.string.status_drive_ok)\n is SyncOutcome.NotSignedIn -> ctx.getString(R.string.status_drive_sign_in_required)\n is SyncOutcome.Error -> ctx.getString(R.string.status_drive_error, r.message)\n }\n statusFlow.value = (statusFlow.value.first to msg)\n }\n }\n\n fun updateGroup(g: Group, sortIndex: Int = 0) {\n viewModelScope.launch { repo.upsertGroup(g, sortIndex) }\n }\n\n fun deleteGroup(g: Group) {\n viewModelScope.launch {\n // Cancel scheduled/ringing alarms for the group's reminders before the\n // FK cascade deletes them, otherwise their OS alarms are orphaned and\n // still fire for a reminder that no longer appears in any list.\n engine.cancelActiveForGroup(g.id)\n repo.deleteGroup(g)\n }\n }\n\n fun setTrips(enabled: Boolean, tomtomKey: String, homeAddress: String, bufferMin: Int) {\n appScope.launch {\n val prevHome = settings.get(SettingsRepository.KEY_TRIP_HOME_ADDRESS).orEmpty()\n settings.set(SettingsRepository.KEY_TRIPS_ENABLED, enabled.toString())\n settings.set(SettingsRepository.KEY_TOMTOM_KEY, tomtomKey.trim())\n settings.set(SettingsRepository.KEY_TRIP_HOME_ADDRESS, homeAddress.trim())\n settings.set(SettingsRepository.KEY_TRIP_BUFFER_MIN, bufferMin.coerceAtLeast(1).toString())\n // The cached home coordinates are only valid for the old address.\n if (homeAddress.trim() != prevHome) {\n settings.clear(SettingsRepository.KEY_TRIP_HOME_LAT)\n settings.clear(SettingsRepository.KEY_TRIP_HOME_LON)\n }\n runCatching { trips.syncNow() }\n }\n }\n\n fun setTripCalendars(ids: Set<Long>) {\n appScope.launch {\n settings.set(SettingsRepository.KEY_TRIP_CALENDAR_IDS, ids.joinToString(\",\"))\n runCatching { trips.syncNow() }\n }\n }\n\n /** Called after the calendar permission is granted so trips import immediately. */\n fun onCalendarPermissionResult() {\n appScope.launch { runCatching { trips.syncNow() } }\n // Nudge the state flow so the calendar list / permission flag re-evaluate.\n tripStatusFlow.value = tripStatusFlow.value\n }\n\n /** Validates the TomTom key + home address by geocoding the home address. */\n fun testRoute() {\n viewModelScope.launch {\n val home = settings.get(SettingsRepository.KEY_TRIP_HOME_ADDRESS).orEmpty()\n val key = settings.get(SettingsRepository.KEY_TOMTOM_KEY).orEmpty()\n tripStatusFlow.value = when {\n key.isBlank() -> ctx.getString(R.string.trips_status_no_key)\n home.isBlank() -> ctx.getString(R.string.trips_status_no_home)\n else -> {\n val geo = tomtom.geocode(home)\n if (geo != null) ctx.getString(R.string.trips_status_ok)\n else ctx.getString(R.string.trips_status_error)\n }\n }\n }\n }\n\n fun setDeepSeek(apiKey: String, model: String, baseUrl: String, context: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_DEEPSEEK_API_KEY, apiKey)\n settings.set(SettingsRepository.KEY_DEEPSEEK_MODEL, model.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_MODEL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_BASE_URL, baseUrl.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_CONTEXT, context.trim())\n }\n }\n}\n"}, "ac7dc67aad55bf476b10ffaf5f669f7eb6c89393": {"t": 1785755947.222359, "n": 13757, "h": "a50b410d41250558", "c": "package app.nock.android.ui.settings\n\nimport android.content.Context\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport app.nock.android.R\nimport app.nock.android.data.NockRepository\nimport app.nock.android.data.SeedGroupLocaleSync\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.data.dao.ActiveEscalationDao\nimport app.nock.android.data.json.ChainJson\nimport app.nock.android.domain.escalation.EscalationEngine\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.di.ApplicationScope\nimport app.nock.android.sync.DriveSyncClient\nimport app.nock.android.sync.SyncOutcome\nimport app.nock.android.history.AlarmHistoryLogger\nimport app.nock.android.telegram.TelegramSender\nimport app.nock.android.trip.CalendarInfo\nimport app.nock.android.trip.CalendarRepository\nimport app.nock.android.trip.TomTomClient\nimport app.nock.android.trip.TripSyncManager\nimport app.nock.android.domain.trip.TripDefaults\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\ndata class SettingsState(\n val chain: EscalationChain? = null,\n val telegramToken: String = \"\",\n val telegramChat: String = \"\",\n val driveEmail: String? = null,\n val driveLastSyncMs: Long? = null,\n val telegramStatus: String? = null,\n val driveStatus: String? = null,\n val groups: List<Group> = emptyList(),\n val deepSeekApiKey: String = \"\",\n val deepSeekModel: String = SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n val deepSeekBaseUrl: String = SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n val deepSeekContext: String = \"\",\n val tripsEnabled: Boolean = false,\n val tomtomKey: String = \"\",\n val tripHomeAddress: String = \"\",\n val tripBufferMin: Int = (TripDefaults.BUFFER_MS / 60_000L).toInt(),\n val tripStatus: String? = null,\n val tripHasCalendarPermission: Boolean = false,\n val tripCalendars: List<CalendarInfo> = emptyList(),\n val tripSelectedCalendarIds: Set<Long> = emptySet(),\n)\n\n@HiltViewModel\nclass SettingsViewModel @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val settings: SettingsRepository,\n private val repo: NockRepository,\n private val telegram: TelegramSender,\n private val drive: DriveSyncClient,\n private val engine: EscalationEngine,\n private val seedGroupLocaleSync: SeedGroupLocaleSync,\n private val alarmHistory: AlarmHistoryLogger,\n private val activeDao: ActiveEscalationDao,\n private val trips: TripSyncManager,\n private val tomtom: TomTomClient,\n private val calendar: CalendarRepository,\n @ApplicationScope private val appScope: CoroutineScope,\n) : ViewModel() {\n\n fun syncSeedGroupNames() {\n viewModelScope.launch { seedGroupLocaleSync.sync() }\n }\n\n fun alarmHistoryDump(): String = alarmHistory.dump()\n fun clearAlarmHistory() = alarmHistory.clear()\n\n /**\n * The history log with a live snapshot of every current alarm and its\n * state appended — used by the Copy button so a pasted report shows both\n * what happened and what is scheduled right now.\n */\n suspend fun alarmHistoryWithCurrentState(): String {\n val now = System.currentTimeMillis()\n val groups = repo.getGroups().associateBy { it.id }\n val states = repo.getAllReminders().map { r ->\n val group = groups[r.groupId]\n val active = activeDao.getByReminderId(r.id)\n val activeState = active?.let { row ->\n val chain = runCatching { ChainJson.decode(row.chainSnapshotJson) }.getOrNull()\n val stageType = chain?.let { it.stage(row.nextStageIndex.coerceIn(0, it.lastIndex)).type }\n stageType?.let { AlarmHistoryLogger.ActiveState(it, row.nextFireAtMs) }\n }\n // The chain currently in flight (snapshotted when it started) if the\n // alarm is mid-escalation, else the chain it would walk next fire:\n // the group's override or the global default.\n val chain = active?.let { runCatching { ChainJson.decode(it.chainSnapshotJson) }.getOrNull() }\n ?: group?.let { repo.effectiveChain(it) }\n ?: settings.getStageChain()\n AlarmHistoryLogger.AlarmState(\n name = r.name,\n group = group?.name,\n schedule = r.schedule,\n nextFireAtMs = r.nextFireAt,\n lastCompletedAt = r.lastCompletedAt,\n active = activeState,\n chain = chain,\n )\n }\n return alarmHistory.dump() + alarmHistory.snapshot(states, now)\n }\n\n private val statusFlow = MutableStateFlow(Pair<String?, String?>(null, null))\n private val tripStatusFlow = MutableStateFlow<String?>(null)\n\n val state: StateFlow<SettingsState> = combine(\n settings.observeAll(),\n repo.observeGroups(),\n statusFlow,\n tripStatusFlow\n ) { kv, groups, status, tripStatus ->\n val chain = (kv[SettingsRepository.KEY_STAGE_CHAIN])?.let {\n runCatching { app.nock.android.data.json.ChainJson.decode(it) }.getOrNull()\n } ?: app.nock.android.domain.model.DefaultChain.CHAIN\n SettingsState(\n chain = chain,\n telegramToken = kv[SettingsRepository.KEY_TELEGRAM_TOKEN].orEmpty(),\n telegramChat = kv[SettingsRepository.KEY_TELEGRAM_CHAT].orEmpty(),\n driveEmail = kv[SettingsRepository.KEY_DRIVE_ACCOUNT],\n driveLastSyncMs = kv[SettingsRepository.KEY_DRIVE_LAST_SYNC_MS]?.toLongOrNull(),\n telegramStatus = status.first,\n driveStatus = status.second,\n groups = groups,\n deepSeekApiKey = kv[SettingsRepository.KEY_DEEPSEEK_API_KEY].orEmpty(),\n deepSeekModel = kv[SettingsRepository.KEY_DEEPSEEK_MODEL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n deepSeekBaseUrl = kv[SettingsRepository.KEY_DEEPSEEK_BASE_URL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n deepSeekContext = kv[SettingsRepository.KEY_DEEPSEEK_CONTEXT].orEmpty(),\n tripsEnabled = kv[SettingsRepository.KEY_TRIPS_ENABLED]?.toBooleanStrictOrNull() == true,\n tomtomKey = kv[SettingsRepository.KEY_TOMTOM_KEY].orEmpty(),\n tripHomeAddress = kv[SettingsRepository.KEY_TRIP_HOME_ADDRESS].orEmpty(),\n tripBufferMin = kv[SettingsRepository.KEY_TRIP_BUFFER_MIN]?.toIntOrNull()\n ?: (TripDefaults.BUFFER_MS / 60_000L).toInt(),\n tripStatus = tripStatus,\n tripHasCalendarPermission = calendar.hasPermission(),\n tripCalendars = if (calendar.hasPermission()) calendar.listCalendars() else emptyList(),\n tripSelectedCalendarIds = kv[SettingsRepository.KEY_TRIP_CALENDAR_IDS]\n ?.split(',')?.mapNotNull { it.trim().toLongOrNull() }?.toSet().orEmpty(),\n )\n }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsState())\n\n // The global chain editor saves on every tweak; mark it dirty so we re-arm the\n // affected (no-override) groups once, when the user leaves the screen.\n @Volatile private var chainEdited = false\n\n fun setChain(chain: EscalationChain) {\n chainEdited = true\n viewModelScope.launch { settings.setStageChain(chain) }\n }\n\n /**\n * Apply a global-chain edit to already-armed reminders, called when the\n * Notifications screen is left. Runs on the application scope (not\n * viewModelScope, which is cancelled as the screen's ViewModel is cleared) so\n * the re-arm always completes. Re-persists the latest chain first so the\n * re-arm can't read a stale value if the final keystroke's write was still in\n * flight (or lost to the same cancellation).\n */\n fun applyChainEditsIfDirty() {\n if (!chainEdited) return\n chainEdited = false\n val chain = state.value.chain ?: return\n appScope.launch {\n settings.setStageChain(chain)\n engine.rearmDefaultChainGroups()\n }\n }\n\n fun setTelegram(token: String, chat: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_TELEGRAM_TOKEN, token)\n settings.set(SettingsRepository.KEY_TELEGRAM_CHAT, chat)\n }\n }\n\n fun testTelegram() {\n viewModelScope.launch {\n val token = settings.get(SettingsRepository.KEY_TELEGRAM_TOKEN).orEmpty()\n val chat = settings.get(SettingsRepository.KEY_TELEGRAM_CHAT).orEmpty()\n val res = telegram.sendRaw(token, chat, ctx.getString(R.string.telegram_test_message), silent = false)\n val msg = if (res.ok) ctx.getString(R.string.status_telegram_ok)\n else ctx.getString(R.string.status_telegram_error, res.errorDescription.orEmpty())\n statusFlow.value = (msg to statusFlow.value.second)\n }\n }\n\n fun setDriveEmail(email: String?) {\n viewModelScope.launch {\n if (email == null) settings.clear(SettingsRepository.KEY_DRIVE_ACCOUNT)\n else settings.set(SettingsRepository.KEY_DRIVE_ACCOUNT, email)\n }\n }\n\n fun setDriveStatus(message: String?) {\n statusFlow.value = (statusFlow.value.first to message)\n }\n\n fun syncPush() = runDrive { drive.pushSnapshot() }\n fun syncPull() = runDrive { drive.pullIfNewer() }\n\n private fun runDrive(block: suspend () -> SyncOutcome) {\n viewModelScope.launch {\n val r = block()\n val msg = when (r) {\n is SyncOutcome.Ok -> ctx.getString(R.string.status_drive_ok)\n is SyncOutcome.NotSignedIn -> ctx.getString(R.string.status_drive_sign_in_required)\n is SyncOutcome.Error -> ctx.getString(R.string.status_drive_error, r.message)\n }\n statusFlow.value = (statusFlow.value.first to msg)\n }\n }\n\n fun updateGroup(g: Group, sortIndex: Int = 0) {\n viewModelScope.launch { repo.upsertGroup(g, sortIndex) }\n }\n\n fun deleteGroup(g: Group) {\n viewModelScope.launch {\n // Cancel scheduled/ringing alarms for the group's reminders before the\n // FK cascade deletes them, otherwise their OS alarms are orphaned and\n // still fire for a reminder that no longer appears in any list.\n engine.cancelActiveForGroup(g.id)\n repo.deleteGroup(g)\n }\n }\n\n fun setTrips(enabled: Boolean, tomtomKey: String, homeAddress: String, bufferMin: Int) {\n appScope.launch {\n val prevHome = settings.get(SettingsRepository.KEY_TRIP_HOME_ADDRESS).orEmpty()\n settings.set(SettingsRepository.KEY_TRIPS_ENABLED, enabled.toString())\n settings.set(SettingsRepository.KEY_TOMTOM_KEY, tomtomKey.trim())\n settings.set(SettingsRepository.KEY_TRIP_HOME_ADDRESS, homeAddress.trim())\n settings.set(SettingsRepository.KEY_TRIP_BUFFER_MIN, bufferMin.coerceAtLeast(1).toString())\n // The cached home coordinates are only valid for the old address.\n if (homeAddress.trim() != prevHome) {\n settings.clear(SettingsRepository.KEY_TRIP_HOME_LAT)\n settings.clear(SettingsRepository.KEY_TRIP_HOME_LON)\n }\n runCatching { trips.syncNow() }\n }\n }\n\n fun setTripCalendars(ids: Set<Long>) {\n appScope.launch {\n settings.set(SettingsRepository.KEY_TRIP_CALENDAR_IDS, ids.joinToString(\",\"))\n runCatching { trips.syncNow() }\n }\n }\n\n /** Called after the calendar permission is granted so trips import immediately. */\n fun onCalendarPermissionResult() {\n appScope.launch { runCatching { trips.syncNow() } }\n // Nudge the state flow so the calendar list / permission flag re-evaluate.\n tripStatusFlow.value = tripStatusFlow.value\n }\n\n /** Validates the TomTom key + home address by geocoding the home address. */\n fun testRoute() {\n viewModelScope.launch {\n val home = settings.get(SettingsRepository.KEY_TRIP_HOME_ADDRESS).orEmpty()\n val key = settings.get(SettingsRepository.KEY_TOMTOM_KEY).orEmpty()\n tripStatusFlow.value = when {\n key.isBlank() -> ctx.getString(R.string.trips_status_no_key)\n home.isBlank() -> ctx.getString(R.string.trips_status_no_home)\n else -> {\n val geo = tomtom.geocode(home)\n if (geo != null) ctx.getString(R.string.trips_status_ok)\n else ctx.getString(R.string.trips_status_error)\n }\n }\n }\n }\n\n fun setDeepSeek(apiKey: String, model: String, baseUrl: String, context: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_DEEPSEEK_API_KEY, apiKey)\n settings.set(SettingsRepository.KEY_DEEPSEEK_MODEL, model.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_MODEL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_BASE_URL, baseUrl.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_CONTEXT, context.trim())\n }\n }\n}\n"}, "21c89a091549d77fb8036079580ea992a493bfa8": {"t": 1785755948.5832815, "n": 10356, "h": "92da2c69b18ce35e", "c": "package app.nock.android.ui.settings\n\nimport android.content.Context\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport app.nock.android.R\nimport app.nock.android.data.NockRepository\nimport app.nock.android.data.SeedGroupLocaleSync\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.data.dao.ActiveEscalationDao\nimport app.nock.android.data.json.ChainJson\nimport app.nock.android.domain.escalation.EscalationEngine\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.di.ApplicationScope\nimport app.nock.android.sync.DriveSyncClient\nimport app.nock.android.sync.SyncOutcome\nimport app.nock.android.history.AlarmHistoryLogger\nimport app.nock.android.telegram.TelegramSender\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\ndata class SettingsState(\n val chain: EscalationChain? = null,\n val telegramToken: String = \"\",\n val telegramChat: String = \"\",\n val driveEmail: String? = null,\n val driveLastSyncMs: Long? = null,\n val telegramStatus: String? = null,\n val driveStatus: String? = null,\n val groups: List<Group> = emptyList(),\n val deepSeekApiKey: String = \"\",\n val deepSeekModel: String = SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n val deepSeekBaseUrl: String = SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n val deepSeekContext: String = \"\",\n // null = not set (default notification tone); \"\" = silent; else a sound URI.\n val preAlarmSoundUri: String? = null,\n)\n\n@HiltViewModel\nclass SettingsViewModel @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val settings: SettingsRepository,\n private val repo: NockRepository,\n private val telegram: TelegramSender,\n private val drive: DriveSyncClient,\n private val engine: EscalationEngine,\n private val seedGroupLocaleSync: SeedGroupLocaleSync,\n private val alarmHistory: AlarmHistoryLogger,\n private val activeDao: ActiveEscalationDao,\n @ApplicationScope private val appScope: CoroutineScope,\n) : ViewModel() {\n\n fun syncSeedGroupNames() {\n viewModelScope.launch { seedGroupLocaleSync.sync() }\n }\n\n fun alarmHistoryDump(): String = alarmHistory.dump()\n fun clearAlarmHistory() = alarmHistory.clear()\n\n /**\n * The history log with a live snapshot of every current alarm and its\n * state appended — used by the Copy button so a pasted report shows both\n * what happened and what is scheduled right now.\n */\n suspend fun alarmHistoryWithCurrentState(): String {\n val now = System.currentTimeMillis()\n val groups = repo.getGroups().associateBy { it.id }\n val states = repo.getAllReminders().map { r ->\n val group = groups[r.groupId]\n val active = activeDao.getByReminderId(r.id)\n val activeState = active?.let { row ->\n val chain = runCatching { ChainJson.decode(row.chainSnapshotJson) }.getOrNull()\n val stageType = chain?.let { it.stage(row.nextStageIndex.coerceIn(0, it.lastIndex)).type }\n stageType?.let { AlarmHistoryLogger.ActiveState(it, row.nextFireAtMs) }\n }\n // The chain currently in flight (snapshotted when it started) if the\n // alarm is mid-escalation, else the chain it would walk next fire:\n // the group's override or the global default.\n val chain = active?.let { runCatching { ChainJson.decode(it.chainSnapshotJson) }.getOrNull() }\n ?: group?.let { repo.effectiveChain(it) }\n ?: settings.getStageChain()\n AlarmHistoryLogger.AlarmState(\n name = r.name,\n group = group?.name,\n schedule = r.schedule,\n nextFireAtMs = r.nextFireAt,\n lastCompletedAt = r.lastCompletedAt,\n active = activeState,\n chain = chain,\n )\n }\n return alarmHistory.dump() + alarmHistory.snapshot(states, now)\n }\n\n private val statusFlow = MutableStateFlow(Pair<String?, String?>(null, null))\n\n val state: StateFlow<SettingsState> = combine(\n settings.observeAll(),\n repo.observeGroups(),\n statusFlow\n ) { kv, groups, status ->\n val chain = (kv[SettingsRepository.KEY_STAGE_CHAIN])?.let {\n runCatching { app.nock.android.data.json.ChainJson.decode(it) }.getOrNull()\n } ?: app.nock.android.domain.model.DefaultChain.CHAIN\n SettingsState(\n chain = chain,\n telegramToken = kv[SettingsRepository.KEY_TELEGRAM_TOKEN].orEmpty(),\n telegramChat = kv[SettingsRepository.KEY_TELEGRAM_CHAT].orEmpty(),\n driveEmail = kv[SettingsRepository.KEY_DRIVE_ACCOUNT],\n driveLastSyncMs = kv[SettingsRepository.KEY_DRIVE_LAST_SYNC_MS]?.toLongOrNull(),\n telegramStatus = status.first,\n driveStatus = status.second,\n groups = groups,\n deepSeekApiKey = kv[SettingsRepository.KEY_DEEPSEEK_API_KEY].orEmpty(),\n deepSeekModel = kv[SettingsRepository.KEY_DEEPSEEK_MODEL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n deepSeekBaseUrl = kv[SettingsRepository.KEY_DEEPSEEK_BASE_URL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n deepSeekContext = kv[SettingsRepository.KEY_DEEPSEEK_CONTEXT].orEmpty(),\n preAlarmSoundUri = kv[SettingsRepository.KEY_PREALARM_SOUND],\n )\n }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsState())\n\n // The global chain editor saves on every tweak; mark it dirty so we re-arm the\n // affected (no-override) groups once, when the user leaves the screen.\n @Volatile private var chainEdited = false\n\n fun setChain(chain: EscalationChain) {\n chainEdited = true\n viewModelScope.launch { settings.setStageChain(chain) }\n }\n\n /**\n * Apply a global-chain edit to already-armed reminders, called when the\n * Notifications screen is left. Runs on the application scope (not\n * viewModelScope, which is cancelled as the screen's ViewModel is cleared) so\n * the re-arm always completes. Re-persists the latest chain first so the\n * re-arm can't read a stale value if the final keystroke's write was still in\n * flight (or lost to the same cancellation).\n */\n fun applyChainEditsIfDirty() {\n if (!chainEdited) return\n chainEdited = false\n val chain = state.value.chain ?: return\n appScope.launch {\n settings.setStageChain(chain)\n engine.rearmDefaultChainGroups()\n }\n }\n\n /** Persist the chosen pre-alarm sound: null URI = silent, else the sound URI. */\n fun setPreAlarmSound(uri: String?) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_PREALARM_SOUND, uri ?: \"\")\n }\n }\n\n fun setTelegram(token: String, chat: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_TELEGRAM_TOKEN, token)\n settings.set(SettingsRepository.KEY_TELEGRAM_CHAT, chat)\n }\n }\n\n fun testTelegram() {\n viewModelScope.launch {\n val token = settings.get(SettingsRepository.KEY_TELEGRAM_TOKEN).orEmpty()\n val chat = settings.get(SettingsRepository.KEY_TELEGRAM_CHAT).orEmpty()\n val res = telegram.sendRaw(token, chat, ctx.getString(R.string.telegram_test_message), silent = false)\n val msg = if (res.ok) ctx.getString(R.string.status_telegram_ok)\n else ctx.getString(R.string.status_telegram_error, res.errorDescription.orEmpty())\n statusFlow.value = (msg to statusFlow.value.second)\n }\n }\n\n fun setDriveEmail(email: String?) {\n viewModelScope.launch {\n if (email == null) settings.clear(SettingsRepository.KEY_DRIVE_ACCOUNT)\n else settings.set(SettingsRepository.KEY_DRIVE_ACCOUNT, email)\n }\n }\n\n fun setDriveStatus(message: String?) {\n statusFlow.value = (statusFlow.value.first to message)\n }\n\n fun syncPush() = runDrive { drive.pushSnapshot() }\n fun syncPull() = runDrive { drive.pullIfNewer() }\n\n private fun runDrive(block: suspend () -> SyncOutcome) {\n viewModelScope.launch {\n val r = block()\n val msg = when (r) {\n is SyncOutcome.Ok -> ctx.getString(R.string.status_drive_ok)\n is SyncOutcome.NotSignedIn -> ctx.getString(R.string.status_drive_sign_in_required)\n is SyncOutcome.Error -> ctx.getString(R.string.status_drive_error, r.message)\n }\n statusFlow.value = (statusFlow.value.first to msg)\n }\n }\n\n fun updateGroup(g: Group, sortIndex: Int = 0) {\n viewModelScope.launch { repo.upsertGroup(g, sortIndex) }\n }\n\n fun deleteGroup(g: Group) {\n viewModelScope.launch {\n // Cancel scheduled/ringing alarms for the group's reminders before the\n // FK cascade deletes them, otherwise their OS alarms are orphaned and\n // still fire for a reminder that no longer appears in any list.\n engine.cancelActiveForGroup(g.id)\n repo.deleteGroup(g)\n }\n }\n\n fun setDeepSeek(apiKey: String, model: String, baseUrl: String, context: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_DEEPSEEK_API_KEY, apiKey)\n settings.set(SettingsRepository.KEY_DEEPSEEK_MODEL, model.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_MODEL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_BASE_URL, baseUrl.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_CONTEXT, context.trim())\n }\n }\n}\n"}, "6b8ab37fc9bad550d12e9d3709cbee989fe677dc": {"t": 1785755950.0908487, "n": 9907, "h": "5a7e2a4191e29696", "c": "package app.nock.android.ui.settings\n\nimport android.content.Context\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport app.nock.android.R\nimport app.nock.android.data.NockRepository\nimport app.nock.android.data.SeedGroupLocaleSync\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.data.dao.ActiveEscalationDao\nimport app.nock.android.data.json.ChainJson\nimport app.nock.android.domain.escalation.EscalationEngine\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.di.ApplicationScope\nimport app.nock.android.sync.DriveSyncClient\nimport app.nock.android.sync.SyncOutcome\nimport app.nock.android.history.AlarmHistoryLogger\nimport app.nock.android.telegram.TelegramSender\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\ndata class SettingsState(\n val chain: EscalationChain? = null,\n val telegramToken: String = \"\",\n val telegramChat: String = \"\",\n val driveEmail: String? = null,\n val driveLastSyncMs: Long? = null,\n val telegramStatus: String? = null,\n val driveStatus: String? = null,\n val groups: List<Group> = emptyList(),\n val deepSeekApiKey: String = \"\",\n val deepSeekModel: String = SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n val deepSeekBaseUrl: String = SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n val deepSeekContext: String = \"\",\n)\n\n@HiltViewModel\nclass SettingsViewModel @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val settings: SettingsRepository,\n private val repo: NockRepository,\n private val telegram: TelegramSender,\n private val drive: DriveSyncClient,\n private val engine: EscalationEngine,\n private val seedGroupLocaleSync: SeedGroupLocaleSync,\n private val alarmHistory: AlarmHistoryLogger,\n private val activeDao: ActiveEscalationDao,\n @ApplicationScope private val appScope: CoroutineScope,\n) : ViewModel() {\n\n fun syncSeedGroupNames() {\n viewModelScope.launch { seedGroupLocaleSync.sync() }\n }\n\n fun alarmHistoryDump(): String = alarmHistory.dump()\n fun clearAlarmHistory() = alarmHistory.clear()\n\n /**\n * The history log with a live snapshot of every current alarm and its\n * state appended — used by the Copy button so a pasted report shows both\n * what happened and what is scheduled right now.\n */\n suspend fun alarmHistoryWithCurrentState(): String {\n val now = System.currentTimeMillis()\n val groups = repo.getGroups().associateBy { it.id }\n val states = repo.getAllReminders().map { r ->\n val group = groups[r.groupId]\n val active = activeDao.getByReminderId(r.id)\n val activeState = active?.let { row ->\n val chain = runCatching { ChainJson.decode(row.chainSnapshotJson) }.getOrNull()\n val stageType = chain?.let { it.stage(row.nextStageIndex.coerceIn(0, it.lastIndex)).type }\n stageType?.let { AlarmHistoryLogger.ActiveState(it, row.nextFireAtMs) }\n }\n // The chain currently in flight (snapshotted when it started) if the\n // alarm is mid-escalation, else the chain it would walk next fire:\n // the group's override or the global default.\n val chain = active?.let { runCatching { ChainJson.decode(it.chainSnapshotJson) }.getOrNull() }\n ?: group?.let { repo.effectiveChain(it) }\n ?: settings.getStageChain()\n AlarmHistoryLogger.AlarmState(\n name = r.name,\n group = group?.name,\n schedule = r.schedule,\n nextFireAtMs = r.nextFireAt,\n lastCompletedAt = r.lastCompletedAt,\n active = activeState,\n chain = chain,\n )\n }\n return alarmHistory.dump() + alarmHistory.snapshot(states, now)\n }\n\n private val statusFlow = MutableStateFlow(Pair<String?, String?>(null, null))\n\n val state: StateFlow<SettingsState> = combine(\n settings.observeAll(),\n repo.observeGroups(),\n statusFlow\n ) { kv, groups, status ->\n val chain = (kv[SettingsRepository.KEY_STAGE_CHAIN])?.let {\n runCatching { app.nock.android.data.json.ChainJson.decode(it) }.getOrNull()\n } ?: app.nock.android.domain.model.DefaultChain.CHAIN\n SettingsState(\n chain = chain,\n telegramToken = kv[SettingsRepository.KEY_TELEGRAM_TOKEN].orEmpty(),\n telegramChat = kv[SettingsRepository.KEY_TELEGRAM_CHAT].orEmpty(),\n driveEmail = kv[SettingsRepository.KEY_DRIVE_ACCOUNT],\n driveLastSyncMs = kv[SettingsRepository.KEY_DRIVE_LAST_SYNC_MS]?.toLongOrNull(),\n telegramStatus = status.first,\n driveStatus = status.second,\n groups = groups,\n deepSeekApiKey = kv[SettingsRepository.KEY_DEEPSEEK_API_KEY].orEmpty(),\n deepSeekModel = kv[SettingsRepository.KEY_DEEPSEEK_MODEL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n deepSeekBaseUrl = kv[SettingsRepository.KEY_DEEPSEEK_BASE_URL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n deepSeekContext = kv[SettingsRepository.KEY_DEEPSEEK_CONTEXT].orEmpty(),\n )\n }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsState())\n\n // The global chain editor saves on every tweak; mark it dirty so we re-arm the\n // affected (no-override) groups once, when the user leaves the screen.\n @Volatile private var chainEdited = false\n\n fun setChain(chain: EscalationChain) {\n chainEdited = true\n viewModelScope.launch { settings.setStageChain(chain) }\n }\n\n /**\n * Apply a global-chain edit to already-armed reminders, called when the\n * Notifications screen is left. Runs on the application scope (not\n * viewModelScope, which is cancelled as the screen's ViewModel is cleared) so\n * the re-arm always completes. Re-persists the latest chain first so the\n * re-arm can't read a stale value if the final keystroke's write was still in\n * flight (or lost to the same cancellation).\n */\n fun applyChainEditsIfDirty() {\n if (!chainEdited) return\n chainEdited = false\n val chain = state.value.chain ?: return\n appScope.launch {\n settings.setStageChain(chain)\n engine.rearmDefaultChainGroups()\n }\n }\n\n fun setTelegram(token: String, chat: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_TELEGRAM_TOKEN, token)\n settings.set(SettingsRepository.KEY_TELEGRAM_CHAT, chat)\n }\n }\n\n fun testTelegram() {\n viewModelScope.launch {\n val token = settings.get(SettingsRepository.KEY_TELEGRAM_TOKEN).orEmpty()\n val chat = settings.get(SettingsRepository.KEY_TELEGRAM_CHAT).orEmpty()\n val res = telegram.sendRaw(token, chat, ctx.getString(R.string.telegram_test_message), silent = false)\n val msg = if (res.ok) ctx.getString(R.string.status_telegram_ok)\n else ctx.getString(R.string.status_telegram_error, res.errorDescription.orEmpty())\n statusFlow.value = (msg to statusFlow.value.second)\n }\n }\n\n fun setDriveEmail(email: String?) {\n viewModelScope.launch {\n if (email == null) settings.clear(SettingsRepository.KEY_DRIVE_ACCOUNT)\n else settings.set(SettingsRepository.KEY_DRIVE_ACCOUNT, email)\n }\n }\n\n fun setDriveStatus(message: String?) {\n statusFlow.value = (statusFlow.value.first to message)\n }\n\n fun syncPush() = runDrive { drive.pushSnapshot() }\n fun syncPull() = runDrive { drive.pullIfNewer() }\n\n private fun runDrive(block: suspend () -> SyncOutcome) {\n viewModelScope.launch {\n val r = block()\n val msg = when (r) {\n is SyncOutcome.Ok -> ctx.getString(R.string.status_drive_ok)\n is SyncOutcome.NotSignedIn -> ctx.getString(R.string.status_drive_sign_in_required)\n is SyncOutcome.Error -> ctx.getString(R.string.status_drive_error, r.message)\n }\n statusFlow.value = (statusFlow.value.first to msg)\n }\n }\n\n fun updateGroup(g: Group, sortIndex: Int = 0) {\n viewModelScope.launch { repo.upsertGroup(g, sortIndex) }\n }\n\n fun deleteGroup(g: Group) {\n viewModelScope.launch {\n // Cancel scheduled/ringing alarms for the group's reminders before the\n // FK cascade deletes them, otherwise their OS alarms are orphaned and\n // still fire for a reminder that no longer appears in any list.\n engine.cancelActiveForGroup(g.id)\n repo.deleteGroup(g)\n }\n }\n\n fun setDeepSeek(apiKey: String, model: String, baseUrl: String, context: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_DEEPSEEK_API_KEY, apiKey)\n settings.set(SettingsRepository.KEY_DEEPSEEK_MODEL, model.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_MODEL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_BASE_URL, baseUrl.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_CONTEXT, context.trim())\n }\n }\n}\n"}, "d01b77340a2d847677741f98ea4216d82124ce61": {"t": 1785755951.4843562, "n": 8795, "h": "7c7a3106e9e2fea1", "c": "package app.nock.android.ui.settings\n\nimport android.content.Context\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport app.nock.android.R\nimport app.nock.android.data.NockRepository\nimport app.nock.android.data.SeedGroupLocaleSync\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.data.dao.ActiveEscalationDao\nimport app.nock.android.data.json.ChainJson\nimport app.nock.android.domain.escalation.EscalationEngine\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.sync.DriveSyncClient\nimport app.nock.android.sync.SyncOutcome\nimport app.nock.android.history.AlarmHistoryLogger\nimport app.nock.android.telegram.TelegramSender\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\ndata class SettingsState(\n val chain: EscalationChain? = null,\n val telegramToken: String = \"\",\n val telegramChat: String = \"\",\n val driveEmail: String? = null,\n val driveLastSyncMs: Long? = null,\n val telegramStatus: String? = null,\n val driveStatus: String? = null,\n val groups: List<Group> = emptyList(),\n val deepSeekApiKey: String = \"\",\n val deepSeekModel: String = SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n val deepSeekBaseUrl: String = SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n val deepSeekContext: String = \"\",\n)\n\n@HiltViewModel\nclass SettingsViewModel @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val settings: SettingsRepository,\n private val repo: NockRepository,\n private val telegram: TelegramSender,\n private val drive: DriveSyncClient,\n private val engine: EscalationEngine,\n private val seedGroupLocaleSync: SeedGroupLocaleSync,\n private val alarmHistory: AlarmHistoryLogger,\n private val activeDao: ActiveEscalationDao,\n) : ViewModel() {\n\n fun syncSeedGroupNames() {\n viewModelScope.launch { seedGroupLocaleSync.sync() }\n }\n\n fun alarmHistoryDump(): String = alarmHistory.dump()\n fun clearAlarmHistory() = alarmHistory.clear()\n\n /**\n * The history log with a live snapshot of every current alarm and its\n * state appended — used by the Copy button so a pasted report shows both\n * what happened and what is scheduled right now.\n */\n suspend fun alarmHistoryWithCurrentState(): String {\n val now = System.currentTimeMillis()\n val groups = repo.getGroups().associateBy { it.id }\n val states = repo.getAllReminders().map { r ->\n val group = groups[r.groupId]\n val active = activeDao.getByReminderId(r.id)\n val activeState = active?.let { row ->\n val chain = runCatching { ChainJson.decode(row.chainSnapshotJson) }.getOrNull()\n val stageType = chain?.let { it.stage(row.nextStageIndex.coerceIn(0, it.lastIndex)).type }\n stageType?.let { AlarmHistoryLogger.ActiveState(it, row.nextFireAtMs) }\n }\n // The chain currently in flight (snapshotted when it started) if the\n // alarm is mid-escalation, else the chain it would walk next fire:\n // the group's override or the global default.\n val chain = active?.let { runCatching { ChainJson.decode(it.chainSnapshotJson) }.getOrNull() }\n ?: group?.let { repo.effectiveChain(it) }\n ?: settings.getStageChain()\n AlarmHistoryLogger.AlarmState(\n name = r.name,\n group = group?.name,\n schedule = r.schedule,\n nextFireAtMs = r.nextFireAt,\n lastCompletedAt = r.lastCompletedAt,\n active = activeState,\n chain = chain,\n )\n }\n return alarmHistory.dump() + alarmHistory.snapshot(states, now)\n }\n\n private val statusFlow = MutableStateFlow(Pair<String?, String?>(null, null))\n\n val state: StateFlow<SettingsState> = combine(\n settings.observeAll(),\n repo.observeGroups(),\n statusFlow\n ) { kv, groups, status ->\n val chain = (kv[SettingsRepository.KEY_STAGE_CHAIN])?.let {\n runCatching { app.nock.android.data.json.ChainJson.decode(it) }.getOrNull()\n } ?: app.nock.android.domain.model.DefaultChain.CHAIN\n SettingsState(\n chain = chain,\n telegramToken = kv[SettingsRepository.KEY_TELEGRAM_TOKEN].orEmpty(),\n telegramChat = kv[SettingsRepository.KEY_TELEGRAM_CHAT].orEmpty(),\n driveEmail = kv[SettingsRepository.KEY_DRIVE_ACCOUNT],\n driveLastSyncMs = kv[SettingsRepository.KEY_DRIVE_LAST_SYNC_MS]?.toLongOrNull(),\n telegramStatus = status.first,\n driveStatus = status.second,\n groups = groups,\n deepSeekApiKey = kv[SettingsRepository.KEY_DEEPSEEK_API_KEY].orEmpty(),\n deepSeekModel = kv[SettingsRepository.KEY_DEEPSEEK_MODEL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n deepSeekBaseUrl = kv[SettingsRepository.KEY_DEEPSEEK_BASE_URL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n deepSeekContext = kv[SettingsRepository.KEY_DEEPSEEK_CONTEXT].orEmpty(),\n )\n }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsState())\n\n fun setChain(chain: EscalationChain) {\n viewModelScope.launch { settings.setStageChain(chain) }\n }\n\n fun setTelegram(token: String, chat: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_TELEGRAM_TOKEN, token)\n settings.set(SettingsRepository.KEY_TELEGRAM_CHAT, chat)\n }\n }\n\n fun testTelegram() {\n viewModelScope.launch {\n val token = settings.get(SettingsRepository.KEY_TELEGRAM_TOKEN).orEmpty()\n val chat = settings.get(SettingsRepository.KEY_TELEGRAM_CHAT).orEmpty()\n val res = telegram.sendRaw(token, chat, ctx.getString(R.string.telegram_test_message), silent = false)\n val msg = if (res.ok) ctx.getString(R.string.status_telegram_ok)\n else ctx.getString(R.string.status_telegram_error, res.errorDescription.orEmpty())\n statusFlow.value = (msg to statusFlow.value.second)\n }\n }\n\n fun setDriveEmail(email: String?) {\n viewModelScope.launch {\n if (email == null) settings.clear(SettingsRepository.KEY_DRIVE_ACCOUNT)\n else settings.set(SettingsRepository.KEY_DRIVE_ACCOUNT, email)\n }\n }\n\n fun setDriveStatus(message: String?) {\n statusFlow.value = (statusFlow.value.first to message)\n }\n\n fun syncPush() = runDrive { drive.pushSnapshot() }\n fun syncPull() = runDrive { drive.pullIfNewer() }\n\n private fun runDrive(block: suspend () -> SyncOutcome) {\n viewModelScope.launch {\n val r = block()\n val msg = when (r) {\n is SyncOutcome.Ok -> ctx.getString(R.string.status_drive_ok)\n is SyncOutcome.NotSignedIn -> ctx.getString(R.string.status_drive_sign_in_required)\n is SyncOutcome.Error -> ctx.getString(R.string.status_drive_error, r.message)\n }\n statusFlow.value = (statusFlow.value.first to msg)\n }\n }\n\n fun updateGroup(g: Group, sortIndex: Int = 0) {\n viewModelScope.launch { repo.upsertGroup(g, sortIndex) }\n }\n\n fun deleteGroup(g: Group) {\n viewModelScope.launch {\n // Cancel scheduled/ringing alarms for the group's reminders before the\n // FK cascade deletes them, otherwise their OS alarms are orphaned and\n // still fire for a reminder that no longer appears in any list.\n engine.cancelActiveForGroup(g.id)\n repo.deleteGroup(g)\n }\n }\n\n fun setDeepSeek(apiKey: String, model: String, baseUrl: String, context: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_DEEPSEEK_API_KEY, apiKey)\n settings.set(SettingsRepository.KEY_DEEPSEEK_MODEL, model.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_MODEL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_BASE_URL, baseUrl.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_CONTEXT, context.trim())\n }\n }\n}\n"}, "9ababb6514f558fc4399f0b4eddbf6853d58b746": {"t": 1785755952.9916797, "n": 8485, "h": "11dc86c1744d4072", "c": "package app.nock.android.ui.settings\n\nimport android.content.Context\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport app.nock.android.R\nimport app.nock.android.data.NockRepository\nimport app.nock.android.data.SeedGroupLocaleSync\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.data.dao.ActiveEscalationDao\nimport app.nock.android.data.json.ChainJson\nimport app.nock.android.domain.escalation.EscalationEngine\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.sync.DriveSyncClient\nimport app.nock.android.sync.SyncOutcome\nimport app.nock.android.history.AlarmHistoryLogger\nimport app.nock.android.telegram.TelegramSender\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\ndata class SettingsState(\n val chain: EscalationChain? = null,\n val telegramToken: String = \"\",\n val telegramChat: String = \"\",\n val driveEmail: String? = null,\n val driveLastSyncMs: Long? = null,\n val telegramStatus: String? = null,\n val driveStatus: String? = null,\n val groups: List<Group> = emptyList(),\n val deepSeekApiKey: String = \"\",\n val deepSeekModel: String = SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n val deepSeekBaseUrl: String = SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n val deepSeekContext: String = \"\",\n)\n\n@HiltViewModel\nclass SettingsViewModel @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val settings: SettingsRepository,\n private val repo: NockRepository,\n private val telegram: TelegramSender,\n private val drive: DriveSyncClient,\n private val engine: EscalationEngine,\n private val seedGroupLocaleSync: SeedGroupLocaleSync,\n private val alarmHistory: AlarmHistoryLogger,\n private val activeDao: ActiveEscalationDao,\n) : ViewModel() {\n\n fun syncSeedGroupNames() {\n viewModelScope.launch { seedGroupLocaleSync.sync() }\n }\n\n fun alarmHistoryDump(): String = alarmHistory.dump()\n fun clearAlarmHistory() = alarmHistory.clear()\n\n /**\n * The history log with a live snapshot of every current alarm and its\n * state appended — used by the Copy button so a pasted report shows both\n * what happened and what is scheduled right now.\n */\n suspend fun alarmHistoryWithCurrentState(): String {\n val now = System.currentTimeMillis()\n val groups = repo.getGroups().associateBy { it.id }\n val states = repo.getAllReminders().map { r ->\n val group = groups[r.groupId]\n val active = activeDao.getByReminderId(r.id)\n val activeState = active?.let { row ->\n val chain = runCatching { ChainJson.decode(row.chainSnapshotJson) }.getOrNull()\n val stageType = chain?.let { it.stage(row.nextStageIndex.coerceIn(0, it.lastIndex)).type }\n stageType?.let { AlarmHistoryLogger.ActiveState(it, row.nextFireAtMs) }\n }\n // The chain currently in flight (snapshotted when it started) if the\n // alarm is mid-escalation, else the chain it would walk next fire:\n // the group's override or the global default.\n val chain = active?.let { runCatching { ChainJson.decode(it.chainSnapshotJson) }.getOrNull() }\n ?: group?.let { repo.effectiveChain(it) }\n ?: settings.getStageChain()\n AlarmHistoryLogger.AlarmState(\n name = r.name,\n group = group?.name,\n schedule = r.schedule,\n nextFireAtMs = r.nextFireAt,\n lastCompletedAt = r.lastCompletedAt,\n active = activeState,\n chain = chain,\n )\n }\n return alarmHistory.dump() + alarmHistory.snapshot(states, now)\n }\n\n private val statusFlow = MutableStateFlow(Pair<String?, String?>(null, null))\n\n val state: StateFlow<SettingsState> = combine(\n settings.observeAll(),\n repo.observeGroups(),\n statusFlow\n ) { kv, groups, status ->\n val chain = (kv[SettingsRepository.KEY_STAGE_CHAIN])?.let {\n runCatching { app.nock.android.data.json.ChainJson.decode(it) }.getOrNull()\n } ?: app.nock.android.domain.model.DefaultChain.CHAIN\n SettingsState(\n chain = chain,\n telegramToken = kv[SettingsRepository.KEY_TELEGRAM_TOKEN].orEmpty(),\n telegramChat = kv[SettingsRepository.KEY_TELEGRAM_CHAT].orEmpty(),\n driveEmail = kv[SettingsRepository.KEY_DRIVE_ACCOUNT],\n driveLastSyncMs = kv[SettingsRepository.KEY_DRIVE_LAST_SYNC_MS]?.toLongOrNull(),\n telegramStatus = status.first,\n driveStatus = status.second,\n groups = groups,\n deepSeekApiKey = kv[SettingsRepository.KEY_DEEPSEEK_API_KEY].orEmpty(),\n deepSeekModel = kv[SettingsRepository.KEY_DEEPSEEK_MODEL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n deepSeekBaseUrl = kv[SettingsRepository.KEY_DEEPSEEK_BASE_URL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n deepSeekContext = kv[SettingsRepository.KEY_DEEPSEEK_CONTEXT].orEmpty(),\n )\n }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsState())\n\n fun setChain(chain: EscalationChain) {\n viewModelScope.launch { settings.setStageChain(chain) }\n }\n\n fun setTelegram(token: String, chat: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_TELEGRAM_TOKEN, token)\n settings.set(SettingsRepository.KEY_TELEGRAM_CHAT, chat)\n }\n }\n\n fun testTelegram() {\n viewModelScope.launch {\n val token = settings.get(SettingsRepository.KEY_TELEGRAM_TOKEN).orEmpty()\n val chat = settings.get(SettingsRepository.KEY_TELEGRAM_CHAT).orEmpty()\n val res = telegram.sendRaw(token, chat, ctx.getString(R.string.telegram_test_message), silent = false)\n val msg = if (res.ok) ctx.getString(R.string.status_telegram_ok)\n else ctx.getString(R.string.status_telegram_error, res.errorDescription.orEmpty())\n statusFlow.value = (msg to statusFlow.value.second)\n }\n }\n\n fun setDriveEmail(email: String?) {\n viewModelScope.launch {\n if (email == null) settings.clear(SettingsRepository.KEY_DRIVE_ACCOUNT)\n else settings.set(SettingsRepository.KEY_DRIVE_ACCOUNT, email)\n }\n }\n\n fun setDriveStatus(message: String?) {\n statusFlow.value = (statusFlow.value.first to message)\n }\n\n fun syncPush() = runDrive { drive.pushSnapshot() }\n fun syncPull() = runDrive { drive.pullIfNewer() }\n\n private fun runDrive(block: suspend () -> SyncOutcome) {\n viewModelScope.launch {\n val r = block()\n val msg = when (r) {\n is SyncOutcome.Ok -> ctx.getString(R.string.status_drive_ok)\n is SyncOutcome.NotSignedIn -> ctx.getString(R.string.status_drive_sign_in_required)\n is SyncOutcome.Error -> ctx.getString(R.string.status_drive_error, r.message)\n }\n statusFlow.value = (statusFlow.value.first to msg)\n }\n }\n\n fun updateGroup(g: Group, sortIndex: Int = 0) {\n viewModelScope.launch { repo.upsertGroup(g, sortIndex) }\n }\n\n fun deleteGroup(g: Group) {\n viewModelScope.launch { repo.deleteGroup(g) }\n }\n\n fun setDeepSeek(apiKey: String, model: String, baseUrl: String, context: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_DEEPSEEK_API_KEY, apiKey)\n settings.set(SettingsRepository.KEY_DEEPSEEK_MODEL, model.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_MODEL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_BASE_URL, baseUrl.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_CONTEXT, context.trim())\n }\n }\n}\n"}, "fccc4b1f2a478f1c8f3d888af4cad1671936a375": {"t": 1785755954.4768775, "n": 7994, "h": "7f7d4565375c228e", "c": "package app.nock.android.ui.settings\n\nimport android.content.Context\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport app.nock.android.R\nimport app.nock.android.data.NockRepository\nimport app.nock.android.data.SeedGroupLocaleSync\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.data.dao.ActiveEscalationDao\nimport app.nock.android.data.json.ChainJson\nimport app.nock.android.domain.escalation.EscalationEngine\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.sync.DriveSyncClient\nimport app.nock.android.sync.SyncOutcome\nimport app.nock.android.history.AlarmHistoryLogger\nimport app.nock.android.telegram.TelegramSender\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\ndata class SettingsState(\n val chain: EscalationChain? = null,\n val telegramToken: String = \"\",\n val telegramChat: String = \"\",\n val driveEmail: String? = null,\n val driveLastSyncMs: Long? = null,\n val telegramStatus: String? = null,\n val driveStatus: String? = null,\n val groups: List<Group> = emptyList(),\n val deepSeekApiKey: String = \"\",\n val deepSeekModel: String = SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n val deepSeekBaseUrl: String = SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n val deepSeekContext: String = \"\",\n)\n\n@HiltViewModel\nclass SettingsViewModel @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val settings: SettingsRepository,\n private val repo: NockRepository,\n private val telegram: TelegramSender,\n private val drive: DriveSyncClient,\n private val engine: EscalationEngine,\n private val seedGroupLocaleSync: SeedGroupLocaleSync,\n private val alarmHistory: AlarmHistoryLogger,\n private val activeDao: ActiveEscalationDao,\n) : ViewModel() {\n\n fun syncSeedGroupNames() {\n viewModelScope.launch { seedGroupLocaleSync.sync() }\n }\n\n fun alarmHistoryDump(): String = alarmHistory.dump()\n fun clearAlarmHistory() = alarmHistory.clear()\n\n /**\n * The history log with a live snapshot of every current alarm and its\n * state appended — used by the Copy button so a pasted report shows both\n * what happened and what is scheduled right now.\n */\n suspend fun alarmHistoryWithCurrentState(): String {\n val now = System.currentTimeMillis()\n val groups = repo.getGroups().associateBy { it.id }\n val states = repo.getAllReminders().map { r ->\n val active = activeDao.getByReminderId(r.id)\n val activeState = active?.let { row ->\n val chain = runCatching { ChainJson.decode(row.chainSnapshotJson) }.getOrNull()\n val stageType = chain?.let { it.stage(row.nextStageIndex.coerceIn(0, it.lastIndex)).type }\n stageType?.let { AlarmHistoryLogger.ActiveState(it, row.nextFireAtMs) }\n }\n AlarmHistoryLogger.AlarmState(\n name = r.name,\n group = groups[r.groupId]?.name,\n schedule = r.schedule,\n nextFireAtMs = r.nextFireAt,\n lastCompletedAt = r.lastCompletedAt,\n active = activeState,\n )\n }\n return alarmHistory.dump() + alarmHistory.snapshot(states, now)\n }\n\n private val statusFlow = MutableStateFlow(Pair<String?, String?>(null, null))\n\n val state: StateFlow<SettingsState> = combine(\n settings.observeAll(),\n repo.observeGroups(),\n statusFlow\n ) { kv, groups, status ->\n val chain = (kv[SettingsRepository.KEY_STAGE_CHAIN])?.let {\n runCatching { app.nock.android.data.json.ChainJson.decode(it) }.getOrNull()\n } ?: app.nock.android.domain.model.DefaultChain.CHAIN\n SettingsState(\n chain = chain,\n telegramToken = kv[SettingsRepository.KEY_TELEGRAM_TOKEN].orEmpty(),\n telegramChat = kv[SettingsRepository.KEY_TELEGRAM_CHAT].orEmpty(),\n driveEmail = kv[SettingsRepository.KEY_DRIVE_ACCOUNT],\n driveLastSyncMs = kv[SettingsRepository.KEY_DRIVE_LAST_SYNC_MS]?.toLongOrNull(),\n telegramStatus = status.first,\n driveStatus = status.second,\n groups = groups,\n deepSeekApiKey = kv[SettingsRepository.KEY_DEEPSEEK_API_KEY].orEmpty(),\n deepSeekModel = kv[SettingsRepository.KEY_DEEPSEEK_MODEL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n deepSeekBaseUrl = kv[SettingsRepository.KEY_DEEPSEEK_BASE_URL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n deepSeekContext = kv[SettingsRepository.KEY_DEEPSEEK_CONTEXT].orEmpty(),\n )\n }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsState())\n\n fun setChain(chain: EscalationChain) {\n viewModelScope.launch { settings.setStageChain(chain) }\n }\n\n fun setTelegram(token: String, chat: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_TELEGRAM_TOKEN, token)\n settings.set(SettingsRepository.KEY_TELEGRAM_CHAT, chat)\n }\n }\n\n fun testTelegram() {\n viewModelScope.launch {\n val token = settings.get(SettingsRepository.KEY_TELEGRAM_TOKEN).orEmpty()\n val chat = settings.get(SettingsRepository.KEY_TELEGRAM_CHAT).orEmpty()\n val res = telegram.sendRaw(token, chat, ctx.getString(R.string.telegram_test_message), silent = false)\n val msg = if (res.ok) ctx.getString(R.string.status_telegram_ok)\n else ctx.getString(R.string.status_telegram_error, res.errorDescription.orEmpty())\n statusFlow.value = (msg to statusFlow.value.second)\n }\n }\n\n fun setDriveEmail(email: String?) {\n viewModelScope.launch {\n if (email == null) settings.clear(SettingsRepository.KEY_DRIVE_ACCOUNT)\n else settings.set(SettingsRepository.KEY_DRIVE_ACCOUNT, email)\n }\n }\n\n fun setDriveStatus(message: String?) {\n statusFlow.value = (statusFlow.value.first to message)\n }\n\n fun syncPush() = runDrive { drive.pushSnapshot() }\n fun syncPull() = runDrive { drive.pullIfNewer() }\n\n private fun runDrive(block: suspend () -> SyncOutcome) {\n viewModelScope.launch {\n val r = block()\n val msg = when (r) {\n is SyncOutcome.Ok -> ctx.getString(R.string.status_drive_ok)\n is SyncOutcome.NotSignedIn -> ctx.getString(R.string.status_drive_sign_in_required)\n is SyncOutcome.Error -> ctx.getString(R.string.status_drive_error, r.message)\n }\n statusFlow.value = (statusFlow.value.first to msg)\n }\n }\n\n fun updateGroup(g: Group, sortIndex: Int = 0) {\n viewModelScope.launch { repo.upsertGroup(g, sortIndex) }\n }\n\n fun deleteGroup(g: Group) {\n viewModelScope.launch { repo.deleteGroup(g) }\n }\n\n fun setDeepSeek(apiKey: String, model: String, baseUrl: String, context: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_DEEPSEEK_API_KEY, apiKey)\n settings.set(SettingsRepository.KEY_DEEPSEEK_MODEL, model.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_MODEL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_BASE_URL, baseUrl.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_CONTEXT, context.trim())\n }\n }\n}\n"}, "b640818ae5c827a2beaeab222b10db628e45486f": {"t": 1785755956.1539125, "n": 6595, "h": "e995b31b6672b481", "c": "package app.nock.android.ui.settings\n\nimport android.content.Context\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport app.nock.android.R\nimport app.nock.android.data.NockRepository\nimport app.nock.android.data.SeedGroupLocaleSync\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.domain.escalation.EscalationEngine\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.sync.DriveSyncClient\nimport app.nock.android.sync.SyncOutcome\nimport app.nock.android.history.AlarmHistoryLogger\nimport app.nock.android.telegram.TelegramSender\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\ndata class SettingsState(\n val chain: EscalationChain? = null,\n val telegramToken: String = \"\",\n val telegramChat: String = \"\",\n val driveEmail: String? = null,\n val driveLastSyncMs: Long? = null,\n val telegramStatus: String? = null,\n val driveStatus: String? = null,\n val groups: List<Group> = emptyList(),\n val deepSeekApiKey: String = \"\",\n val deepSeekModel: String = SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n val deepSeekBaseUrl: String = SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n val deepSeekContext: String = \"\",\n)\n\n@HiltViewModel\nclass SettingsViewModel @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val settings: SettingsRepository,\n private val repo: NockRepository,\n private val telegram: TelegramSender,\n private val drive: DriveSyncClient,\n private val engine: EscalationEngine,\n private val seedGroupLocaleSync: SeedGroupLocaleSync,\n private val alarmHistory: AlarmHistoryLogger,\n) : ViewModel() {\n\n fun syncSeedGroupNames() {\n viewModelScope.launch { seedGroupLocaleSync.sync() }\n }\n\n fun alarmHistoryDump(): String = alarmHistory.dump()\n fun clearAlarmHistory() = alarmHistory.clear()\n\n private val statusFlow = MutableStateFlow(Pair<String?, String?>(null, null))\n\n val state: StateFlow<SettingsState> = combine(\n settings.observeAll(),\n repo.observeGroups(),\n statusFlow\n ) { kv, groups, status ->\n val chain = (kv[SettingsRepository.KEY_STAGE_CHAIN])?.let {\n runCatching { app.nock.android.data.json.ChainJson.decode(it) }.getOrNull()\n } ?: app.nock.android.domain.model.DefaultChain.CHAIN\n SettingsState(\n chain = chain,\n telegramToken = kv[SettingsRepository.KEY_TELEGRAM_TOKEN].orEmpty(),\n telegramChat = kv[SettingsRepository.KEY_TELEGRAM_CHAT].orEmpty(),\n driveEmail = kv[SettingsRepository.KEY_DRIVE_ACCOUNT],\n driveLastSyncMs = kv[SettingsRepository.KEY_DRIVE_LAST_SYNC_MS]?.toLongOrNull(),\n telegramStatus = status.first,\n driveStatus = status.second,\n groups = groups,\n deepSeekApiKey = kv[SettingsRepository.KEY_DEEPSEEK_API_KEY].orEmpty(),\n deepSeekModel = kv[SettingsRepository.KEY_DEEPSEEK_MODEL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n deepSeekBaseUrl = kv[SettingsRepository.KEY_DEEPSEEK_BASE_URL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n deepSeekContext = kv[SettingsRepository.KEY_DEEPSEEK_CONTEXT].orEmpty(),\n )\n }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsState())\n\n fun setChain(chain: EscalationChain) {\n viewModelScope.launch { settings.setStageChain(chain) }\n }\n\n fun setTelegram(token: String, chat: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_TELEGRAM_TOKEN, token)\n settings.set(SettingsRepository.KEY_TELEGRAM_CHAT, chat)\n }\n }\n\n fun testTelegram() {\n viewModelScope.launch {\n val token = settings.get(SettingsRepository.KEY_TELEGRAM_TOKEN).orEmpty()\n val chat = settings.get(SettingsRepository.KEY_TELEGRAM_CHAT).orEmpty()\n val res = telegram.sendRaw(token, chat, ctx.getString(R.string.telegram_test_message), silent = false)\n val msg = if (res.ok) ctx.getString(R.string.status_telegram_ok)\n else ctx.getString(R.string.status_telegram_error, res.errorDescription.orEmpty())\n statusFlow.value = (msg to statusFlow.value.second)\n }\n }\n\n fun setDriveEmail(email: String?) {\n viewModelScope.launch {\n if (email == null) settings.clear(SettingsRepository.KEY_DRIVE_ACCOUNT)\n else settings.set(SettingsRepository.KEY_DRIVE_ACCOUNT, email)\n }\n }\n\n fun setDriveStatus(message: String?) {\n statusFlow.value = (statusFlow.value.first to message)\n }\n\n fun syncPush() = runDrive { drive.pushSnapshot() }\n fun syncPull() = runDrive { drive.pullIfNewer() }\n\n private fun runDrive(block: suspend () -> SyncOutcome) {\n viewModelScope.launch {\n val r = block()\n val msg = when (r) {\n is SyncOutcome.Ok -> ctx.getString(R.string.status_drive_ok)\n is SyncOutcome.NotSignedIn -> ctx.getString(R.string.status_drive_sign_in_required)\n is SyncOutcome.Error -> ctx.getString(R.string.status_drive_error, r.message)\n }\n statusFlow.value = (statusFlow.value.first to msg)\n }\n }\n\n fun updateGroup(g: Group, sortIndex: Int = 0) {\n viewModelScope.launch { repo.upsertGroup(g, sortIndex) }\n }\n\n fun deleteGroup(g: Group) {\n viewModelScope.launch { repo.deleteGroup(g) }\n }\n\n fun setDeepSeek(apiKey: String, model: String, baseUrl: String, context: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_DEEPSEEK_API_KEY, apiKey)\n settings.set(SettingsRepository.KEY_DEEPSEEK_MODEL, model.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_MODEL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_BASE_URL, baseUrl.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_CONTEXT, context.trim())\n }\n }\n}\n"}, "b065b3dd7fd122028f8c8c8fb1e1cca5679c7bc8": {"t": 1785755957.7641091, "n": 6568, "h": "daee352b00cb6f28", "c": "package app.nock.android.ui.settings\n\nimport android.content.Context\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport app.nock.android.R\nimport app.nock.android.data.NockRepository\nimport app.nock.android.data.SeedGroupLocaleSync\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.domain.escalation.EscalationEngine\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.sync.DriveSyncClient\nimport app.nock.android.sync.SyncOutcome\nimport app.nock.android.telegram.TelegramSender\nimport app.nock.android.voice.VoiceLogger\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\ndata class SettingsState(\n val chain: EscalationChain? = null,\n val telegramToken: String = \"\",\n val telegramChat: String = \"\",\n val driveEmail: String? = null,\n val driveLastSyncMs: Long? = null,\n val telegramStatus: String? = null,\n val driveStatus: String? = null,\n val groups: List<Group> = emptyList(),\n val deepSeekApiKey: String = \"\",\n val deepSeekModel: String = SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n val deepSeekBaseUrl: String = SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n val deepSeekContext: String = \"\",\n)\n\n@HiltViewModel\nclass SettingsViewModel @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val settings: SettingsRepository,\n private val repo: NockRepository,\n private val telegram: TelegramSender,\n private val drive: DriveSyncClient,\n private val engine: EscalationEngine,\n private val seedGroupLocaleSync: SeedGroupLocaleSync,\n private val voiceLogger: VoiceLogger,\n) : ViewModel() {\n\n fun syncSeedGroupNames() {\n viewModelScope.launch { seedGroupLocaleSync.sync() }\n }\n\n fun voiceLogDump(): String = voiceLogger.dump()\n fun clearVoiceLog() = voiceLogger.clear()\n\n private val statusFlow = MutableStateFlow(Pair<String?, String?>(null, null))\n\n val state: StateFlow<SettingsState> = combine(\n settings.observeAll(),\n repo.observeGroups(),\n statusFlow\n ) { kv, groups, status ->\n val chain = (kv[SettingsRepository.KEY_STAGE_CHAIN])?.let {\n runCatching { app.nock.android.data.json.ChainJson.decode(it) }.getOrNull()\n } ?: app.nock.android.domain.model.DefaultChain.CHAIN\n SettingsState(\n chain = chain,\n telegramToken = kv[SettingsRepository.KEY_TELEGRAM_TOKEN].orEmpty(),\n telegramChat = kv[SettingsRepository.KEY_TELEGRAM_CHAT].orEmpty(),\n driveEmail = kv[SettingsRepository.KEY_DRIVE_ACCOUNT],\n driveLastSyncMs = kv[SettingsRepository.KEY_DRIVE_LAST_SYNC_MS]?.toLongOrNull(),\n telegramStatus = status.first,\n driveStatus = status.second,\n groups = groups,\n deepSeekApiKey = kv[SettingsRepository.KEY_DEEPSEEK_API_KEY].orEmpty(),\n deepSeekModel = kv[SettingsRepository.KEY_DEEPSEEK_MODEL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n deepSeekBaseUrl = kv[SettingsRepository.KEY_DEEPSEEK_BASE_URL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n deepSeekContext = kv[SettingsRepository.KEY_DEEPSEEK_CONTEXT].orEmpty(),\n )\n }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsState())\n\n fun setChain(chain: EscalationChain) {\n viewModelScope.launch { settings.setStageChain(chain) }\n }\n\n fun setTelegram(token: String, chat: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_TELEGRAM_TOKEN, token)\n settings.set(SettingsRepository.KEY_TELEGRAM_CHAT, chat)\n }\n }\n\n fun testTelegram() {\n viewModelScope.launch {\n val token = settings.get(SettingsRepository.KEY_TELEGRAM_TOKEN).orEmpty()\n val chat = settings.get(SettingsRepository.KEY_TELEGRAM_CHAT).orEmpty()\n val res = telegram.sendRaw(token, chat, ctx.getString(R.string.telegram_test_message), silent = false)\n val msg = if (res.ok) ctx.getString(R.string.status_telegram_ok)\n else ctx.getString(R.string.status_telegram_error, res.errorDescription.orEmpty())\n statusFlow.value = (msg to statusFlow.value.second)\n }\n }\n\n fun setDriveEmail(email: String?) {\n viewModelScope.launch {\n if (email == null) settings.clear(SettingsRepository.KEY_DRIVE_ACCOUNT)\n else settings.set(SettingsRepository.KEY_DRIVE_ACCOUNT, email)\n }\n }\n\n fun setDriveStatus(message: String?) {\n statusFlow.value = (statusFlow.value.first to message)\n }\n\n fun syncPush() = runDrive { drive.pushSnapshot() }\n fun syncPull() = runDrive { drive.pullIfNewer() }\n\n private fun runDrive(block: suspend () -> SyncOutcome) {\n viewModelScope.launch {\n val r = block()\n val msg = when (r) {\n is SyncOutcome.Ok -> ctx.getString(R.string.status_drive_ok)\n is SyncOutcome.NotSignedIn -> ctx.getString(R.string.status_drive_sign_in_required)\n is SyncOutcome.Error -> ctx.getString(R.string.status_drive_error, r.message)\n }\n statusFlow.value = (statusFlow.value.first to msg)\n }\n }\n\n fun updateGroup(g: Group, sortIndex: Int = 0) {\n viewModelScope.launch { repo.upsertGroup(g, sortIndex) }\n }\n\n fun deleteGroup(g: Group) {\n viewModelScope.launch { repo.deleteGroup(g) }\n }\n\n fun setDeepSeek(apiKey: String, model: String, baseUrl: String, context: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_DEEPSEEK_API_KEY, apiKey)\n settings.set(SettingsRepository.KEY_DEEPSEEK_MODEL, model.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_MODEL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_BASE_URL, baseUrl.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_CONTEXT, context.trim())\n }\n }\n}\n"}, "44e2a411d379ce6bb30accdd87fea87251f8e09a": {"t": 1785755959.165983, "n": 6346, "h": "7f93c6e294f51876", "c": "package app.nock.android.ui.settings\n\nimport android.content.Context\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport app.nock.android.R\nimport app.nock.android.data.NockRepository\nimport app.nock.android.data.SeedGroupLocaleSync\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.domain.escalation.EscalationEngine\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.sync.DriveSyncClient\nimport app.nock.android.sync.SyncOutcome\nimport app.nock.android.telegram.TelegramSender\nimport app.nock.android.voice.VoiceLogger\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\ndata class SettingsState(\n val chain: EscalationChain? = null,\n val telegramToken: String = \"\",\n val telegramChat: String = \"\",\n val driveEmail: String? = null,\n val driveLastSyncMs: Long? = null,\n val telegramStatus: String? = null,\n val driveStatus: String? = null,\n val groups: List<Group> = emptyList(),\n val deepSeekApiKey: String = \"\",\n val deepSeekModel: String = SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n val deepSeekBaseUrl: String = SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n)\n\n@HiltViewModel\nclass SettingsViewModel @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val settings: SettingsRepository,\n private val repo: NockRepository,\n private val telegram: TelegramSender,\n private val drive: DriveSyncClient,\n private val engine: EscalationEngine,\n private val seedGroupLocaleSync: SeedGroupLocaleSync,\n private val voiceLogger: VoiceLogger,\n) : ViewModel() {\n\n fun syncSeedGroupNames() {\n viewModelScope.launch { seedGroupLocaleSync.sync() }\n }\n\n fun voiceLogDump(): String = voiceLogger.dump()\n fun clearVoiceLog() = voiceLogger.clear()\n\n private val statusFlow = MutableStateFlow(Pair<String?, String?>(null, null))\n\n val state: StateFlow<SettingsState> = combine(\n settings.observeAll(),\n repo.observeGroups(),\n statusFlow\n ) { kv, groups, status ->\n val chain = (kv[SettingsRepository.KEY_STAGE_CHAIN])?.let {\n runCatching { app.nock.android.data.json.ChainJson.decode(it) }.getOrNull()\n } ?: app.nock.android.domain.model.DefaultChain.CHAIN\n SettingsState(\n chain = chain,\n telegramToken = kv[SettingsRepository.KEY_TELEGRAM_TOKEN].orEmpty(),\n telegramChat = kv[SettingsRepository.KEY_TELEGRAM_CHAT].orEmpty(),\n driveEmail = kv[SettingsRepository.KEY_DRIVE_ACCOUNT],\n driveLastSyncMs = kv[SettingsRepository.KEY_DRIVE_LAST_SYNC_MS]?.toLongOrNull(),\n telegramStatus = status.first,\n driveStatus = status.second,\n groups = groups,\n deepSeekApiKey = kv[SettingsRepository.KEY_DEEPSEEK_API_KEY].orEmpty(),\n deepSeekModel = kv[SettingsRepository.KEY_DEEPSEEK_MODEL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n deepSeekBaseUrl = kv[SettingsRepository.KEY_DEEPSEEK_BASE_URL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n )\n }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsState())\n\n fun setChain(chain: EscalationChain) {\n viewModelScope.launch { settings.setStageChain(chain) }\n }\n\n fun setTelegram(token: String, chat: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_TELEGRAM_TOKEN, token)\n settings.set(SettingsRepository.KEY_TELEGRAM_CHAT, chat)\n }\n }\n\n fun testTelegram() {\n viewModelScope.launch {\n val token = settings.get(SettingsRepository.KEY_TELEGRAM_TOKEN).orEmpty()\n val chat = settings.get(SettingsRepository.KEY_TELEGRAM_CHAT).orEmpty()\n val res = telegram.sendRaw(token, chat, ctx.getString(R.string.telegram_test_message), silent = false)\n val msg = if (res.ok) ctx.getString(R.string.status_telegram_ok)\n else ctx.getString(R.string.status_telegram_error, res.errorDescription.orEmpty())\n statusFlow.value = (msg to statusFlow.value.second)\n }\n }\n\n fun setDriveEmail(email: String?) {\n viewModelScope.launch {\n if (email == null) settings.clear(SettingsRepository.KEY_DRIVE_ACCOUNT)\n else settings.set(SettingsRepository.KEY_DRIVE_ACCOUNT, email)\n }\n }\n\n fun setDriveStatus(message: String?) {\n statusFlow.value = (statusFlow.value.first to message)\n }\n\n fun syncPush() = runDrive { drive.pushSnapshot() }\n fun syncPull() = runDrive { drive.pullIfNewer() }\n\n private fun runDrive(block: suspend () -> SyncOutcome) {\n viewModelScope.launch {\n val r = block()\n val msg = when (r) {\n is SyncOutcome.Ok -> ctx.getString(R.string.status_drive_ok)\n is SyncOutcome.NotSignedIn -> ctx.getString(R.string.status_drive_sign_in_required)\n is SyncOutcome.Error -> ctx.getString(R.string.status_drive_error, r.message)\n }\n statusFlow.value = (statusFlow.value.first to msg)\n }\n }\n\n fun updateGroup(g: Group, sortIndex: Int = 0) {\n viewModelScope.launch { repo.upsertGroup(g, sortIndex) }\n }\n\n fun deleteGroup(g: Group) {\n viewModelScope.launch { repo.deleteGroup(g) }\n }\n\n fun setDeepSeek(apiKey: String, model: String, baseUrl: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_DEEPSEEK_API_KEY, apiKey)\n settings.set(SettingsRepository.KEY_DEEPSEEK_MODEL, model.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_MODEL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_BASE_URL, baseUrl.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL })\n }\n }\n}\n"}, "3b3cd08ff3dc89e0390749b24f1a60bbe660bc87": {"t": 1785755960.6851037, "n": 6163, "h": "40ffe7fb4865bef3", "c": "package app.nock.android.ui.settings\n\nimport android.content.Context\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport app.nock.android.R\nimport app.nock.android.data.NockRepository\nimport app.nock.android.data.SeedGroupLocaleSync\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.domain.escalation.EscalationEngine\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.sync.DriveSyncClient\nimport app.nock.android.sync.SyncOutcome\nimport app.nock.android.telegram.TelegramSender\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\ndata class SettingsState(\n val chain: EscalationChain? = null,\n val telegramToken: String = \"\",\n val telegramChat: String = \"\",\n val driveEmail: String? = null,\n val driveLastSyncMs: Long? = null,\n val telegramStatus: String? = null,\n val driveStatus: String? = null,\n val groups: List<Group> = emptyList(),\n val deepSeekApiKey: String = \"\",\n val deepSeekModel: String = SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n val deepSeekBaseUrl: String = SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n)\n\n@HiltViewModel\nclass SettingsViewModel @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val settings: SettingsRepository,\n private val repo: NockRepository,\n private val telegram: TelegramSender,\n private val drive: DriveSyncClient,\n private val engine: EscalationEngine,\n private val seedGroupLocaleSync: SeedGroupLocaleSync,\n) : ViewModel() {\n\n fun syncSeedGroupNames() {\n viewModelScope.launch { seedGroupLocaleSync.sync() }\n }\n\n private val statusFlow = MutableStateFlow(Pair<String?, String?>(null, null))\n\n val state: StateFlow<SettingsState> = combine(\n settings.observeAll(),\n repo.observeGroups(),\n statusFlow\n ) { kv, groups, status ->\n val chain = (kv[SettingsRepository.KEY_STAGE_CHAIN])?.let {\n runCatching { app.nock.android.data.json.ChainJson.decode(it) }.getOrNull()\n } ?: app.nock.android.domain.model.DefaultChain.CHAIN\n SettingsState(\n chain = chain,\n telegramToken = kv[SettingsRepository.KEY_TELEGRAM_TOKEN].orEmpty(),\n telegramChat = kv[SettingsRepository.KEY_TELEGRAM_CHAT].orEmpty(),\n driveEmail = kv[SettingsRepository.KEY_DRIVE_ACCOUNT],\n driveLastSyncMs = kv[SettingsRepository.KEY_DRIVE_LAST_SYNC_MS]?.toLongOrNull(),\n telegramStatus = status.first,\n driveStatus = status.second,\n groups = groups,\n deepSeekApiKey = kv[SettingsRepository.KEY_DEEPSEEK_API_KEY].orEmpty(),\n deepSeekModel = kv[SettingsRepository.KEY_DEEPSEEK_MODEL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_MODEL,\n deepSeekBaseUrl = kv[SettingsRepository.KEY_DEEPSEEK_BASE_URL]?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL,\n )\n }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsState())\n\n fun setChain(chain: EscalationChain) {\n viewModelScope.launch { settings.setStageChain(chain) }\n }\n\n fun setTelegram(token: String, chat: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_TELEGRAM_TOKEN, token)\n settings.set(SettingsRepository.KEY_TELEGRAM_CHAT, chat)\n }\n }\n\n fun testTelegram() {\n viewModelScope.launch {\n val token = settings.get(SettingsRepository.KEY_TELEGRAM_TOKEN).orEmpty()\n val chat = settings.get(SettingsRepository.KEY_TELEGRAM_CHAT).orEmpty()\n val res = telegram.sendRaw(token, chat, ctx.getString(R.string.telegram_test_message), silent = false)\n val msg = if (res.ok) ctx.getString(R.string.status_telegram_ok)\n else ctx.getString(R.string.status_telegram_error, res.errorDescription.orEmpty())\n statusFlow.value = (msg to statusFlow.value.second)\n }\n }\n\n fun setDriveEmail(email: String?) {\n viewModelScope.launch {\n if (email == null) settings.clear(SettingsRepository.KEY_DRIVE_ACCOUNT)\n else settings.set(SettingsRepository.KEY_DRIVE_ACCOUNT, email)\n }\n }\n\n fun setDriveStatus(message: String?) {\n statusFlow.value = (statusFlow.value.first to message)\n }\n\n fun syncPush() = runDrive { drive.pushSnapshot() }\n fun syncPull() = runDrive { drive.pullIfNewer() }\n\n private fun runDrive(block: suspend () -> SyncOutcome) {\n viewModelScope.launch {\n val r = block()\n val msg = when (r) {\n is SyncOutcome.Ok -> ctx.getString(R.string.status_drive_ok)\n is SyncOutcome.NotSignedIn -> ctx.getString(R.string.status_drive_sign_in_required)\n is SyncOutcome.Error -> ctx.getString(R.string.status_drive_error, r.message)\n }\n statusFlow.value = (statusFlow.value.first to msg)\n }\n }\n\n fun updateGroup(g: Group, sortIndex: Int = 0) {\n viewModelScope.launch { repo.upsertGroup(g, sortIndex) }\n }\n\n fun deleteGroup(g: Group) {\n viewModelScope.launch { repo.deleteGroup(g) }\n }\n\n fun setDeepSeek(apiKey: String, model: String, baseUrl: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_DEEPSEEK_API_KEY, apiKey)\n settings.set(SettingsRepository.KEY_DEEPSEEK_MODEL, model.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_MODEL })\n settings.set(SettingsRepository.KEY_DEEPSEEK_BASE_URL, baseUrl.ifBlank { SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL })\n }\n }\n}\n"}, "ad40317899fe6eda3630568e0575acabab53567f": {"t": 1785755962.118405, "n": 5107, "h": "92aa8f14436ebbfd", "c": "package app.nock.android.ui.settings\n\nimport android.content.Context\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport app.nock.android.R\nimport app.nock.android.data.NockRepository\nimport app.nock.android.data.SeedGroupLocaleSync\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.domain.escalation.EscalationEngine\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.sync.DriveSyncClient\nimport app.nock.android.sync.SyncOutcome\nimport app.nock.android.telegram.TelegramSender\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\ndata class SettingsState(\n val chain: EscalationChain? = null,\n val telegramToken: String = \"\",\n val telegramChat: String = \"\",\n val driveEmail: String? = null,\n val driveLastSyncMs: Long? = null,\n val telegramStatus: String? = null,\n val driveStatus: String? = null,\n val groups: List<Group> = emptyList(),\n)\n\n@HiltViewModel\nclass SettingsViewModel @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val settings: SettingsRepository,\n private val repo: NockRepository,\n private val telegram: TelegramSender,\n private val drive: DriveSyncClient,\n private val engine: EscalationEngine,\n private val seedGroupLocaleSync: SeedGroupLocaleSync,\n) : ViewModel() {\n\n fun syncSeedGroupNames() {\n viewModelScope.launch { seedGroupLocaleSync.sync() }\n }\n\n private val statusFlow = MutableStateFlow(Pair<String?, String?>(null, null))\n\n val state: StateFlow<SettingsState> = combine(\n settings.observeAll(),\n repo.observeGroups(),\n statusFlow\n ) { kv, groups, status ->\n val chain = (kv[SettingsRepository.KEY_STAGE_CHAIN])?.let {\n runCatching { app.nock.android.data.json.ChainJson.decode(it) }.getOrNull()\n } ?: app.nock.android.domain.model.DefaultChain.CHAIN\n SettingsState(\n chain = chain,\n telegramToken = kv[SettingsRepository.KEY_TELEGRAM_TOKEN].orEmpty(),\n telegramChat = kv[SettingsRepository.KEY_TELEGRAM_CHAT].orEmpty(),\n driveEmail = kv[SettingsRepository.KEY_DRIVE_ACCOUNT],\n driveLastSyncMs = kv[SettingsRepository.KEY_DRIVE_LAST_SYNC_MS]?.toLongOrNull(),\n telegramStatus = status.first,\n driveStatus = status.second,\n groups = groups\n )\n }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsState())\n\n fun setChain(chain: EscalationChain) {\n viewModelScope.launch { settings.setStageChain(chain) }\n }\n\n fun setTelegram(token: String, chat: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_TELEGRAM_TOKEN, token)\n settings.set(SettingsRepository.KEY_TELEGRAM_CHAT, chat)\n }\n }\n\n fun testTelegram() {\n viewModelScope.launch {\n val token = settings.get(SettingsRepository.KEY_TELEGRAM_TOKEN).orEmpty()\n val chat = settings.get(SettingsRepository.KEY_TELEGRAM_CHAT).orEmpty()\n val res = telegram.sendRaw(token, chat, ctx.getString(R.string.telegram_test_message), silent = false)\n val msg = if (res.ok) ctx.getString(R.string.status_telegram_ok)\n else ctx.getString(R.string.status_telegram_error, res.errorDescription.orEmpty())\n statusFlow.value = (msg to statusFlow.value.second)\n }\n }\n\n fun setDriveEmail(email: String?) {\n viewModelScope.launch {\n if (email == null) settings.clear(SettingsRepository.KEY_DRIVE_ACCOUNT)\n else settings.set(SettingsRepository.KEY_DRIVE_ACCOUNT, email)\n }\n }\n\n fun setDriveStatus(message: String?) {\n statusFlow.value = (statusFlow.value.first to message)\n }\n\n fun syncPush() = runDrive { drive.pushSnapshot() }\n fun syncPull() = runDrive { drive.pullIfNewer() }\n\n private fun runDrive(block: suspend () -> SyncOutcome) {\n viewModelScope.launch {\n val r = block()\n val msg = when (r) {\n is SyncOutcome.Ok -> ctx.getString(R.string.status_drive_ok)\n is SyncOutcome.NotSignedIn -> ctx.getString(R.string.status_drive_sign_in_required)\n is SyncOutcome.Error -> ctx.getString(R.string.status_drive_error, r.message)\n }\n statusFlow.value = (statusFlow.value.first to msg)\n }\n }\n\n fun updateGroup(g: Group, sortIndex: Int = 0) {\n viewModelScope.launch { repo.upsertGroup(g, sortIndex) }\n }\n\n fun deleteGroup(g: Group) {\n viewModelScope.launch { repo.deleteGroup(g) }\n }\n}\n"}, "59ae772d61fc618777d73c5283d5396465ef9b7e": {"t": 1785755963.5898476, "n": 4994, "h": "79421a445400c787", "c": "package app.nock.android.ui.settings\n\nimport android.content.Context\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport app.nock.android.R\nimport app.nock.android.data.NockRepository\nimport app.nock.android.data.SeedGroupLocaleSync\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.domain.escalation.EscalationEngine\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.sync.DriveSyncClient\nimport app.nock.android.sync.SyncOutcome\nimport app.nock.android.telegram.TelegramSender\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\ndata class SettingsState(\n val chain: EscalationChain? = null,\n val telegramToken: String = \"\",\n val telegramChat: String = \"\",\n val driveEmail: String? = null,\n val driveLastSyncMs: Long? = null,\n val telegramStatus: String? = null,\n val driveStatus: String? = null,\n val groups: List<Group> = emptyList(),\n)\n\n@HiltViewModel\nclass SettingsViewModel @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val settings: SettingsRepository,\n private val repo: NockRepository,\n private val telegram: TelegramSender,\n private val drive: DriveSyncClient,\n private val engine: EscalationEngine,\n private val seedGroupLocaleSync: SeedGroupLocaleSync,\n) : ViewModel() {\n\n fun syncSeedGroupNames() {\n viewModelScope.launch { seedGroupLocaleSync.sync() }\n }\n\n private val statusFlow = MutableStateFlow(Pair<String?, String?>(null, null))\n\n val state: StateFlow<SettingsState> = combine(\n settings.observeAll(),\n repo.observeGroups(),\n statusFlow\n ) { kv, groups, status ->\n val chain = (kv[SettingsRepository.KEY_STAGE_CHAIN])?.let {\n runCatching { app.nock.android.data.json.ChainJson.decode(it) }.getOrNull()\n } ?: app.nock.android.domain.model.DefaultChain.CHAIN\n SettingsState(\n chain = chain,\n telegramToken = kv[SettingsRepository.KEY_TELEGRAM_TOKEN].orEmpty(),\n telegramChat = kv[SettingsRepository.KEY_TELEGRAM_CHAT].orEmpty(),\n driveEmail = kv[SettingsRepository.KEY_DRIVE_ACCOUNT],\n driveLastSyncMs = kv[SettingsRepository.KEY_DRIVE_LAST_SYNC_MS]?.toLongOrNull(),\n telegramStatus = status.first,\n driveStatus = status.second,\n groups = groups\n )\n }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsState())\n\n fun setChain(chain: EscalationChain) {\n viewModelScope.launch { settings.setStageChain(chain) }\n }\n\n fun setTelegram(token: String, chat: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_TELEGRAM_TOKEN, token)\n settings.set(SettingsRepository.KEY_TELEGRAM_CHAT, chat)\n }\n }\n\n fun testTelegram() {\n viewModelScope.launch {\n val token = settings.get(SettingsRepository.KEY_TELEGRAM_TOKEN).orEmpty()\n val chat = settings.get(SettingsRepository.KEY_TELEGRAM_CHAT).orEmpty()\n val res = telegram.sendRaw(token, chat, ctx.getString(R.string.telegram_test_message), silent = false)\n val msg = if (res.ok) ctx.getString(R.string.status_telegram_ok)\n else ctx.getString(R.string.status_telegram_error, res.errorDescription.orEmpty())\n statusFlow.value = (msg to statusFlow.value.second)\n }\n }\n\n fun setDriveEmail(email: String?) {\n viewModelScope.launch {\n if (email == null) settings.clear(SettingsRepository.KEY_DRIVE_ACCOUNT)\n else settings.set(SettingsRepository.KEY_DRIVE_ACCOUNT, email)\n }\n }\n\n fun syncPush() = runDrive { drive.pushSnapshot() }\n fun syncPull() = runDrive { drive.pullIfNewer() }\n\n private fun runDrive(block: suspend () -> SyncOutcome) {\n viewModelScope.launch {\n val r = block()\n val msg = when (r) {\n is SyncOutcome.Ok -> ctx.getString(R.string.status_drive_ok)\n is SyncOutcome.NotSignedIn -> ctx.getString(R.string.status_drive_sign_in_required)\n is SyncOutcome.Error -> ctx.getString(R.string.status_drive_error, r.message)\n }\n statusFlow.value = (statusFlow.value.first to msg)\n }\n }\n\n fun updateGroup(g: Group, sortIndex: Int = 0) {\n viewModelScope.launch { repo.upsertGroup(g, sortIndex) }\n }\n\n fun deleteGroup(g: Group) {\n viewModelScope.launch { repo.deleteGroup(g) }\n }\n}\n"}, "a1a53b47e02c587d44e9dcc84e9dfd622869386d": {"t": 1785755964.9289865, "n": 4788, "h": "1694e4b29ed6a907", "c": "package app.nock.android.ui.settings\n\nimport android.content.Context\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport app.nock.android.R\nimport app.nock.android.data.NockRepository\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.domain.escalation.EscalationEngine\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.sync.DriveSyncClient\nimport app.nock.android.sync.SyncOutcome\nimport app.nock.android.telegram.TelegramSender\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\ndata class SettingsState(\n val chain: EscalationChain? = null,\n val telegramToken: String = \"\",\n val telegramChat: String = \"\",\n val driveEmail: String? = null,\n val driveLastSyncMs: Long? = null,\n val telegramStatus: String? = null,\n val driveStatus: String? = null,\n val groups: List<Group> = emptyList(),\n)\n\n@HiltViewModel\nclass SettingsViewModel @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val settings: SettingsRepository,\n private val repo: NockRepository,\n private val telegram: TelegramSender,\n private val drive: DriveSyncClient,\n private val engine: EscalationEngine,\n) : ViewModel() {\n\n private val statusFlow = MutableStateFlow(Pair<String?, String?>(null, null))\n\n val state: StateFlow<SettingsState> = combine(\n settings.observeAll(),\n repo.observeGroups(),\n statusFlow\n ) { kv, groups, status ->\n val chain = (kv[SettingsRepository.KEY_STAGE_CHAIN])?.let {\n runCatching { app.nock.android.data.json.ChainJson.decode(it) }.getOrNull()\n } ?: app.nock.android.domain.model.DefaultChain.CHAIN\n SettingsState(\n chain = chain,\n telegramToken = kv[SettingsRepository.KEY_TELEGRAM_TOKEN].orEmpty(),\n telegramChat = kv[SettingsRepository.KEY_TELEGRAM_CHAT].orEmpty(),\n driveEmail = kv[SettingsRepository.KEY_DRIVE_ACCOUNT],\n driveLastSyncMs = kv[SettingsRepository.KEY_DRIVE_LAST_SYNC_MS]?.toLongOrNull(),\n telegramStatus = status.first,\n driveStatus = status.second,\n groups = groups\n )\n }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsState())\n\n fun setChain(chain: EscalationChain) {\n viewModelScope.launch { settings.setStageChain(chain) }\n }\n\n fun setTelegram(token: String, chat: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_TELEGRAM_TOKEN, token)\n settings.set(SettingsRepository.KEY_TELEGRAM_CHAT, chat)\n }\n }\n\n fun testTelegram() {\n viewModelScope.launch {\n val token = settings.get(SettingsRepository.KEY_TELEGRAM_TOKEN).orEmpty()\n val chat = settings.get(SettingsRepository.KEY_TELEGRAM_CHAT).orEmpty()\n val res = telegram.sendRaw(token, chat, ctx.getString(R.string.telegram_test_message), silent = false)\n val msg = if (res.ok) ctx.getString(R.string.status_telegram_ok)\n else ctx.getString(R.string.status_telegram_error, res.errorDescription.orEmpty())\n statusFlow.value = (msg to statusFlow.value.second)\n }\n }\n\n fun setDriveEmail(email: String?) {\n viewModelScope.launch {\n if (email == null) settings.clear(SettingsRepository.KEY_DRIVE_ACCOUNT)\n else settings.set(SettingsRepository.KEY_DRIVE_ACCOUNT, email)\n }\n }\n\n fun syncPush() = runDrive { drive.pushSnapshot() }\n fun syncPull() = runDrive { drive.pullIfNewer() }\n\n private fun runDrive(block: suspend () -> SyncOutcome) {\n viewModelScope.launch {\n val r = block()\n val msg = when (r) {\n is SyncOutcome.Ok -> ctx.getString(R.string.status_drive_ok)\n is SyncOutcome.NotSignedIn -> ctx.getString(R.string.status_drive_sign_in_required)\n is SyncOutcome.Error -> ctx.getString(R.string.status_drive_error, r.message)\n }\n statusFlow.value = (statusFlow.value.first to msg)\n }\n }\n\n fun updateGroup(g: Group, sortIndex: Int = 0) {\n viewModelScope.launch { repo.upsertGroup(g, sortIndex) }\n }\n\n fun deleteGroup(g: Group) {\n viewModelScope.launch { repo.deleteGroup(g) }\n }\n}\n"}, "d7c68558bc13b226949f3b0363b5c609e6cb333c": {"t": 1785755966.4371688, "n": 4407, "h": "13dca17958c407d3", "c": "package app.nock.android.ui.settings\n\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport app.nock.android.data.NockRepository\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.domain.escalation.EscalationEngine\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.sync.DriveSyncClient\nimport app.nock.android.sync.SyncOutcome\nimport app.nock.android.telegram.TelegramSender\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\ndata class SettingsState(\n val chain: EscalationChain? = null,\n val telegramToken: String = \"\",\n val telegramChat: String = \"\",\n val driveEmail: String? = null,\n val driveLastSyncMs: Long? = null,\n val telegramStatus: String? = null,\n val driveStatus: String? = null,\n val groups: List<Group> = emptyList(),\n)\n\n@HiltViewModel\nclass SettingsViewModel @Inject constructor(\n private val settings: SettingsRepository,\n private val repo: NockRepository,\n private val telegram: TelegramSender,\n private val drive: DriveSyncClient,\n private val engine: EscalationEngine,\n) : ViewModel() {\n\n private val statusFlow = MutableStateFlow(Pair<String?, String?>(null, null))\n\n val state: StateFlow<SettingsState> = combine(\n settings.observeAll(),\n repo.observeGroups(),\n statusFlow\n ) { kv, groups, status ->\n val chain = (kv[SettingsRepository.KEY_STAGE_CHAIN])?.let {\n runCatching { app.nock.android.data.json.ChainJson.decode(it) }.getOrNull()\n } ?: app.nock.android.domain.model.DefaultChain.CHAIN\n SettingsState(\n chain = chain,\n telegramToken = kv[SettingsRepository.KEY_TELEGRAM_TOKEN].orEmpty(),\n telegramChat = kv[SettingsRepository.KEY_TELEGRAM_CHAT].orEmpty(),\n driveEmail = kv[SettingsRepository.KEY_DRIVE_ACCOUNT],\n driveLastSyncMs = kv[SettingsRepository.KEY_DRIVE_LAST_SYNC_MS]?.toLongOrNull(),\n telegramStatus = status.first,\n driveStatus = status.second,\n groups = groups\n )\n }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsState())\n\n fun setChain(chain: EscalationChain) {\n viewModelScope.launch { settings.setStageChain(chain) }\n }\n\n fun setTelegram(token: String, chat: String) {\n viewModelScope.launch {\n settings.set(SettingsRepository.KEY_TELEGRAM_TOKEN, token)\n settings.set(SettingsRepository.KEY_TELEGRAM_CHAT, chat)\n }\n }\n\n fun testTelegram() {\n viewModelScope.launch {\n val token = settings.get(SettingsRepository.KEY_TELEGRAM_TOKEN).orEmpty()\n val chat = settings.get(SettingsRepository.KEY_TELEGRAM_CHAT).orEmpty()\n val res = telegram.sendRaw(token, chat, \"🔔 Nock test message\", silent = false)\n statusFlow.value = (\"Telegram: ${if (res.ok) \"OK\" else \"error — ${res.errorDescription}\"}\" to statusFlow.value.second)\n }\n }\n\n fun setDriveEmail(email: String?) {\n viewModelScope.launch {\n if (email == null) settings.clear(SettingsRepository.KEY_DRIVE_ACCOUNT)\n else settings.set(SettingsRepository.KEY_DRIVE_ACCOUNT, email)\n }\n }\n\n fun syncPush() = runDrive { drive.pushSnapshot() }\n fun syncPull() = runDrive { drive.pullIfNewer() }\n\n private fun runDrive(block: suspend () -> SyncOutcome) {\n viewModelScope.launch {\n val r = block()\n val msg = when (r) {\n is SyncOutcome.Ok -> \"Drive: OK\"\n is SyncOutcome.NotSignedIn -> \"Drive: sign in required\"\n is SyncOutcome.Error -> \"Drive: ${r.message}\"\n }\n statusFlow.value = (statusFlow.value.first to msg)\n }\n }\n\n fun updateGroup(g: Group, sortIndex: Int = 0) {\n viewModelScope.launch { repo.upsertGroup(g, sortIndex) }\n }\n\n fun deleteGroup(g: Group) {\n viewModelScope.launch { repo.deleteGroup(g) }\n }\n}\n"}}, "app/src/main/java/app/nock/android/voice/DeepSeekReminderParser.kt": {"62435763959e697b61607d4ea76b9ca03d580950": {"t": 1785745728.1496458, "n": 9024, "h": "ee10984693b5b48b", "c": "package app.nock.android.voice\n\nimport android.content.Context\nimport app.nock.android.R\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.Schedule\nimport com.squareup.moshi.JsonAdapter\nimport com.squareup.moshi.Moshi\nimport com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\nsealed class DeepSeekParseResult {\n data class Ok(\n val name: String?,\n val schedule: Schedule,\n val groupHint: String?,\n ) : DeepSeekParseResult()\n object NotConfigured : DeepSeekParseResult()\n data class Failed(val message: String, val transient: Boolean = false) : DeepSeekParseResult()\n}\n\n@Singleton\nclass DeepSeekReminderParser @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val deepSeek: DeepSeekClient,\n private val settings: SettingsRepository,\n) {\n private val moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()\n private val specAdapter: JsonAdapter<VoiceAlarmSpec> =\n moshi.adapter(VoiceAlarmSpec::class.java).lenient()\n\n suspend fun isConfigured(): Boolean =\n !settings.get(SettingsRepository.KEY_DEEPSEEK_API_KEY).isNullOrBlank()\n\n suspend fun parse(text: String, groups: List<Group>): DeepSeekParseResult {\n val trimmed = text.trim()\n if (trimmed.isEmpty()) return DeepSeekParseResult.Failed(ctx.getString(R.string.voice_error_empty_transcript))\n if (groups.isEmpty()) return DeepSeekParseResult.Failed(ctx.getString(R.string.voice_error_no_groups))\n if (!isConfigured()) return DeepSeekParseResult.NotConfigured\n\n val now = LocalDateTime.now()\n val zone = ZoneId.systemDefault()\n val userContext = settings.get(SettingsRepository.KEY_DEEPSEEK_CONTEXT)?.trim().orEmpty()\n val system = buildSystemPrompt(now, zone, groups, userContext)\n\n return when (val r = deepSeek.complete(system, trimmed, jsonMode = true)) {\n is DeepSeekResult.Error -> DeepSeekParseResult.Failed(r.message, transient = r.transient)\n is DeepSeekResult.Ok -> buildResult(r.content, now, zone)\n }\n }\n\n private fun buildResult(json: String, now: LocalDateTime, zone: ZoneId): DeepSeekParseResult {\n val spec = runCatching { specAdapter.fromJson(json) }.getOrNull()\n ?: return DeepSeekParseResult.Failed(ctx.getString(R.string.voice_error_bad_json))\n val schedule = runCatching { toSchedule(spec, now, zone) }.getOrElse { t ->\n return DeepSeekParseResult.Failed(t.message ?: ctx.getString(R.string.voice_error_bad_schedule))\n }\n return DeepSeekParseResult.Ok(\n name = spec.name?.trim()?.takeIf { it.isNotBlank() },\n schedule = schedule,\n groupHint = spec.groupHint?.trim()?.takeIf { it.isNotBlank() },\n )\n }\n\n // DeepSeek only ever produces a single, one-shot reminder. Recurring phrasing\n // (\"every day at 8\") is collapsed by the prompt to the next single occurrence,\n // so any returned scheduleType is treated as ONESHOT.\n private fun toSchedule(spec: VoiceAlarmSpec, now: LocalDateTime, zone: ZoneId): Schedule {\n val iso = spec.oneShotIso ?: error(ctx.getString(R.string.voice_error_missing_field, \"oneShotIso\"))\n val dt = parseIso(iso, now)\n return Schedule.OneShot(dt.atZone(zone).toInstant().toEpochMilli())\n }\n\n private fun parseIso(iso: String, now: LocalDateTime): LocalDateTime {\n runCatching { LocalDateTime.parse(iso) }.getOrNull()?.let { return it }\n runCatching { LocalDateTime.parse(iso, DateTimeFormatter.ISO_DATE_TIME) }.getOrNull()?.let { return it }\n runCatching { java.time.LocalDate.parse(iso).atStartOfDay() }.getOrNull()?.let { return it }\n return now\n }\n\n private fun buildSystemPrompt(\n now: LocalDateTime,\n zone: ZoneId,\n groups: List<Group>,\n userContext: String,\n ): String {\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd'T'HH:mm:ss\")\n val dow = now.dayOfWeek.name\n // The calendar-import group (Appointments / Termine) is populated from the\n // device calendar, not from voice capture, so never offer it as a target.\n val groupCatalog = groups\n .filter { it.seedKey != TRIPS_SEED_KEY }\n .joinToString(\"\\n\") { g ->\n val desc = SEED_GROUP_DESCRIPTIONS[g.seedKey]\n if (desc != null) \" - \\\"${g.name}\\\" — $desc\" else \" - \\\"${g.name}\\\" (custom group)\"\n }\n val personalContext = if (userContext.isNotBlank()) {\n \"\\n\\n\" + \"\"\"\n Personal context provided by the user (use it to resolve names, relationships,\n pets, places, and preferences when interpreting the phrase; never let it override\n an explicit instruction in the phrase, and never invent reminders from it):\n $userContext\n \"\"\".trimIndent()\n } else \"\"\n return \"\"\"\n You convert a spoken or typed phrase into a JSON specification for a single one-shot reminder/alarm.\n\n Every reminder fires exactly once, at one specific date and time. The app does not\n support recurring reminders here, so you must always resolve the phrase to a single moment.\n\n Output ONLY a single JSON object, no prose, with these fields:\n - name (string, required): a short imperative reminder label, max 60 chars. Do not include time-of-day.\n - scheduleType (string, required): always \"ONESHOT\".\n - oneShotIso (string, required): local datetime in ISO format YYYY-MM-DDTHH:mm:ss (no zone suffix, interpreted in the user's local zone).\n - groupHint (string, optional): MUST exactly match one of the group names listed below (verbatim, case-insensitive).\n\n Schedule rules (always ONESHOT):\n - \"tomorrow at 3pm\", \"Friday at noon\" => the single matching datetime.\n - \"in N minutes/hours\" => oneShotIso = now + that duration.\n - If the phrase is recurring (\"every day at 8\", \"each morning\", \"every Monday\", \"every 4 hours\"),\n do NOT try to repeat it. Pick the NEXT single occurrence from now and use that as oneShotIso.\n - If only a time of day is given with no date, use today if that time is still in the future, otherwise tomorrow.\n - Times like \"8\" / \"8pm\" / \"20:00\" map to a 24-hour local time.\n - All times are interpreted in the user's local time zone.\n\n Group selection (be careful — this is often the wrong field):\n - Before emitting JSON, think about the SUBJECT of the reminder (what is being done, for whom/what), not the wording.\n - Match that subject to the best group from the catalog below using the descriptions, not just name overlap.\n - Heuristics: take a pill / vitamin / insulin / inhaler => meds. Walk / feed / vet / groom an animal => pets.\n Stretch / drink water / meditate / sleep / hygiene / journal => self-care.\n Buy / pick up / shop / appointment / pharmacy / bank => errands.\n Vacuum / laundry / dishes / trash / water plants / bills => household.\n Meeting / call / deadline / report / email / study => work.\n - If the user explicitly names a group (\"add to Pets\"), use that name verbatim and ignore the heuristics.\n - If no group clearly fits, OMIT groupHint entirely — do NOT pick a random one and do NOT invent a name.\n\n Context:\n - Current local datetime: ${now.format(fmt)} (${zone.id})\n - Day of week today: $dow\n - Available groups:\n$groupCatalog\n \"\"\".trimIndent() + personalContext\n }\n\n companion object {\n // Seed key of the calendar-import group created by TripSyncManager.\n private const val TRIPS_SEED_KEY = \"trips\"\n\n private val SEED_GROUP_DESCRIPTIONS = mapOf(\n \"pets\" to \"anything about pets or animals: feeding, walking, vet visits, grooming, litter box, cage cleaning.\",\n \"meds\" to \"medication and supplements: taking pills, vitamins, insulin, inhaler, prescription refills, doctor-prescribed treatments.\",\n \"selfcare\" to \"personal wellness and habits: exercise, stretching, hydration, meditation, hygiene, skincare, sleep, journaling.\",\n \"household\" to \"chores done at home: cleaning, laundry, dishes, trash, watering plants, repairs, recurring home bills.\",\n \"work\" to \"job and study tasks: meetings, calls, deadlines, projects, emails, reports, classes.\",\n \"errands\" to \"tasks done outside the home: shopping, pickups, appointments, deliveries, banking, post office.\",\n )\n }\n}\n"}, "b9e8a0d5d930d59df30855e0dd4e9a86efebda10": {"t": 1785755942.7465482, "n": 9024, "h": "ee10984693b5b48b", "c": "package app.nock.android.voice\n\nimport android.content.Context\nimport app.nock.android.R\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.Schedule\nimport com.squareup.moshi.JsonAdapter\nimport com.squareup.moshi.Moshi\nimport com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\nsealed class DeepSeekParseResult {\n data class Ok(\n val name: String?,\n val schedule: Schedule,\n val groupHint: String?,\n ) : DeepSeekParseResult()\n object NotConfigured : DeepSeekParseResult()\n data class Failed(val message: String, val transient: Boolean = false) : DeepSeekParseResult()\n}\n\n@Singleton\nclass DeepSeekReminderParser @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val deepSeek: DeepSeekClient,\n private val settings: SettingsRepository,\n) {\n private val moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()\n private val specAdapter: JsonAdapter<VoiceAlarmSpec> =\n moshi.adapter(VoiceAlarmSpec::class.java).lenient()\n\n suspend fun isConfigured(): Boolean =\n !settings.get(SettingsRepository.KEY_DEEPSEEK_API_KEY).isNullOrBlank()\n\n suspend fun parse(text: String, groups: List<Group>): DeepSeekParseResult {\n val trimmed = text.trim()\n if (trimmed.isEmpty()) return DeepSeekParseResult.Failed(ctx.getString(R.string.voice_error_empty_transcript))\n if (groups.isEmpty()) return DeepSeekParseResult.Failed(ctx.getString(R.string.voice_error_no_groups))\n if (!isConfigured()) return DeepSeekParseResult.NotConfigured\n\n val now = LocalDateTime.now()\n val zone = ZoneId.systemDefault()\n val userContext = settings.get(SettingsRepository.KEY_DEEPSEEK_CONTEXT)?.trim().orEmpty()\n val system = buildSystemPrompt(now, zone, groups, userContext)\n\n return when (val r = deepSeek.complete(system, trimmed, jsonMode = true)) {\n is DeepSeekResult.Error -> DeepSeekParseResult.Failed(r.message, transient = r.transient)\n is DeepSeekResult.Ok -> buildResult(r.content, now, zone)\n }\n }\n\n private fun buildResult(json: String, now: LocalDateTime, zone: ZoneId): DeepSeekParseResult {\n val spec = runCatching { specAdapter.fromJson(json) }.getOrNull()\n ?: return DeepSeekParseResult.Failed(ctx.getString(R.string.voice_error_bad_json))\n val schedule = runCatching { toSchedule(spec, now, zone) }.getOrElse { t ->\n return DeepSeekParseResult.Failed(t.message ?: ctx.getString(R.string.voice_error_bad_schedule))\n }\n return DeepSeekParseResult.Ok(\n name = spec.name?.trim()?.takeIf { it.isNotBlank() },\n schedule = schedule,\n groupHint = spec.groupHint?.trim()?.takeIf { it.isNotBlank() },\n )\n }\n\n // DeepSeek only ever produces a single, one-shot reminder. Recurring phrasing\n // (\"every day at 8\") is collapsed by the prompt to the next single occurrence,\n // so any returned scheduleType is treated as ONESHOT.\n private fun toSchedule(spec: VoiceAlarmSpec, now: LocalDateTime, zone: ZoneId): Schedule {\n val iso = spec.oneShotIso ?: error(ctx.getString(R.string.voice_error_missing_field, \"oneShotIso\"))\n val dt = parseIso(iso, now)\n return Schedule.OneShot(dt.atZone(zone).toInstant().toEpochMilli())\n }\n\n private fun parseIso(iso: String, now: LocalDateTime): LocalDateTime {\n runCatching { LocalDateTime.parse(iso) }.getOrNull()?.let { return it }\n runCatching { LocalDateTime.parse(iso, DateTimeFormatter.ISO_DATE_TIME) }.getOrNull()?.let { return it }\n runCatching { java.time.LocalDate.parse(iso).atStartOfDay() }.getOrNull()?.let { return it }\n return now\n }\n\n private fun buildSystemPrompt(\n now: LocalDateTime,\n zone: ZoneId,\n groups: List<Group>,\n userContext: String,\n ): String {\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd'T'HH:mm:ss\")\n val dow = now.dayOfWeek.name\n // The calendar-import group (Appointments / Termine) is populated from the\n // device calendar, not from voice capture, so never offer it as a target.\n val groupCatalog = groups\n .filter { it.seedKey != TRIPS_SEED_KEY }\n .joinToString(\"\\n\") { g ->\n val desc = SEED_GROUP_DESCRIPTIONS[g.seedKey]\n if (desc != null) \" - \\\"${g.name}\\\" — $desc\" else \" - \\\"${g.name}\\\" (custom group)\"\n }\n val personalContext = if (userContext.isNotBlank()) {\n \"\\n\\n\" + \"\"\"\n Personal context provided by the user (use it to resolve names, relationships,\n pets, places, and preferences when interpreting the phrase; never let it override\n an explicit instruction in the phrase, and never invent reminders from it):\n $userContext\n \"\"\".trimIndent()\n } else \"\"\n return \"\"\"\n You convert a spoken or typed phrase into a JSON specification for a single one-shot reminder/alarm.\n\n Every reminder fires exactly once, at one specific date and time. The app does not\n support recurring reminders here, so you must always resolve the phrase to a single moment.\n\n Output ONLY a single JSON object, no prose, with these fields:\n - name (string, required): a short imperative reminder label, max 60 chars. Do not include time-of-day.\n - scheduleType (string, required): always \"ONESHOT\".\n - oneShotIso (string, required): local datetime in ISO format YYYY-MM-DDTHH:mm:ss (no zone suffix, interpreted in the user's local zone).\n - groupHint (string, optional): MUST exactly match one of the group names listed below (verbatim, case-insensitive).\n\n Schedule rules (always ONESHOT):\n - \"tomorrow at 3pm\", \"Friday at noon\" => the single matching datetime.\n - \"in N minutes/hours\" => oneShotIso = now + that duration.\n - If the phrase is recurring (\"every day at 8\", \"each morning\", \"every Monday\", \"every 4 hours\"),\n do NOT try to repeat it. Pick the NEXT single occurrence from now and use that as oneShotIso.\n - If only a time of day is given with no date, use today if that time is still in the future, otherwise tomorrow.\n - Times like \"8\" / \"8pm\" / \"20:00\" map to a 24-hour local time.\n - All times are interpreted in the user's local time zone.\n\n Group selection (be careful — this is often the wrong field):\n - Before emitting JSON, think about the SUBJECT of the reminder (what is being done, for whom/what), not the wording.\n - Match that subject to the best group from the catalog below using the descriptions, not just name overlap.\n - Heuristics: take a pill / vitamin / insulin / inhaler => meds. Walk / feed / vet / groom an animal => pets.\n Stretch / drink water / meditate / sleep / hygiene / journal => self-care.\n Buy / pick up / shop / appointment / pharmacy / bank => errands.\n Vacuum / laundry / dishes / trash / water plants / bills => household.\n Meeting / call / deadline / report / email / study => work.\n - If the user explicitly names a group (\"add to Pets\"), use that name verbatim and ignore the heuristics.\n - If no group clearly fits, OMIT groupHint entirely — do NOT pick a random one and do NOT invent a name.\n\n Context:\n - Current local datetime: ${now.format(fmt)} (${zone.id})\n - Day of week today: $dow\n - Available groups:\n$groupCatalog\n \"\"\".trimIndent() + personalContext\n }\n\n companion object {\n // Seed key of the calendar-import group created by TripSyncManager.\n private const val TRIPS_SEED_KEY = \"trips\"\n\n private val SEED_GROUP_DESCRIPTIONS = mapOf(\n \"pets\" to \"anything about pets or animals: feeding, walking, vet visits, grooming, litter box, cage cleaning.\",\n \"meds\" to \"medication and supplements: taking pills, vitamins, insulin, inhaler, prescription refills, doctor-prescribed treatments.\",\n \"selfcare\" to \"personal wellness and habits: exercise, stretching, hydration, meditation, hygiene, skincare, sleep, journaling.\",\n \"household\" to \"chores done at home: cleaning, laundry, dishes, trash, watering plants, repairs, recurring home bills.\",\n \"work\" to \"job and study tasks: meetings, calls, deadlines, projects, emails, reports, classes.\",\n \"errands\" to \"tasks done outside the home: shopping, pickups, appointments, deliveries, banking, post office.\",\n )\n }\n}\n"}, "60df51bde4b40322103395aa38e531bc74ed0090": {"t": 1785755944.0574334, "n": 11775, "h": "3f49147bfda00050", "c": "package app.nock.android.voice\n\nimport android.content.Context\nimport app.nock.android.R\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.Schedule\nimport com.squareup.moshi.JsonAdapter\nimport com.squareup.moshi.Moshi\nimport com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport java.time.DayOfWeek\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\nsealed class DeepSeekParseResult {\n data class Ok(\n val name: String?,\n val schedule: Schedule,\n val groupHint: String?,\n ) : DeepSeekParseResult()\n object NotConfigured : DeepSeekParseResult()\n data class Failed(val message: String, val transient: Boolean = false) : DeepSeekParseResult()\n}\n\n@Singleton\nclass DeepSeekReminderParser @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val deepSeek: DeepSeekClient,\n private val settings: SettingsRepository,\n) {\n private val moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()\n private val specAdapter: JsonAdapter<VoiceAlarmSpec> =\n moshi.adapter(VoiceAlarmSpec::class.java).lenient()\n\n suspend fun isConfigured(): Boolean =\n !settings.get(SettingsRepository.KEY_DEEPSEEK_API_KEY).isNullOrBlank()\n\n suspend fun parse(text: String, groups: List<Group>): DeepSeekParseResult {\n val trimmed = text.trim()\n if (trimmed.isEmpty()) return DeepSeekParseResult.Failed(ctx.getString(R.string.voice_error_empty_transcript))\n if (groups.isEmpty()) return DeepSeekParseResult.Failed(ctx.getString(R.string.voice_error_no_groups))\n if (!isConfigured()) return DeepSeekParseResult.NotConfigured\n\n val now = LocalDateTime.now()\n val zone = ZoneId.systemDefault()\n val userContext = settings.get(SettingsRepository.KEY_DEEPSEEK_CONTEXT)?.trim().orEmpty()\n val system = buildSystemPrompt(now, zone, groups, userContext)\n\n return when (val r = deepSeek.complete(system, trimmed, jsonMode = true)) {\n is DeepSeekResult.Error -> DeepSeekParseResult.Failed(r.message, transient = r.transient)\n is DeepSeekResult.Ok -> buildResult(r.content, now, zone)\n }\n }\n\n private fun buildResult(json: String, now: LocalDateTime, zone: ZoneId): DeepSeekParseResult {\n val spec = runCatching { specAdapter.fromJson(json) }.getOrNull()\n ?: return DeepSeekParseResult.Failed(ctx.getString(R.string.voice_error_bad_json))\n val schedule = runCatching { toSchedule(spec, now, zone) }.getOrElse { t ->\n return DeepSeekParseResult.Failed(t.message ?: ctx.getString(R.string.voice_error_bad_schedule))\n }\n return DeepSeekParseResult.Ok(\n name = spec.name?.trim()?.takeIf { it.isNotBlank() },\n schedule = schedule,\n groupHint = spec.groupHint?.trim()?.takeIf { it.isNotBlank() },\n )\n }\n\n private fun toSchedule(spec: VoiceAlarmSpec, now: LocalDateTime, zone: ZoneId): Schedule {\n val type = spec.scheduleType?.uppercase()?.replace('-', '_') ?: \"ONESHOT\"\n return when (type) {\n \"ONESHOT\", \"ONCE\", \"ONE_SHOT\" -> {\n val iso = spec.oneShotIso ?: error(ctx.getString(R.string.voice_error_missing_field, \"oneShotIso\"))\n val dt = parseIso(iso, now)\n Schedule.OneShot(dt.atZone(zone).toInstant().toEpochMilli())\n }\n \"DAILY\" -> Schedule.Daily(parseTimesOfDay(spec.timesOfDay))\n \"WEEKLY\" -> Schedule.Weekly(\n daysOfWeek = parseDaysOfWeek(spec.daysOfWeek),\n timesOfDayMinutes = parseTimesOfDay(spec.timesOfDay)\n )\n \"MONTHLY\" -> Schedule.Monthly(\n dayOfMonth = spec.dayOfMonth?.coerceIn(1, 31)\n ?: error(ctx.getString(R.string.voice_error_missing_field, \"dayOfMonth\")),\n timeOfDayMinutes = parseSingleTime(spec.timeOfDay)\n )\n \"INTERVAL\", \"INTERVAL_FROM_LAST\" -> {\n val mins = spec.intervalMinutes?.coerceAtLeast(1)\n ?: error(ctx.getString(R.string.voice_error_missing_field, \"intervalMinutes\"))\n val interval = mins * 60_000L\n // Anchor the fixed cadence at the first fire (now + interval).\n Schedule.IntervalFromStart(interval, System.currentTimeMillis() + interval)\n }\n \"ON_UNLOCK\", \"ONUNLOCK\" -> Schedule.OnUnlock(System.currentTimeMillis())\n else -> error(ctx.getString(R.string.voice_error_unknown_schedule, type))\n }\n }\n\n private fun parseIso(iso: String, now: LocalDateTime): LocalDateTime {\n runCatching { LocalDateTime.parse(iso) }.getOrNull()?.let { return it }\n runCatching { LocalDateTime.parse(iso, DateTimeFormatter.ISO_DATE_TIME) }.getOrNull()?.let { return it }\n runCatching { java.time.LocalDate.parse(iso).atStartOfDay() }.getOrNull()?.let { return it }\n return now\n }\n\n private fun parseTimesOfDay(values: List<String>?): List<Int> {\n val list = values?.mapNotNull { parseTimeStringOrNull(it) }?.distinct()?.sorted().orEmpty()\n if (list.isEmpty()) error(ctx.getString(R.string.voice_error_missing_field, \"timesOfDay\"))\n return list\n }\n\n private fun parseSingleTime(value: String?): Int {\n return value?.let { parseTimeStringOrNull(it) }\n ?: error(ctx.getString(R.string.voice_error_missing_field, \"timeOfDay\"))\n }\n\n private fun parseTimeStringOrNull(s: String): Int? {\n val parts = s.trim().split(\":\")\n if (parts.size < 2) return null\n val h = parts[0].toIntOrNull() ?: return null\n val m = parts[1].toIntOrNull() ?: return null\n if (h !in 0..23 || m !in 0..59) return null\n return h * 60 + m\n }\n\n private fun parseDaysOfWeek(values: List<String>?): Set<DayOfWeek> {\n val out = values?.mapNotNull { runCatching { DayOfWeek.valueOf(it.uppercase()) }.getOrNull() }?.toSet().orEmpty()\n if (out.isEmpty()) error(ctx.getString(R.string.voice_error_missing_field, \"daysOfWeek\"))\n return out\n }\n\n private fun buildSystemPrompt(\n now: LocalDateTime,\n zone: ZoneId,\n groups: List<Group>,\n userContext: String,\n ): String {\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd'T'HH:mm:ss\")\n val dow = now.dayOfWeek.name\n // The calendar-import group (Appointments / Termine) is populated from the\n // device calendar, not from voice capture, so never offer it as a target.\n val groupCatalog = groups\n .filter { it.seedKey != TRIPS_SEED_KEY }\n .joinToString(\"\\n\") { g ->\n val desc = SEED_GROUP_DESCRIPTIONS[g.seedKey]\n if (desc != null) \" - \\\"${g.name}\\\" — $desc\" else \" - \\\"${g.name}\\\" (custom group)\"\n }\n val personalContext = if (userContext.isNotBlank()) {\n \"\\n\\n\" + \"\"\"\n Personal context provided by the user (use it to resolve names, relationships,\n pets, places, and preferences when interpreting the phrase; never let it override\n an explicit instruction in the phrase, and never invent reminders from it):\n $userContext\n \"\"\".trimIndent()\n } else \"\"\n return \"\"\"\n You convert a spoken or typed phrase into a JSON specification for a single reminder/alarm.\n\n Output ONLY a single JSON object, no prose, with these fields (only include fields relevant to the chosen scheduleType):\n - name (string, required): a short imperative reminder label, max 60 chars. Do not include time-of-day.\n - scheduleType (string, required): one of ONESHOT, DAILY, WEEKLY, MONTHLY, INTERVAL, ON_UNLOCK.\n - oneShotIso (string, required when scheduleType=ONESHOT): local datetime in ISO format YYYY-MM-DDTHH:mm:ss (no zone suffix, interpreted in the user's local zone).\n - timesOfDay (array of \"HH:mm\" strings, required for DAILY and WEEKLY): 24-hour clock.\n - daysOfWeek (array of strings, required for WEEKLY): MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY.\n - dayOfMonth (integer 1-31, required for MONTHLY).\n - timeOfDay (\"HH:mm\" string, required for MONTHLY): 24-hour clock.\n - intervalMinutes (integer >= 1, required for INTERVAL): minutes between firings, measured from the user's last \"Done\".\n - groupHint (string, optional): MUST exactly match one of the group names listed below (verbatim, case-insensitive).\n\n Schedule rules:\n - Prefer ONESHOT for an unambiguous single event (\"tomorrow at 3pm\", \"in 20 minutes\").\n - Prefer DAILY / WEEKLY when the user says \"every day\", \"each morning\", \"every Monday and Wednesday\", etc.\n - Prefer INTERVAL when the user says \"every 4 hours\" or \"every 30 minutes\" (the time between firings, not a fixed time of day).\n - Prefer ON_UNLOCK only when the user explicitly says something like \"next time I unlock my phone\".\n - \"in N minutes/hours\" => ONESHOT, oneShotIso = now + that duration.\n - Times like \"8\" / \"8pm\" / \"20:00\" => \"20:00\" (HH:mm).\n - All times are interpreted in the user's local time zone.\n\n Group selection (be careful — this is often the wrong field):\n - Before emitting JSON, think about the SUBJECT of the reminder (what is being done, for whom/what), not the wording.\n - Match that subject to the best group from the catalog below using the descriptions, not just name overlap.\n - Heuristics: take a pill / vitamin / insulin / inhaler => meds. Walk / feed / vet / groom an animal => pets.\n Stretch / drink water / meditate / sleep / hygiene / journal => self-care.\n Buy / pick up / shop / appointment / pharmacy / bank => errands.\n Vacuum / laundry / dishes / trash / water plants / bills => household.\n Meeting / call / deadline / report / email / study => work.\n - If the user explicitly names a group (\"add to Pets\"), use that name verbatim and ignore the heuristics.\n - If no group clearly fits, OMIT groupHint entirely — do NOT pick a random one and do NOT invent a name.\n\n Context:\n - Current local datetime: ${now.format(fmt)} (${zone.id})\n - Day of week today: $dow\n - Available groups:\n$groupCatalog\n \"\"\".trimIndent() + personalContext\n }\n\n companion object {\n // Seed key of the calendar-import group created by TripSyncManager.\n private const val TRIPS_SEED_KEY = \"trips\"\n\n private val SEED_GROUP_DESCRIPTIONS = mapOf(\n \"pets\" to \"anything about pets or animals: feeding, walking, vet visits, grooming, litter box, cage cleaning.\",\n \"meds\" to \"medication and supplements: taking pills, vitamins, insulin, inhaler, prescription refills, doctor-prescribed treatments.\",\n \"selfcare\" to \"personal wellness and habits: exercise, stretching, hydration, meditation, hygiene, skincare, sleep, journaling.\",\n \"household\" to \"chores done at home: cleaning, laundry, dishes, trash, watering plants, repairs, recurring home bills.\",\n \"work\" to \"job and study tasks: meetings, calls, deadlines, projects, emails, reports, classes.\",\n \"errands\" to \"tasks done outside the home: shopping, pickups, appointments, deliveries, banking, post office.\",\n )\n }\n}\n"}, "2632b1b535ba948f78c4a59f988da5acaf062bf1": {"t": 1785755945.5489285, "n": 11615, "h": "6ad3437ca64dc9db", "c": "package app.nock.android.voice\n\nimport android.content.Context\nimport app.nock.android.R\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.Schedule\nimport com.squareup.moshi.JsonAdapter\nimport com.squareup.moshi.Moshi\nimport com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport java.time.DayOfWeek\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\nsealed class DeepSeekParseResult {\n data class Ok(\n val name: String?,\n val schedule: Schedule,\n val groupHint: String?,\n ) : DeepSeekParseResult()\n object NotConfigured : DeepSeekParseResult()\n data class Failed(val message: String, val transient: Boolean = false) : DeepSeekParseResult()\n}\n\n@Singleton\nclass DeepSeekReminderParser @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val deepSeek: DeepSeekClient,\n private val settings: SettingsRepository,\n) {\n private val moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()\n private val specAdapter: JsonAdapter<VoiceAlarmSpec> =\n moshi.adapter(VoiceAlarmSpec::class.java).lenient()\n\n suspend fun isConfigured(): Boolean =\n !settings.get(SettingsRepository.KEY_DEEPSEEK_API_KEY).isNullOrBlank()\n\n suspend fun parse(text: String, groups: List<Group>): DeepSeekParseResult {\n val trimmed = text.trim()\n if (trimmed.isEmpty()) return DeepSeekParseResult.Failed(ctx.getString(R.string.voice_error_empty_transcript))\n if (groups.isEmpty()) return DeepSeekParseResult.Failed(ctx.getString(R.string.voice_error_no_groups))\n if (!isConfigured()) return DeepSeekParseResult.NotConfigured\n\n val now = LocalDateTime.now()\n val zone = ZoneId.systemDefault()\n val userContext = settings.get(SettingsRepository.KEY_DEEPSEEK_CONTEXT)?.trim().orEmpty()\n val system = buildSystemPrompt(now, zone, groups, userContext)\n\n return when (val r = deepSeek.complete(system, trimmed, jsonMode = true)) {\n is DeepSeekResult.Error -> DeepSeekParseResult.Failed(r.message, transient = r.transient)\n is DeepSeekResult.Ok -> buildResult(r.content, now, zone)\n }\n }\n\n private fun buildResult(json: String, now: LocalDateTime, zone: ZoneId): DeepSeekParseResult {\n val spec = runCatching { specAdapter.fromJson(json) }.getOrNull()\n ?: return DeepSeekParseResult.Failed(ctx.getString(R.string.voice_error_bad_json))\n val schedule = runCatching { toSchedule(spec, now, zone) }.getOrElse { t ->\n return DeepSeekParseResult.Failed(t.message ?: ctx.getString(R.string.voice_error_bad_schedule))\n }\n return DeepSeekParseResult.Ok(\n name = spec.name?.trim()?.takeIf { it.isNotBlank() },\n schedule = schedule,\n groupHint = spec.groupHint?.trim()?.takeIf { it.isNotBlank() },\n )\n }\n\n private fun toSchedule(spec: VoiceAlarmSpec, now: LocalDateTime, zone: ZoneId): Schedule {\n val type = spec.scheduleType?.uppercase()?.replace('-', '_') ?: \"ONESHOT\"\n return when (type) {\n \"ONESHOT\", \"ONCE\", \"ONE_SHOT\" -> {\n val iso = spec.oneShotIso ?: error(ctx.getString(R.string.voice_error_missing_field, \"oneShotIso\"))\n val dt = parseIso(iso, now)\n Schedule.OneShot(dt.atZone(zone).toInstant().toEpochMilli())\n }\n \"DAILY\" -> Schedule.Daily(parseTimesOfDay(spec.timesOfDay))\n \"WEEKLY\" -> Schedule.Weekly(\n daysOfWeek = parseDaysOfWeek(spec.daysOfWeek),\n timesOfDayMinutes = parseTimesOfDay(spec.timesOfDay)\n )\n \"MONTHLY\" -> Schedule.Monthly(\n dayOfMonth = spec.dayOfMonth?.coerceIn(1, 31)\n ?: error(ctx.getString(R.string.voice_error_missing_field, \"dayOfMonth\")),\n timeOfDayMinutes = parseSingleTime(spec.timeOfDay)\n )\n \"INTERVAL\", \"INTERVAL_FROM_LAST\" -> {\n val mins = spec.intervalMinutes?.coerceAtLeast(1)\n ?: error(ctx.getString(R.string.voice_error_missing_field, \"intervalMinutes\"))\n Schedule.IntervalFromLast(mins * 60_000L)\n }\n \"ON_UNLOCK\", \"ONUNLOCK\" -> Schedule.OnUnlock(System.currentTimeMillis())\n else -> error(ctx.getString(R.string.voice_error_unknown_schedule, type))\n }\n }\n\n private fun parseIso(iso: String, now: LocalDateTime): LocalDateTime {\n runCatching { LocalDateTime.parse(iso) }.getOrNull()?.let { return it }\n runCatching { LocalDateTime.parse(iso, DateTimeFormatter.ISO_DATE_TIME) }.getOrNull()?.let { return it }\n runCatching { java.time.LocalDate.parse(iso).atStartOfDay() }.getOrNull()?.let { return it }\n return now\n }\n\n private fun parseTimesOfDay(values: List<String>?): List<Int> {\n val list = values?.mapNotNull { parseTimeStringOrNull(it) }?.distinct()?.sorted().orEmpty()\n if (list.isEmpty()) error(ctx.getString(R.string.voice_error_missing_field, \"timesOfDay\"))\n return list\n }\n\n private fun parseSingleTime(value: String?): Int {\n return value?.let { parseTimeStringOrNull(it) }\n ?: error(ctx.getString(R.string.voice_error_missing_field, \"timeOfDay\"))\n }\n\n private fun parseTimeStringOrNull(s: String): Int? {\n val parts = s.trim().split(\":\")\n if (parts.size < 2) return null\n val h = parts[0].toIntOrNull() ?: return null\n val m = parts[1].toIntOrNull() ?: return null\n if (h !in 0..23 || m !in 0..59) return null\n return h * 60 + m\n }\n\n private fun parseDaysOfWeek(values: List<String>?): Set<DayOfWeek> {\n val out = values?.mapNotNull { runCatching { DayOfWeek.valueOf(it.uppercase()) }.getOrNull() }?.toSet().orEmpty()\n if (out.isEmpty()) error(ctx.getString(R.string.voice_error_missing_field, \"daysOfWeek\"))\n return out\n }\n\n private fun buildSystemPrompt(\n now: LocalDateTime,\n zone: ZoneId,\n groups: List<Group>,\n userContext: String,\n ): String {\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd'T'HH:mm:ss\")\n val dow = now.dayOfWeek.name\n // The calendar-import group (Appointments / Termine) is populated from the\n // device calendar, not from voice capture, so never offer it as a target.\n val groupCatalog = groups\n .filter { it.seedKey != TRIPS_SEED_KEY }\n .joinToString(\"\\n\") { g ->\n val desc = SEED_GROUP_DESCRIPTIONS[g.seedKey]\n if (desc != null) \" - \\\"${g.name}\\\" — $desc\" else \" - \\\"${g.name}\\\" (custom group)\"\n }\n val personalContext = if (userContext.isNotBlank()) {\n \"\\n\\n\" + \"\"\"\n Personal context provided by the user (use it to resolve names, relationships,\n pets, places, and preferences when interpreting the phrase; never let it override\n an explicit instruction in the phrase, and never invent reminders from it):\n $userContext\n \"\"\".trimIndent()\n } else \"\"\n return \"\"\"\n You convert a spoken or typed phrase into a JSON specification for a single reminder/alarm.\n\n Output ONLY a single JSON object, no prose, with these fields (only include fields relevant to the chosen scheduleType):\n - name (string, required): a short imperative reminder label, max 60 chars. Do not include time-of-day.\n - scheduleType (string, required): one of ONESHOT, DAILY, WEEKLY, MONTHLY, INTERVAL, ON_UNLOCK.\n - oneShotIso (string, required when scheduleType=ONESHOT): local datetime in ISO format YYYY-MM-DDTHH:mm:ss (no zone suffix, interpreted in the user's local zone).\n - timesOfDay (array of \"HH:mm\" strings, required for DAILY and WEEKLY): 24-hour clock.\n - daysOfWeek (array of strings, required for WEEKLY): MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY.\n - dayOfMonth (integer 1-31, required for MONTHLY).\n - timeOfDay (\"HH:mm\" string, required for MONTHLY): 24-hour clock.\n - intervalMinutes (integer >= 1, required for INTERVAL): minutes between firings, measured from the user's last \"Done\".\n - groupHint (string, optional): MUST exactly match one of the group names listed below (verbatim, case-insensitive).\n\n Schedule rules:\n - Prefer ONESHOT for an unambiguous single event (\"tomorrow at 3pm\", \"in 20 minutes\").\n - Prefer DAILY / WEEKLY when the user says \"every day\", \"each morning\", \"every Monday and Wednesday\", etc.\n - Prefer INTERVAL when the user says \"every 4 hours\" or \"every 30 minutes\" (the time between firings, not a fixed time of day).\n - Prefer ON_UNLOCK only when the user explicitly says something like \"next time I unlock my phone\".\n - \"in N minutes/hours\" => ONESHOT, oneShotIso = now + that duration.\n - Times like \"8\" / \"8pm\" / \"20:00\" => \"20:00\" (HH:mm).\n - All times are interpreted in the user's local time zone.\n\n Group selection (be careful — this is often the wrong field):\n - Before emitting JSON, think about the SUBJECT of the reminder (what is being done, for whom/what), not the wording.\n - Match that subject to the best group from the catalog below using the descriptions, not just name overlap.\n - Heuristics: take a pill / vitamin / insulin / inhaler => meds. Walk / feed / vet / groom an animal => pets.\n Stretch / drink water / meditate / sleep / hygiene / journal => self-care.\n Buy / pick up / shop / appointment / pharmacy / bank => errands.\n Vacuum / laundry / dishes / trash / water plants / bills => household.\n Meeting / call / deadline / report / email / study => work.\n - If the user explicitly names a group (\"add to Pets\"), use that name verbatim and ignore the heuristics.\n - If no group clearly fits, OMIT groupHint entirely — do NOT pick a random one and do NOT invent a name.\n\n Context:\n - Current local datetime: ${now.format(fmt)} (${zone.id})\n - Day of week today: $dow\n - Available groups:\n$groupCatalog\n \"\"\".trimIndent() + personalContext\n }\n\n companion object {\n // Seed key of the calendar-import group created by TripSyncManager.\n private const val TRIPS_SEED_KEY = \"trips\"\n\n private val SEED_GROUP_DESCRIPTIONS = mapOf(\n \"pets\" to \"anything about pets or animals: feeding, walking, vet visits, grooming, litter box, cage cleaning.\",\n \"meds\" to \"medication and supplements: taking pills, vitamins, insulin, inhaler, prescription refills, doctor-prescribed treatments.\",\n \"selfcare\" to \"personal wellness and habits: exercise, stretching, hydration, meditation, hygiene, skincare, sleep, journaling.\",\n \"household\" to \"chores done at home: cleaning, laundry, dishes, trash, watering plants, repairs, recurring home bills.\",\n \"work\" to \"job and study tasks: meetings, calls, deadlines, projects, emails, reports, classes.\",\n \"errands\" to \"tasks done outside the home: shopping, pickups, appointments, deliveries, banking, post office.\",\n )\n }\n}\n"}, "b065b3dd7fd122028f8c8c8fb1e1cca5679c7bc8": {"t": 1785755946.8152032, "n": 11241, "h": "1a8ec114d267fe80", "c": "package app.nock.android.voice\n\nimport android.content.Context\nimport app.nock.android.R\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.Schedule\nimport com.squareup.moshi.JsonAdapter\nimport com.squareup.moshi.Moshi\nimport com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport java.time.DayOfWeek\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\nsealed class DeepSeekParseResult {\n data class Ok(\n val name: String?,\n val schedule: Schedule,\n val groupHint: String?,\n ) : DeepSeekParseResult()\n object NotConfigured : DeepSeekParseResult()\n data class Failed(val message: String, val transient: Boolean = false) : DeepSeekParseResult()\n}\n\n@Singleton\nclass DeepSeekReminderParser @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val deepSeek: DeepSeekClient,\n private val settings: SettingsRepository,\n) {\n private val moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()\n private val specAdapter: JsonAdapter<VoiceAlarmSpec> =\n moshi.adapter(VoiceAlarmSpec::class.java).lenient()\n\n suspend fun isConfigured(): Boolean =\n !settings.get(SettingsRepository.KEY_DEEPSEEK_API_KEY).isNullOrBlank()\n\n suspend fun parse(text: String, groups: List<Group>): DeepSeekParseResult {\n val trimmed = text.trim()\n if (trimmed.isEmpty()) return DeepSeekParseResult.Failed(ctx.getString(R.string.voice_error_empty_transcript))\n if (groups.isEmpty()) return DeepSeekParseResult.Failed(ctx.getString(R.string.voice_error_no_groups))\n if (!isConfigured()) return DeepSeekParseResult.NotConfigured\n\n val now = LocalDateTime.now()\n val zone = ZoneId.systemDefault()\n val userContext = settings.get(SettingsRepository.KEY_DEEPSEEK_CONTEXT)?.trim().orEmpty()\n val system = buildSystemPrompt(now, zone, groups, userContext)\n\n return when (val r = deepSeek.complete(system, trimmed, jsonMode = true)) {\n is DeepSeekResult.Error -> DeepSeekParseResult.Failed(r.message, transient = r.transient)\n is DeepSeekResult.Ok -> buildResult(r.content, now, zone)\n }\n }\n\n private fun buildResult(json: String, now: LocalDateTime, zone: ZoneId): DeepSeekParseResult {\n val spec = runCatching { specAdapter.fromJson(json) }.getOrNull()\n ?: return DeepSeekParseResult.Failed(ctx.getString(R.string.voice_error_bad_json))\n val schedule = runCatching { toSchedule(spec, now, zone) }.getOrElse { t ->\n return DeepSeekParseResult.Failed(t.message ?: ctx.getString(R.string.voice_error_bad_schedule))\n }\n return DeepSeekParseResult.Ok(\n name = spec.name?.trim()?.takeIf { it.isNotBlank() },\n schedule = schedule,\n groupHint = spec.groupHint?.trim()?.takeIf { it.isNotBlank() },\n )\n }\n\n private fun toSchedule(spec: VoiceAlarmSpec, now: LocalDateTime, zone: ZoneId): Schedule {\n val type = spec.scheduleType?.uppercase()?.replace('-', '_') ?: \"ONESHOT\"\n return when (type) {\n \"ONESHOT\", \"ONCE\", \"ONE_SHOT\" -> {\n val iso = spec.oneShotIso ?: error(ctx.getString(R.string.voice_error_missing_field, \"oneShotIso\"))\n val dt = parseIso(iso, now)\n Schedule.OneShot(dt.atZone(zone).toInstant().toEpochMilli())\n }\n \"DAILY\" -> Schedule.Daily(parseTimesOfDay(spec.timesOfDay))\n \"WEEKLY\" -> Schedule.Weekly(\n daysOfWeek = parseDaysOfWeek(spec.daysOfWeek),\n timesOfDayMinutes = parseTimesOfDay(spec.timesOfDay)\n )\n \"MONTHLY\" -> Schedule.Monthly(\n dayOfMonth = spec.dayOfMonth?.coerceIn(1, 31)\n ?: error(ctx.getString(R.string.voice_error_missing_field, \"dayOfMonth\")),\n timeOfDayMinutes = parseSingleTime(spec.timeOfDay)\n )\n \"INTERVAL\", \"INTERVAL_FROM_LAST\" -> {\n val mins = spec.intervalMinutes?.coerceAtLeast(1)\n ?: error(ctx.getString(R.string.voice_error_missing_field, \"intervalMinutes\"))\n Schedule.IntervalFromLast(mins * 60_000L)\n }\n \"ON_UNLOCK\", \"ONUNLOCK\" -> Schedule.OnUnlock(System.currentTimeMillis())\n else -> error(ctx.getString(R.string.voice_error_unknown_schedule, type))\n }\n }\n\n private fun parseIso(iso: String, now: LocalDateTime): LocalDateTime {\n runCatching { LocalDateTime.parse(iso) }.getOrNull()?.let { return it }\n runCatching { LocalDateTime.parse(iso, DateTimeFormatter.ISO_DATE_TIME) }.getOrNull()?.let { return it }\n runCatching { java.time.LocalDate.parse(iso).atStartOfDay() }.getOrNull()?.let { return it }\n return now\n }\n\n private fun parseTimesOfDay(values: List<String>?): List<Int> {\n val list = values?.mapNotNull { parseTimeStringOrNull(it) }?.distinct()?.sorted().orEmpty()\n if (list.isEmpty()) error(ctx.getString(R.string.voice_error_missing_field, \"timesOfDay\"))\n return list\n }\n\n private fun parseSingleTime(value: String?): Int {\n return value?.let { parseTimeStringOrNull(it) }\n ?: error(ctx.getString(R.string.voice_error_missing_field, \"timeOfDay\"))\n }\n\n private fun parseTimeStringOrNull(s: String): Int? {\n val parts = s.trim().split(\":\")\n if (parts.size < 2) return null\n val h = parts[0].toIntOrNull() ?: return null\n val m = parts[1].toIntOrNull() ?: return null\n if (h !in 0..23 || m !in 0..59) return null\n return h * 60 + m\n }\n\n private fun parseDaysOfWeek(values: List<String>?): Set<DayOfWeek> {\n val out = values?.mapNotNull { runCatching { DayOfWeek.valueOf(it.uppercase()) }.getOrNull() }?.toSet().orEmpty()\n if (out.isEmpty()) error(ctx.getString(R.string.voice_error_missing_field, \"daysOfWeek\"))\n return out\n }\n\n private fun buildSystemPrompt(\n now: LocalDateTime,\n zone: ZoneId,\n groups: List<Group>,\n userContext: String,\n ): String {\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd'T'HH:mm:ss\")\n val dow = now.dayOfWeek.name\n val groupCatalog = groups.joinToString(\"\\n\") { g ->\n val desc = SEED_GROUP_DESCRIPTIONS[g.seedKey]\n if (desc != null) \" - \\\"${g.name}\\\" — $desc\" else \" - \\\"${g.name}\\\" (custom group)\"\n }\n val personalContext = if (userContext.isNotBlank()) {\n \"\\n\\n\" + \"\"\"\n Personal context provided by the user (use it to resolve names, relationships,\n pets, places, and preferences when interpreting the phrase; never let it override\n an explicit instruction in the phrase, and never invent reminders from it):\n $userContext\n \"\"\".trimIndent()\n } else \"\"\n return \"\"\"\n You convert a spoken or typed phrase into a JSON specification for a single reminder/alarm.\n\n Output ONLY a single JSON object, no prose, with these fields (only include fields relevant to the chosen scheduleType):\n - name (string, required): a short imperative reminder label, max 60 chars. Do not include time-of-day.\n - scheduleType (string, required): one of ONESHOT, DAILY, WEEKLY, MONTHLY, INTERVAL, ON_UNLOCK.\n - oneShotIso (string, required when scheduleType=ONESHOT): local datetime in ISO format YYYY-MM-DDTHH:mm:ss (no zone suffix, interpreted in the user's local zone).\n - timesOfDay (array of \"HH:mm\" strings, required for DAILY and WEEKLY): 24-hour clock.\n - daysOfWeek (array of strings, required for WEEKLY): MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY.\n - dayOfMonth (integer 1-31, required for MONTHLY).\n - timeOfDay (\"HH:mm\" string, required for MONTHLY): 24-hour clock.\n - intervalMinutes (integer >= 1, required for INTERVAL): minutes between firings, measured from the user's last \"Done\".\n - groupHint (string, optional): MUST exactly match one of the group names listed below (verbatim, case-insensitive).\n\n Schedule rules:\n - Prefer ONESHOT for an unambiguous single event (\"tomorrow at 3pm\", \"in 20 minutes\").\n - Prefer DAILY / WEEKLY when the user says \"every day\", \"each morning\", \"every Monday and Wednesday\", etc.\n - Prefer INTERVAL when the user says \"every 4 hours\" or \"every 30 minutes\" (the time between firings, not a fixed time of day).\n - Prefer ON_UNLOCK only when the user explicitly says something like \"next time I unlock my phone\".\n - \"in N minutes/hours\" => ONESHOT, oneShotIso = now + that duration.\n - Times like \"8\" / \"8pm\" / \"20:00\" => \"20:00\" (HH:mm).\n - All times are interpreted in the user's local time zone.\n\n Group selection (be careful — this is often the wrong field):\n - Before emitting JSON, think about the SUBJECT of the reminder (what is being done, for whom/what), not the wording.\n - Match that subject to the best group from the catalog below using the descriptions, not just name overlap.\n - Heuristics: take a pill / vitamin / insulin / inhaler => meds. Walk / feed / vet / groom an animal => pets.\n Stretch / drink water / meditate / sleep / hygiene / journal => self-care.\n Buy / pick up / shop / appointment / pharmacy / bank => errands.\n Vacuum / laundry / dishes / trash / water plants / bills => household.\n Meeting / call / deadline / report / email / study => work.\n - If the user explicitly names a group (\"add to Pets\"), use that name verbatim and ignore the heuristics.\n - If no group clearly fits, OMIT groupHint entirely — do NOT pick a random one and do NOT invent a name.\n\n Context:\n - Current local datetime: ${now.format(fmt)} (${zone.id})\n - Day of week today: $dow\n - Available groups:\n$groupCatalog\n \"\"\".trimIndent() + personalContext\n }\n\n companion object {\n private val SEED_GROUP_DESCRIPTIONS = mapOf(\n \"pets\" to \"anything about pets or animals: feeding, walking, vet visits, grooming, litter box, cage cleaning.\",\n \"meds\" to \"medication and supplements: taking pills, vitamins, insulin, inhaler, prescription refills, doctor-prescribed treatments.\",\n \"selfcare\" to \"personal wellness and habits: exercise, stretching, hydration, meditation, hygiene, skincare, sleep, journaling.\",\n \"household\" to \"chores done at home: cleaning, laundry, dishes, trash, watering plants, repairs, recurring home bills.\",\n \"work\" to \"job and study tasks: meetings, calls, deadlines, projects, emails, reports, classes.\",\n \"errands\" to \"tasks done outside the home: shopping, pickups, appointments, deliveries, banking, post office.\",\n )\n }\n}\n"}, "5bcfdf8534dce79e3ee41ab5de3e9655aba55d22": {"t": 1785755948.179311, "n": 10604, "h": "3c458723e767da73", "c": "package app.nock.android.voice\n\nimport android.content.Context\nimport app.nock.android.R\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.Schedule\nimport com.squareup.moshi.JsonAdapter\nimport com.squareup.moshi.Moshi\nimport com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport java.time.DayOfWeek\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\nsealed class DeepSeekParseResult {\n data class Ok(\n val name: String?,\n val schedule: Schedule,\n val groupHint: String?,\n ) : DeepSeekParseResult()\n object NotConfigured : DeepSeekParseResult()\n data class Failed(val message: String, val transient: Boolean = false) : DeepSeekParseResult()\n}\n\n@Singleton\nclass DeepSeekReminderParser @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val deepSeek: DeepSeekClient,\n private val settings: SettingsRepository,\n) {\n private val moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()\n private val specAdapter: JsonAdapter<VoiceAlarmSpec> =\n moshi.adapter(VoiceAlarmSpec::class.java).lenient()\n\n suspend fun isConfigured(): Boolean =\n !settings.get(SettingsRepository.KEY_DEEPSEEK_API_KEY).isNullOrBlank()\n\n suspend fun parse(text: String, groups: List<Group>): DeepSeekParseResult {\n val trimmed = text.trim()\n if (trimmed.isEmpty()) return DeepSeekParseResult.Failed(ctx.getString(R.string.voice_error_empty_transcript))\n if (groups.isEmpty()) return DeepSeekParseResult.Failed(ctx.getString(R.string.voice_error_no_groups))\n if (!isConfigured()) return DeepSeekParseResult.NotConfigured\n\n val now = LocalDateTime.now()\n val zone = ZoneId.systemDefault()\n val system = buildSystemPrompt(now, zone, groups)\n\n return when (val r = deepSeek.complete(system, trimmed, jsonMode = true)) {\n is DeepSeekResult.Error -> DeepSeekParseResult.Failed(r.message, transient = r.transient)\n is DeepSeekResult.Ok -> buildResult(r.content, now, zone)\n }\n }\n\n private fun buildResult(json: String, now: LocalDateTime, zone: ZoneId): DeepSeekParseResult {\n val spec = runCatching { specAdapter.fromJson(json) }.getOrNull()\n ?: return DeepSeekParseResult.Failed(ctx.getString(R.string.voice_error_bad_json))\n val schedule = runCatching { toSchedule(spec, now, zone) }.getOrElse { t ->\n return DeepSeekParseResult.Failed(t.message ?: ctx.getString(R.string.voice_error_bad_schedule))\n }\n return DeepSeekParseResult.Ok(\n name = spec.name?.trim()?.takeIf { it.isNotBlank() },\n schedule = schedule,\n groupHint = spec.groupHint?.trim()?.takeIf { it.isNotBlank() },\n )\n }\n\n private fun toSchedule(spec: VoiceAlarmSpec, now: LocalDateTime, zone: ZoneId): Schedule {\n val type = spec.scheduleType?.uppercase()?.replace('-', '_') ?: \"ONESHOT\"\n return when (type) {\n \"ONESHOT\", \"ONCE\", \"ONE_SHOT\" -> {\n val iso = spec.oneShotIso ?: error(ctx.getString(R.string.voice_error_missing_field, \"oneShotIso\"))\n val dt = parseIso(iso, now)\n Schedule.OneShot(dt.atZone(zone).toInstant().toEpochMilli())\n }\n \"DAILY\" -> Schedule.Daily(parseTimesOfDay(spec.timesOfDay))\n \"WEEKLY\" -> Schedule.Weekly(\n daysOfWeek = parseDaysOfWeek(spec.daysOfWeek),\n timesOfDayMinutes = parseTimesOfDay(spec.timesOfDay)\n )\n \"MONTHLY\" -> Schedule.Monthly(\n dayOfMonth = spec.dayOfMonth?.coerceIn(1, 31)\n ?: error(ctx.getString(R.string.voice_error_missing_field, \"dayOfMonth\")),\n timeOfDayMinutes = parseSingleTime(spec.timeOfDay)\n )\n \"INTERVAL\", \"INTERVAL_FROM_LAST\" -> {\n val mins = spec.intervalMinutes?.coerceAtLeast(1)\n ?: error(ctx.getString(R.string.voice_error_missing_field, \"intervalMinutes\"))\n Schedule.IntervalFromLast(mins * 60_000L)\n }\n \"ON_UNLOCK\", \"ONUNLOCK\" -> Schedule.OnUnlock(System.currentTimeMillis())\n else -> error(ctx.getString(R.string.voice_error_unknown_schedule, type))\n }\n }\n\n private fun parseIso(iso: String, now: LocalDateTime): LocalDateTime {\n runCatching { LocalDateTime.parse(iso) }.getOrNull()?.let { return it }\n runCatching { LocalDateTime.parse(iso, DateTimeFormatter.ISO_DATE_TIME) }.getOrNull()?.let { return it }\n runCatching { java.time.LocalDate.parse(iso).atStartOfDay() }.getOrNull()?.let { return it }\n return now\n }\n\n private fun parseTimesOfDay(values: List<String>?): List<Int> {\n val list = values?.mapNotNull { parseTimeStringOrNull(it) }?.distinct()?.sorted().orEmpty()\n if (list.isEmpty()) error(ctx.getString(R.string.voice_error_missing_field, \"timesOfDay\"))\n return list\n }\n\n private fun parseSingleTime(value: String?): Int {\n return value?.let { parseTimeStringOrNull(it) }\n ?: error(ctx.getString(R.string.voice_error_missing_field, \"timeOfDay\"))\n }\n\n private fun parseTimeStringOrNull(s: String): Int? {\n val parts = s.trim().split(\":\")\n if (parts.size < 2) return null\n val h = parts[0].toIntOrNull() ?: return null\n val m = parts[1].toIntOrNull() ?: return null\n if (h !in 0..23 || m !in 0..59) return null\n return h * 60 + m\n }\n\n private fun parseDaysOfWeek(values: List<String>?): Set<DayOfWeek> {\n val out = values?.mapNotNull { runCatching { DayOfWeek.valueOf(it.uppercase()) }.getOrNull() }?.toSet().orEmpty()\n if (out.isEmpty()) error(ctx.getString(R.string.voice_error_missing_field, \"daysOfWeek\"))\n return out\n }\n\n private fun buildSystemPrompt(now: LocalDateTime, zone: ZoneId, groups: List<Group>): String {\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd'T'HH:mm:ss\")\n val dow = now.dayOfWeek.name\n val groupCatalog = groups.joinToString(\"\\n\") { g ->\n val desc = SEED_GROUP_DESCRIPTIONS[g.seedKey]\n if (desc != null) \" - \\\"${g.name}\\\" — $desc\" else \" - \\\"${g.name}\\\" (custom group)\"\n }\n return \"\"\"\n You convert a spoken or typed phrase into a JSON specification for a single reminder/alarm.\n\n Output ONLY a single JSON object, no prose, with these fields (only include fields relevant to the chosen scheduleType):\n - name (string, required): a short imperative reminder label, max 60 chars. Do not include time-of-day.\n - scheduleType (string, required): one of ONESHOT, DAILY, WEEKLY, MONTHLY, INTERVAL, ON_UNLOCK.\n - oneShotIso (string, required when scheduleType=ONESHOT): local datetime in ISO format YYYY-MM-DDTHH:mm:ss (no zone suffix, interpreted in the user's local zone).\n - timesOfDay (array of \"HH:mm\" strings, required for DAILY and WEEKLY): 24-hour clock.\n - daysOfWeek (array of strings, required for WEEKLY): MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY.\n - dayOfMonth (integer 1-31, required for MONTHLY).\n - timeOfDay (\"HH:mm\" string, required for MONTHLY): 24-hour clock.\n - intervalMinutes (integer >= 1, required for INTERVAL): minutes between firings, measured from the user's last \"Done\".\n - groupHint (string, optional): MUST exactly match one of the group names listed below (verbatim, case-insensitive).\n\n Schedule rules:\n - Prefer ONESHOT for an unambiguous single event (\"tomorrow at 3pm\", \"in 20 minutes\").\n - Prefer DAILY / WEEKLY when the user says \"every day\", \"each morning\", \"every Monday and Wednesday\", etc.\n - Prefer INTERVAL when the user says \"every 4 hours\" or \"every 30 minutes\" (the time between firings, not a fixed time of day).\n - Prefer ON_UNLOCK only when the user explicitly says something like \"next time I unlock my phone\".\n - \"in N minutes/hours\" => ONESHOT, oneShotIso = now + that duration.\n - Times like \"8\" / \"8pm\" / \"20:00\" => \"20:00\" (HH:mm).\n - All times are interpreted in the user's local time zone.\n\n Group selection (be careful — this is often the wrong field):\n - Before emitting JSON, think about the SUBJECT of the reminder (what is being done, for whom/what), not the wording.\n - Match that subject to the best group from the catalog below using the descriptions, not just name overlap.\n - Heuristics: take a pill / vitamin / insulin / inhaler => meds. Walk / feed / vet / groom an animal => pets.\n Stretch / drink water / meditate / sleep / hygiene / journal => self-care.\n Buy / pick up / shop / appointment / pharmacy / bank => errands.\n Vacuum / laundry / dishes / trash / water plants / bills => household.\n Meeting / call / deadline / report / email / study => work.\n - If the user explicitly names a group (\"add to Pets\"), use that name verbatim and ignore the heuristics.\n - If no group clearly fits, OMIT groupHint entirely — do NOT pick a random one and do NOT invent a name.\n\n Context:\n - Current local datetime: ${now.format(fmt)} (${zone.id})\n - Day of week today: $dow\n - Available groups:\n$groupCatalog\n \"\"\".trimIndent()\n }\n\n companion object {\n private val SEED_GROUP_DESCRIPTIONS = mapOf(\n \"pets\" to \"anything about pets or animals: feeding, walking, vet visits, grooming, litter box, cage cleaning.\",\n \"meds\" to \"medication and supplements: taking pills, vitamins, insulin, inhaler, prescription refills, doctor-prescribed treatments.\",\n \"selfcare\" to \"personal wellness and habits: exercise, stretching, hydration, meditation, hygiene, skincare, sleep, journaling.\",\n \"household\" to \"chores done at home: cleaning, laundry, dishes, trash, watering plants, repairs, recurring home bills.\",\n \"work\" to \"job and study tasks: meetings, calls, deadlines, projects, emails, reports, classes.\",\n \"errands\" to \"tasks done outside the home: shopping, pickups, appointments, deliveries, banking, post office.\",\n )\n }\n}\n"}, "73e7ee4eb7e831a00e6e1dfaa0dfb8389da6b812": {"t": 1785755949.4370847, "n": 10547, "h": "c905dbc633aa8b3d", "c": "package app.nock.android.voice\n\nimport android.content.Context\nimport app.nock.android.R\nimport app.nock.android.data.SettingsRepository\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.Schedule\nimport com.squareup.moshi.JsonAdapter\nimport com.squareup.moshi.Moshi\nimport com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport java.time.DayOfWeek\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\nsealed class DeepSeekParseResult {\n data class Ok(\n val name: String?,\n val schedule: Schedule,\n val groupHint: String?,\n ) : DeepSeekParseResult()\n object NotConfigured : DeepSeekParseResult()\n data class Failed(val message: String) : DeepSeekParseResult()\n}\n\n@Singleton\nclass DeepSeekReminderParser @Inject constructor(\n @ApplicationContext private val ctx: Context,\n private val deepSeek: DeepSeekClient,\n private val settings: SettingsRepository,\n) {\n private val moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()\n private val specAdapter: JsonAdapter<VoiceAlarmSpec> =\n moshi.adapter(VoiceAlarmSpec::class.java).lenient()\n\n suspend fun isConfigured(): Boolean =\n !settings.get(SettingsRepository.KEY_DEEPSEEK_API_KEY).isNullOrBlank()\n\n suspend fun parse(text: String, groups: List<Group>): DeepSeekParseResult {\n val trimmed = text.trim()\n if (trimmed.isEmpty()) return DeepSeekParseResult.Failed(ctx.getString(R.string.voice_error_empty_transcript))\n if (groups.isEmpty()) return DeepSeekParseResult.Failed(ctx.getString(R.string.voice_error_no_groups))\n if (!isConfigured()) return DeepSeekParseResult.NotConfigured\n\n val now = LocalDateTime.now()\n val zone = ZoneId.systemDefault()\n val system = buildSystemPrompt(now, zone, groups)\n\n return when (val r = deepSeek.complete(system, trimmed, jsonMode = true)) {\n is DeepSeekResult.Error -> DeepSeekParseResult.Failed(r.message)\n is DeepSeekResult.Ok -> buildResult(r.content, now, zone)\n }\n }\n\n private fun buildResult(json: String, now: LocalDateTime, zone: ZoneId): DeepSeekParseResult {\n val spec = runCatching { specAdapter.fromJson(json) }.getOrNull()\n ?: return DeepSeekParseResult.Failed(ctx.getString(R.string.voice_error_bad_json))\n val schedule = runCatching { toSchedule(spec, now, zone) }.getOrElse { t ->\n return DeepSeekParseResult.Failed(t.message ?: ctx.getString(R.string.voice_error_bad_schedule))\n }\n return DeepSeekParseResult.Ok(\n name = spec.name?.trim()?.takeIf { it.isNotBlank() },\n schedule = schedule,\n groupHint = spec.groupHint?.trim()?.takeIf { it.isNotBlank() },\n )\n }\n\n private fun toSchedule(spec: VoiceAlarmSpec, now: LocalDateTime, zone: ZoneId): Schedule {\n val type = spec.scheduleType?.uppercase()?.replace('-', '_') ?: \"ONESHOT\"\n return when (type) {\n \"ONESHOT\", \"ONCE\", \"ONE_SHOT\" -> {\n val iso = spec.oneShotIso ?: error(ctx.getString(R.string.voice_error_missing_field, \"oneShotIso\"))\n val dt = parseIso(iso, now)\n Schedule.OneShot(dt.atZone(zone).toInstant().toEpochMilli())\n }\n \"DAILY\" -> Schedule.Daily(parseTimesOfDay(spec.timesOfDay))\n \"WEEKLY\" -> Schedule.Weekly(\n daysOfWeek = parseDaysOfWeek(spec.daysOfWeek),\n timesOfDayMinutes = parseTimesOfDay(spec.timesOfDay)\n )\n \"MONTHLY\" -> Schedule.Monthly(\n dayOfMonth = spec.dayOfMonth?.coerceIn(1, 31)\n ?: error(ctx.getString(R.string.voice_error_missing_field, \"dayOfMonth\")),\n timeOfDayMinutes = parseSingleTime(spec.timeOfDay)\n )\n \"INTERVAL\", \"INTERVAL_FROM_LAST\" -> {\n val mins = spec.intervalMinutes?.coerceAtLeast(1)\n ?: error(ctx.getString(R.string.voice_error_missing_field, \"intervalMinutes\"))\n Schedule.IntervalFromLast(mins * 60_000L)\n }\n \"ON_UNLOCK\", \"ONUNLOCK\" -> Schedule.OnUnlock(System.currentTimeMillis())\n else -> error(ctx.getString(R.string.voice_error_unknown_schedule, type))\n }\n }\n\n private fun parseIso(iso: String, now: LocalDateTime): LocalDateTime {\n runCatching { LocalDateTime.parse(iso) }.getOrNull()?.let { return it }\n runCatching { LocalDateTime.parse(iso, DateTimeFormatter.ISO_DATE_TIME) }.getOrNull()?.let { return it }\n runCatching { java.time.LocalDate.parse(iso).atStartOfDay() }.getOrNull()?.let { return it }\n return now\n }\n\n private fun parseTimesOfDay(values: List<String>?): List<Int> {\n val list = values?.mapNotNull { parseTimeStringOrNull(it) }?.distinct()?.sorted().orEmpty()\n if (list.isEmpty()) error(ctx.getString(R.string.voice_error_missing_field, \"timesOfDay\"))\n return list\n }\n\n private fun parseSingleTime(value: String?): Int {\n return value?.let { parseTimeStringOrNull(it) }\n ?: error(ctx.getString(R.string.voice_error_missing_field, \"timeOfDay\"))\n }\n\n private fun parseTimeStringOrNull(s: String): Int? {\n val parts = s.trim().split(\":\")\n if (parts.size < 2) return null\n val h = parts[0].toIntOrNull() ?: return null\n val m = parts[1].toIntOrNull() ?: return null\n if (h !in 0..23 || m !in 0..59) return null\n return h * 60 + m\n }\n\n private fun parseDaysOfWeek(values: List<String>?): Set<DayOfWeek> {\n val out = values?.mapNotNull { runCatching { DayOfWeek.valueOf(it.uppercase()) }.getOrNull() }?.toSet().orEmpty()\n if (out.isEmpty()) error(ctx.getString(R.string.voice_error_missing_field, \"daysOfWeek\"))\n return out\n }\n\n private fun buildSystemPrompt(now: LocalDateTime, zone: ZoneId, groups: List<Group>): String {\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd'T'HH:mm:ss\")\n val dow = now.dayOfWeek.name\n val groupCatalog = groups.joinToString(\"\\n\") { g ->\n val desc = SEED_GROUP_DESCRIPTIONS[g.seedKey]\n if (desc != null) \" - \\\"${g.name}\\\" — $desc\" else \" - \\\"${g.name}\\\" (custom group)\"\n }\n return \"\"\"\n You convert a spoken or typed phrase into a JSON specification for a single reminder/alarm.\n\n Output ONLY a single JSON object, no prose, with these fields (only include fields relevant to the chosen scheduleType):\n - name (string, required): a short imperative reminder label, max 60 chars. Do not include time-of-day.\n - scheduleType (string, required): one of ONESHOT, DAILY, WEEKLY, MONTHLY, INTERVAL, ON_UNLOCK.\n - oneShotIso (string, required when scheduleType=ONESHOT): local datetime in ISO format YYYY-MM-DDTHH:mm:ss (no zone suffix, interpreted in the user's local zone).\n - timesOfDay (array of \"HH:mm\" strings, required for DAILY and WEEKLY): 24-hour clock.\n - daysOfWeek (array of strings, required for WEEKLY): MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY.\n - dayOfMonth (integer 1-31, required for MONTHLY).\n - timeOfDay (\"HH:mm\" string, required for MONTHLY): 24-hour clock.\n - intervalMinutes (integer >= 1, required for INTERVAL): minutes between firings, measured from the user's last \"Done\".\n - groupHint (string, optional): MUST exactly match one of the group names listed below (verbatim, case-insensitive).\n\n Schedule rules:\n - Prefer ONESHOT for an unambiguous single event (\"tomorrow at 3pm\", \"in 20 minutes\").\n - Prefer DAILY / WEEKLY when the user says \"every day\", \"each morning\", \"every Monday and Wednesday\", etc.\n - Prefer INTERVAL when the user says \"every 4 hours\" or \"every 30 minutes\" (the time between firings, not a fixed time of day).\n - Prefer ON_UNLOCK only when the user explicitly says something like \"next time I unlock my phone\".\n - \"in N minutes/hours\" => ONESHOT, oneShotIso = now + that duration.\n - Times like \"8\" / \"8pm\" / \"20:00\" => \"20:00\" (HH:mm).\n - All times are interpreted in the user's local time zone.\n\n Group selection (be careful — this is often the wrong field):\n - Before emitting JSON, think about the SUBJECT of the reminder (what is being done, for whom/what), not the wording.\n - Match that subject to the best group from the catalog below using the descriptions, not just name overlap.\n - Heuristics: take a pill / vitamin / insulin / inhaler => meds. Walk / feed / vet / groom an animal => pets.\n Stretch / drink water / meditate / sleep / hygiene / journal => self-care.\n Buy / pick up / shop / appointment / pharmacy / bank => errands.\n Vacuum / laundry / dishes / trash / water plants / bills => household.\n Meeting / call / deadline / report / email / study => work.\n - If the user explicitly names a group (\"add to Pets\"), use that name verbatim and ignore the heuristics.\n - If no group clearly fits, OMIT groupHint entirely — do NOT pick a random one and do NOT invent a name.\n\n Context:\n - Current local datetime: ${now.format(fmt)} (${zone.id})\n - Day of week today: $dow\n - Available groups:\n$groupCatalog\n \"\"\".trimIndent()\n }\n\n companion object {\n private val SEED_GROUP_DESCRIPTIONS = mapOf(\n \"pets\" to \"anything about pets or animals: feeding, walking, vet visits, grooming, litter box, cage cleaning.\",\n \"meds\" to \"medication and supplements: taking pills, vitamins, insulin, inhaler, prescription refills, doctor-prescribed treatments.\",\n \"selfcare\" to \"personal wellness and habits: exercise, stretching, hydration, meditation, hygiene, skincare, sleep, journaling.\",\n \"household\" to \"chores done at home: cleaning, laundry, dishes, trash, watering plants, repairs, recurring home bills.\",\n \"work\" to \"job and study tasks: meetings, calls, deadlines, projects, emails, reports, classes.\",\n \"errands\" to \"tasks done outside the home: shopping, pickups, appointments, deliveries, banking, post office.\",\n )\n }\n}\n"}}, "app/src/main/java/app/nock/android/voice/DeepSeekClient.kt": {"62435763959e697b61607d4ea76b9ca03d580950": {"t": 1785745731.7935064, "n": 4241, "h": "8eff9f7556d16d5f", "c": "package app.nock.android.voice\n\nimport app.nock.android.data.SettingsRepository\nimport com.squareup.moshi.JsonAdapter\nimport com.squareup.moshi.Moshi\nimport com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.withContext\nimport okhttp3.MediaType.Companion.toMediaType\nimport okhttp3.OkHttpClient\nimport okhttp3.Request\nimport okhttp3.RequestBody.Companion.toRequestBody\nimport java.io.IOException\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\nsealed class DeepSeekResult {\n data class Ok(val content: String) : DeepSeekResult()\n data class Error(val message: String, val transient: Boolean = false) : DeepSeekResult()\n}\n\ndata class DeepSeekMessage(val role: String, val content: String)\n\ndata class DeepSeekResponseFormat(val type: String)\n\ndata class DeepSeekRequest(\n val model: String,\n val messages: List<DeepSeekMessage>,\n val temperature: Double = 0.1,\n val response_format: DeepSeekResponseFormat? = null\n)\n\ndata class DeepSeekChoice(val message: DeepSeekMessage?)\n\ndata class DeepSeekResponse(val choices: List<DeepSeekChoice>?)\n\n@Singleton\nclass DeepSeekClient @Inject constructor(\n private val client: OkHttpClient,\n private val settings: SettingsRepository\n) {\n private val moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()\n private val requestAdapter: JsonAdapter<DeepSeekRequest> = moshi.adapter(DeepSeekRequest::class.java)\n private val responseAdapter: JsonAdapter<DeepSeekResponse> = moshi.adapter(DeepSeekResponse::class.java)\n\n suspend fun complete(\n systemPrompt: String,\n userPrompt: String,\n jsonMode: Boolean = true\n ): DeepSeekResult = withContext(Dispatchers.IO) {\n val apiKey = settings.get(SettingsRepository.KEY_DEEPSEEK_API_KEY)?.takeIf { it.isNotBlank() }\n ?: return@withContext DeepSeekResult.Error(\"DeepSeek API key not set\")\n val model = settings.get(SettingsRepository.KEY_DEEPSEEK_MODEL)?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_MODEL\n val baseUrl = settings.get(SettingsRepository.KEY_DEEPSEEK_BASE_URL)?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL\n\n val req = DeepSeekRequest(\n model = model,\n messages = listOf(\n DeepSeekMessage(\"system\", systemPrompt),\n DeepSeekMessage(\"user\", userPrompt)\n ),\n response_format = if (jsonMode) DeepSeekResponseFormat(\"json_object\") else null\n )\n val bodyJson = requestAdapter.toJson(req)\n val url = baseUrl.trimEnd('/') + \"/v1/chat/completions\"\n val request = Request.Builder()\n .url(url)\n .addHeader(\"Authorization\", \"Bearer $apiKey\")\n .addHeader(\"Content-Type\", \"application/json\")\n .post(bodyJson.toRequestBody(\"application/json\".toMediaType()))\n .build()\n try {\n client.newCall(request).execute().use { resp ->\n val raw = resp.body?.string().orEmpty()\n if (!resp.isSuccessful) {\n val transient = resp.code == 429 || resp.code in 500..599\n return@withContext DeepSeekResult.Error(\n \"HTTP ${resp.code}: ${raw.take(200)}\",\n transient = transient\n )\n }\n val parsed = runCatching { responseAdapter.fromJson(raw) }.getOrNull()\n val content = parsed?.choices?.firstOrNull()?.message?.content\n if (content.isNullOrBlank()) {\n DeepSeekResult.Error(\"Empty response from model\")\n } else {\n DeepSeekResult.Ok(content)\n }\n }\n } catch (t: Throwable) {\n // SocketTimeoutException, ConnectException, UnknownHostException, etc. are all IOException —\n // these are the things worth retrying. Anything else (programming error) we surface as terminal.\n DeepSeekResult.Error(\n t.message ?: t::class.simpleName ?: \"unknown\",\n transient = t is IOException\n )\n }\n }\n}\n"}, "5bcfdf8534dce79e3ee41ab5de3e9655aba55d22": {"t": 1785755941.3396885, "n": 4241, "h": "8eff9f7556d16d5f", "c": "package app.nock.android.voice\n\nimport app.nock.android.data.SettingsRepository\nimport com.squareup.moshi.JsonAdapter\nimport com.squareup.moshi.Moshi\nimport com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.withContext\nimport okhttp3.MediaType.Companion.toMediaType\nimport okhttp3.OkHttpClient\nimport okhttp3.Request\nimport okhttp3.RequestBody.Companion.toRequestBody\nimport java.io.IOException\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\nsealed class DeepSeekResult {\n data class Ok(val content: String) : DeepSeekResult()\n data class Error(val message: String, val transient: Boolean = false) : DeepSeekResult()\n}\n\ndata class DeepSeekMessage(val role: String, val content: String)\n\ndata class DeepSeekResponseFormat(val type: String)\n\ndata class DeepSeekRequest(\n val model: String,\n val messages: List<DeepSeekMessage>,\n val temperature: Double = 0.1,\n val response_format: DeepSeekResponseFormat? = null\n)\n\ndata class DeepSeekChoice(val message: DeepSeekMessage?)\n\ndata class DeepSeekResponse(val choices: List<DeepSeekChoice>?)\n\n@Singleton\nclass DeepSeekClient @Inject constructor(\n private val client: OkHttpClient,\n private val settings: SettingsRepository\n) {\n private val moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()\n private val requestAdapter: JsonAdapter<DeepSeekRequest> = moshi.adapter(DeepSeekRequest::class.java)\n private val responseAdapter: JsonAdapter<DeepSeekResponse> = moshi.adapter(DeepSeekResponse::class.java)\n\n suspend fun complete(\n systemPrompt: String,\n userPrompt: String,\n jsonMode: Boolean = true\n ): DeepSeekResult = withContext(Dispatchers.IO) {\n val apiKey = settings.get(SettingsRepository.KEY_DEEPSEEK_API_KEY)?.takeIf { it.isNotBlank() }\n ?: return@withContext DeepSeekResult.Error(\"DeepSeek API key not set\")\n val model = settings.get(SettingsRepository.KEY_DEEPSEEK_MODEL)?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_MODEL\n val baseUrl = settings.get(SettingsRepository.KEY_DEEPSEEK_BASE_URL)?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL\n\n val req = DeepSeekRequest(\n model = model,\n messages = listOf(\n DeepSeekMessage(\"system\", systemPrompt),\n DeepSeekMessage(\"user\", userPrompt)\n ),\n response_format = if (jsonMode) DeepSeekResponseFormat(\"json_object\") else null\n )\n val bodyJson = requestAdapter.toJson(req)\n val url = baseUrl.trimEnd('/') + \"/v1/chat/completions\"\n val request = Request.Builder()\n .url(url)\n .addHeader(\"Authorization\", \"Bearer $apiKey\")\n .addHeader(\"Content-Type\", \"application/json\")\n .post(bodyJson.toRequestBody(\"application/json\".toMediaType()))\n .build()\n try {\n client.newCall(request).execute().use { resp ->\n val raw = resp.body?.string().orEmpty()\n if (!resp.isSuccessful) {\n val transient = resp.code == 429 || resp.code in 500..599\n return@withContext DeepSeekResult.Error(\n \"HTTP ${resp.code}: ${raw.take(200)}\",\n transient = transient\n )\n }\n val parsed = runCatching { responseAdapter.fromJson(raw) }.getOrNull()\n val content = parsed?.choices?.firstOrNull()?.message?.content\n if (content.isNullOrBlank()) {\n DeepSeekResult.Error(\"Empty response from model\")\n } else {\n DeepSeekResult.Ok(content)\n }\n }\n } catch (t: Throwable) {\n // SocketTimeoutException, ConnectException, UnknownHostException, etc. are all IOException —\n // these are the things worth retrying. Anything else (programming error) we surface as terminal.\n DeepSeekResult.Error(\n t.message ?: t::class.simpleName ?: \"unknown\",\n transient = t is IOException\n )\n }\n }\n}\n"}, "3b3cd08ff3dc89e0390749b24f1a60bbe660bc87": {"t": 1785755942.6594346, "n": 3719, "h": "ad6ac736a2b8b5dc", "c": "package app.nock.android.voice\n\nimport app.nock.android.data.SettingsRepository\nimport com.squareup.moshi.JsonAdapter\nimport com.squareup.moshi.Moshi\nimport com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.withContext\nimport okhttp3.MediaType.Companion.toMediaType\nimport okhttp3.OkHttpClient\nimport okhttp3.Request\nimport okhttp3.RequestBody.Companion.toRequestBody\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\nsealed class DeepSeekResult {\n data class Ok(val content: String) : DeepSeekResult()\n data class Error(val message: String) : DeepSeekResult()\n}\n\ndata class DeepSeekMessage(val role: String, val content: String)\n\ndata class DeepSeekResponseFormat(val type: String)\n\ndata class DeepSeekRequest(\n val model: String,\n val messages: List<DeepSeekMessage>,\n val temperature: Double = 0.1,\n val response_format: DeepSeekResponseFormat? = null\n)\n\ndata class DeepSeekChoice(val message: DeepSeekMessage?)\n\ndata class DeepSeekResponse(val choices: List<DeepSeekChoice>?)\n\n@Singleton\nclass DeepSeekClient @Inject constructor(\n private val client: OkHttpClient,\n private val settings: SettingsRepository\n) {\n private val moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()\n private val requestAdapter: JsonAdapter<DeepSeekRequest> = moshi.adapter(DeepSeekRequest::class.java)\n private val responseAdapter: JsonAdapter<DeepSeekResponse> = moshi.adapter(DeepSeekResponse::class.java)\n\n suspend fun complete(\n systemPrompt: String,\n userPrompt: String,\n jsonMode: Boolean = true\n ): DeepSeekResult = withContext(Dispatchers.IO) {\n val apiKey = settings.get(SettingsRepository.KEY_DEEPSEEK_API_KEY)?.takeIf { it.isNotBlank() }\n ?: return@withContext DeepSeekResult.Error(\"DeepSeek API key not set\")\n val model = settings.get(SettingsRepository.KEY_DEEPSEEK_MODEL)?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_MODEL\n val baseUrl = settings.get(SettingsRepository.KEY_DEEPSEEK_BASE_URL)?.takeIf { it.isNotBlank() }\n ?: SettingsRepository.DEFAULT_DEEPSEEK_BASE_URL\n\n val req = DeepSeekRequest(\n model = model,\n messages = listOf(\n DeepSeekMessage(\"system\", systemPrompt),\n DeepSeekMessage(\"user\", userPrompt)\n ),\n response_format = if (jsonMode) DeepSeekResponseFormat(\"json_object\") else null\n )\n val bodyJson = requestAdapter.toJson(req)\n val url = baseUrl.trimEnd('/') + \"/v1/chat/completions\"\n val request = Request.Builder()\n .url(url)\n .addHeader(\"Authorization\", \"Bearer $apiKey\")\n .addHeader(\"Content-Type\", \"application/json\")\n .post(bodyJson.toRequestBody(\"application/json\".toMediaType()))\n .build()\n try {\n client.newCall(request).execute().use { resp ->\n val raw = resp.body?.string().orEmpty()\n if (!resp.isSuccessful) {\n return@withContext DeepSeekResult.Error(\"HTTP ${resp.code}: ${raw.take(200)}\")\n }\n val parsed = runCatching { responseAdapter.fromJson(raw) }.getOrNull()\n val content = parsed?.choices?.firstOrNull()?.message?.content\n if (content.isNullOrBlank()) {\n DeepSeekResult.Error(\"Empty response from model\")\n } else {\n DeepSeekResult.Ok(content)\n }\n }\n } catch (t: Throwable) {\n DeepSeekResult.Error(t.message ?: t::class.simpleName ?: \"unknown\")\n }\n }\n}\n"}}, "app/src/main/java/app/nock/android/ui/settings/SettingsScreen.kt": {"62435763959e697b61607d4ea76b9ca03d580950": {"t": 1785745733.2489464, "n": 42818, "h": "0deb383cd65617f4", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.media.RingtoneManager\nimport android.os.Build\nimport android.util.Log\nimport androidx.activity.compose.BackHandler\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.ArrowBack\nimport androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.material.icons.filled.Build\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.automirrored.filled.Send\nimport androidx.compose.material.icons.outlined.BugReport\nimport androidx.compose.material.icons.outlined.CalendarMonth\nimport androidx.compose.material.icons.outlined.CloudSync\nimport androidx.compose.material.icons.outlined.Hub\nimport androidx.compose.material.icons.outlined.Notifications\nimport androidx.compose.material.icons.outlined.Psychology\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.compose.material.icons.outlined.Tune\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.ui.components.ChainEditor\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n/** Settings category routes, used both for navigation and for the landing list. */\nobject SettingsCategory {\n const val GENERAL = \"general\"\n const val NOTIFICATIONS = \"notifications\"\n const val INTEGRATIONS = \"integrations\"\n const val DIAGNOSTICS = \"diagnostics\"\n const val DEBUG = \"debug\"\n}\n\n/** Per-integration routes, used by the Integrations landing list and navigation. */\nobject IntegrationCategory {\n const val TELEGRAM = \"telegram\"\n const val CALENDAR = \"calendar\"\n const val DEEPSEEK = \"deepseek\"\n const val DRIVE = \"drive\"\n}\n\n/**\n * Settings landing screen: a short list of categories that each drill into their\n * own screen — the standard Android settings pattern, rather than one long scroll.\n */\n@Composable\nfun SettingsScreen(\n onOpenCategory: (String) -> Unit = {},\n onEditGroup: (Long) -> Unit = {},\n vm: SettingsViewModel = hiltViewModel(),\n) {\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState())\n ) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n CategoryRow(\n icon = Icons.Outlined.Tune,\n title = stringResource(R.string.settings_cat_general),\n subtitle = stringResource(R.string.settings_cat_general_sub),\n onClick = { onOpenCategory(SettingsCategory.GENERAL) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Notifications,\n title = stringResource(R.string.settings_cat_notifications),\n subtitle = stringResource(R.string.settings_cat_notifications_sub),\n onClick = { onOpenCategory(SettingsCategory.NOTIFICATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Hub,\n title = stringResource(R.string.settings_cat_integrations),\n subtitle = stringResource(R.string.settings_cat_integrations_sub),\n onClick = { onOpenCategory(SettingsCategory.INTEGRATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.BugReport,\n title = stringResource(R.string.settings_cat_diagnostics),\n subtitle = stringResource(R.string.settings_cat_diagnostics_sub),\n onClick = { onOpenCategory(SettingsCategory.DIAGNOSTICS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Filled.Build,\n title = stringResource(R.string.settings_cat_debug),\n subtitle = stringResource(R.string.settings_cat_debug_sub),\n onClick = { onOpenCategory(SettingsCategory.DEBUG) }\n )\n }\n }\n }\n}\n\n@Composable\nprivate fun CategoryRow(\n icon: ImageVector,\n title: String,\n subtitle: String,\n onClick: () -> Unit,\n) {\n ListItem(\n headlineContent = { Text(title) },\n supportingContent = { Text(subtitle) },\n leadingContent = {\n Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)\n },\n trailingContent = {\n Icon(\n Icons.AutoMirrored.Filled.KeyboardArrowRight,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.outline\n )\n },\n colors = ListItemDefaults.colors(containerColor = Color.Transparent),\n modifier = Modifier.clickable(onClick = onClick)\n )\n}\n\n/** Shared scaffold for a settings category sub-screen: title + back arrow + scrolling content. */\n@Composable\nprivate fun CategoryScaffold(\n title: String,\n onBack: () -> Unit,\n content: @Composable ColumnScope.() -> Unit,\n) {\n BackHandler(onBack = onBack)\n Scaffold(\n topBar = {\n TopAppBar(\n title = { Text(title) },\n navigationIcon = {\n IconButton(onClick = onBack) {\n Icon(\n Icons.AutoMirrored.Filled.ArrowBack,\n contentDescription = stringResource(R.string.back)\n )\n }\n }\n )\n }\n ) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp),\n content = content\n )\n }\n}\n\n@Composable\nfun GeneralSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_general), onBack) {\n LanguageSection(vm)\n UpdateSection()\n }\n}\n\n@Composable\nfun NotificationsSettingsScreen(\n onBack: () -> Unit,\n onEditGroup: (Long) -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n // Editing the global chain saves per-keystroke; apply it to already-armed\n // reminders once, when this screen leaves composition (back, system back, or\n // navigating into a group editor — the re-arm is idempotent either way).\n DisposableEffect(Unit) {\n onDispose { vm.applyChainEditsIfDirty() }\n }\n CategoryScaffold(stringResource(R.string.settings_cat_notifications), onBack) {\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n PreAlarmSoundSection(state.preAlarmSoundUri, vm)\n GroupsSection(state.groups, onEditGroup, onAddGroup = { onEditGroup(0L) })\n }\n}\n\n@Composable\nprivate fun PreAlarmSoundSection(soundUri: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult\n val picked: android.net.Uri? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {\n result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, android.net.Uri::class.java)\n } else {\n @Suppress(\"DEPRECATION\")\n result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)\n }\n // A null picked URI means the user chose \"Silent\".\n vm.setPreAlarmSound(picked?.toString())\n }\n\n val currentLabel = when {\n soundUri == null -> stringResource(R.string.settings_prealarm_sound_default)\n soundUri.isBlank() -> stringResource(R.string.settings_prealarm_sound_silent)\n else -> remember(soundUri) {\n runCatching { RingtoneManager.getRingtone(ctx, android.net.Uri.parse(soundUri))?.getTitle(ctx) }\n .getOrNull()\n } ?: stringResource(R.string.settings_prealarm_sound_custom)\n }\n\n SectionCard(stringResource(R.string.settings_prealarm_sound_title)) {\n Text(\n stringResource(R.string.settings_prealarm_sound_desc),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline,\n )\n Spacer(Modifier.height(8.dp))\n Text(\n stringResource(R.string.settings_prealarm_sound_current, currentLabel),\n style = MaterialTheme.typography.bodyMedium,\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = {\n val existing = soundUri?.takeIf { it.isNotBlank() }?.let { android.net.Uri.parse(it) }\n val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {\n putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION)\n putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, ctx.getString(R.string.settings_prealarm_sound_title))\n putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)\n putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)\n putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))\n putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, existing)\n }\n launcher.launch(intent)\n }) { Text(stringResource(R.string.settings_prealarm_sound_choose)) }\n }\n}\n\n/**\n * Integrations landing screen: each integration drills into its own sub-screen,\n * mirroring the top-level settings landing pattern.\n */\n@Composable\nfun IntegrationsSettingsScreen(\n onBack: () -> Unit,\n onOpenIntegration: (String) -> Unit = {},\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_integrations), onBack) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n CategoryRow(\n icon = Icons.AutoMirrored.Filled.Send,\n title = stringResource(R.string.settings_telegram_title),\n subtitle = stringResource(R.string.settings_int_telegram_sub),\n onClick = { onOpenIntegration(IntegrationCategory.TELEGRAM) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.CalendarMonth,\n title = stringResource(R.string.trips_title),\n subtitle = stringResource(R.string.settings_int_calendar_sub),\n onClick = { onOpenIntegration(IntegrationCategory.CALENDAR) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Psychology,\n title = stringResource(R.string.settings_deepseek_title),\n subtitle = stringResource(R.string.settings_int_deepseek_sub),\n onClick = { onOpenIntegration(IntegrationCategory.DEEPSEEK) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.CloudSync,\n title = stringResource(R.string.settings_drive_title),\n subtitle = stringResource(R.string.settings_int_drive_sub),\n onClick = { onOpenIntegration(IntegrationCategory.DRIVE) }\n )\n }\n }\n}\n\n@Composable\nfun TelegramSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_telegram_title), onBack) {\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n }\n}\n\n@Composable\nfun CalendarSettingsScreen(\n onBack: () -> Unit,\n onOpenManualImport: () -> Unit = {},\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.trips_title), onBack) {\n TripsSection(state, vm, onOpenManualImport)\n }\n}\n\n@Composable\nfun DeepSeekSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_deepseek_title), onBack) {\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, state.deepSeekContext, vm)\n }\n}\n\n@Composable\nfun DriveSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_drive_title), onBack) {\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n }\n}\n\n@Composable\nfun DiagnosticsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_diagnostics), onBack) {\n AlarmHistorySection(vm)\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n val downloadFailedMsg = stringResource(R.string.settings_update_download_failed)\n SectionCard(stringResource(R.string.settings_update_title)) {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(stringResource(R.string.settings_update_checking), style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(R.string.settings_update_available), fontWeight = FontWeight.SemiBold)\n Text(\n stringResource(\n R.string.settings_update_build_change,\n s.info.currentVersionCode,\n s.info.remoteVersionCode\n ),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(downloadFailedMsg)\n }\n }\n }) { Text(stringResource(R.string.settings_update_action_update)) }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n stringResource(R.string.settings_update_downloading, (s.progress * 100).toInt()),\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(stringResource(R.string.settings_update_opening_installer), style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n stringResource(R.string.settings_update_up_to_date),\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_recheck)) }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_retry)) }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n // Same editor as the per-group override in the group editor, so the two\n // can't drift apart.\n ChainEditor(chain = chain, onChange = onChange)\n }\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, onEditGroup: (Long) -> Unit, onAddGroup: () -> Unit) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, onClick = { onEditGroup(g.id) })\n HorizontalDivider()\n }\n TextButton(onClick = onAddGroup) {\n Icon(Icons.Filled.Add, contentDescription = null)\n Spacer(Modifier.width(4.dp))\n Text(stringResource(R.string.add))\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, onClick: () -> Unit) {\n Row(\n verticalAlignment = Alignment.CenterVertically,\n modifier = Modifier\n .fillMaxWidth()\n .clickable(onClick = onClick)\n .padding(vertical = 8.dp)\n ) {\n app.nock.android.ui.components.GroupAvatar(g, size = 28.dp)\n Spacer(Modifier.width(12.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = onClick) {\n Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))\n }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun TripsSection(state: SettingsState, vm: SettingsViewModel, onOpenManualImport: () -> Unit = {}) {\n var enabled by remember(state.tripsEnabled) { mutableStateOf(state.tripsEnabled) }\n var key by remember(state.tomtomKey) { mutableStateOf(state.tomtomKey) }\n var home by remember(state.tripHomeAddress) { mutableStateOf(state.tripHomeAddress) }\n var buffer by remember(state.tripBufferMin) { mutableStateOf(state.tripBufferMin.toString()) }\n\n val permLauncher = rememberLauncherForActivityResult(\n ActivityResultContracts.RequestPermission()\n ) { granted -> if (granted) vm.onCalendarPermissionResult() }\n\n fun persist() = vm.setTrips(enabled, key, home, buffer.toIntOrNull() ?: state.tripBufferMin)\n\n SectionCard(stringResource(R.string.trips_title)) {\n Text(\n stringResource(R.string.trips_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(stringResource(R.string.trips_enable), modifier = Modifier.weight(1f))\n Switch(\n checked = enabled,\n onCheckedChange = {\n enabled = it\n if (it && !state.tripHasCalendarPermission) {\n permLauncher.launch(android.Manifest.permission.READ_CALENDAR)\n }\n persist()\n }\n )\n }\n\n if (enabled) {\n Spacer(Modifier.height(12.dp))\n OutlinedTextField(\n value = key,\n onValueChange = { key = it },\n label = { Text(stringResource(R.string.trips_tomtom_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = home,\n onValueChange = { home = it },\n label = { Text(stringResource(R.string.trips_home_address)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = buffer,\n onValueChange = { v -> buffer = v.filter { it.isDigit() }.take(3) },\n label = { Text(stringResource(R.string.trips_buffer_minutes)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(12.dp))\n androidx.compose.foundation.layout.FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { persist() }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { persist(); vm.testRoute() }) {\n Text(stringResource(R.string.trips_test_route))\n }\n OutlinedButton(\n onClick = { persist(); vm.syncTripsNow() },\n enabled = state.tripHasCalendarPermission,\n ) {\n Text(stringResource(R.string.trips_sync_now))\n }\n }\n state.tripStatus?.let {\n Spacer(Modifier.height(8.dp))\n Text(it, style = MaterialTheme.typography.bodySmall)\n }\n\n Spacer(Modifier.height(20.dp))\n TripSyncLogSection(vm)\n\n Spacer(Modifier.height(16.dp))\n if (!state.tripHasCalendarPermission) {\n Text(\n stringResource(R.string.trips_calendar_permission_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { permLauncher.launch(android.Manifest.permission.READ_CALENDAR) }) {\n Text(stringResource(R.string.trips_grant_calendar))\n }\n } else {\n Text(\n stringResource(R.string.trips_calendars_title),\n style = MaterialTheme.typography.bodyMedium,\n fontWeight = FontWeight.SemiBold\n )\n Text(\n stringResource(R.string.trips_calendars_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n // Only the calendars the user explicitly checks are watched;\n // nothing is selected by default.\n val selected = state.tripSelectedCalendarIds\n state.tripCalendars.forEach { cal ->\n Row(verticalAlignment = Alignment.CenterVertically) {\n Checkbox(\n checked = cal.id in selected,\n onCheckedChange = { checked ->\n val next = if (checked) selected + cal.id else selected - cal.id\n vm.setTripCalendars(next)\n }\n )\n Column {\n Text(cal.displayName, style = MaterialTheme.typography.bodyMedium)\n if (cal.accountName.isNotBlank()) {\n Text(\n cal.accountName,\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n }\n }\n }\n\n Spacer(Modifier.height(16.dp))\n HorizontalDivider()\n Spacer(Modifier.height(16.dp))\n Text(\n stringResource(R.string.trips_manual_import_title),\n style = MaterialTheme.typography.bodyMedium,\n fontWeight = FontWeight.SemiBold\n )\n Text(\n stringResource(R.string.trips_manual_import_desc),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = onOpenManualImport) {\n Text(stringResource(R.string.trips_manual_import_open))\n }\n }\n }\n }\n}\n\n/**\n * A copyable diagnostic of the most recent calendar sync, shown right under the\n * Sync button. When an event that should have become an alarm doesn't, the user\n * taps Sync now, then copies this — it lists every event found and why each was\n * or wasn't turned into an alarm.\n */\n@Composable\nprivate fun TripSyncLogSection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n val logText by vm.tripSyncLogText.collectAsState()\n val copiedMsg = stringResource(R.string.trips_synclog_copied)\n\n Text(\n stringResource(R.string.trips_synclog_title),\n style = MaterialTheme.typography.bodyMedium,\n fontWeight = FontWeight.SemiBold\n )\n Text(\n stringResource(R.string.trips_synclog_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(\n onClick = {\n clipboard.setText(AnnotatedString(logText))\n scope.launch { snackbarHostState.showSnackbar(copiedMsg) }\n },\n enabled = logText.isNotBlank()\n ) {\n Text(stringResource(R.string.trips_synclog_copy))\n }\n OutlinedButton(onClick = { vm.clearTripSyncLog() }, enabled = logText.isNotBlank()) {\n Text(stringResource(R.string.trips_synclog_clear))\n }\n }\n Spacer(Modifier.height(8.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 320.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.trips_synclog_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, context: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n var c by remember(context) { mutableStateOf(context) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(16.dp))\n Text(\n stringResource(R.string.settings_deepseek_context_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c,\n onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_deepseek_context)) },\n placeholder = { Text(stringResource(R.string.settings_deepseek_context_placeholder)) },\n singleLine = false,\n minLines = 3,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u, c) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n\n@Composable\nprivate fun AlarmHistorySection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n var logText by remember { mutableStateOf(vm.alarmHistoryDump()) }\n val copiedMsg = stringResource(R.string.settings_alarm_history_copied)\n\n SectionCard(stringResource(R.string.settings_alarm_history_title)) {\n Text(\n stringResource(R.string.settings_alarm_history_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n OutlinedButton(onClick = { logText = vm.alarmHistoryDump() }) {\n Text(stringResource(R.string.settings_alarm_history_refresh))\n }\n Button(onClick = {\n scope.launch {\n val snapshot = vm.alarmHistoryWithCurrentState()\n logText = snapshot\n clipboard.setText(AnnotatedString(snapshot))\n snackbarHostState.showSnackbar(copiedMsg)\n }\n }) {\n Text(stringResource(R.string.settings_alarm_history_copy))\n }\n OutlinedButton(onClick = {\n vm.clearAlarmHistory()\n logText = \"\"\n }) {\n Text(stringResource(R.string.settings_alarm_history_clear))\n }\n }\n Spacer(Modifier.height(12.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 360.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.settings_alarm_history_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n }\n}\n"}, "93d9a58eb32533ad4ec13fa5b3122485d1bc1859": {"t": 1785755943.8232558, "n": 42818, "h": "0deb383cd65617f4", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.media.RingtoneManager\nimport android.os.Build\nimport android.util.Log\nimport androidx.activity.compose.BackHandler\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.ArrowBack\nimport androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.material.icons.filled.Build\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.automirrored.filled.Send\nimport androidx.compose.material.icons.outlined.BugReport\nimport androidx.compose.material.icons.outlined.CalendarMonth\nimport androidx.compose.material.icons.outlined.CloudSync\nimport androidx.compose.material.icons.outlined.Hub\nimport androidx.compose.material.icons.outlined.Notifications\nimport androidx.compose.material.icons.outlined.Psychology\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.compose.material.icons.outlined.Tune\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.ui.components.ChainEditor\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n/** Settings category routes, used both for navigation and for the landing list. */\nobject SettingsCategory {\n const val GENERAL = \"general\"\n const val NOTIFICATIONS = \"notifications\"\n const val INTEGRATIONS = \"integrations\"\n const val DIAGNOSTICS = \"diagnostics\"\n const val DEBUG = \"debug\"\n}\n\n/** Per-integration routes, used by the Integrations landing list and navigation. */\nobject IntegrationCategory {\n const val TELEGRAM = \"telegram\"\n const val CALENDAR = \"calendar\"\n const val DEEPSEEK = \"deepseek\"\n const val DRIVE = \"drive\"\n}\n\n/**\n * Settings landing screen: a short list of categories that each drill into their\n * own screen — the standard Android settings pattern, rather than one long scroll.\n */\n@Composable\nfun SettingsScreen(\n onOpenCategory: (String) -> Unit = {},\n onEditGroup: (Long) -> Unit = {},\n vm: SettingsViewModel = hiltViewModel(),\n) {\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState())\n ) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n CategoryRow(\n icon = Icons.Outlined.Tune,\n title = stringResource(R.string.settings_cat_general),\n subtitle = stringResource(R.string.settings_cat_general_sub),\n onClick = { onOpenCategory(SettingsCategory.GENERAL) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Notifications,\n title = stringResource(R.string.settings_cat_notifications),\n subtitle = stringResource(R.string.settings_cat_notifications_sub),\n onClick = { onOpenCategory(SettingsCategory.NOTIFICATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Hub,\n title = stringResource(R.string.settings_cat_integrations),\n subtitle = stringResource(R.string.settings_cat_integrations_sub),\n onClick = { onOpenCategory(SettingsCategory.INTEGRATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.BugReport,\n title = stringResource(R.string.settings_cat_diagnostics),\n subtitle = stringResource(R.string.settings_cat_diagnostics_sub),\n onClick = { onOpenCategory(SettingsCategory.DIAGNOSTICS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Filled.Build,\n title = stringResource(R.string.settings_cat_debug),\n subtitle = stringResource(R.string.settings_cat_debug_sub),\n onClick = { onOpenCategory(SettingsCategory.DEBUG) }\n )\n }\n }\n }\n}\n\n@Composable\nprivate fun CategoryRow(\n icon: ImageVector,\n title: String,\n subtitle: String,\n onClick: () -> Unit,\n) {\n ListItem(\n headlineContent = { Text(title) },\n supportingContent = { Text(subtitle) },\n leadingContent = {\n Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)\n },\n trailingContent = {\n Icon(\n Icons.AutoMirrored.Filled.KeyboardArrowRight,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.outline\n )\n },\n colors = ListItemDefaults.colors(containerColor = Color.Transparent),\n modifier = Modifier.clickable(onClick = onClick)\n )\n}\n\n/** Shared scaffold for a settings category sub-screen: title + back arrow + scrolling content. */\n@Composable\nprivate fun CategoryScaffold(\n title: String,\n onBack: () -> Unit,\n content: @Composable ColumnScope.() -> Unit,\n) {\n BackHandler(onBack = onBack)\n Scaffold(\n topBar = {\n TopAppBar(\n title = { Text(title) },\n navigationIcon = {\n IconButton(onClick = onBack) {\n Icon(\n Icons.AutoMirrored.Filled.ArrowBack,\n contentDescription = stringResource(R.string.back)\n )\n }\n }\n )\n }\n ) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp),\n content = content\n )\n }\n}\n\n@Composable\nfun GeneralSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_general), onBack) {\n LanguageSection(vm)\n UpdateSection()\n }\n}\n\n@Composable\nfun NotificationsSettingsScreen(\n onBack: () -> Unit,\n onEditGroup: (Long) -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n // Editing the global chain saves per-keystroke; apply it to already-armed\n // reminders once, when this screen leaves composition (back, system back, or\n // navigating into a group editor — the re-arm is idempotent either way).\n DisposableEffect(Unit) {\n onDispose { vm.applyChainEditsIfDirty() }\n }\n CategoryScaffold(stringResource(R.string.settings_cat_notifications), onBack) {\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n PreAlarmSoundSection(state.preAlarmSoundUri, vm)\n GroupsSection(state.groups, onEditGroup, onAddGroup = { onEditGroup(0L) })\n }\n}\n\n@Composable\nprivate fun PreAlarmSoundSection(soundUri: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult\n val picked: android.net.Uri? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {\n result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, android.net.Uri::class.java)\n } else {\n @Suppress(\"DEPRECATION\")\n result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)\n }\n // A null picked URI means the user chose \"Silent\".\n vm.setPreAlarmSound(picked?.toString())\n }\n\n val currentLabel = when {\n soundUri == null -> stringResource(R.string.settings_prealarm_sound_default)\n soundUri.isBlank() -> stringResource(R.string.settings_prealarm_sound_silent)\n else -> remember(soundUri) {\n runCatching { RingtoneManager.getRingtone(ctx, android.net.Uri.parse(soundUri))?.getTitle(ctx) }\n .getOrNull()\n } ?: stringResource(R.string.settings_prealarm_sound_custom)\n }\n\n SectionCard(stringResource(R.string.settings_prealarm_sound_title)) {\n Text(\n stringResource(R.string.settings_prealarm_sound_desc),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline,\n )\n Spacer(Modifier.height(8.dp))\n Text(\n stringResource(R.string.settings_prealarm_sound_current, currentLabel),\n style = MaterialTheme.typography.bodyMedium,\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = {\n val existing = soundUri?.takeIf { it.isNotBlank() }?.let { android.net.Uri.parse(it) }\n val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {\n putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION)\n putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, ctx.getString(R.string.settings_prealarm_sound_title))\n putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)\n putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)\n putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))\n putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, existing)\n }\n launcher.launch(intent)\n }) { Text(stringResource(R.string.settings_prealarm_sound_choose)) }\n }\n}\n\n/**\n * Integrations landing screen: each integration drills into its own sub-screen,\n * mirroring the top-level settings landing pattern.\n */\n@Composable\nfun IntegrationsSettingsScreen(\n onBack: () -> Unit,\n onOpenIntegration: (String) -> Unit = {},\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_integrations), onBack) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n CategoryRow(\n icon = Icons.AutoMirrored.Filled.Send,\n title = stringResource(R.string.settings_telegram_title),\n subtitle = stringResource(R.string.settings_int_telegram_sub),\n onClick = { onOpenIntegration(IntegrationCategory.TELEGRAM) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.CalendarMonth,\n title = stringResource(R.string.trips_title),\n subtitle = stringResource(R.string.settings_int_calendar_sub),\n onClick = { onOpenIntegration(IntegrationCategory.CALENDAR) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Psychology,\n title = stringResource(R.string.settings_deepseek_title),\n subtitle = stringResource(R.string.settings_int_deepseek_sub),\n onClick = { onOpenIntegration(IntegrationCategory.DEEPSEEK) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.CloudSync,\n title = stringResource(R.string.settings_drive_title),\n subtitle = stringResource(R.string.settings_int_drive_sub),\n onClick = { onOpenIntegration(IntegrationCategory.DRIVE) }\n )\n }\n }\n}\n\n@Composable\nfun TelegramSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_telegram_title), onBack) {\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n }\n}\n\n@Composable\nfun CalendarSettingsScreen(\n onBack: () -> Unit,\n onOpenManualImport: () -> Unit = {},\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.trips_title), onBack) {\n TripsSection(state, vm, onOpenManualImport)\n }\n}\n\n@Composable\nfun DeepSeekSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_deepseek_title), onBack) {\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, state.deepSeekContext, vm)\n }\n}\n\n@Composable\nfun DriveSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_drive_title), onBack) {\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n }\n}\n\n@Composable\nfun DiagnosticsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_diagnostics), onBack) {\n AlarmHistorySection(vm)\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n val downloadFailedMsg = stringResource(R.string.settings_update_download_failed)\n SectionCard(stringResource(R.string.settings_update_title)) {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(stringResource(R.string.settings_update_checking), style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(R.string.settings_update_available), fontWeight = FontWeight.SemiBold)\n Text(\n stringResource(\n R.string.settings_update_build_change,\n s.info.currentVersionCode,\n s.info.remoteVersionCode\n ),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(downloadFailedMsg)\n }\n }\n }) { Text(stringResource(R.string.settings_update_action_update)) }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n stringResource(R.string.settings_update_downloading, (s.progress * 100).toInt()),\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(stringResource(R.string.settings_update_opening_installer), style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n stringResource(R.string.settings_update_up_to_date),\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_recheck)) }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_retry)) }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n // Same editor as the per-group override in the group editor, so the two\n // can't drift apart.\n ChainEditor(chain = chain, onChange = onChange)\n }\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, onEditGroup: (Long) -> Unit, onAddGroup: () -> Unit) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, onClick = { onEditGroup(g.id) })\n HorizontalDivider()\n }\n TextButton(onClick = onAddGroup) {\n Icon(Icons.Filled.Add, contentDescription = null)\n Spacer(Modifier.width(4.dp))\n Text(stringResource(R.string.add))\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, onClick: () -> Unit) {\n Row(\n verticalAlignment = Alignment.CenterVertically,\n modifier = Modifier\n .fillMaxWidth()\n .clickable(onClick = onClick)\n .padding(vertical = 8.dp)\n ) {\n app.nock.android.ui.components.GroupAvatar(g, size = 28.dp)\n Spacer(Modifier.width(12.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = onClick) {\n Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))\n }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun TripsSection(state: SettingsState, vm: SettingsViewModel, onOpenManualImport: () -> Unit = {}) {\n var enabled by remember(state.tripsEnabled) { mutableStateOf(state.tripsEnabled) }\n var key by remember(state.tomtomKey) { mutableStateOf(state.tomtomKey) }\n var home by remember(state.tripHomeAddress) { mutableStateOf(state.tripHomeAddress) }\n var buffer by remember(state.tripBufferMin) { mutableStateOf(state.tripBufferMin.toString()) }\n\n val permLauncher = rememberLauncherForActivityResult(\n ActivityResultContracts.RequestPermission()\n ) { granted -> if (granted) vm.onCalendarPermissionResult() }\n\n fun persist() = vm.setTrips(enabled, key, home, buffer.toIntOrNull() ?: state.tripBufferMin)\n\n SectionCard(stringResource(R.string.trips_title)) {\n Text(\n stringResource(R.string.trips_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(stringResource(R.string.trips_enable), modifier = Modifier.weight(1f))\n Switch(\n checked = enabled,\n onCheckedChange = {\n enabled = it\n if (it && !state.tripHasCalendarPermission) {\n permLauncher.launch(android.Manifest.permission.READ_CALENDAR)\n }\n persist()\n }\n )\n }\n\n if (enabled) {\n Spacer(Modifier.height(12.dp))\n OutlinedTextField(\n value = key,\n onValueChange = { key = it },\n label = { Text(stringResource(R.string.trips_tomtom_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = home,\n onValueChange = { home = it },\n label = { Text(stringResource(R.string.trips_home_address)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = buffer,\n onValueChange = { v -> buffer = v.filter { it.isDigit() }.take(3) },\n label = { Text(stringResource(R.string.trips_buffer_minutes)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(12.dp))\n androidx.compose.foundation.layout.FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { persist() }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { persist(); vm.testRoute() }) {\n Text(stringResource(R.string.trips_test_route))\n }\n OutlinedButton(\n onClick = { persist(); vm.syncTripsNow() },\n enabled = state.tripHasCalendarPermission,\n ) {\n Text(stringResource(R.string.trips_sync_now))\n }\n }\n state.tripStatus?.let {\n Spacer(Modifier.height(8.dp))\n Text(it, style = MaterialTheme.typography.bodySmall)\n }\n\n Spacer(Modifier.height(20.dp))\n TripSyncLogSection(vm)\n\n Spacer(Modifier.height(16.dp))\n if (!state.tripHasCalendarPermission) {\n Text(\n stringResource(R.string.trips_calendar_permission_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { permLauncher.launch(android.Manifest.permission.READ_CALENDAR) }) {\n Text(stringResource(R.string.trips_grant_calendar))\n }\n } else {\n Text(\n stringResource(R.string.trips_calendars_title),\n style = MaterialTheme.typography.bodyMedium,\n fontWeight = FontWeight.SemiBold\n )\n Text(\n stringResource(R.string.trips_calendars_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n // Only the calendars the user explicitly checks are watched;\n // nothing is selected by default.\n val selected = state.tripSelectedCalendarIds\n state.tripCalendars.forEach { cal ->\n Row(verticalAlignment = Alignment.CenterVertically) {\n Checkbox(\n checked = cal.id in selected,\n onCheckedChange = { checked ->\n val next = if (checked) selected + cal.id else selected - cal.id\n vm.setTripCalendars(next)\n }\n )\n Column {\n Text(cal.displayName, style = MaterialTheme.typography.bodyMedium)\n if (cal.accountName.isNotBlank()) {\n Text(\n cal.accountName,\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n }\n }\n }\n\n Spacer(Modifier.height(16.dp))\n HorizontalDivider()\n Spacer(Modifier.height(16.dp))\n Text(\n stringResource(R.string.trips_manual_import_title),\n style = MaterialTheme.typography.bodyMedium,\n fontWeight = FontWeight.SemiBold\n )\n Text(\n stringResource(R.string.trips_manual_import_desc),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = onOpenManualImport) {\n Text(stringResource(R.string.trips_manual_import_open))\n }\n }\n }\n }\n}\n\n/**\n * A copyable diagnostic of the most recent calendar sync, shown right under the\n * Sync button. When an event that should have become an alarm doesn't, the user\n * taps Sync now, then copies this — it lists every event found and why each was\n * or wasn't turned into an alarm.\n */\n@Composable\nprivate fun TripSyncLogSection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n val logText by vm.tripSyncLogText.collectAsState()\n val copiedMsg = stringResource(R.string.trips_synclog_copied)\n\n Text(\n stringResource(R.string.trips_synclog_title),\n style = MaterialTheme.typography.bodyMedium,\n fontWeight = FontWeight.SemiBold\n )\n Text(\n stringResource(R.string.trips_synclog_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(\n onClick = {\n clipboard.setText(AnnotatedString(logText))\n scope.launch { snackbarHostState.showSnackbar(copiedMsg) }\n },\n enabled = logText.isNotBlank()\n ) {\n Text(stringResource(R.string.trips_synclog_copy))\n }\n OutlinedButton(onClick = { vm.clearTripSyncLog() }, enabled = logText.isNotBlank()) {\n Text(stringResource(R.string.trips_synclog_clear))\n }\n }\n Spacer(Modifier.height(8.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 320.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.trips_synclog_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, context: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n var c by remember(context) { mutableStateOf(context) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(16.dp))\n Text(\n stringResource(R.string.settings_deepseek_context_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c,\n onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_deepseek_context)) },\n placeholder = { Text(stringResource(R.string.settings_deepseek_context_placeholder)) },\n singleLine = false,\n minLines = 3,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u, c) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n\n@Composable\nprivate fun AlarmHistorySection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n var logText by remember { mutableStateOf(vm.alarmHistoryDump()) }\n val copiedMsg = stringResource(R.string.settings_alarm_history_copied)\n\n SectionCard(stringResource(R.string.settings_alarm_history_title)) {\n Text(\n stringResource(R.string.settings_alarm_history_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n OutlinedButton(onClick = { logText = vm.alarmHistoryDump() }) {\n Text(stringResource(R.string.settings_alarm_history_refresh))\n }\n Button(onClick = {\n scope.launch {\n val snapshot = vm.alarmHistoryWithCurrentState()\n logText = snapshot\n clipboard.setText(AnnotatedString(snapshot))\n snackbarHostState.showSnackbar(copiedMsg)\n }\n }) {\n Text(stringResource(R.string.settings_alarm_history_copy))\n }\n OutlinedButton(onClick = {\n vm.clearAlarmHistory()\n logText = \"\"\n }) {\n Text(stringResource(R.string.settings_alarm_history_clear))\n }\n }\n Spacer(Modifier.height(12.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 360.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.settings_alarm_history_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n }\n}\n"}, "68db563a995854666c333ed793b920ca529fc982": {"t": 1785755945.534474, "n": 47945, "h": "5530612b81658bf7", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.media.RingtoneManager\nimport android.os.Build\nimport android.util.Log\nimport androidx.activity.compose.BackHandler\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.ArrowBack\nimport androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Build\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.automirrored.filled.Send\nimport androidx.compose.material.icons.outlined.BugReport\nimport androidx.compose.material.icons.outlined.CalendarMonth\nimport androidx.compose.material.icons.outlined.CloudSync\nimport androidx.compose.material.icons.outlined.Hub\nimport androidx.compose.material.icons.outlined.Notifications\nimport androidx.compose.material.icons.outlined.Psychology\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.compose.material.icons.outlined.Tune\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n/** Settings category routes, used both for navigation and for the landing list. */\nobject SettingsCategory {\n const val GENERAL = \"general\"\n const val NOTIFICATIONS = \"notifications\"\n const val INTEGRATIONS = \"integrations\"\n const val DIAGNOSTICS = \"diagnostics\"\n const val DEBUG = \"debug\"\n}\n\n/** Per-integration routes, used by the Integrations landing list and navigation. */\nobject IntegrationCategory {\n const val TELEGRAM = \"telegram\"\n const val CALENDAR = \"calendar\"\n const val DEEPSEEK = \"deepseek\"\n const val DRIVE = \"drive\"\n}\n\n/**\n * Settings landing screen: a short list of categories that each drill into their\n * own screen — the standard Android settings pattern, rather than one long scroll.\n */\n@Composable\nfun SettingsScreen(\n onOpenCategory: (String) -> Unit = {},\n onEditGroup: (Long) -> Unit = {},\n vm: SettingsViewModel = hiltViewModel(),\n) {\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState())\n ) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n CategoryRow(\n icon = Icons.Outlined.Tune,\n title = stringResource(R.string.settings_cat_general),\n subtitle = stringResource(R.string.settings_cat_general_sub),\n onClick = { onOpenCategory(SettingsCategory.GENERAL) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Notifications,\n title = stringResource(R.string.settings_cat_notifications),\n subtitle = stringResource(R.string.settings_cat_notifications_sub),\n onClick = { onOpenCategory(SettingsCategory.NOTIFICATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Hub,\n title = stringResource(R.string.settings_cat_integrations),\n subtitle = stringResource(R.string.settings_cat_integrations_sub),\n onClick = { onOpenCategory(SettingsCategory.INTEGRATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.BugReport,\n title = stringResource(R.string.settings_cat_diagnostics),\n subtitle = stringResource(R.string.settings_cat_diagnostics_sub),\n onClick = { onOpenCategory(SettingsCategory.DIAGNOSTICS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Filled.Build,\n title = stringResource(R.string.settings_cat_debug),\n subtitle = stringResource(R.string.settings_cat_debug_sub),\n onClick = { onOpenCategory(SettingsCategory.DEBUG) }\n )\n }\n }\n }\n}\n\n@Composable\nprivate fun CategoryRow(\n icon: ImageVector,\n title: String,\n subtitle: String,\n onClick: () -> Unit,\n) {\n ListItem(\n headlineContent = { Text(title) },\n supportingContent = { Text(subtitle) },\n leadingContent = {\n Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)\n },\n trailingContent = {\n Icon(\n Icons.AutoMirrored.Filled.KeyboardArrowRight,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.outline\n )\n },\n colors = ListItemDefaults.colors(containerColor = Color.Transparent),\n modifier = Modifier.clickable(onClick = onClick)\n )\n}\n\n/** Shared scaffold for a settings category sub-screen: title + back arrow + scrolling content. */\n@Composable\nprivate fun CategoryScaffold(\n title: String,\n onBack: () -> Unit,\n content: @Composable ColumnScope.() -> Unit,\n) {\n BackHandler(onBack = onBack)\n Scaffold(\n topBar = {\n TopAppBar(\n title = { Text(title) },\n navigationIcon = {\n IconButton(onClick = onBack) {\n Icon(\n Icons.AutoMirrored.Filled.ArrowBack,\n contentDescription = stringResource(R.string.back)\n )\n }\n }\n )\n }\n ) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp),\n content = content\n )\n }\n}\n\n@Composable\nfun GeneralSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_general), onBack) {\n LanguageSection(vm)\n UpdateSection()\n }\n}\n\n@Composable\nfun NotificationsSettingsScreen(\n onBack: () -> Unit,\n onEditGroup: (Long) -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n // Editing the global chain saves per-keystroke; apply it to already-armed\n // reminders once, when this screen leaves composition (back, system back, or\n // navigating into a group editor — the re-arm is idempotent either way).\n DisposableEffect(Unit) {\n onDispose { vm.applyChainEditsIfDirty() }\n }\n CategoryScaffold(stringResource(R.string.settings_cat_notifications), onBack) {\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n PreAlarmSoundSection(state.preAlarmSoundUri, vm)\n GroupsSection(state.groups, onEditGroup, onAddGroup = { onEditGroup(0L) })\n }\n}\n\n@Composable\nprivate fun PreAlarmSoundSection(soundUri: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult\n val picked: android.net.Uri? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {\n result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, android.net.Uri::class.java)\n } else {\n @Suppress(\"DEPRECATION\")\n result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)\n }\n // A null picked URI means the user chose \"Silent\".\n vm.setPreAlarmSound(picked?.toString())\n }\n\n val currentLabel = when {\n soundUri == null -> stringResource(R.string.settings_prealarm_sound_default)\n soundUri.isBlank() -> stringResource(R.string.settings_prealarm_sound_silent)\n else -> remember(soundUri) {\n runCatching { RingtoneManager.getRingtone(ctx, android.net.Uri.parse(soundUri))?.getTitle(ctx) }\n .getOrNull()\n } ?: stringResource(R.string.settings_prealarm_sound_custom)\n }\n\n SectionCard(stringResource(R.string.settings_prealarm_sound_title)) {\n Text(\n stringResource(R.string.settings_prealarm_sound_desc),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline,\n )\n Spacer(Modifier.height(8.dp))\n Text(\n stringResource(R.string.settings_prealarm_sound_current, currentLabel),\n style = MaterialTheme.typography.bodyMedium,\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = {\n val existing = soundUri?.takeIf { it.isNotBlank() }?.let { android.net.Uri.parse(it) }\n val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {\n putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION)\n putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, ctx.getString(R.string.settings_prealarm_sound_title))\n putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)\n putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)\n putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))\n putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, existing)\n }\n launcher.launch(intent)\n }) { Text(stringResource(R.string.settings_prealarm_sound_choose)) }\n }\n}\n\n/**\n * Integrations landing screen: each integration drills into its own sub-screen,\n * mirroring the top-level settings landing pattern.\n */\n@Composable\nfun IntegrationsSettingsScreen(\n onBack: () -> Unit,\n onOpenIntegration: (String) -> Unit = {},\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_integrations), onBack) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n CategoryRow(\n icon = Icons.AutoMirrored.Filled.Send,\n title = stringResource(R.string.settings_telegram_title),\n subtitle = stringResource(R.string.settings_int_telegram_sub),\n onClick = { onOpenIntegration(IntegrationCategory.TELEGRAM) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.CalendarMonth,\n title = stringResource(R.string.trips_title),\n subtitle = stringResource(R.string.settings_int_calendar_sub),\n onClick = { onOpenIntegration(IntegrationCategory.CALENDAR) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Psychology,\n title = stringResource(R.string.settings_deepseek_title),\n subtitle = stringResource(R.string.settings_int_deepseek_sub),\n onClick = { onOpenIntegration(IntegrationCategory.DEEPSEEK) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.CloudSync,\n title = stringResource(R.string.settings_drive_title),\n subtitle = stringResource(R.string.settings_int_drive_sub),\n onClick = { onOpenIntegration(IntegrationCategory.DRIVE) }\n )\n }\n }\n}\n\n@Composable\nfun TelegramSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_telegram_title), onBack) {\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n }\n}\n\n@Composable\nfun CalendarSettingsScreen(\n onBack: () -> Unit,\n onOpenManualImport: () -> Unit = {},\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.trips_title), onBack) {\n TripsSection(state, vm, onOpenManualImport)\n }\n}\n\n@Composable\nfun DeepSeekSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_deepseek_title), onBack) {\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, state.deepSeekContext, vm)\n }\n}\n\n@Composable\nfun DriveSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_drive_title), onBack) {\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n }\n}\n\n@Composable\nfun DiagnosticsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_diagnostics), onBack) {\n AlarmHistorySection(vm)\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n val downloadFailedMsg = stringResource(R.string.settings_update_download_failed)\n SectionCard(stringResource(R.string.settings_update_title)) {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(stringResource(R.string.settings_update_checking), style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(R.string.settings_update_available), fontWeight = FontWeight.SemiBold)\n Text(\n stringResource(\n R.string.settings_update_build_change,\n s.info.currentVersionCode,\n s.info.remoteVersionCode\n ),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(downloadFailedMsg)\n }\n }\n }) { Text(stringResource(R.string.settings_update_action_update)) }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n stringResource(R.string.settings_update_downloading, (s.progress * 100).toInt()),\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(stringResource(R.string.settings_update_opening_installer), style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n stringResource(R.string.settings_update_up_to_date),\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_recheck)) }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_retry)) }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\nprivate fun stageTypeLabel(type: StageType): Int = app.nock.android.ui.components.stageTypeLabel(type)\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Box(\n modifier = Modifier\n .size(40.dp)\n .clip(CircleShape)\n .background(MaterialTheme.colorScheme.surfaceContainerHigh),\n contentAlignment = Alignment.Center\n ) {\n Icon(\n imageVector = app.nock.android.ui.components.stageIcon(stage.type),\n contentDescription = null,\n modifier = Modifier.size(20.dp),\n tint = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, onEditGroup: (Long) -> Unit, onAddGroup: () -> Unit) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, onClick = { onEditGroup(g.id) })\n HorizontalDivider()\n }\n TextButton(onClick = onAddGroup) {\n Icon(Icons.Filled.Add, contentDescription = null)\n Spacer(Modifier.width(4.dp))\n Text(stringResource(R.string.add))\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, onClick: () -> Unit) {\n Row(\n verticalAlignment = Alignment.CenterVertically,\n modifier = Modifier\n .fillMaxWidth()\n .clickable(onClick = onClick)\n .padding(vertical = 8.dp)\n ) {\n app.nock.android.ui.components.GroupAvatar(g, size = 28.dp)\n Spacer(Modifier.width(12.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = onClick) {\n Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))\n }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun TripsSection(state: SettingsState, vm: SettingsViewModel, onOpenManualImport: () -> Unit = {}) {\n var enabled by remember(state.tripsEnabled) { mutableStateOf(state.tripsEnabled) }\n var key by remember(state.tomtomKey) { mutableStateOf(state.tomtomKey) }\n var home by remember(state.tripHomeAddress) { mutableStateOf(state.tripHomeAddress) }\n var buffer by remember(state.tripBufferMin) { mutableStateOf(state.tripBufferMin.toString()) }\n\n val permLauncher = rememberLauncherForActivityResult(\n ActivityResultContracts.RequestPermission()\n ) { granted -> if (granted) vm.onCalendarPermissionResult() }\n\n fun persist() = vm.setTrips(enabled, key, home, buffer.toIntOrNull() ?: state.tripBufferMin)\n\n SectionCard(stringResource(R.string.trips_title)) {\n Text(\n stringResource(R.string.trips_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(stringResource(R.string.trips_enable), modifier = Modifier.weight(1f))\n Switch(\n checked = enabled,\n onCheckedChange = {\n enabled = it\n if (it && !state.tripHasCalendarPermission) {\n permLauncher.launch(android.Manifest.permission.READ_CALENDAR)\n }\n persist()\n }\n )\n }\n\n if (enabled) {\n Spacer(Modifier.height(12.dp))\n OutlinedTextField(\n value = key,\n onValueChange = { key = it },\n label = { Text(stringResource(R.string.trips_tomtom_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = home,\n onValueChange = { home = it },\n label = { Text(stringResource(R.string.trips_home_address)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = buffer,\n onValueChange = { v -> buffer = v.filter { it.isDigit() }.take(3) },\n label = { Text(stringResource(R.string.trips_buffer_minutes)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(12.dp))\n androidx.compose.foundation.layout.FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { persist() }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { persist(); vm.testRoute() }) {\n Text(stringResource(R.string.trips_test_route))\n }\n OutlinedButton(\n onClick = { persist(); vm.syncTripsNow() },\n enabled = state.tripHasCalendarPermission,\n ) {\n Text(stringResource(R.string.trips_sync_now))\n }\n }\n state.tripStatus?.let {\n Spacer(Modifier.height(8.dp))\n Text(it, style = MaterialTheme.typography.bodySmall)\n }\n\n Spacer(Modifier.height(20.dp))\n TripSyncLogSection(vm)\n\n Spacer(Modifier.height(16.dp))\n if (!state.tripHasCalendarPermission) {\n Text(\n stringResource(R.string.trips_calendar_permission_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { permLauncher.launch(android.Manifest.permission.READ_CALENDAR) }) {\n Text(stringResource(R.string.trips_grant_calendar))\n }\n } else {\n Text(\n stringResource(R.string.trips_calendars_title),\n style = MaterialTheme.typography.bodyMedium,\n fontWeight = FontWeight.SemiBold\n )\n Text(\n stringResource(R.string.trips_calendars_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n // Only the calendars the user explicitly checks are watched;\n // nothing is selected by default.\n val selected = state.tripSelectedCalendarIds\n state.tripCalendars.forEach { cal ->\n Row(verticalAlignment = Alignment.CenterVertically) {\n Checkbox(\n checked = cal.id in selected,\n onCheckedChange = { checked ->\n val next = if (checked) selected + cal.id else selected - cal.id\n vm.setTripCalendars(next)\n }\n )\n Column {\n Text(cal.displayName, style = MaterialTheme.typography.bodyMedium)\n if (cal.accountName.isNotBlank()) {\n Text(\n cal.accountName,\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n }\n }\n }\n\n Spacer(Modifier.height(16.dp))\n HorizontalDivider()\n Spacer(Modifier.height(16.dp))\n Text(\n stringResource(R.string.trips_manual_import_title),\n style = MaterialTheme.typography.bodyMedium,\n fontWeight = FontWeight.SemiBold\n )\n Text(\n stringResource(R.string.trips_manual_import_desc),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = onOpenManualImport) {\n Text(stringResource(R.string.trips_manual_import_open))\n }\n }\n }\n }\n}\n\n/**\n * A copyable diagnostic of the most recent calendar sync, shown right under the\n * Sync button. When an event that should have become an alarm doesn't, the user\n * taps Sync now, then copies this — it lists every event found and why each was\n * or wasn't turned into an alarm.\n */\n@Composable\nprivate fun TripSyncLogSection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n val logText by vm.tripSyncLogText.collectAsState()\n val copiedMsg = stringResource(R.string.trips_synclog_copied)\n\n Text(\n stringResource(R.string.trips_synclog_title),\n style = MaterialTheme.typography.bodyMedium,\n fontWeight = FontWeight.SemiBold\n )\n Text(\n stringResource(R.string.trips_synclog_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(\n onClick = {\n clipboard.setText(AnnotatedString(logText))\n scope.launch { snackbarHostState.showSnackbar(copiedMsg) }\n },\n enabled = logText.isNotBlank()\n ) {\n Text(stringResource(R.string.trips_synclog_copy))\n }\n OutlinedButton(onClick = { vm.clearTripSyncLog() }, enabled = logText.isNotBlank()) {\n Text(stringResource(R.string.trips_synclog_clear))\n }\n }\n Spacer(Modifier.height(8.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 320.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.trips_synclog_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, context: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n var c by remember(context) { mutableStateOf(context) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(16.dp))\n Text(\n stringResource(R.string.settings_deepseek_context_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c,\n onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_deepseek_context)) },\n placeholder = { Text(stringResource(R.string.settings_deepseek_context_placeholder)) },\n singleLine = false,\n minLines = 3,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u, c) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n\n@Composable\nprivate fun AlarmHistorySection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n var logText by remember { mutableStateOf(vm.alarmHistoryDump()) }\n val copiedMsg = stringResource(R.string.settings_alarm_history_copied)\n\n SectionCard(stringResource(R.string.settings_alarm_history_title)) {\n Text(\n stringResource(R.string.settings_alarm_history_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n OutlinedButton(onClick = { logText = vm.alarmHistoryDump() }) {\n Text(stringResource(R.string.settings_alarm_history_refresh))\n }\n Button(onClick = {\n scope.launch {\n val snapshot = vm.alarmHistoryWithCurrentState()\n logText = snapshot\n clipboard.setText(AnnotatedString(snapshot))\n snackbarHostState.showSnackbar(copiedMsg)\n }\n }) {\n Text(stringResource(R.string.settings_alarm_history_copy))\n }\n OutlinedButton(onClick = {\n vm.clearAlarmHistory()\n logText = \"\"\n }) {\n Text(stringResource(R.string.settings_alarm_history_clear))\n }\n }\n Spacer(Modifier.height(12.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 360.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.settings_alarm_history_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n }\n}\n"}, "b9380aa63a08bb34bbcd2e129dda0f064c6a6923": {"t": 1785755947.1253054, "n": 47046, "h": "d6cc5afc1c068af1", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.media.RingtoneManager\nimport android.os.Build\nimport android.util.Log\nimport androidx.activity.compose.BackHandler\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.ArrowBack\nimport androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Build\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.automirrored.filled.Send\nimport androidx.compose.material.icons.outlined.BugReport\nimport androidx.compose.material.icons.outlined.CalendarMonth\nimport androidx.compose.material.icons.outlined.CloudSync\nimport androidx.compose.material.icons.outlined.Hub\nimport androidx.compose.material.icons.outlined.Notifications\nimport androidx.compose.material.icons.outlined.Psychology\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.compose.material.icons.outlined.Tune\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n/** Settings category routes, used both for navigation and for the landing list. */\nobject SettingsCategory {\n const val GENERAL = \"general\"\n const val NOTIFICATIONS = \"notifications\"\n const val INTEGRATIONS = \"integrations\"\n const val DIAGNOSTICS = \"diagnostics\"\n const val DEBUG = \"debug\"\n}\n\n/** Per-integration routes, used by the Integrations landing list and navigation. */\nobject IntegrationCategory {\n const val TELEGRAM = \"telegram\"\n const val CALENDAR = \"calendar\"\n const val DEEPSEEK = \"deepseek\"\n const val DRIVE = \"drive\"\n}\n\n/**\n * Settings landing screen: a short list of categories that each drill into their\n * own screen — the standard Android settings pattern, rather than one long scroll.\n */\n@Composable\nfun SettingsScreen(\n onOpenCategory: (String) -> Unit = {},\n onEditGroup: (Long) -> Unit = {},\n vm: SettingsViewModel = hiltViewModel(),\n) {\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState())\n ) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n CategoryRow(\n icon = Icons.Outlined.Tune,\n title = stringResource(R.string.settings_cat_general),\n subtitle = stringResource(R.string.settings_cat_general_sub),\n onClick = { onOpenCategory(SettingsCategory.GENERAL) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Notifications,\n title = stringResource(R.string.settings_cat_notifications),\n subtitle = stringResource(R.string.settings_cat_notifications_sub),\n onClick = { onOpenCategory(SettingsCategory.NOTIFICATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Hub,\n title = stringResource(R.string.settings_cat_integrations),\n subtitle = stringResource(R.string.settings_cat_integrations_sub),\n onClick = { onOpenCategory(SettingsCategory.INTEGRATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.BugReport,\n title = stringResource(R.string.settings_cat_diagnostics),\n subtitle = stringResource(R.string.settings_cat_diagnostics_sub),\n onClick = { onOpenCategory(SettingsCategory.DIAGNOSTICS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Filled.Build,\n title = stringResource(R.string.settings_cat_debug),\n subtitle = stringResource(R.string.settings_cat_debug_sub),\n onClick = { onOpenCategory(SettingsCategory.DEBUG) }\n )\n }\n }\n }\n}\n\n@Composable\nprivate fun CategoryRow(\n icon: ImageVector,\n title: String,\n subtitle: String,\n onClick: () -> Unit,\n) {\n ListItem(\n headlineContent = { Text(title) },\n supportingContent = { Text(subtitle) },\n leadingContent = {\n Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)\n },\n trailingContent = {\n Icon(\n Icons.AutoMirrored.Filled.KeyboardArrowRight,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.outline\n )\n },\n colors = ListItemDefaults.colors(containerColor = Color.Transparent),\n modifier = Modifier.clickable(onClick = onClick)\n )\n}\n\n/** Shared scaffold for a settings category sub-screen: title + back arrow + scrolling content. */\n@Composable\nprivate fun CategoryScaffold(\n title: String,\n onBack: () -> Unit,\n content: @Composable ColumnScope.() -> Unit,\n) {\n BackHandler(onBack = onBack)\n Scaffold(\n topBar = {\n TopAppBar(\n title = { Text(title) },\n navigationIcon = {\n IconButton(onClick = onBack) {\n Icon(\n Icons.AutoMirrored.Filled.ArrowBack,\n contentDescription = stringResource(R.string.back)\n )\n }\n }\n )\n }\n ) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp),\n content = content\n )\n }\n}\n\n@Composable\nfun GeneralSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_general), onBack) {\n LanguageSection(vm)\n UpdateSection()\n }\n}\n\n@Composable\nfun NotificationsSettingsScreen(\n onBack: () -> Unit,\n onEditGroup: (Long) -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n // Editing the global chain saves per-keystroke; apply it to already-armed\n // reminders once, when this screen leaves composition (back, system back, or\n // navigating into a group editor — the re-arm is idempotent either way).\n DisposableEffect(Unit) {\n onDispose { vm.applyChainEditsIfDirty() }\n }\n CategoryScaffold(stringResource(R.string.settings_cat_notifications), onBack) {\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n PreAlarmSoundSection(state.preAlarmSoundUri, vm)\n GroupsSection(state.groups, onEditGroup, onAddGroup = { onEditGroup(0L) })\n }\n}\n\n@Composable\nprivate fun PreAlarmSoundSection(soundUri: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult\n val picked: android.net.Uri? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {\n result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, android.net.Uri::class.java)\n } else {\n @Suppress(\"DEPRECATION\")\n result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)\n }\n // A null picked URI means the user chose \"Silent\".\n vm.setPreAlarmSound(picked?.toString())\n }\n\n val currentLabel = when {\n soundUri == null -> stringResource(R.string.settings_prealarm_sound_default)\n soundUri.isBlank() -> stringResource(R.string.settings_prealarm_sound_silent)\n else -> remember(soundUri) {\n runCatching { RingtoneManager.getRingtone(ctx, android.net.Uri.parse(soundUri))?.getTitle(ctx) }\n .getOrNull()\n } ?: stringResource(R.string.settings_prealarm_sound_custom)\n }\n\n SectionCard(stringResource(R.string.settings_prealarm_sound_title)) {\n Text(\n stringResource(R.string.settings_prealarm_sound_desc),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline,\n )\n Spacer(Modifier.height(8.dp))\n Text(\n stringResource(R.string.settings_prealarm_sound_current, currentLabel),\n style = MaterialTheme.typography.bodyMedium,\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = {\n val existing = soundUri?.takeIf { it.isNotBlank() }?.let { android.net.Uri.parse(it) }\n val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {\n putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION)\n putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, ctx.getString(R.string.settings_prealarm_sound_title))\n putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)\n putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)\n putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))\n putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, existing)\n }\n launcher.launch(intent)\n }) { Text(stringResource(R.string.settings_prealarm_sound_choose)) }\n }\n}\n\n/**\n * Integrations landing screen: each integration drills into its own sub-screen,\n * mirroring the top-level settings landing pattern.\n */\n@Composable\nfun IntegrationsSettingsScreen(\n onBack: () -> Unit,\n onOpenIntegration: (String) -> Unit = {},\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_integrations), onBack) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n CategoryRow(\n icon = Icons.AutoMirrored.Filled.Send,\n title = stringResource(R.string.settings_telegram_title),\n subtitle = stringResource(R.string.settings_int_telegram_sub),\n onClick = { onOpenIntegration(IntegrationCategory.TELEGRAM) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.CalendarMonth,\n title = stringResource(R.string.trips_title),\n subtitle = stringResource(R.string.settings_int_calendar_sub),\n onClick = { onOpenIntegration(IntegrationCategory.CALENDAR) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Psychology,\n title = stringResource(R.string.settings_deepseek_title),\n subtitle = stringResource(R.string.settings_int_deepseek_sub),\n onClick = { onOpenIntegration(IntegrationCategory.DEEPSEEK) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.CloudSync,\n title = stringResource(R.string.settings_drive_title),\n subtitle = stringResource(R.string.settings_int_drive_sub),\n onClick = { onOpenIntegration(IntegrationCategory.DRIVE) }\n )\n }\n }\n}\n\n@Composable\nfun TelegramSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_telegram_title), onBack) {\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n }\n}\n\n@Composable\nfun CalendarSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.trips_title), onBack) {\n TripsSection(state, vm)\n }\n}\n\n@Composable\nfun DeepSeekSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_deepseek_title), onBack) {\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, state.deepSeekContext, vm)\n }\n}\n\n@Composable\nfun DriveSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_drive_title), onBack) {\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n }\n}\n\n@Composable\nfun DiagnosticsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_diagnostics), onBack) {\n AlarmHistorySection(vm)\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n val downloadFailedMsg = stringResource(R.string.settings_update_download_failed)\n SectionCard(stringResource(R.string.settings_update_title)) {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(stringResource(R.string.settings_update_checking), style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(R.string.settings_update_available), fontWeight = FontWeight.SemiBold)\n Text(\n stringResource(\n R.string.settings_update_build_change,\n s.info.currentVersionCode,\n s.info.remoteVersionCode\n ),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(downloadFailedMsg)\n }\n }\n }) { Text(stringResource(R.string.settings_update_action_update)) }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n stringResource(R.string.settings_update_downloading, (s.progress * 100).toInt()),\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(stringResource(R.string.settings_update_opening_installer), style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n stringResource(R.string.settings_update_up_to_date),\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_recheck)) }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_retry)) }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\nprivate fun stageTypeLabel(type: StageType): Int = app.nock.android.ui.components.stageTypeLabel(type)\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Box(\n modifier = Modifier\n .size(40.dp)\n .clip(CircleShape)\n .background(MaterialTheme.colorScheme.surfaceContainerHigh),\n contentAlignment = Alignment.Center\n ) {\n Icon(\n imageVector = app.nock.android.ui.components.stageIcon(stage.type),\n contentDescription = null,\n modifier = Modifier.size(20.dp),\n tint = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, onEditGroup: (Long) -> Unit, onAddGroup: () -> Unit) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, onClick = { onEditGroup(g.id) })\n HorizontalDivider()\n }\n TextButton(onClick = onAddGroup) {\n Icon(Icons.Filled.Add, contentDescription = null)\n Spacer(Modifier.width(4.dp))\n Text(stringResource(R.string.add))\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, onClick: () -> Unit) {\n Row(\n verticalAlignment = Alignment.CenterVertically,\n modifier = Modifier\n .fillMaxWidth()\n .clickable(onClick = onClick)\n .padding(vertical = 8.dp)\n ) {\n app.nock.android.ui.components.GroupAvatar(g, size = 28.dp)\n Spacer(Modifier.width(12.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = onClick) {\n Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))\n }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun TripsSection(state: SettingsState, vm: SettingsViewModel) {\n var enabled by remember(state.tripsEnabled) { mutableStateOf(state.tripsEnabled) }\n var key by remember(state.tomtomKey) { mutableStateOf(state.tomtomKey) }\n var home by remember(state.tripHomeAddress) { mutableStateOf(state.tripHomeAddress) }\n var buffer by remember(state.tripBufferMin) { mutableStateOf(state.tripBufferMin.toString()) }\n\n val permLauncher = rememberLauncherForActivityResult(\n ActivityResultContracts.RequestPermission()\n ) { granted -> if (granted) vm.onCalendarPermissionResult() }\n\n fun persist() = vm.setTrips(enabled, key, home, buffer.toIntOrNull() ?: state.tripBufferMin)\n\n SectionCard(stringResource(R.string.trips_title)) {\n Text(\n stringResource(R.string.trips_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(stringResource(R.string.trips_enable), modifier = Modifier.weight(1f))\n Switch(\n checked = enabled,\n onCheckedChange = {\n enabled = it\n if (it && !state.tripHasCalendarPermission) {\n permLauncher.launch(android.Manifest.permission.READ_CALENDAR)\n }\n persist()\n }\n )\n }\n\n if (enabled) {\n Spacer(Modifier.height(12.dp))\n OutlinedTextField(\n value = key,\n onValueChange = { key = it },\n label = { Text(stringResource(R.string.trips_tomtom_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = home,\n onValueChange = { home = it },\n label = { Text(stringResource(R.string.trips_home_address)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = buffer,\n onValueChange = { v -> buffer = v.filter { it.isDigit() }.take(3) },\n label = { Text(stringResource(R.string.trips_buffer_minutes)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(12.dp))\n androidx.compose.foundation.layout.FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { persist() }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { persist(); vm.testRoute() }) {\n Text(stringResource(R.string.trips_test_route))\n }\n OutlinedButton(\n onClick = { persist(); vm.syncTripsNow() },\n enabled = state.tripHasCalendarPermission,\n ) {\n Text(stringResource(R.string.trips_sync_now))\n }\n }\n state.tripStatus?.let {\n Spacer(Modifier.height(8.dp))\n Text(it, style = MaterialTheme.typography.bodySmall)\n }\n\n Spacer(Modifier.height(20.dp))\n TripSyncLogSection(vm)\n\n Spacer(Modifier.height(16.dp))\n if (!state.tripHasCalendarPermission) {\n Text(\n stringResource(R.string.trips_calendar_permission_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { permLauncher.launch(android.Manifest.permission.READ_CALENDAR) }) {\n Text(stringResource(R.string.trips_grant_calendar))\n }\n } else {\n Text(\n stringResource(R.string.trips_calendars_title),\n style = MaterialTheme.typography.bodyMedium,\n fontWeight = FontWeight.SemiBold\n )\n Text(\n stringResource(R.string.trips_calendars_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n // Only the calendars the user explicitly checks are watched;\n // nothing is selected by default.\n val selected = state.tripSelectedCalendarIds\n state.tripCalendars.forEach { cal ->\n Row(verticalAlignment = Alignment.CenterVertically) {\n Checkbox(\n checked = cal.id in selected,\n onCheckedChange = { checked ->\n val next = if (checked) selected + cal.id else selected - cal.id\n vm.setTripCalendars(next)\n }\n )\n Column {\n Text(cal.displayName, style = MaterialTheme.typography.bodyMedium)\n if (cal.accountName.isNotBlank()) {\n Text(\n cal.accountName,\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n }\n }\n }\n }\n }\n }\n}\n\n/**\n * A copyable diagnostic of the most recent calendar sync, shown right under the\n * Sync button. When an event that should have become an alarm doesn't, the user\n * taps Sync now, then copies this — it lists every event found and why each was\n * or wasn't turned into an alarm.\n */\n@Composable\nprivate fun TripSyncLogSection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n val logText by vm.tripSyncLogText.collectAsState()\n val copiedMsg = stringResource(R.string.trips_synclog_copied)\n\n Text(\n stringResource(R.string.trips_synclog_title),\n style = MaterialTheme.typography.bodyMedium,\n fontWeight = FontWeight.SemiBold\n )\n Text(\n stringResource(R.string.trips_synclog_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(\n onClick = {\n clipboard.setText(AnnotatedString(logText))\n scope.launch { snackbarHostState.showSnackbar(copiedMsg) }\n },\n enabled = logText.isNotBlank()\n ) {\n Text(stringResource(R.string.trips_synclog_copy))\n }\n OutlinedButton(onClick = { vm.clearTripSyncLog() }, enabled = logText.isNotBlank()) {\n Text(stringResource(R.string.trips_synclog_clear))\n }\n }\n Spacer(Modifier.height(8.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 320.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.trips_synclog_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, context: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n var c by remember(context) { mutableStateOf(context) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(16.dp))\n Text(\n stringResource(R.string.settings_deepseek_context_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c,\n onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_deepseek_context)) },\n placeholder = { Text(stringResource(R.string.settings_deepseek_context_placeholder)) },\n singleLine = false,\n minLines = 3,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u, c) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n\n@Composable\nprivate fun AlarmHistorySection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n var logText by remember { mutableStateOf(vm.alarmHistoryDump()) }\n val copiedMsg = stringResource(R.string.settings_alarm_history_copied)\n\n SectionCard(stringResource(R.string.settings_alarm_history_title)) {\n Text(\n stringResource(R.string.settings_alarm_history_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n OutlinedButton(onClick = { logText = vm.alarmHistoryDump() }) {\n Text(stringResource(R.string.settings_alarm_history_refresh))\n }\n Button(onClick = {\n scope.launch {\n val snapshot = vm.alarmHistoryWithCurrentState()\n logText = snapshot\n clipboard.setText(AnnotatedString(snapshot))\n snackbarHostState.showSnackbar(copiedMsg)\n }\n }) {\n Text(stringResource(R.string.settings_alarm_history_copy))\n }\n OutlinedButton(onClick = {\n vm.clearAlarmHistory()\n logText = \"\"\n }) {\n Text(stringResource(R.string.settings_alarm_history_clear))\n }\n }\n Spacer(Modifier.height(12.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 360.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.settings_alarm_history_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n }\n}\n"}, "92b756173d9a37160e99b61f3024a24230cead9f": {"t": 1785755948.563573, "n": 44644, "h": "75aa75d0f2460318", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.media.RingtoneManager\nimport android.os.Build\nimport android.util.Log\nimport androidx.activity.compose.BackHandler\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.ArrowBack\nimport androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Build\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.automirrored.filled.Send\nimport androidx.compose.material.icons.outlined.BugReport\nimport androidx.compose.material.icons.outlined.CalendarMonth\nimport androidx.compose.material.icons.outlined.CloudSync\nimport androidx.compose.material.icons.outlined.Hub\nimport androidx.compose.material.icons.outlined.Notifications\nimport androidx.compose.material.icons.outlined.Psychology\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.compose.material.icons.outlined.Tune\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n/** Settings category routes, used both for navigation and for the landing list. */\nobject SettingsCategory {\n const val GENERAL = \"general\"\n const val NOTIFICATIONS = \"notifications\"\n const val INTEGRATIONS = \"integrations\"\n const val DIAGNOSTICS = \"diagnostics\"\n const val DEBUG = \"debug\"\n}\n\n/** Per-integration routes, used by the Integrations landing list and navigation. */\nobject IntegrationCategory {\n const val TELEGRAM = \"telegram\"\n const val CALENDAR = \"calendar\"\n const val DEEPSEEK = \"deepseek\"\n const val DRIVE = \"drive\"\n}\n\n/**\n * Settings landing screen: a short list of categories that each drill into their\n * own screen — the standard Android settings pattern, rather than one long scroll.\n */\n@Composable\nfun SettingsScreen(\n onOpenCategory: (String) -> Unit = {},\n onEditGroup: (Long) -> Unit = {},\n vm: SettingsViewModel = hiltViewModel(),\n) {\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState())\n ) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n CategoryRow(\n icon = Icons.Outlined.Tune,\n title = stringResource(R.string.settings_cat_general),\n subtitle = stringResource(R.string.settings_cat_general_sub),\n onClick = { onOpenCategory(SettingsCategory.GENERAL) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Notifications,\n title = stringResource(R.string.settings_cat_notifications),\n subtitle = stringResource(R.string.settings_cat_notifications_sub),\n onClick = { onOpenCategory(SettingsCategory.NOTIFICATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Hub,\n title = stringResource(R.string.settings_cat_integrations),\n subtitle = stringResource(R.string.settings_cat_integrations_sub),\n onClick = { onOpenCategory(SettingsCategory.INTEGRATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.BugReport,\n title = stringResource(R.string.settings_cat_diagnostics),\n subtitle = stringResource(R.string.settings_cat_diagnostics_sub),\n onClick = { onOpenCategory(SettingsCategory.DIAGNOSTICS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Filled.Build,\n title = stringResource(R.string.settings_cat_debug),\n subtitle = stringResource(R.string.settings_cat_debug_sub),\n onClick = { onOpenCategory(SettingsCategory.DEBUG) }\n )\n }\n }\n }\n}\n\n@Composable\nprivate fun CategoryRow(\n icon: ImageVector,\n title: String,\n subtitle: String,\n onClick: () -> Unit,\n) {\n ListItem(\n headlineContent = { Text(title) },\n supportingContent = { Text(subtitle) },\n leadingContent = {\n Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)\n },\n trailingContent = {\n Icon(\n Icons.AutoMirrored.Filled.KeyboardArrowRight,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.outline\n )\n },\n colors = ListItemDefaults.colors(containerColor = Color.Transparent),\n modifier = Modifier.clickable(onClick = onClick)\n )\n}\n\n/** Shared scaffold for a settings category sub-screen: title + back arrow + scrolling content. */\n@Composable\nprivate fun CategoryScaffold(\n title: String,\n onBack: () -> Unit,\n content: @Composable ColumnScope.() -> Unit,\n) {\n BackHandler(onBack = onBack)\n Scaffold(\n topBar = {\n TopAppBar(\n title = { Text(title) },\n navigationIcon = {\n IconButton(onClick = onBack) {\n Icon(\n Icons.AutoMirrored.Filled.ArrowBack,\n contentDescription = stringResource(R.string.back)\n )\n }\n }\n )\n }\n ) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp),\n content = content\n )\n }\n}\n\n@Composable\nfun GeneralSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_general), onBack) {\n LanguageSection(vm)\n UpdateSection()\n }\n}\n\n@Composable\nfun NotificationsSettingsScreen(\n onBack: () -> Unit,\n onEditGroup: (Long) -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n // Editing the global chain saves per-keystroke; apply it to already-armed\n // reminders once, when this screen leaves composition (back, system back, or\n // navigating into a group editor — the re-arm is idempotent either way).\n DisposableEffect(Unit) {\n onDispose { vm.applyChainEditsIfDirty() }\n }\n CategoryScaffold(stringResource(R.string.settings_cat_notifications), onBack) {\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n PreAlarmSoundSection(state.preAlarmSoundUri, vm)\n GroupsSection(state.groups, onEditGroup, onAddGroup = { onEditGroup(0L) })\n }\n}\n\n@Composable\nprivate fun PreAlarmSoundSection(soundUri: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult\n val picked: android.net.Uri? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {\n result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, android.net.Uri::class.java)\n } else {\n @Suppress(\"DEPRECATION\")\n result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)\n }\n // A null picked URI means the user chose \"Silent\".\n vm.setPreAlarmSound(picked?.toString())\n }\n\n val currentLabel = when {\n soundUri == null -> stringResource(R.string.settings_prealarm_sound_default)\n soundUri.isBlank() -> stringResource(R.string.settings_prealarm_sound_silent)\n else -> remember(soundUri) {\n runCatching { RingtoneManager.getRingtone(ctx, android.net.Uri.parse(soundUri))?.getTitle(ctx) }\n .getOrNull()\n } ?: stringResource(R.string.settings_prealarm_sound_custom)\n }\n\n SectionCard(stringResource(R.string.settings_prealarm_sound_title)) {\n Text(\n stringResource(R.string.settings_prealarm_sound_desc),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline,\n )\n Spacer(Modifier.height(8.dp))\n Text(\n stringResource(R.string.settings_prealarm_sound_current, currentLabel),\n style = MaterialTheme.typography.bodyMedium,\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = {\n val existing = soundUri?.takeIf { it.isNotBlank() }?.let { android.net.Uri.parse(it) }\n val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {\n putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION)\n putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, ctx.getString(R.string.settings_prealarm_sound_title))\n putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)\n putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)\n putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))\n putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, existing)\n }\n launcher.launch(intent)\n }) { Text(stringResource(R.string.settings_prealarm_sound_choose)) }\n }\n}\n\n/**\n * Integrations landing screen: each integration drills into its own sub-screen,\n * mirroring the top-level settings landing pattern.\n */\n@Composable\nfun IntegrationsSettingsScreen(\n onBack: () -> Unit,\n onOpenIntegration: (String) -> Unit = {},\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_integrations), onBack) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n CategoryRow(\n icon = Icons.AutoMirrored.Filled.Send,\n title = stringResource(R.string.settings_telegram_title),\n subtitle = stringResource(R.string.settings_int_telegram_sub),\n onClick = { onOpenIntegration(IntegrationCategory.TELEGRAM) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.CalendarMonth,\n title = stringResource(R.string.trips_title),\n subtitle = stringResource(R.string.settings_int_calendar_sub),\n onClick = { onOpenIntegration(IntegrationCategory.CALENDAR) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Psychology,\n title = stringResource(R.string.settings_deepseek_title),\n subtitle = stringResource(R.string.settings_int_deepseek_sub),\n onClick = { onOpenIntegration(IntegrationCategory.DEEPSEEK) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.CloudSync,\n title = stringResource(R.string.settings_drive_title),\n subtitle = stringResource(R.string.settings_int_drive_sub),\n onClick = { onOpenIntegration(IntegrationCategory.DRIVE) }\n )\n }\n }\n}\n\n@Composable\nfun TelegramSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_telegram_title), onBack) {\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n }\n}\n\n@Composable\nfun CalendarSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.trips_title), onBack) {\n TripsSection(state, vm)\n }\n}\n\n@Composable\nfun DeepSeekSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_deepseek_title), onBack) {\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, state.deepSeekContext, vm)\n }\n}\n\n@Composable\nfun DriveSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_drive_title), onBack) {\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n }\n}\n\n@Composable\nfun DiagnosticsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_diagnostics), onBack) {\n AlarmHistorySection(vm)\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n val downloadFailedMsg = stringResource(R.string.settings_update_download_failed)\n SectionCard(stringResource(R.string.settings_update_title)) {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(stringResource(R.string.settings_update_checking), style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(R.string.settings_update_available), fontWeight = FontWeight.SemiBold)\n Text(\n stringResource(\n R.string.settings_update_build_change,\n s.info.currentVersionCode,\n s.info.remoteVersionCode\n ),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(downloadFailedMsg)\n }\n }\n }) { Text(stringResource(R.string.settings_update_action_update)) }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n stringResource(R.string.settings_update_downloading, (s.progress * 100).toInt()),\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(stringResource(R.string.settings_update_opening_installer), style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n stringResource(R.string.settings_update_up_to_date),\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_recheck)) }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_retry)) }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\nprivate fun stageTypeLabel(type: StageType): Int = app.nock.android.ui.components.stageTypeLabel(type)\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Box(\n modifier = Modifier\n .size(40.dp)\n .clip(CircleShape)\n .background(MaterialTheme.colorScheme.surfaceContainerHigh),\n contentAlignment = Alignment.Center\n ) {\n Icon(\n imageVector = app.nock.android.ui.components.stageIcon(stage.type),\n contentDescription = null,\n modifier = Modifier.size(20.dp),\n tint = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, onEditGroup: (Long) -> Unit, onAddGroup: () -> Unit) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, onClick = { onEditGroup(g.id) })\n HorizontalDivider()\n }\n TextButton(onClick = onAddGroup) {\n Icon(Icons.Filled.Add, contentDescription = null)\n Spacer(Modifier.width(4.dp))\n Text(stringResource(R.string.add))\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, onClick: () -> Unit) {\n Row(\n verticalAlignment = Alignment.CenterVertically,\n modifier = Modifier\n .fillMaxWidth()\n .clickable(onClick = onClick)\n .padding(vertical = 8.dp)\n ) {\n app.nock.android.ui.components.GroupAvatar(g, size = 28.dp)\n Spacer(Modifier.width(12.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = onClick) {\n Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))\n }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun TripsSection(state: SettingsState, vm: SettingsViewModel) {\n var enabled by remember(state.tripsEnabled) { mutableStateOf(state.tripsEnabled) }\n var key by remember(state.tomtomKey) { mutableStateOf(state.tomtomKey) }\n var home by remember(state.tripHomeAddress) { mutableStateOf(state.tripHomeAddress) }\n var buffer by remember(state.tripBufferMin) { mutableStateOf(state.tripBufferMin.toString()) }\n\n val permLauncher = rememberLauncherForActivityResult(\n ActivityResultContracts.RequestPermission()\n ) { granted -> if (granted) vm.onCalendarPermissionResult() }\n\n fun persist() = vm.setTrips(enabled, key, home, buffer.toIntOrNull() ?: state.tripBufferMin)\n\n SectionCard(stringResource(R.string.trips_title)) {\n Text(\n stringResource(R.string.trips_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(stringResource(R.string.trips_enable), modifier = Modifier.weight(1f))\n Switch(\n checked = enabled,\n onCheckedChange = {\n enabled = it\n if (it && !state.tripHasCalendarPermission) {\n permLauncher.launch(android.Manifest.permission.READ_CALENDAR)\n }\n persist()\n }\n )\n }\n\n if (enabled) {\n Spacer(Modifier.height(12.dp))\n OutlinedTextField(\n value = key,\n onValueChange = { key = it },\n label = { Text(stringResource(R.string.trips_tomtom_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = home,\n onValueChange = { home = it },\n label = { Text(stringResource(R.string.trips_home_address)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = buffer,\n onValueChange = { v -> buffer = v.filter { it.isDigit() }.take(3) },\n label = { Text(stringResource(R.string.trips_buffer_minutes)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(12.dp))\n androidx.compose.foundation.layout.FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { persist() }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { persist(); vm.testRoute() }) {\n Text(stringResource(R.string.trips_test_route))\n }\n OutlinedButton(\n onClick = { persist(); vm.syncTripsNow() },\n enabled = state.tripHasCalendarPermission,\n ) {\n Text(stringResource(R.string.trips_sync_now))\n }\n }\n state.tripStatus?.let {\n Spacer(Modifier.height(8.dp))\n Text(it, style = MaterialTheme.typography.bodySmall)\n }\n\n Spacer(Modifier.height(16.dp))\n if (!state.tripHasCalendarPermission) {\n Text(\n stringResource(R.string.trips_calendar_permission_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { permLauncher.launch(android.Manifest.permission.READ_CALENDAR) }) {\n Text(stringResource(R.string.trips_grant_calendar))\n }\n } else {\n Text(\n stringResource(R.string.trips_calendars_title),\n style = MaterialTheme.typography.bodyMedium,\n fontWeight = FontWeight.SemiBold\n )\n Text(\n stringResource(R.string.trips_calendars_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n // Only the calendars the user explicitly checks are watched;\n // nothing is selected by default.\n val selected = state.tripSelectedCalendarIds\n state.tripCalendars.forEach { cal ->\n Row(verticalAlignment = Alignment.CenterVertically) {\n Checkbox(\n checked = cal.id in selected,\n onCheckedChange = { checked ->\n val next = if (checked) selected + cal.id else selected - cal.id\n vm.setTripCalendars(next)\n }\n )\n Column {\n Text(cal.displayName, style = MaterialTheme.typography.bodyMedium)\n if (cal.accountName.isNotBlank()) {\n Text(\n cal.accountName,\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n }\n }\n }\n }\n }\n }\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, context: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n var c by remember(context) { mutableStateOf(context) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(16.dp))\n Text(\n stringResource(R.string.settings_deepseek_context_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c,\n onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_deepseek_context)) },\n placeholder = { Text(stringResource(R.string.settings_deepseek_context_placeholder)) },\n singleLine = false,\n minLines = 3,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u, c) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n\n@Composable\nprivate fun AlarmHistorySection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n var logText by remember { mutableStateOf(vm.alarmHistoryDump()) }\n val copiedMsg = stringResource(R.string.settings_alarm_history_copied)\n\n SectionCard(stringResource(R.string.settings_alarm_history_title)) {\n Text(\n stringResource(R.string.settings_alarm_history_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n OutlinedButton(onClick = { logText = vm.alarmHistoryDump() }) {\n Text(stringResource(R.string.settings_alarm_history_refresh))\n }\n Button(onClick = {\n scope.launch {\n val snapshot = vm.alarmHistoryWithCurrentState()\n logText = snapshot\n clipboard.setText(AnnotatedString(snapshot))\n snackbarHostState.showSnackbar(copiedMsg)\n }\n }) {\n Text(stringResource(R.string.settings_alarm_history_copy))\n }\n OutlinedButton(onClick = {\n vm.clearAlarmHistory()\n logText = \"\"\n }) {\n Text(stringResource(R.string.settings_alarm_history_clear))\n }\n }\n Spacer(Modifier.height(12.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 360.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.settings_alarm_history_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n }\n}\n"}, "9f2ad25e60416602e9c7d3f6d4c0de4bba57fd44": {"t": 1785755950.1711621, "n": 41649, "h": "01dc485943168359", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.media.RingtoneManager\nimport android.os.Build\nimport android.util.Log\nimport androidx.activity.compose.BackHandler\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.ArrowBack\nimport androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Build\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.BugReport\nimport androidx.compose.material.icons.outlined.Hub\nimport androidx.compose.material.icons.outlined.Notifications\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.compose.material.icons.outlined.Tune\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n/** Settings category routes, used both for navigation and for the landing list. */\nobject SettingsCategory {\n const val GENERAL = \"general\"\n const val NOTIFICATIONS = \"notifications\"\n const val INTEGRATIONS = \"integrations\"\n const val DIAGNOSTICS = \"diagnostics\"\n const val DEBUG = \"debug\"\n}\n\n/**\n * Settings landing screen: a short list of categories that each drill into their\n * own screen — the standard Android settings pattern, rather than one long scroll.\n */\n@Composable\nfun SettingsScreen(\n onOpenCategory: (String) -> Unit = {},\n onEditGroup: (Long) -> Unit = {},\n vm: SettingsViewModel = hiltViewModel(),\n) {\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState())\n ) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n CategoryRow(\n icon = Icons.Outlined.Tune,\n title = stringResource(R.string.settings_cat_general),\n subtitle = stringResource(R.string.settings_cat_general_sub),\n onClick = { onOpenCategory(SettingsCategory.GENERAL) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Notifications,\n title = stringResource(R.string.settings_cat_notifications),\n subtitle = stringResource(R.string.settings_cat_notifications_sub),\n onClick = { onOpenCategory(SettingsCategory.NOTIFICATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Hub,\n title = stringResource(R.string.settings_cat_integrations),\n subtitle = stringResource(R.string.settings_cat_integrations_sub),\n onClick = { onOpenCategory(SettingsCategory.INTEGRATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.BugReport,\n title = stringResource(R.string.settings_cat_diagnostics),\n subtitle = stringResource(R.string.settings_cat_diagnostics_sub),\n onClick = { onOpenCategory(SettingsCategory.DIAGNOSTICS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Filled.Build,\n title = stringResource(R.string.settings_cat_debug),\n subtitle = stringResource(R.string.settings_cat_debug_sub),\n onClick = { onOpenCategory(SettingsCategory.DEBUG) }\n )\n }\n }\n }\n}\n\n@Composable\nprivate fun CategoryRow(\n icon: ImageVector,\n title: String,\n subtitle: String,\n onClick: () -> Unit,\n) {\n ListItem(\n headlineContent = { Text(title) },\n supportingContent = { Text(subtitle) },\n leadingContent = {\n Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)\n },\n trailingContent = {\n Icon(\n Icons.AutoMirrored.Filled.KeyboardArrowRight,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.outline\n )\n },\n colors = ListItemDefaults.colors(containerColor = Color.Transparent),\n modifier = Modifier.clickable(onClick = onClick)\n )\n}\n\n/** Shared scaffold for a settings category sub-screen: title + back arrow + scrolling content. */\n@Composable\nprivate fun CategoryScaffold(\n title: String,\n onBack: () -> Unit,\n content: @Composable ColumnScope.() -> Unit,\n) {\n BackHandler(onBack = onBack)\n Scaffold(\n topBar = {\n TopAppBar(\n title = { Text(title) },\n navigationIcon = {\n IconButton(onClick = onBack) {\n Icon(\n Icons.AutoMirrored.Filled.ArrowBack,\n contentDescription = stringResource(R.string.back)\n )\n }\n }\n )\n }\n ) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp),\n content = content\n )\n }\n}\n\n@Composable\nfun GeneralSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_general), onBack) {\n LanguageSection(vm)\n UpdateSection()\n }\n}\n\n@Composable\nfun NotificationsSettingsScreen(\n onBack: () -> Unit,\n onEditGroup: (Long) -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n // Editing the global chain saves per-keystroke; apply it to already-armed\n // reminders once, when this screen leaves composition (back, system back, or\n // navigating into a group editor — the re-arm is idempotent either way).\n DisposableEffect(Unit) {\n onDispose { vm.applyChainEditsIfDirty() }\n }\n CategoryScaffold(stringResource(R.string.settings_cat_notifications), onBack) {\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n PreAlarmSoundSection(state.preAlarmSoundUri, vm)\n GroupsSection(state.groups, onEditGroup, onAddGroup = { onEditGroup(0L) })\n }\n}\n\n@Composable\nprivate fun PreAlarmSoundSection(soundUri: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult\n val picked: android.net.Uri? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {\n result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, android.net.Uri::class.java)\n } else {\n @Suppress(\"DEPRECATION\")\n result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)\n }\n // A null picked URI means the user chose \"Silent\".\n vm.setPreAlarmSound(picked?.toString())\n }\n\n val currentLabel = when {\n soundUri == null -> stringResource(R.string.settings_prealarm_sound_default)\n soundUri.isBlank() -> stringResource(R.string.settings_prealarm_sound_silent)\n else -> remember(soundUri) {\n runCatching { RingtoneManager.getRingtone(ctx, android.net.Uri.parse(soundUri))?.getTitle(ctx) }\n .getOrNull()\n } ?: stringResource(R.string.settings_prealarm_sound_custom)\n }\n\n SectionCard(stringResource(R.string.settings_prealarm_sound_title)) {\n Text(\n stringResource(R.string.settings_prealarm_sound_desc),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline,\n )\n Spacer(Modifier.height(8.dp))\n Text(\n stringResource(R.string.settings_prealarm_sound_current, currentLabel),\n style = MaterialTheme.typography.bodyMedium,\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = {\n val existing = soundUri?.takeIf { it.isNotBlank() }?.let { android.net.Uri.parse(it) }\n val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {\n putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION)\n putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, ctx.getString(R.string.settings_prealarm_sound_title))\n putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)\n putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)\n putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))\n putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, existing)\n }\n launcher.launch(intent)\n }) { Text(stringResource(R.string.settings_prealarm_sound_choose)) }\n }\n}\n\n@Composable\nfun IntegrationsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_cat_integrations), onBack) {\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n TripsSection(state, vm)\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, state.deepSeekContext, vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n }\n}\n\n@Composable\nfun DiagnosticsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_diagnostics), onBack) {\n AlarmHistorySection(vm)\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n val downloadFailedMsg = stringResource(R.string.settings_update_download_failed)\n SectionCard(stringResource(R.string.settings_update_title)) {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(stringResource(R.string.settings_update_checking), style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(R.string.settings_update_available), fontWeight = FontWeight.SemiBold)\n Text(\n stringResource(\n R.string.settings_update_build_change,\n s.info.currentVersionCode,\n s.info.remoteVersionCode\n ),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(downloadFailedMsg)\n }\n }\n }) { Text(stringResource(R.string.settings_update_action_update)) }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n stringResource(R.string.settings_update_downloading, (s.progress * 100).toInt()),\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(stringResource(R.string.settings_update_opening_installer), style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n stringResource(R.string.settings_update_up_to_date),\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_recheck)) }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_retry)) }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\nprivate fun stageTypeLabel(type: StageType): Int = app.nock.android.ui.components.stageTypeLabel(type)\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Box(\n modifier = Modifier\n .size(40.dp)\n .clip(CircleShape)\n .background(MaterialTheme.colorScheme.surfaceContainerHigh),\n contentAlignment = Alignment.Center\n ) {\n Icon(\n imageVector = app.nock.android.ui.components.stageIcon(stage.type),\n contentDescription = null,\n modifier = Modifier.size(20.dp),\n tint = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, onEditGroup: (Long) -> Unit, onAddGroup: () -> Unit) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, onClick = { onEditGroup(g.id) })\n HorizontalDivider()\n }\n TextButton(onClick = onAddGroup) {\n Icon(Icons.Filled.Add, contentDescription = null)\n Spacer(Modifier.width(4.dp))\n Text(stringResource(R.string.add))\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, onClick: () -> Unit) {\n Row(\n verticalAlignment = Alignment.CenterVertically,\n modifier = Modifier\n .fillMaxWidth()\n .clickable(onClick = onClick)\n .padding(vertical = 8.dp)\n ) {\n app.nock.android.ui.components.GroupAvatar(g, size = 28.dp)\n Spacer(Modifier.width(12.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = onClick) {\n Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))\n }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun TripsSection(state: SettingsState, vm: SettingsViewModel) {\n var enabled by remember(state.tripsEnabled) { mutableStateOf(state.tripsEnabled) }\n var key by remember(state.tomtomKey) { mutableStateOf(state.tomtomKey) }\n var home by remember(state.tripHomeAddress) { mutableStateOf(state.tripHomeAddress) }\n var buffer by remember(state.tripBufferMin) { mutableStateOf(state.tripBufferMin.toString()) }\n\n val permLauncher = rememberLauncherForActivityResult(\n ActivityResultContracts.RequestPermission()\n ) { granted -> if (granted) vm.onCalendarPermissionResult() }\n\n fun persist() = vm.setTrips(enabled, key, home, buffer.toIntOrNull() ?: state.tripBufferMin)\n\n SectionCard(stringResource(R.string.trips_title)) {\n Text(\n stringResource(R.string.trips_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(stringResource(R.string.trips_enable), modifier = Modifier.weight(1f))\n Switch(\n checked = enabled,\n onCheckedChange = {\n enabled = it\n if (it && !state.tripHasCalendarPermission) {\n permLauncher.launch(android.Manifest.permission.READ_CALENDAR)\n }\n persist()\n }\n )\n }\n\n if (enabled) {\n Spacer(Modifier.height(12.dp))\n OutlinedTextField(\n value = key,\n onValueChange = { key = it },\n label = { Text(stringResource(R.string.trips_tomtom_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = home,\n onValueChange = { home = it },\n label = { Text(stringResource(R.string.trips_home_address)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = buffer,\n onValueChange = { v -> buffer = v.filter { it.isDigit() }.take(3) },\n label = { Text(stringResource(R.string.trips_buffer_minutes)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(12.dp))\n androidx.compose.foundation.layout.FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { persist() }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { persist(); vm.testRoute() }) {\n Text(stringResource(R.string.trips_test_route))\n }\n OutlinedButton(\n onClick = { persist(); vm.syncTripsNow() },\n enabled = state.tripHasCalendarPermission,\n ) {\n Text(stringResource(R.string.trips_sync_now))\n }\n }\n state.tripStatus?.let {\n Spacer(Modifier.height(8.dp))\n Text(it, style = MaterialTheme.typography.bodySmall)\n }\n\n Spacer(Modifier.height(16.dp))\n if (!state.tripHasCalendarPermission) {\n Text(\n stringResource(R.string.trips_calendar_permission_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { permLauncher.launch(android.Manifest.permission.READ_CALENDAR) }) {\n Text(stringResource(R.string.trips_grant_calendar))\n }\n } else {\n Text(\n stringResource(R.string.trips_calendars_title),\n style = MaterialTheme.typography.bodyMedium,\n fontWeight = FontWeight.SemiBold\n )\n Text(\n stringResource(R.string.trips_calendars_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n // Only the calendars the user explicitly checks are watched;\n // nothing is selected by default.\n val selected = state.tripSelectedCalendarIds\n state.tripCalendars.forEach { cal ->\n Row(verticalAlignment = Alignment.CenterVertically) {\n Checkbox(\n checked = cal.id in selected,\n onCheckedChange = { checked ->\n val next = if (checked) selected + cal.id else selected - cal.id\n vm.setTripCalendars(next)\n }\n )\n Column {\n Text(cal.displayName, style = MaterialTheme.typography.bodyMedium)\n if (cal.accountName.isNotBlank()) {\n Text(\n cal.accountName,\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n }\n }\n }\n }\n }\n }\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, context: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n var c by remember(context) { mutableStateOf(context) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(16.dp))\n Text(\n stringResource(R.string.settings_deepseek_context_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c,\n onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_deepseek_context)) },\n placeholder = { Text(stringResource(R.string.settings_deepseek_context_placeholder)) },\n singleLine = false,\n minLines = 3,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u, c) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n\n@Composable\nprivate fun AlarmHistorySection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n var logText by remember { mutableStateOf(vm.alarmHistoryDump()) }\n val copiedMsg = stringResource(R.string.settings_alarm_history_copied)\n\n SectionCard(stringResource(R.string.settings_alarm_history_title)) {\n Text(\n stringResource(R.string.settings_alarm_history_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n OutlinedButton(onClick = { logText = vm.alarmHistoryDump() }) {\n Text(stringResource(R.string.settings_alarm_history_refresh))\n }\n Button(onClick = {\n scope.launch {\n val snapshot = vm.alarmHistoryWithCurrentState()\n logText = snapshot\n clipboard.setText(AnnotatedString(snapshot))\n snackbarHostState.showSnackbar(copiedMsg)\n }\n }) {\n Text(stringResource(R.string.settings_alarm_history_copy))\n }\n OutlinedButton(onClick = {\n vm.clearAlarmHistory()\n logText = \"\"\n }) {\n Text(stringResource(R.string.settings_alarm_history_clear))\n }\n }\n Spacer(Modifier.height(12.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 360.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.settings_alarm_history_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n }\n}\n"}, "798d969d6f84b3433aae3f3fab30b95aa2176685": {"t": 1785755951.6939836, "n": 41347, "h": "c802daafac96e8f4", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.media.RingtoneManager\nimport android.os.Build\nimport android.util.Log\nimport androidx.activity.compose.BackHandler\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.ArrowBack\nimport androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Build\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.BugReport\nimport androidx.compose.material.icons.outlined.Hub\nimport androidx.compose.material.icons.outlined.Notifications\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.compose.material.icons.outlined.Tune\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n/** Settings category routes, used both for navigation and for the landing list. */\nobject SettingsCategory {\n const val GENERAL = \"general\"\n const val NOTIFICATIONS = \"notifications\"\n const val INTEGRATIONS = \"integrations\"\n const val DIAGNOSTICS = \"diagnostics\"\n const val DEBUG = \"debug\"\n}\n\n/**\n * Settings landing screen: a short list of categories that each drill into their\n * own screen — the standard Android settings pattern, rather than one long scroll.\n */\n@Composable\nfun SettingsScreen(\n onOpenCategory: (String) -> Unit = {},\n onEditGroup: (Long) -> Unit = {},\n vm: SettingsViewModel = hiltViewModel(),\n) {\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState())\n ) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n CategoryRow(\n icon = Icons.Outlined.Tune,\n title = stringResource(R.string.settings_cat_general),\n subtitle = stringResource(R.string.settings_cat_general_sub),\n onClick = { onOpenCategory(SettingsCategory.GENERAL) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Notifications,\n title = stringResource(R.string.settings_cat_notifications),\n subtitle = stringResource(R.string.settings_cat_notifications_sub),\n onClick = { onOpenCategory(SettingsCategory.NOTIFICATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Hub,\n title = stringResource(R.string.settings_cat_integrations),\n subtitle = stringResource(R.string.settings_cat_integrations_sub),\n onClick = { onOpenCategory(SettingsCategory.INTEGRATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.BugReport,\n title = stringResource(R.string.settings_cat_diagnostics),\n subtitle = stringResource(R.string.settings_cat_diagnostics_sub),\n onClick = { onOpenCategory(SettingsCategory.DIAGNOSTICS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Filled.Build,\n title = stringResource(R.string.settings_cat_debug),\n subtitle = stringResource(R.string.settings_cat_debug_sub),\n onClick = { onOpenCategory(SettingsCategory.DEBUG) }\n )\n }\n }\n }\n}\n\n@Composable\nprivate fun CategoryRow(\n icon: ImageVector,\n title: String,\n subtitle: String,\n onClick: () -> Unit,\n) {\n ListItem(\n headlineContent = { Text(title) },\n supportingContent = { Text(subtitle) },\n leadingContent = {\n Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)\n },\n trailingContent = {\n Icon(\n Icons.AutoMirrored.Filled.KeyboardArrowRight,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.outline\n )\n },\n colors = ListItemDefaults.colors(containerColor = Color.Transparent),\n modifier = Modifier.clickable(onClick = onClick)\n )\n}\n\n/** Shared scaffold for a settings category sub-screen: title + back arrow + scrolling content. */\n@Composable\nprivate fun CategoryScaffold(\n title: String,\n onBack: () -> Unit,\n content: @Composable ColumnScope.() -> Unit,\n) {\n BackHandler(onBack = onBack)\n Scaffold(\n topBar = {\n TopAppBar(\n title = { Text(title) },\n navigationIcon = {\n IconButton(onClick = onBack) {\n Icon(\n Icons.AutoMirrored.Filled.ArrowBack,\n contentDescription = stringResource(R.string.back)\n )\n }\n }\n )\n }\n ) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp),\n content = content\n )\n }\n}\n\n@Composable\nfun GeneralSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_general), onBack) {\n LanguageSection(vm)\n UpdateSection()\n }\n}\n\n@Composable\nfun NotificationsSettingsScreen(\n onBack: () -> Unit,\n onEditGroup: (Long) -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n // Editing the global chain saves per-keystroke; apply it to already-armed\n // reminders once, when this screen leaves composition (back, system back, or\n // navigating into a group editor — the re-arm is idempotent either way).\n DisposableEffect(Unit) {\n onDispose { vm.applyChainEditsIfDirty() }\n }\n CategoryScaffold(stringResource(R.string.settings_cat_notifications), onBack) {\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n PreAlarmSoundSection(state.preAlarmSoundUri, vm)\n GroupsSection(state.groups, onEditGroup, onAddGroup = { onEditGroup(0L) })\n }\n}\n\n@Composable\nprivate fun PreAlarmSoundSection(soundUri: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult\n val picked: android.net.Uri? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {\n result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, android.net.Uri::class.java)\n } else {\n @Suppress(\"DEPRECATION\")\n result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)\n }\n // A null picked URI means the user chose \"Silent\".\n vm.setPreAlarmSound(picked?.toString())\n }\n\n val currentLabel = when {\n soundUri == null -> stringResource(R.string.settings_prealarm_sound_default)\n soundUri.isBlank() -> stringResource(R.string.settings_prealarm_sound_silent)\n else -> remember(soundUri) {\n runCatching { RingtoneManager.getRingtone(ctx, android.net.Uri.parse(soundUri))?.getTitle(ctx) }\n .getOrNull()\n } ?: stringResource(R.string.settings_prealarm_sound_custom)\n }\n\n SectionCard(stringResource(R.string.settings_prealarm_sound_title)) {\n Text(\n stringResource(R.string.settings_prealarm_sound_desc),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline,\n )\n Spacer(Modifier.height(8.dp))\n Text(\n stringResource(R.string.settings_prealarm_sound_current, currentLabel),\n style = MaterialTheme.typography.bodyMedium,\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = {\n val existing = soundUri?.takeIf { it.isNotBlank() }?.let { android.net.Uri.parse(it) }\n val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {\n putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION)\n putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, ctx.getString(R.string.settings_prealarm_sound_title))\n putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)\n putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)\n putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))\n putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, existing)\n }\n launcher.launch(intent)\n }) { Text(stringResource(R.string.settings_prealarm_sound_choose)) }\n }\n}\n\n@Composable\nfun IntegrationsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_cat_integrations), onBack) {\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n TripsSection(state, vm)\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, state.deepSeekContext, vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n }\n}\n\n@Composable\nfun DiagnosticsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_diagnostics), onBack) {\n AlarmHistorySection(vm)\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n val downloadFailedMsg = stringResource(R.string.settings_update_download_failed)\n SectionCard(stringResource(R.string.settings_update_title)) {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(stringResource(R.string.settings_update_checking), style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(R.string.settings_update_available), fontWeight = FontWeight.SemiBold)\n Text(\n stringResource(\n R.string.settings_update_build_change,\n s.info.currentVersionCode,\n s.info.remoteVersionCode\n ),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(downloadFailedMsg)\n }\n }\n }) { Text(stringResource(R.string.settings_update_action_update)) }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n stringResource(R.string.settings_update_downloading, (s.progress * 100).toInt()),\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(stringResource(R.string.settings_update_opening_installer), style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n stringResource(R.string.settings_update_up_to_date),\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_recheck)) }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_retry)) }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\nprivate fun stageTypeLabel(type: StageType): Int = app.nock.android.ui.components.stageTypeLabel(type)\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Box(\n modifier = Modifier\n .size(40.dp)\n .clip(CircleShape)\n .background(MaterialTheme.colorScheme.surfaceContainerHigh),\n contentAlignment = Alignment.Center\n ) {\n Icon(\n imageVector = app.nock.android.ui.components.stageIcon(stage.type),\n contentDescription = null,\n modifier = Modifier.size(20.dp),\n tint = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, onEditGroup: (Long) -> Unit, onAddGroup: () -> Unit) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, onClick = { onEditGroup(g.id) })\n HorizontalDivider()\n }\n TextButton(onClick = onAddGroup) {\n Icon(Icons.Filled.Add, contentDescription = null)\n Spacer(Modifier.width(4.dp))\n Text(stringResource(R.string.add))\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, onClick: () -> Unit) {\n Row(\n verticalAlignment = Alignment.CenterVertically,\n modifier = Modifier\n .fillMaxWidth()\n .clickable(onClick = onClick)\n .padding(vertical = 8.dp)\n ) {\n app.nock.android.ui.components.GroupAvatar(g, size = 28.dp)\n Spacer(Modifier.width(12.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = onClick) {\n Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))\n }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun TripsSection(state: SettingsState, vm: SettingsViewModel) {\n var enabled by remember(state.tripsEnabled) { mutableStateOf(state.tripsEnabled) }\n var key by remember(state.tomtomKey) { mutableStateOf(state.tomtomKey) }\n var home by remember(state.tripHomeAddress) { mutableStateOf(state.tripHomeAddress) }\n var buffer by remember(state.tripBufferMin) { mutableStateOf(state.tripBufferMin.toString()) }\n\n val permLauncher = rememberLauncherForActivityResult(\n ActivityResultContracts.RequestPermission()\n ) { granted -> if (granted) vm.onCalendarPermissionResult() }\n\n fun persist() = vm.setTrips(enabled, key, home, buffer.toIntOrNull() ?: state.tripBufferMin)\n\n SectionCard(stringResource(R.string.trips_title)) {\n Text(\n stringResource(R.string.trips_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(stringResource(R.string.trips_enable), modifier = Modifier.weight(1f))\n Switch(\n checked = enabled,\n onCheckedChange = {\n enabled = it\n if (it && !state.tripHasCalendarPermission) {\n permLauncher.launch(android.Manifest.permission.READ_CALENDAR)\n }\n persist()\n }\n )\n }\n\n if (enabled) {\n Spacer(Modifier.height(12.dp))\n OutlinedTextField(\n value = key,\n onValueChange = { key = it },\n label = { Text(stringResource(R.string.trips_tomtom_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = home,\n onValueChange = { home = it },\n label = { Text(stringResource(R.string.trips_home_address)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = buffer,\n onValueChange = { v -> buffer = v.filter { it.isDigit() }.take(3) },\n label = { Text(stringResource(R.string.trips_buffer_minutes)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { persist() }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { persist(); vm.testRoute() }) {\n Text(stringResource(R.string.trips_test_route))\n }\n }\n state.tripStatus?.let {\n Spacer(Modifier.height(8.dp))\n Text(it, style = MaterialTheme.typography.bodySmall)\n }\n\n Spacer(Modifier.height(16.dp))\n if (!state.tripHasCalendarPermission) {\n Text(\n stringResource(R.string.trips_calendar_permission_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { permLauncher.launch(android.Manifest.permission.READ_CALENDAR) }) {\n Text(stringResource(R.string.trips_grant_calendar))\n }\n } else {\n Text(\n stringResource(R.string.trips_calendars_title),\n style = MaterialTheme.typography.bodyMedium,\n fontWeight = FontWeight.SemiBold\n )\n Text(\n stringResource(R.string.trips_calendars_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n // Only the calendars the user explicitly checks are watched;\n // nothing is selected by default.\n val selected = state.tripSelectedCalendarIds\n state.tripCalendars.forEach { cal ->\n Row(verticalAlignment = Alignment.CenterVertically) {\n Checkbox(\n checked = cal.id in selected,\n onCheckedChange = { checked ->\n val next = if (checked) selected + cal.id else selected - cal.id\n vm.setTripCalendars(next)\n }\n )\n Column {\n Text(cal.displayName, style = MaterialTheme.typography.bodyMedium)\n if (cal.accountName.isNotBlank()) {\n Text(\n cal.accountName,\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n }\n }\n }\n }\n }\n }\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, context: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n var c by remember(context) { mutableStateOf(context) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(16.dp))\n Text(\n stringResource(R.string.settings_deepseek_context_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c,\n onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_deepseek_context)) },\n placeholder = { Text(stringResource(R.string.settings_deepseek_context_placeholder)) },\n singleLine = false,\n minLines = 3,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u, c) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n\n@Composable\nprivate fun AlarmHistorySection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n var logText by remember { mutableStateOf(vm.alarmHistoryDump()) }\n val copiedMsg = stringResource(R.string.settings_alarm_history_copied)\n\n SectionCard(stringResource(R.string.settings_alarm_history_title)) {\n Text(\n stringResource(R.string.settings_alarm_history_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n OutlinedButton(onClick = { logText = vm.alarmHistoryDump() }) {\n Text(stringResource(R.string.settings_alarm_history_refresh))\n }\n Button(onClick = {\n scope.launch {\n val snapshot = vm.alarmHistoryWithCurrentState()\n logText = snapshot\n clipboard.setText(AnnotatedString(snapshot))\n snackbarHostState.showSnackbar(copiedMsg)\n }\n }) {\n Text(stringResource(R.string.settings_alarm_history_copy))\n }\n OutlinedButton(onClick = {\n vm.clearAlarmHistory()\n logText = \"\"\n }) {\n Text(stringResource(R.string.settings_alarm_history_clear))\n }\n }\n Spacer(Modifier.height(12.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 360.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.settings_alarm_history_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n }\n}\n"}, "4809b188e7bda181ca7bcdb8d91e078cfde16927": {"t": 1785755953.1437383, "n": 38514, "h": "9232b872bc823cce", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.util.Log\nimport androidx.activity.compose.BackHandler\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.ArrowBack\nimport androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Build\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.BugReport\nimport androidx.compose.material.icons.outlined.Hub\nimport androidx.compose.material.icons.outlined.Notifications\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.compose.material.icons.outlined.Tune\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n/** Settings category routes, used both for navigation and for the landing list. */\nobject SettingsCategory {\n const val GENERAL = \"general\"\n const val NOTIFICATIONS = \"notifications\"\n const val INTEGRATIONS = \"integrations\"\n const val DIAGNOSTICS = \"diagnostics\"\n const val DEBUG = \"debug\"\n}\n\n/**\n * Settings landing screen: a short list of categories that each drill into their\n * own screen — the standard Android settings pattern, rather than one long scroll.\n */\n@Composable\nfun SettingsScreen(\n onOpenCategory: (String) -> Unit = {},\n onEditGroup: (Long) -> Unit = {},\n vm: SettingsViewModel = hiltViewModel(),\n) {\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState())\n ) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n CategoryRow(\n icon = Icons.Outlined.Tune,\n title = stringResource(R.string.settings_cat_general),\n subtitle = stringResource(R.string.settings_cat_general_sub),\n onClick = { onOpenCategory(SettingsCategory.GENERAL) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Notifications,\n title = stringResource(R.string.settings_cat_notifications),\n subtitle = stringResource(R.string.settings_cat_notifications_sub),\n onClick = { onOpenCategory(SettingsCategory.NOTIFICATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Hub,\n title = stringResource(R.string.settings_cat_integrations),\n subtitle = stringResource(R.string.settings_cat_integrations_sub),\n onClick = { onOpenCategory(SettingsCategory.INTEGRATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.BugReport,\n title = stringResource(R.string.settings_cat_diagnostics),\n subtitle = stringResource(R.string.settings_cat_diagnostics_sub),\n onClick = { onOpenCategory(SettingsCategory.DIAGNOSTICS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Filled.Build,\n title = stringResource(R.string.settings_cat_debug),\n subtitle = stringResource(R.string.settings_cat_debug_sub),\n onClick = { onOpenCategory(SettingsCategory.DEBUG) }\n )\n }\n }\n }\n}\n\n@Composable\nprivate fun CategoryRow(\n icon: ImageVector,\n title: String,\n subtitle: String,\n onClick: () -> Unit,\n) {\n ListItem(\n headlineContent = { Text(title) },\n supportingContent = { Text(subtitle) },\n leadingContent = {\n Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)\n },\n trailingContent = {\n Icon(\n Icons.AutoMirrored.Filled.KeyboardArrowRight,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.outline\n )\n },\n colors = ListItemDefaults.colors(containerColor = Color.Transparent),\n modifier = Modifier.clickable(onClick = onClick)\n )\n}\n\n/** Shared scaffold for a settings category sub-screen: title + back arrow + scrolling content. */\n@Composable\nprivate fun CategoryScaffold(\n title: String,\n onBack: () -> Unit,\n content: @Composable ColumnScope.() -> Unit,\n) {\n BackHandler(onBack = onBack)\n Scaffold(\n topBar = {\n TopAppBar(\n title = { Text(title) },\n navigationIcon = {\n IconButton(onClick = onBack) {\n Icon(\n Icons.AutoMirrored.Filled.ArrowBack,\n contentDescription = stringResource(R.string.back)\n )\n }\n }\n )\n }\n ) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp),\n content = content\n )\n }\n}\n\n@Composable\nfun GeneralSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_general), onBack) {\n LanguageSection(vm)\n UpdateSection()\n }\n}\n\n@Composable\nfun NotificationsSettingsScreen(\n onBack: () -> Unit,\n onEditGroup: (Long) -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n // Editing the global chain saves per-keystroke; apply it to already-armed\n // reminders once, when this screen leaves composition (back, system back, or\n // navigating into a group editor — the re-arm is idempotent either way).\n DisposableEffect(Unit) {\n onDispose { vm.applyChainEditsIfDirty() }\n }\n CategoryScaffold(stringResource(R.string.settings_cat_notifications), onBack) {\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n GroupsSection(state.groups, onEditGroup, onAddGroup = { onEditGroup(0L) })\n }\n}\n\n@Composable\nfun IntegrationsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_cat_integrations), onBack) {\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n TripsSection(state, vm)\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, state.deepSeekContext, vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n }\n}\n\n@Composable\nfun DiagnosticsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_diagnostics), onBack) {\n AlarmHistorySection(vm)\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n val downloadFailedMsg = stringResource(R.string.settings_update_download_failed)\n SectionCard(stringResource(R.string.settings_update_title)) {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(stringResource(R.string.settings_update_checking), style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(R.string.settings_update_available), fontWeight = FontWeight.SemiBold)\n Text(\n stringResource(\n R.string.settings_update_build_change,\n s.info.currentVersionCode,\n s.info.remoteVersionCode\n ),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(downloadFailedMsg)\n }\n }\n }) { Text(stringResource(R.string.settings_update_action_update)) }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n stringResource(R.string.settings_update_downloading, (s.progress * 100).toInt()),\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(stringResource(R.string.settings_update_opening_installer), style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n stringResource(R.string.settings_update_up_to_date),\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_recheck)) }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_retry)) }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\nprivate fun stageTypeLabel(type: StageType): Int = app.nock.android.ui.components.stageTypeLabel(type)\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Box(\n modifier = Modifier\n .size(40.dp)\n .clip(CircleShape)\n .background(MaterialTheme.colorScheme.surfaceContainerHigh),\n contentAlignment = Alignment.Center\n ) {\n Icon(\n imageVector = app.nock.android.ui.components.stageIcon(stage.type),\n contentDescription = null,\n modifier = Modifier.size(20.dp),\n tint = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, onEditGroup: (Long) -> Unit, onAddGroup: () -> Unit) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, onClick = { onEditGroup(g.id) })\n HorizontalDivider()\n }\n TextButton(onClick = onAddGroup) {\n Icon(Icons.Filled.Add, contentDescription = null)\n Spacer(Modifier.width(4.dp))\n Text(stringResource(R.string.add))\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, onClick: () -> Unit) {\n Row(\n verticalAlignment = Alignment.CenterVertically,\n modifier = Modifier\n .fillMaxWidth()\n .clickable(onClick = onClick)\n .padding(vertical = 8.dp)\n ) {\n app.nock.android.ui.components.GroupAvatar(g, size = 28.dp)\n Spacer(Modifier.width(12.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = onClick) {\n Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))\n }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun TripsSection(state: SettingsState, vm: SettingsViewModel) {\n var enabled by remember(state.tripsEnabled) { mutableStateOf(state.tripsEnabled) }\n var key by remember(state.tomtomKey) { mutableStateOf(state.tomtomKey) }\n var home by remember(state.tripHomeAddress) { mutableStateOf(state.tripHomeAddress) }\n var buffer by remember(state.tripBufferMin) { mutableStateOf(state.tripBufferMin.toString()) }\n\n val permLauncher = rememberLauncherForActivityResult(\n ActivityResultContracts.RequestPermission()\n ) { granted -> if (granted) vm.onCalendarPermissionResult() }\n\n fun persist() = vm.setTrips(enabled, key, home, buffer.toIntOrNull() ?: state.tripBufferMin)\n\n SectionCard(stringResource(R.string.trips_title)) {\n Text(\n stringResource(R.string.trips_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(stringResource(R.string.trips_enable), modifier = Modifier.weight(1f))\n Switch(\n checked = enabled,\n onCheckedChange = {\n enabled = it\n if (it && !state.tripHasCalendarPermission) {\n permLauncher.launch(android.Manifest.permission.READ_CALENDAR)\n }\n persist()\n }\n )\n }\n\n if (enabled) {\n Spacer(Modifier.height(12.dp))\n OutlinedTextField(\n value = key,\n onValueChange = { key = it },\n label = { Text(stringResource(R.string.trips_tomtom_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = home,\n onValueChange = { home = it },\n label = { Text(stringResource(R.string.trips_home_address)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = buffer,\n onValueChange = { v -> buffer = v.filter { it.isDigit() }.take(3) },\n label = { Text(stringResource(R.string.trips_buffer_minutes)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { persist() }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { persist(); vm.testRoute() }) {\n Text(stringResource(R.string.trips_test_route))\n }\n }\n state.tripStatus?.let {\n Spacer(Modifier.height(8.dp))\n Text(it, style = MaterialTheme.typography.bodySmall)\n }\n\n Spacer(Modifier.height(16.dp))\n if (!state.tripHasCalendarPermission) {\n Text(\n stringResource(R.string.trips_calendar_permission_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { permLauncher.launch(android.Manifest.permission.READ_CALENDAR) }) {\n Text(stringResource(R.string.trips_grant_calendar))\n }\n } else {\n Text(\n stringResource(R.string.trips_calendars_title),\n style = MaterialTheme.typography.bodyMedium,\n fontWeight = FontWeight.SemiBold\n )\n Text(\n stringResource(R.string.trips_calendars_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n // Only the calendars the user explicitly checks are watched;\n // nothing is selected by default.\n val selected = state.tripSelectedCalendarIds\n state.tripCalendars.forEach { cal ->\n Row(verticalAlignment = Alignment.CenterVertically) {\n Checkbox(\n checked = cal.id in selected,\n onCheckedChange = { checked ->\n val next = if (checked) selected + cal.id else selected - cal.id\n vm.setTripCalendars(next)\n }\n )\n Column {\n Text(cal.displayName, style = MaterialTheme.typography.bodyMedium)\n if (cal.accountName.isNotBlank()) {\n Text(\n cal.accountName,\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n }\n }\n }\n }\n }\n }\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, context: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n var c by remember(context) { mutableStateOf(context) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(16.dp))\n Text(\n stringResource(R.string.settings_deepseek_context_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c,\n onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_deepseek_context)) },\n placeholder = { Text(stringResource(R.string.settings_deepseek_context_placeholder)) },\n singleLine = false,\n minLines = 3,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u, c) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n\n@Composable\nprivate fun AlarmHistorySection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n var logText by remember { mutableStateOf(vm.alarmHistoryDump()) }\n val copiedMsg = stringResource(R.string.settings_alarm_history_copied)\n\n SectionCard(stringResource(R.string.settings_alarm_history_title)) {\n Text(\n stringResource(R.string.settings_alarm_history_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n OutlinedButton(onClick = { logText = vm.alarmHistoryDump() }) {\n Text(stringResource(R.string.settings_alarm_history_refresh))\n }\n Button(onClick = {\n scope.launch {\n val snapshot = vm.alarmHistoryWithCurrentState()\n logText = snapshot\n clipboard.setText(AnnotatedString(snapshot))\n snackbarHostState.showSnackbar(copiedMsg)\n }\n }) {\n Text(stringResource(R.string.settings_alarm_history_copy))\n }\n OutlinedButton(onClick = {\n vm.clearAlarmHistory()\n logText = \"\"\n }) {\n Text(stringResource(R.string.settings_alarm_history_clear))\n }\n }\n Spacer(Modifier.height(12.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 360.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.settings_alarm_history_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n }\n}\n"}, "ac7dc67aad55bf476b10ffaf5f669f7eb6c89393": {"t": 1785755954.9045143, "n": 38411, "h": "02025f3ab3948fdb", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.util.Log\nimport androidx.activity.compose.BackHandler\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.ArrowBack\nimport androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Build\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.BugReport\nimport androidx.compose.material.icons.outlined.Hub\nimport androidx.compose.material.icons.outlined.Notifications\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.compose.material.icons.outlined.Tune\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n/** Settings category routes, used both for navigation and for the landing list. */\nobject SettingsCategory {\n const val GENERAL = \"general\"\n const val NOTIFICATIONS = \"notifications\"\n const val INTEGRATIONS = \"integrations\"\n const val DIAGNOSTICS = \"diagnostics\"\n const val DEBUG = \"debug\"\n}\n\n/**\n * Settings landing screen: a short list of categories that each drill into their\n * own screen — the standard Android settings pattern, rather than one long scroll.\n */\n@Composable\nfun SettingsScreen(\n onOpenCategory: (String) -> Unit = {},\n onEditGroup: (Long) -> Unit = {},\n vm: SettingsViewModel = hiltViewModel(),\n) {\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState())\n ) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n CategoryRow(\n icon = Icons.Outlined.Tune,\n title = stringResource(R.string.settings_cat_general),\n subtitle = stringResource(R.string.settings_cat_general_sub),\n onClick = { onOpenCategory(SettingsCategory.GENERAL) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Notifications,\n title = stringResource(R.string.settings_cat_notifications),\n subtitle = stringResource(R.string.settings_cat_notifications_sub),\n onClick = { onOpenCategory(SettingsCategory.NOTIFICATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Hub,\n title = stringResource(R.string.settings_cat_integrations),\n subtitle = stringResource(R.string.settings_cat_integrations_sub),\n onClick = { onOpenCategory(SettingsCategory.INTEGRATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.BugReport,\n title = stringResource(R.string.settings_cat_diagnostics),\n subtitle = stringResource(R.string.settings_cat_diagnostics_sub),\n onClick = { onOpenCategory(SettingsCategory.DIAGNOSTICS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Filled.Build,\n title = stringResource(R.string.settings_cat_debug),\n subtitle = stringResource(R.string.settings_cat_debug_sub),\n onClick = { onOpenCategory(SettingsCategory.DEBUG) }\n )\n }\n }\n }\n}\n\n@Composable\nprivate fun CategoryRow(\n icon: ImageVector,\n title: String,\n subtitle: String,\n onClick: () -> Unit,\n) {\n ListItem(\n headlineContent = { Text(title) },\n supportingContent = { Text(subtitle) },\n leadingContent = {\n Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)\n },\n trailingContent = {\n Icon(\n Icons.AutoMirrored.Filled.KeyboardArrowRight,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.outline\n )\n },\n colors = ListItemDefaults.colors(containerColor = Color.Transparent),\n modifier = Modifier.clickable(onClick = onClick)\n )\n}\n\n/** Shared scaffold for a settings category sub-screen: title + back arrow + scrolling content. */\n@Composable\nprivate fun CategoryScaffold(\n title: String,\n onBack: () -> Unit,\n content: @Composable ColumnScope.() -> Unit,\n) {\n BackHandler(onBack = onBack)\n Scaffold(\n topBar = {\n TopAppBar(\n title = { Text(title) },\n navigationIcon = {\n IconButton(onClick = onBack) {\n Icon(\n Icons.AutoMirrored.Filled.ArrowBack,\n contentDescription = stringResource(R.string.back)\n )\n }\n }\n )\n }\n ) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp),\n content = content\n )\n }\n}\n\n@Composable\nfun GeneralSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_general), onBack) {\n LanguageSection(vm)\n UpdateSection()\n }\n}\n\n@Composable\nfun NotificationsSettingsScreen(\n onBack: () -> Unit,\n onEditGroup: (Long) -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n // Editing the global chain saves per-keystroke; apply it to already-armed\n // reminders once, when this screen leaves composition (back, system back, or\n // navigating into a group editor — the re-arm is idempotent either way).\n DisposableEffect(Unit) {\n onDispose { vm.applyChainEditsIfDirty() }\n }\n CategoryScaffold(stringResource(R.string.settings_cat_notifications), onBack) {\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n GroupsSection(state.groups, onEditGroup, onAddGroup = { onEditGroup(0L) })\n }\n}\n\n@Composable\nfun IntegrationsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_cat_integrations), onBack) {\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n TripsSection(state, vm)\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, state.deepSeekContext, vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n }\n}\n\n@Composable\nfun DiagnosticsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_diagnostics), onBack) {\n AlarmHistorySection(vm)\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n val downloadFailedMsg = stringResource(R.string.settings_update_download_failed)\n SectionCard(stringResource(R.string.settings_update_title)) {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(stringResource(R.string.settings_update_checking), style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(R.string.settings_update_available), fontWeight = FontWeight.SemiBold)\n Text(\n stringResource(\n R.string.settings_update_build_change,\n s.info.currentVersionCode,\n s.info.remoteVersionCode\n ),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(downloadFailedMsg)\n }\n }\n }) { Text(stringResource(R.string.settings_update_action_update)) }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n stringResource(R.string.settings_update_downloading, (s.progress * 100).toInt()),\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(stringResource(R.string.settings_update_opening_installer), style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n stringResource(R.string.settings_update_up_to_date),\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_recheck)) }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_retry)) }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\nprivate fun stageTypeLabel(type: StageType): Int = app.nock.android.ui.components.stageTypeLabel(type)\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Box(\n modifier = Modifier\n .size(40.dp)\n .clip(CircleShape)\n .background(MaterialTheme.colorScheme.surfaceContainerHigh),\n contentAlignment = Alignment.Center\n ) {\n Icon(\n imageVector = app.nock.android.ui.components.stageIcon(stage.type),\n contentDescription = null,\n modifier = Modifier.size(20.dp),\n tint = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, onEditGroup: (Long) -> Unit, onAddGroup: () -> Unit) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, onClick = { onEditGroup(g.id) })\n HorizontalDivider()\n }\n TextButton(onClick = onAddGroup) {\n Icon(Icons.Filled.Add, contentDescription = null)\n Spacer(Modifier.width(4.dp))\n Text(stringResource(R.string.add))\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, onClick: () -> Unit) {\n Row(\n verticalAlignment = Alignment.CenterVertically,\n modifier = Modifier\n .fillMaxWidth()\n .clickable(onClick = onClick)\n .padding(vertical = 8.dp)\n ) {\n app.nock.android.ui.components.GroupAvatar(g, size = 28.dp)\n Spacer(Modifier.width(12.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = onClick) {\n Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))\n }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun TripsSection(state: SettingsState, vm: SettingsViewModel) {\n var enabled by remember(state.tripsEnabled) { mutableStateOf(state.tripsEnabled) }\n var key by remember(state.tomtomKey) { mutableStateOf(state.tomtomKey) }\n var home by remember(state.tripHomeAddress) { mutableStateOf(state.tripHomeAddress) }\n var buffer by remember(state.tripBufferMin) { mutableStateOf(state.tripBufferMin.toString()) }\n\n val permLauncher = rememberLauncherForActivityResult(\n ActivityResultContracts.RequestPermission()\n ) { granted -> if (granted) vm.onCalendarPermissionResult() }\n\n fun persist() = vm.setTrips(enabled, key, home, buffer.toIntOrNull() ?: state.tripBufferMin)\n\n SectionCard(stringResource(R.string.trips_title)) {\n Text(\n stringResource(R.string.trips_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(stringResource(R.string.trips_enable), modifier = Modifier.weight(1f))\n Switch(\n checked = enabled,\n onCheckedChange = {\n enabled = it\n if (it && !state.tripHasCalendarPermission) {\n permLauncher.launch(android.Manifest.permission.READ_CALENDAR)\n }\n persist()\n }\n )\n }\n\n if (enabled) {\n Spacer(Modifier.height(12.dp))\n OutlinedTextField(\n value = key,\n onValueChange = { key = it },\n label = { Text(stringResource(R.string.trips_tomtom_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = home,\n onValueChange = { home = it },\n label = { Text(stringResource(R.string.trips_home_address)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = buffer,\n onValueChange = { v -> buffer = v.filter { it.isDigit() }.take(3) },\n label = { Text(stringResource(R.string.trips_buffer_minutes)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { persist() }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { persist(); vm.testRoute() }) {\n Text(stringResource(R.string.trips_test_route))\n }\n }\n state.tripStatus?.let {\n Spacer(Modifier.height(8.dp))\n Text(it, style = MaterialTheme.typography.bodySmall)\n }\n\n Spacer(Modifier.height(16.dp))\n if (!state.tripHasCalendarPermission) {\n Text(\n stringResource(R.string.trips_calendar_permission_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { permLauncher.launch(android.Manifest.permission.READ_CALENDAR) }) {\n Text(stringResource(R.string.trips_grant_calendar))\n }\n } else {\n Text(\n stringResource(R.string.trips_calendars_title),\n style = MaterialTheme.typography.bodyMedium,\n fontWeight = FontWeight.SemiBold\n )\n // Empty stored selection means \"all calendars\" — reflect that as\n // every box checked so the user can deselect from a sensible default.\n val allIds = state.tripCalendars.map { it.id }.toSet()\n val selected = state.tripSelectedCalendarIds.ifEmpty { allIds }\n state.tripCalendars.forEach { cal ->\n Row(verticalAlignment = Alignment.CenterVertically) {\n Checkbox(\n checked = cal.id in selected,\n onCheckedChange = { checked ->\n val next = if (checked) selected + cal.id else selected - cal.id\n vm.setTripCalendars(next)\n }\n )\n Column {\n Text(cal.displayName, style = MaterialTheme.typography.bodyMedium)\n if (cal.accountName.isNotBlank()) {\n Text(\n cal.accountName,\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n }\n }\n }\n }\n }\n }\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, context: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n var c by remember(context) { mutableStateOf(context) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(16.dp))\n Text(\n stringResource(R.string.settings_deepseek_context_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c,\n onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_deepseek_context)) },\n placeholder = { Text(stringResource(R.string.settings_deepseek_context_placeholder)) },\n singleLine = false,\n minLines = 3,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u, c) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n\n@Composable\nprivate fun AlarmHistorySection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n var logText by remember { mutableStateOf(vm.alarmHistoryDump()) }\n val copiedMsg = stringResource(R.string.settings_alarm_history_copied)\n\n SectionCard(stringResource(R.string.settings_alarm_history_title)) {\n Text(\n stringResource(R.string.settings_alarm_history_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n OutlinedButton(onClick = { logText = vm.alarmHistoryDump() }) {\n Text(stringResource(R.string.settings_alarm_history_refresh))\n }\n Button(onClick = {\n scope.launch {\n val snapshot = vm.alarmHistoryWithCurrentState()\n logText = snapshot\n clipboard.setText(AnnotatedString(snapshot))\n snackbarHostState.showSnackbar(copiedMsg)\n }\n }) {\n Text(stringResource(R.string.settings_alarm_history_copy))\n }\n OutlinedButton(onClick = {\n vm.clearAlarmHistory()\n logText = \"\"\n }) {\n Text(stringResource(R.string.settings_alarm_history_clear))\n }\n }\n Spacer(Modifier.height(12.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 360.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.settings_alarm_history_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n }\n}\n"}, "21c89a091549d77fb8036079580ea992a493bfa8": {"t": 1785755956.6390715, "n": 35872, "h": "a18f74f6201e9e3e", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.media.RingtoneManager\nimport android.os.Build\nimport android.util.Log\nimport androidx.activity.compose.BackHandler\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.ArrowBack\nimport androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Build\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.BugReport\nimport androidx.compose.material.icons.outlined.Hub\nimport androidx.compose.material.icons.outlined.Notifications\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.compose.material.icons.outlined.Tune\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n/** Settings category routes, used both for navigation and for the landing list. */\nobject SettingsCategory {\n const val GENERAL = \"general\"\n const val NOTIFICATIONS = \"notifications\"\n const val INTEGRATIONS = \"integrations\"\n const val DIAGNOSTICS = \"diagnostics\"\n const val DEBUG = \"debug\"\n}\n\n/**\n * Settings landing screen: a short list of categories that each drill into their\n * own screen — the standard Android settings pattern, rather than one long scroll.\n */\n@Composable\nfun SettingsScreen(\n onOpenCategory: (String) -> Unit = {},\n onEditGroup: (Long) -> Unit = {},\n vm: SettingsViewModel = hiltViewModel(),\n) {\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState())\n ) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n CategoryRow(\n icon = Icons.Outlined.Tune,\n title = stringResource(R.string.settings_cat_general),\n subtitle = stringResource(R.string.settings_cat_general_sub),\n onClick = { onOpenCategory(SettingsCategory.GENERAL) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Notifications,\n title = stringResource(R.string.settings_cat_notifications),\n subtitle = stringResource(R.string.settings_cat_notifications_sub),\n onClick = { onOpenCategory(SettingsCategory.NOTIFICATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Hub,\n title = stringResource(R.string.settings_cat_integrations),\n subtitle = stringResource(R.string.settings_cat_integrations_sub),\n onClick = { onOpenCategory(SettingsCategory.INTEGRATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.BugReport,\n title = stringResource(R.string.settings_cat_diagnostics),\n subtitle = stringResource(R.string.settings_cat_diagnostics_sub),\n onClick = { onOpenCategory(SettingsCategory.DIAGNOSTICS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Filled.Build,\n title = stringResource(R.string.settings_cat_debug),\n subtitle = stringResource(R.string.settings_cat_debug_sub),\n onClick = { onOpenCategory(SettingsCategory.DEBUG) }\n )\n }\n }\n }\n}\n\n@Composable\nprivate fun CategoryRow(\n icon: ImageVector,\n title: String,\n subtitle: String,\n onClick: () -> Unit,\n) {\n ListItem(\n headlineContent = { Text(title) },\n supportingContent = { Text(subtitle) },\n leadingContent = {\n Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)\n },\n trailingContent = {\n Icon(\n Icons.AutoMirrored.Filled.KeyboardArrowRight,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.outline\n )\n },\n colors = ListItemDefaults.colors(containerColor = Color.Transparent),\n modifier = Modifier.clickable(onClick = onClick)\n )\n}\n\n/** Shared scaffold for a settings category sub-screen: title + back arrow + scrolling content. */\n@Composable\nprivate fun CategoryScaffold(\n title: String,\n onBack: () -> Unit,\n content: @Composable ColumnScope.() -> Unit,\n) {\n BackHandler(onBack = onBack)\n Scaffold(\n topBar = {\n TopAppBar(\n title = { Text(title) },\n navigationIcon = {\n IconButton(onClick = onBack) {\n Icon(\n Icons.AutoMirrored.Filled.ArrowBack,\n contentDescription = stringResource(R.string.back)\n )\n }\n }\n )\n }\n ) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp),\n content = content\n )\n }\n}\n\n@Composable\nfun GeneralSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_general), onBack) {\n LanguageSection(vm)\n UpdateSection()\n }\n}\n\n@Composable\nfun NotificationsSettingsScreen(\n onBack: () -> Unit,\n onEditGroup: (Long) -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n // Editing the global chain saves per-keystroke; apply it to already-armed\n // reminders once, when this screen leaves composition (back, system back, or\n // navigating into a group editor — the re-arm is idempotent either way).\n DisposableEffect(Unit) {\n onDispose { vm.applyChainEditsIfDirty() }\n }\n CategoryScaffold(stringResource(R.string.settings_cat_notifications), onBack) {\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n PreAlarmSoundSection(state.preAlarmSoundUri, vm)\n GroupsSection(state.groups, onEditGroup, onAddGroup = { onEditGroup(0L) })\n }\n}\n\n@Composable\nprivate fun PreAlarmSoundSection(soundUri: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult\n val picked: android.net.Uri? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {\n result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, android.net.Uri::class.java)\n } else {\n @Suppress(\"DEPRECATION\")\n result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)\n }\n // A null picked URI means the user chose \"Silent\".\n vm.setPreAlarmSound(picked?.toString())\n }\n\n val currentLabel = when {\n soundUri == null -> stringResource(R.string.settings_prealarm_sound_default)\n soundUri.isBlank() -> stringResource(R.string.settings_prealarm_sound_silent)\n else -> remember(soundUri) {\n runCatching { RingtoneManager.getRingtone(ctx, android.net.Uri.parse(soundUri))?.getTitle(ctx) }\n .getOrNull()\n } ?: stringResource(R.string.settings_prealarm_sound_custom)\n }\n\n SectionCard(stringResource(R.string.settings_prealarm_sound_title)) {\n Text(\n stringResource(R.string.settings_prealarm_sound_desc),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline,\n )\n Spacer(Modifier.height(8.dp))\n Text(\n stringResource(R.string.settings_prealarm_sound_current, currentLabel),\n style = MaterialTheme.typography.bodyMedium,\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = {\n val existing = soundUri?.takeIf { it.isNotBlank() }?.let { android.net.Uri.parse(it) }\n val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {\n putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION)\n putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, ctx.getString(R.string.settings_prealarm_sound_title))\n putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)\n putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)\n putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))\n putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, existing)\n }\n launcher.launch(intent)\n }) { Text(stringResource(R.string.settings_prealarm_sound_choose)) }\n }\n}\n\n@Composable\nfun IntegrationsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_cat_integrations), onBack) {\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, state.deepSeekContext, vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n }\n}\n\n@Composable\nfun DiagnosticsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_diagnostics), onBack) {\n AlarmHistorySection(vm)\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n val downloadFailedMsg = stringResource(R.string.settings_update_download_failed)\n SectionCard(stringResource(R.string.settings_update_title)) {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(stringResource(R.string.settings_update_checking), style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(R.string.settings_update_available), fontWeight = FontWeight.SemiBold)\n Text(\n stringResource(\n R.string.settings_update_build_change,\n s.info.currentVersionCode,\n s.info.remoteVersionCode\n ),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(downloadFailedMsg)\n }\n }\n }) { Text(stringResource(R.string.settings_update_action_update)) }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n stringResource(R.string.settings_update_downloading, (s.progress * 100).toInt()),\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(stringResource(R.string.settings_update_opening_installer), style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n stringResource(R.string.settings_update_up_to_date),\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_recheck)) }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_retry)) }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\nprivate fun stageTypeLabel(type: StageType): Int = app.nock.android.ui.components.stageTypeLabel(type)\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Box(\n modifier = Modifier\n .size(40.dp)\n .clip(CircleShape)\n .background(MaterialTheme.colorScheme.surfaceContainerHigh),\n contentAlignment = Alignment.Center\n ) {\n Icon(\n imageVector = app.nock.android.ui.components.stageIcon(stage.type),\n contentDescription = null,\n modifier = Modifier.size(20.dp),\n tint = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, onEditGroup: (Long) -> Unit, onAddGroup: () -> Unit) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, onClick = { onEditGroup(g.id) })\n HorizontalDivider()\n }\n TextButton(onClick = onAddGroup) {\n Icon(Icons.Filled.Add, contentDescription = null)\n Spacer(Modifier.width(4.dp))\n Text(stringResource(R.string.add))\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, onClick: () -> Unit) {\n Row(\n verticalAlignment = Alignment.CenterVertically,\n modifier = Modifier\n .fillMaxWidth()\n .clickable(onClick = onClick)\n .padding(vertical = 8.dp)\n ) {\n app.nock.android.ui.components.GroupAvatar(g, size = 28.dp)\n Spacer(Modifier.width(12.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = onClick) {\n Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))\n }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, context: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n var c by remember(context) { mutableStateOf(context) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(16.dp))\n Text(\n stringResource(R.string.settings_deepseek_context_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c,\n onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_deepseek_context)) },\n placeholder = { Text(stringResource(R.string.settings_deepseek_context_placeholder)) },\n singleLine = false,\n minLines = 3,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u, c) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n\n@Composable\nprivate fun AlarmHistorySection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n var logText by remember { mutableStateOf(vm.alarmHistoryDump()) }\n val copiedMsg = stringResource(R.string.settings_alarm_history_copied)\n\n SectionCard(stringResource(R.string.settings_alarm_history_title)) {\n Text(\n stringResource(R.string.settings_alarm_history_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n OutlinedButton(onClick = { logText = vm.alarmHistoryDump() }) {\n Text(stringResource(R.string.settings_alarm_history_refresh))\n }\n Button(onClick = {\n scope.launch {\n val snapshot = vm.alarmHistoryWithCurrentState()\n logText = snapshot\n clipboard.setText(AnnotatedString(snapshot))\n snackbarHostState.showSnackbar(copiedMsg)\n }\n }) {\n Text(stringResource(R.string.settings_alarm_history_copy))\n }\n OutlinedButton(onClick = {\n vm.clearAlarmHistory()\n logText = \"\"\n }) {\n Text(stringResource(R.string.settings_alarm_history_clear))\n }\n }\n Spacer(Modifier.height(12.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 360.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.settings_alarm_history_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n }\n}\n"}, "6b8ab37fc9bad550d12e9d3709cbee989fe677dc": {"t": 1785755958.013248, "n": 33039, "h": "bc085f1d82b0de84", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.util.Log\nimport androidx.activity.compose.BackHandler\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.ArrowBack\nimport androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Build\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.BugReport\nimport androidx.compose.material.icons.outlined.Hub\nimport androidx.compose.material.icons.outlined.Notifications\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.compose.material.icons.outlined.Tune\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n/** Settings category routes, used both for navigation and for the landing list. */\nobject SettingsCategory {\n const val GENERAL = \"general\"\n const val NOTIFICATIONS = \"notifications\"\n const val INTEGRATIONS = \"integrations\"\n const val DIAGNOSTICS = \"diagnostics\"\n const val DEBUG = \"debug\"\n}\n\n/**\n * Settings landing screen: a short list of categories that each drill into their\n * own screen — the standard Android settings pattern, rather than one long scroll.\n */\n@Composable\nfun SettingsScreen(\n onOpenCategory: (String) -> Unit = {},\n onEditGroup: (Long) -> Unit = {},\n vm: SettingsViewModel = hiltViewModel(),\n) {\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState())\n ) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n CategoryRow(\n icon = Icons.Outlined.Tune,\n title = stringResource(R.string.settings_cat_general),\n subtitle = stringResource(R.string.settings_cat_general_sub),\n onClick = { onOpenCategory(SettingsCategory.GENERAL) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Notifications,\n title = stringResource(R.string.settings_cat_notifications),\n subtitle = stringResource(R.string.settings_cat_notifications_sub),\n onClick = { onOpenCategory(SettingsCategory.NOTIFICATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Hub,\n title = stringResource(R.string.settings_cat_integrations),\n subtitle = stringResource(R.string.settings_cat_integrations_sub),\n onClick = { onOpenCategory(SettingsCategory.INTEGRATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.BugReport,\n title = stringResource(R.string.settings_cat_diagnostics),\n subtitle = stringResource(R.string.settings_cat_diagnostics_sub),\n onClick = { onOpenCategory(SettingsCategory.DIAGNOSTICS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Filled.Build,\n title = stringResource(R.string.settings_cat_debug),\n subtitle = stringResource(R.string.settings_cat_debug_sub),\n onClick = { onOpenCategory(SettingsCategory.DEBUG) }\n )\n }\n }\n }\n}\n\n@Composable\nprivate fun CategoryRow(\n icon: ImageVector,\n title: String,\n subtitle: String,\n onClick: () -> Unit,\n) {\n ListItem(\n headlineContent = { Text(title) },\n supportingContent = { Text(subtitle) },\n leadingContent = {\n Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)\n },\n trailingContent = {\n Icon(\n Icons.AutoMirrored.Filled.KeyboardArrowRight,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.outline\n )\n },\n colors = ListItemDefaults.colors(containerColor = Color.Transparent),\n modifier = Modifier.clickable(onClick = onClick)\n )\n}\n\n/** Shared scaffold for a settings category sub-screen: title + back arrow + scrolling content. */\n@Composable\nprivate fun CategoryScaffold(\n title: String,\n onBack: () -> Unit,\n content: @Composable ColumnScope.() -> Unit,\n) {\n BackHandler(onBack = onBack)\n Scaffold(\n topBar = {\n TopAppBar(\n title = { Text(title) },\n navigationIcon = {\n IconButton(onClick = onBack) {\n Icon(\n Icons.AutoMirrored.Filled.ArrowBack,\n contentDescription = stringResource(R.string.back)\n )\n }\n }\n )\n }\n ) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp),\n content = content\n )\n }\n}\n\n@Composable\nfun GeneralSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_general), onBack) {\n LanguageSection(vm)\n UpdateSection()\n }\n}\n\n@Composable\nfun NotificationsSettingsScreen(\n onBack: () -> Unit,\n onEditGroup: (Long) -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n // Editing the global chain saves per-keystroke; apply it to already-armed\n // reminders once, when this screen leaves composition (back, system back, or\n // navigating into a group editor — the re-arm is idempotent either way).\n DisposableEffect(Unit) {\n onDispose { vm.applyChainEditsIfDirty() }\n }\n CategoryScaffold(stringResource(R.string.settings_cat_notifications), onBack) {\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n GroupsSection(state.groups, onEditGroup, onAddGroup = { onEditGroup(0L) })\n }\n}\n\n@Composable\nfun IntegrationsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_cat_integrations), onBack) {\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, state.deepSeekContext, vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n }\n}\n\n@Composable\nfun DiagnosticsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_diagnostics), onBack) {\n AlarmHistorySection(vm)\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n val downloadFailedMsg = stringResource(R.string.settings_update_download_failed)\n SectionCard(stringResource(R.string.settings_update_title)) {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(stringResource(R.string.settings_update_checking), style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(R.string.settings_update_available), fontWeight = FontWeight.SemiBold)\n Text(\n stringResource(\n R.string.settings_update_build_change,\n s.info.currentVersionCode,\n s.info.remoteVersionCode\n ),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(downloadFailedMsg)\n }\n }\n }) { Text(stringResource(R.string.settings_update_action_update)) }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n stringResource(R.string.settings_update_downloading, (s.progress * 100).toInt()),\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(stringResource(R.string.settings_update_opening_installer), style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n stringResource(R.string.settings_update_up_to_date),\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_recheck)) }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_retry)) }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\nprivate fun stageTypeLabel(type: StageType): Int = app.nock.android.ui.components.stageTypeLabel(type)\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Box(\n modifier = Modifier\n .size(40.dp)\n .clip(CircleShape)\n .background(MaterialTheme.colorScheme.surfaceContainerHigh),\n contentAlignment = Alignment.Center\n ) {\n Icon(\n imageVector = app.nock.android.ui.components.stageIcon(stage.type),\n contentDescription = null,\n modifier = Modifier.size(20.dp),\n tint = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, onEditGroup: (Long) -> Unit, onAddGroup: () -> Unit) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, onClick = { onEditGroup(g.id) })\n HorizontalDivider()\n }\n TextButton(onClick = onAddGroup) {\n Icon(Icons.Filled.Add, contentDescription = null)\n Spacer(Modifier.width(4.dp))\n Text(stringResource(R.string.add))\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, onClick: () -> Unit) {\n Row(\n verticalAlignment = Alignment.CenterVertically,\n modifier = Modifier\n .fillMaxWidth()\n .clickable(onClick = onClick)\n .padding(vertical = 8.dp)\n ) {\n app.nock.android.ui.components.GroupAvatar(g, size = 28.dp)\n Spacer(Modifier.width(12.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = onClick) {\n Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))\n }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, context: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n var c by remember(context) { mutableStateOf(context) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(16.dp))\n Text(\n stringResource(R.string.settings_deepseek_context_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c,\n onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_deepseek_context)) },\n placeholder = { Text(stringResource(R.string.settings_deepseek_context_placeholder)) },\n singleLine = false,\n minLines = 3,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u, c) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n\n@Composable\nprivate fun AlarmHistorySection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n var logText by remember { mutableStateOf(vm.alarmHistoryDump()) }\n val copiedMsg = stringResource(R.string.settings_alarm_history_copied)\n\n SectionCard(stringResource(R.string.settings_alarm_history_title)) {\n Text(\n stringResource(R.string.settings_alarm_history_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n OutlinedButton(onClick = { logText = vm.alarmHistoryDump() }) {\n Text(stringResource(R.string.settings_alarm_history_refresh))\n }\n Button(onClick = {\n scope.launch {\n val snapshot = vm.alarmHistoryWithCurrentState()\n logText = snapshot\n clipboard.setText(AnnotatedString(snapshot))\n snackbarHostState.showSnackbar(copiedMsg)\n }\n }) {\n Text(stringResource(R.string.settings_alarm_history_copy))\n }\n OutlinedButton(onClick = {\n vm.clearAlarmHistory()\n logText = \"\"\n }) {\n Text(stringResource(R.string.settings_alarm_history_clear))\n }\n }\n Spacer(Modifier.height(12.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 360.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.settings_alarm_history_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n }\n}\n"}, "7e364f5144cfeada8d93417cc3b498df8d26db4f": {"t": 1785755959.7350452, "n": 32715, "h": "66c227f722efe6ef", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.util.Log\nimport androidx.activity.compose.BackHandler\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.ArrowBack\nimport androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Build\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.BugReport\nimport androidx.compose.material.icons.outlined.Hub\nimport androidx.compose.material.icons.outlined.Notifications\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.compose.material.icons.outlined.Tune\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n/** Settings category routes, used both for navigation and for the landing list. */\nobject SettingsCategory {\n const val GENERAL = \"general\"\n const val NOTIFICATIONS = \"notifications\"\n const val INTEGRATIONS = \"integrations\"\n const val DIAGNOSTICS = \"diagnostics\"\n const val DEBUG = \"debug\"\n}\n\n/**\n * Settings landing screen: a short list of categories that each drill into their\n * own screen — the standard Android settings pattern, rather than one long scroll.\n */\n@Composable\nfun SettingsScreen(\n onOpenCategory: (String) -> Unit = {},\n onEditGroup: (Long) -> Unit = {},\n vm: SettingsViewModel = hiltViewModel(),\n) {\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState())\n ) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n CategoryRow(\n icon = Icons.Outlined.Tune,\n title = stringResource(R.string.settings_cat_general),\n subtitle = stringResource(R.string.settings_cat_general_sub),\n onClick = { onOpenCategory(SettingsCategory.GENERAL) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Notifications,\n title = stringResource(R.string.settings_cat_notifications),\n subtitle = stringResource(R.string.settings_cat_notifications_sub),\n onClick = { onOpenCategory(SettingsCategory.NOTIFICATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Hub,\n title = stringResource(R.string.settings_cat_integrations),\n subtitle = stringResource(R.string.settings_cat_integrations_sub),\n onClick = { onOpenCategory(SettingsCategory.INTEGRATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.BugReport,\n title = stringResource(R.string.settings_cat_diagnostics),\n subtitle = stringResource(R.string.settings_cat_diagnostics_sub),\n onClick = { onOpenCategory(SettingsCategory.DIAGNOSTICS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Filled.Build,\n title = stringResource(R.string.settings_cat_debug),\n subtitle = stringResource(R.string.settings_cat_debug_sub),\n onClick = { onOpenCategory(SettingsCategory.DEBUG) }\n )\n }\n }\n }\n}\n\n@Composable\nprivate fun CategoryRow(\n icon: ImageVector,\n title: String,\n subtitle: String,\n onClick: () -> Unit,\n) {\n ListItem(\n headlineContent = { Text(title) },\n supportingContent = { Text(subtitle) },\n leadingContent = {\n Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)\n },\n trailingContent = {\n Icon(\n Icons.AutoMirrored.Filled.KeyboardArrowRight,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.outline\n )\n },\n colors = ListItemDefaults.colors(containerColor = Color.Transparent),\n modifier = Modifier.clickable(onClick = onClick)\n )\n}\n\n/** Shared scaffold for a settings category sub-screen: title + back arrow + scrolling content. */\n@Composable\nprivate fun CategoryScaffold(\n title: String,\n onBack: () -> Unit,\n content: @Composable ColumnScope.() -> Unit,\n) {\n BackHandler(onBack = onBack)\n Scaffold(\n topBar = {\n TopAppBar(\n title = { Text(title) },\n navigationIcon = {\n IconButton(onClick = onBack) {\n Icon(\n Icons.AutoMirrored.Filled.ArrowBack,\n contentDescription = stringResource(R.string.back)\n )\n }\n }\n )\n }\n ) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp),\n content = content\n )\n }\n}\n\n@Composable\nfun GeneralSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_general), onBack) {\n LanguageSection(vm)\n UpdateSection()\n }\n}\n\n@Composable\nfun NotificationsSettingsScreen(\n onBack: () -> Unit,\n onEditGroup: (Long) -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_cat_notifications), onBack) {\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n GroupsSection(state.groups, onEditGroup, onAddGroup = { onEditGroup(0L) })\n }\n}\n\n@Composable\nfun IntegrationsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_cat_integrations), onBack) {\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, state.deepSeekContext, vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n }\n}\n\n@Composable\nfun DiagnosticsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_diagnostics), onBack) {\n AlarmHistorySection(vm)\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n val downloadFailedMsg = stringResource(R.string.settings_update_download_failed)\n SectionCard(stringResource(R.string.settings_update_title)) {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(stringResource(R.string.settings_update_checking), style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(R.string.settings_update_available), fontWeight = FontWeight.SemiBold)\n Text(\n stringResource(\n R.string.settings_update_build_change,\n s.info.currentVersionCode,\n s.info.remoteVersionCode\n ),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(downloadFailedMsg)\n }\n }\n }) { Text(stringResource(R.string.settings_update_action_update)) }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n stringResource(R.string.settings_update_downloading, (s.progress * 100).toInt()),\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(stringResource(R.string.settings_update_opening_installer), style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n stringResource(R.string.settings_update_up_to_date),\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_recheck)) }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_retry)) }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\nprivate fun stageTypeLabel(type: StageType): Int = app.nock.android.ui.components.stageTypeLabel(type)\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Box(\n modifier = Modifier\n .size(40.dp)\n .clip(CircleShape)\n .background(MaterialTheme.colorScheme.surfaceContainerHigh),\n contentAlignment = Alignment.Center\n ) {\n Icon(\n imageVector = app.nock.android.ui.components.stageIcon(stage.type),\n contentDescription = null,\n modifier = Modifier.size(20.dp),\n tint = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, onEditGroup: (Long) -> Unit, onAddGroup: () -> Unit) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, onClick = { onEditGroup(g.id) })\n HorizontalDivider()\n }\n TextButton(onClick = onAddGroup) {\n Icon(Icons.Filled.Add, contentDescription = null)\n Spacer(Modifier.width(4.dp))\n Text(stringResource(R.string.add))\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, onClick: () -> Unit) {\n Row(\n verticalAlignment = Alignment.CenterVertically,\n modifier = Modifier\n .fillMaxWidth()\n .clickable(onClick = onClick)\n .padding(vertical = 8.dp)\n ) {\n app.nock.android.ui.components.GroupAvatar(g, size = 28.dp)\n Spacer(Modifier.width(12.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = onClick) {\n Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))\n }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, context: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n var c by remember(context) { mutableStateOf(context) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(16.dp))\n Text(\n stringResource(R.string.settings_deepseek_context_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c,\n onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_deepseek_context)) },\n placeholder = { Text(stringResource(R.string.settings_deepseek_context_placeholder)) },\n singleLine = false,\n minLines = 3,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u, c) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n\n@Composable\nprivate fun AlarmHistorySection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n var logText by remember { mutableStateOf(vm.alarmHistoryDump()) }\n val copiedMsg = stringResource(R.string.settings_alarm_history_copied)\n\n SectionCard(stringResource(R.string.settings_alarm_history_title)) {\n Text(\n stringResource(R.string.settings_alarm_history_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n OutlinedButton(onClick = { logText = vm.alarmHistoryDump() }) {\n Text(stringResource(R.string.settings_alarm_history_refresh))\n }\n Button(onClick = {\n scope.launch {\n val snapshot = vm.alarmHistoryWithCurrentState()\n logText = snapshot\n clipboard.setText(AnnotatedString(snapshot))\n snackbarHostState.showSnackbar(copiedMsg)\n }\n }) {\n Text(stringResource(R.string.settings_alarm_history_copy))\n }\n OutlinedButton(onClick = {\n vm.clearAlarmHistory()\n logText = \"\"\n }) {\n Text(stringResource(R.string.settings_alarm_history_clear))\n }\n }\n Spacer(Modifier.height(12.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 360.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.settings_alarm_history_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n }\n}\n"}, "8fc21938cde53a8d13c5503fa71a279369e8d7f5": {"t": 1785755961.1075716, "n": 32277, "h": "18507808400a005e", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.util.Log\nimport androidx.activity.compose.BackHandler\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.ArrowBack\nimport androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.BugReport\nimport androidx.compose.material.icons.outlined.Hub\nimport androidx.compose.material.icons.outlined.Notifications\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.compose.material.icons.outlined.Tune\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n/** Settings category routes, used both for navigation and for the landing list. */\nobject SettingsCategory {\n const val GENERAL = \"general\"\n const val NOTIFICATIONS = \"notifications\"\n const val INTEGRATIONS = \"integrations\"\n const val DIAGNOSTICS = \"diagnostics\"\n}\n\n/**\n * Settings landing screen: a short list of categories that each drill into their\n * own screen — the standard Android settings pattern, rather than one long scroll.\n */\n@Composable\nfun SettingsScreen(\n onOpenCategory: (String) -> Unit = {},\n onEditGroup: (Long) -> Unit = {},\n vm: SettingsViewModel = hiltViewModel(),\n) {\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState())\n ) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n CategoryRow(\n icon = Icons.Outlined.Tune,\n title = stringResource(R.string.settings_cat_general),\n subtitle = stringResource(R.string.settings_cat_general_sub),\n onClick = { onOpenCategory(SettingsCategory.GENERAL) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Notifications,\n title = stringResource(R.string.settings_cat_notifications),\n subtitle = stringResource(R.string.settings_cat_notifications_sub),\n onClick = { onOpenCategory(SettingsCategory.NOTIFICATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Hub,\n title = stringResource(R.string.settings_cat_integrations),\n subtitle = stringResource(R.string.settings_cat_integrations_sub),\n onClick = { onOpenCategory(SettingsCategory.INTEGRATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.BugReport,\n title = stringResource(R.string.settings_cat_diagnostics),\n subtitle = stringResource(R.string.settings_cat_diagnostics_sub),\n onClick = { onOpenCategory(SettingsCategory.DIAGNOSTICS) }\n )\n }\n }\n }\n}\n\n@Composable\nprivate fun CategoryRow(\n icon: ImageVector,\n title: String,\n subtitle: String,\n onClick: () -> Unit,\n) {\n ListItem(\n headlineContent = { Text(title) },\n supportingContent = { Text(subtitle) },\n leadingContent = {\n Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)\n },\n trailingContent = {\n Icon(\n Icons.AutoMirrored.Filled.KeyboardArrowRight,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.outline\n )\n },\n colors = ListItemDefaults.colors(containerColor = Color.Transparent),\n modifier = Modifier.clickable(onClick = onClick)\n )\n}\n\n/** Shared scaffold for a settings category sub-screen: title + back arrow + scrolling content. */\n@Composable\nprivate fun CategoryScaffold(\n title: String,\n onBack: () -> Unit,\n content: @Composable ColumnScope.() -> Unit,\n) {\n BackHandler(onBack = onBack)\n Scaffold(\n topBar = {\n TopAppBar(\n title = { Text(title) },\n navigationIcon = {\n IconButton(onClick = onBack) {\n Icon(\n Icons.AutoMirrored.Filled.ArrowBack,\n contentDescription = stringResource(R.string.back)\n )\n }\n }\n )\n }\n ) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp),\n content = content\n )\n }\n}\n\n@Composable\nfun GeneralSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_general), onBack) {\n LanguageSection(vm)\n UpdateSection()\n }\n}\n\n@Composable\nfun NotificationsSettingsScreen(\n onBack: () -> Unit,\n onEditGroup: (Long) -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_cat_notifications), onBack) {\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n GroupsSection(state.groups, onEditGroup, onAddGroup = { onEditGroup(0L) })\n }\n}\n\n@Composable\nfun IntegrationsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_cat_integrations), onBack) {\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, state.deepSeekContext, vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n }\n}\n\n@Composable\nfun DiagnosticsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_diagnostics), onBack) {\n AlarmHistorySection(vm)\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n val downloadFailedMsg = stringResource(R.string.settings_update_download_failed)\n SectionCard(stringResource(R.string.settings_update_title)) {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(stringResource(R.string.settings_update_checking), style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(R.string.settings_update_available), fontWeight = FontWeight.SemiBold)\n Text(\n stringResource(\n R.string.settings_update_build_change,\n s.info.currentVersionCode,\n s.info.remoteVersionCode\n ),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(downloadFailedMsg)\n }\n }\n }) { Text(stringResource(R.string.settings_update_action_update)) }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n stringResource(R.string.settings_update_downloading, (s.progress * 100).toInt()),\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(stringResource(R.string.settings_update_opening_installer), style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n stringResource(R.string.settings_update_up_to_date),\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_recheck)) }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_retry)) }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\nprivate fun stageTypeLabel(type: StageType): Int = app.nock.android.ui.components.stageTypeLabel(type)\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Box(\n modifier = Modifier\n .size(40.dp)\n .clip(CircleShape)\n .background(MaterialTheme.colorScheme.surfaceContainerHigh),\n contentAlignment = Alignment.Center\n ) {\n Icon(\n imageVector = app.nock.android.ui.components.stageIcon(stage.type),\n contentDescription = null,\n modifier = Modifier.size(20.dp),\n tint = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, onEditGroup: (Long) -> Unit, onAddGroup: () -> Unit) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, onClick = { onEditGroup(g.id) })\n HorizontalDivider()\n }\n TextButton(onClick = onAddGroup) {\n Icon(Icons.Filled.Add, contentDescription = null)\n Spacer(Modifier.width(4.dp))\n Text(stringResource(R.string.add))\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, onClick: () -> Unit) {\n Row(\n verticalAlignment = Alignment.CenterVertically,\n modifier = Modifier\n .fillMaxWidth()\n .clickable(onClick = onClick)\n .padding(vertical = 8.dp)\n ) {\n app.nock.android.ui.components.GroupAvatar(g, size = 28.dp)\n Spacer(Modifier.width(12.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = onClick) {\n Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))\n }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, context: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n var c by remember(context) { mutableStateOf(context) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(16.dp))\n Text(\n stringResource(R.string.settings_deepseek_context_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c,\n onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_deepseek_context)) },\n placeholder = { Text(stringResource(R.string.settings_deepseek_context_placeholder)) },\n singleLine = false,\n minLines = 3,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u, c) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n\n@Composable\nprivate fun AlarmHistorySection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n var logText by remember { mutableStateOf(vm.alarmHistoryDump()) }\n val copiedMsg = stringResource(R.string.settings_alarm_history_copied)\n\n SectionCard(stringResource(R.string.settings_alarm_history_title)) {\n Text(\n stringResource(R.string.settings_alarm_history_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n OutlinedButton(onClick = { logText = vm.alarmHistoryDump() }) {\n Text(stringResource(R.string.settings_alarm_history_refresh))\n }\n Button(onClick = {\n scope.launch {\n val snapshot = vm.alarmHistoryWithCurrentState()\n logText = snapshot\n clipboard.setText(AnnotatedString(snapshot))\n snackbarHostState.showSnackbar(copiedMsg)\n }\n }) {\n Text(stringResource(R.string.settings_alarm_history_copy))\n }\n OutlinedButton(onClick = {\n vm.clearAlarmHistory()\n logText = \"\"\n }) {\n Text(stringResource(R.string.settings_alarm_history_clear))\n }\n }\n Spacer(Modifier.height(12.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 360.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.settings_alarm_history_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n }\n}\n"}, "fccc4b1f2a478f1c8f3d888af4cad1671936a375": {"t": 1785755962.5856411, "n": 27168, "h": "04cc914edbbc0003", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.util.Log\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n@Composable\nfun SettingsScreen(\n onEditGroup: (Long) -> Unit = {},\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp)\n ) {\n LanguageSection(vm)\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n GroupsSection(state.groups, onEditGroup, onAddGroup = { onEditGroup(0L) })\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, state.deepSeekContext, vm)\n AlarmHistorySection(vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n UpdateSection()\n }\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n val downloadFailedMsg = stringResource(R.string.settings_update_download_failed)\n SectionCard(stringResource(R.string.settings_update_title)) {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(stringResource(R.string.settings_update_checking), style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(R.string.settings_update_available), fontWeight = FontWeight.SemiBold)\n Text(\n stringResource(\n R.string.settings_update_build_change,\n s.info.currentVersionCode,\n s.info.remoteVersionCode\n ),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(downloadFailedMsg)\n }\n }\n }) { Text(stringResource(R.string.settings_update_action_update)) }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n stringResource(R.string.settings_update_downloading, (s.progress * 100).toInt()),\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(stringResource(R.string.settings_update_opening_installer), style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n stringResource(R.string.settings_update_up_to_date),\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_recheck)) }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_retry)) }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\nprivate fun stageTypeLabel(type: StageType): Int = app.nock.android.ui.components.stageTypeLabel(type)\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Box(\n modifier = Modifier\n .size(40.dp)\n .clip(CircleShape)\n .background(MaterialTheme.colorScheme.surfaceContainerHigh),\n contentAlignment = Alignment.Center\n ) {\n Icon(\n imageVector = app.nock.android.ui.components.stageIcon(stage.type),\n contentDescription = null,\n modifier = Modifier.size(20.dp),\n tint = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, onEditGroup: (Long) -> Unit, onAddGroup: () -> Unit) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, onClick = { onEditGroup(g.id) })\n HorizontalDivider()\n }\n TextButton(onClick = onAddGroup) {\n Icon(Icons.Filled.Add, contentDescription = null)\n Spacer(Modifier.width(4.dp))\n Text(stringResource(R.string.add))\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, onClick: () -> Unit) {\n Row(\n verticalAlignment = Alignment.CenterVertically,\n modifier = Modifier\n .fillMaxWidth()\n .clickable(onClick = onClick)\n .padding(vertical = 8.dp)\n ) {\n app.nock.android.ui.components.GroupAvatar(g, size = 28.dp)\n Spacer(Modifier.width(12.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = onClick) {\n Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))\n }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, context: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n var c by remember(context) { mutableStateOf(context) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(16.dp))\n Text(\n stringResource(R.string.settings_deepseek_context_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c,\n onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_deepseek_context)) },\n placeholder = { Text(stringResource(R.string.settings_deepseek_context_placeholder)) },\n singleLine = false,\n minLines = 3,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u, c) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n\n@Composable\nprivate fun AlarmHistorySection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n var logText by remember { mutableStateOf(vm.alarmHistoryDump()) }\n val copiedMsg = stringResource(R.string.settings_alarm_history_copied)\n\n SectionCard(stringResource(R.string.settings_alarm_history_title)) {\n Text(\n stringResource(R.string.settings_alarm_history_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n OutlinedButton(onClick = { logText = vm.alarmHistoryDump() }) {\n Text(stringResource(R.string.settings_alarm_history_refresh))\n }\n Button(onClick = {\n scope.launch {\n val snapshot = vm.alarmHistoryWithCurrentState()\n logText = snapshot\n clipboard.setText(AnnotatedString(snapshot))\n snackbarHostState.showSnackbar(copiedMsg)\n }\n }) {\n Text(stringResource(R.string.settings_alarm_history_copy))\n }\n OutlinedButton(onClick = {\n vm.clearAlarmHistory()\n logText = \"\"\n }) {\n Text(stringResource(R.string.settings_alarm_history_clear))\n }\n }\n Spacer(Modifier.height(12.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 360.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.settings_alarm_history_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n }\n}\n"}, "b15ebdba7b4b9ca15e5cfc2454e72a6cf53db5fe": {"t": 1785755964.0239727, "n": 32217, "h": "bf4ad2fc0064579f", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.util.Log\nimport androidx.activity.compose.BackHandler\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.ArrowBack\nimport androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.BugReport\nimport androidx.compose.material.icons.outlined.Hub\nimport androidx.compose.material.icons.outlined.Notifications\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.compose.material.icons.outlined.Tune\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n/** Settings category routes, used both for navigation and for the landing list. */\nobject SettingsCategory {\n const val GENERAL = \"general\"\n const val NOTIFICATIONS = \"notifications\"\n const val INTEGRATIONS = \"integrations\"\n const val DIAGNOSTICS = \"diagnostics\"\n}\n\n/**\n * Settings landing screen: a short list of categories that each drill into their\n * own screen — the standard Android settings pattern, rather than one long scroll.\n */\n@Composable\nfun SettingsScreen(\n onOpenCategory: (String) -> Unit = {},\n onEditGroup: (Long) -> Unit = {},\n vm: SettingsViewModel = hiltViewModel(),\n) {\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState())\n ) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n CategoryRow(\n icon = Icons.Outlined.Tune,\n title = stringResource(R.string.settings_cat_general),\n subtitle = stringResource(R.string.settings_cat_general_sub),\n onClick = { onOpenCategory(SettingsCategory.GENERAL) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Notifications,\n title = stringResource(R.string.settings_cat_notifications),\n subtitle = stringResource(R.string.settings_cat_notifications_sub),\n onClick = { onOpenCategory(SettingsCategory.NOTIFICATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.Hub,\n title = stringResource(R.string.settings_cat_integrations),\n subtitle = stringResource(R.string.settings_cat_integrations_sub),\n onClick = { onOpenCategory(SettingsCategory.INTEGRATIONS) }\n )\n HorizontalDivider()\n CategoryRow(\n icon = Icons.Outlined.BugReport,\n title = stringResource(R.string.settings_cat_diagnostics),\n subtitle = stringResource(R.string.settings_cat_diagnostics_sub),\n onClick = { onOpenCategory(SettingsCategory.DIAGNOSTICS) }\n )\n }\n }\n }\n}\n\n@Composable\nprivate fun CategoryRow(\n icon: ImageVector,\n title: String,\n subtitle: String,\n onClick: () -> Unit,\n) {\n ListItem(\n headlineContent = { Text(title) },\n supportingContent = { Text(subtitle) },\n leadingContent = {\n Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)\n },\n trailingContent = {\n Icon(\n Icons.AutoMirrored.Filled.KeyboardArrowRight,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.outline\n )\n },\n colors = ListItemDefaults.colors(containerColor = Color.Transparent),\n modifier = Modifier.clickable(onClick = onClick)\n )\n}\n\n/** Shared scaffold for a settings category sub-screen: title + back arrow + scrolling content. */\n@Composable\nprivate fun CategoryScaffold(\n title: String,\n onBack: () -> Unit,\n content: @Composable ColumnScope.() -> Unit,\n) {\n BackHandler(onBack = onBack)\n Scaffold(\n topBar = {\n TopAppBar(\n title = { Text(title) },\n navigationIcon = {\n IconButton(onClick = onBack) {\n Icon(\n Icons.AutoMirrored.Filled.ArrowBack,\n contentDescription = stringResource(R.string.back)\n )\n }\n }\n )\n }\n ) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp),\n content = content\n )\n }\n}\n\n@Composable\nfun GeneralSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_general), onBack) {\n LanguageSection(vm)\n UpdateSection()\n }\n}\n\n@Composable\nfun NotificationsSettingsScreen(\n onBack: () -> Unit,\n onEditGroup: (Long) -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_cat_notifications), onBack) {\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n GroupsSection(state.groups, onEditGroup, onAddGroup = { onEditGroup(0L) })\n }\n}\n\n@Composable\nfun IntegrationsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n CategoryScaffold(stringResource(R.string.settings_cat_integrations), onBack) {\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, state.deepSeekContext, vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n }\n}\n\n@Composable\nfun DiagnosticsSettingsScreen(\n onBack: () -> Unit,\n vm: SettingsViewModel = hiltViewModel(),\n) {\n CategoryScaffold(stringResource(R.string.settings_cat_diagnostics), onBack) {\n AlarmHistorySection(vm)\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n val downloadFailedMsg = stringResource(R.string.settings_update_download_failed)\n SectionCard(stringResource(R.string.settings_update_title)) {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(stringResource(R.string.settings_update_checking), style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(R.string.settings_update_available), fontWeight = FontWeight.SemiBold)\n Text(\n stringResource(\n R.string.settings_update_build_change,\n s.info.currentVersionCode,\n s.info.remoteVersionCode\n ),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(downloadFailedMsg)\n }\n }\n }) { Text(stringResource(R.string.settings_update_action_update)) }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n stringResource(R.string.settings_update_downloading, (s.progress * 100).toInt()),\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(stringResource(R.string.settings_update_opening_installer), style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n stringResource(R.string.settings_update_up_to_date),\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_recheck)) }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_retry)) }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\nprivate fun stageTypeLabel(type: StageType): Int = app.nock.android.ui.components.stageTypeLabel(type)\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Box(\n modifier = Modifier\n .size(40.dp)\n .clip(CircleShape)\n .background(MaterialTheme.colorScheme.surfaceContainerHigh),\n contentAlignment = Alignment.Center\n ) {\n Icon(\n imageVector = app.nock.android.ui.components.stageIcon(stage.type),\n contentDescription = null,\n modifier = Modifier.size(20.dp),\n tint = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, onEditGroup: (Long) -> Unit, onAddGroup: () -> Unit) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, onClick = { onEditGroup(g.id) })\n HorizontalDivider()\n }\n TextButton(onClick = onAddGroup) {\n Icon(Icons.Filled.Add, contentDescription = null)\n Spacer(Modifier.width(4.dp))\n Text(stringResource(R.string.add))\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, onClick: () -> Unit) {\n Row(\n verticalAlignment = Alignment.CenterVertically,\n modifier = Modifier\n .fillMaxWidth()\n .clickable(onClick = onClick)\n .padding(vertical = 8.dp)\n ) {\n app.nock.android.ui.components.GroupAvatar(g, size = 28.dp)\n Spacer(Modifier.width(12.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = onClick) {\n Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))\n }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, context: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n var c by remember(context) { mutableStateOf(context) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(16.dp))\n Text(\n stringResource(R.string.settings_deepseek_context_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c,\n onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_deepseek_context)) },\n placeholder = { Text(stringResource(R.string.settings_deepseek_context_placeholder)) },\n singleLine = false,\n minLines = 3,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u, c) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n\n@Composable\nprivate fun AlarmHistorySection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n var logText by remember { mutableStateOf(vm.alarmHistoryDump()) }\n val copiedMsg = stringResource(R.string.settings_alarm_history_copied)\n\n SectionCard(stringResource(R.string.settings_alarm_history_title)) {\n Text(\n stringResource(R.string.settings_alarm_history_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n OutlinedButton(onClick = { logText = vm.alarmHistoryDump() }) {\n Text(stringResource(R.string.settings_alarm_history_refresh))\n }\n Button(onClick = {\n val snapshot = vm.alarmHistoryDump()\n logText = snapshot\n clipboard.setText(AnnotatedString(snapshot))\n scope.launch { snackbarHostState.showSnackbar(copiedMsg) }\n }) {\n Text(stringResource(R.string.settings_alarm_history_copy))\n }\n OutlinedButton(onClick = {\n vm.clearAlarmHistory()\n logText = \"\"\n }) {\n Text(stringResource(R.string.settings_alarm_history_clear))\n }\n }\n Spacer(Modifier.height(12.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 360.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.settings_alarm_history_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n }\n}\n"}, "b640818ae5c827a2beaeab222b10db628e45486f": {"t": 1785755965.4855993, "n": 27108, "h": "73d7bc421d6af6f6", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.util.Log\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n@Composable\nfun SettingsScreen(\n onEditGroup: (Long) -> Unit = {},\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp)\n ) {\n LanguageSection(vm)\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n GroupsSection(state.groups, onEditGroup, onAddGroup = { onEditGroup(0L) })\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, state.deepSeekContext, vm)\n AlarmHistorySection(vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n UpdateSection()\n }\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n val downloadFailedMsg = stringResource(R.string.settings_update_download_failed)\n SectionCard(stringResource(R.string.settings_update_title)) {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(stringResource(R.string.settings_update_checking), style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(R.string.settings_update_available), fontWeight = FontWeight.SemiBold)\n Text(\n stringResource(\n R.string.settings_update_build_change,\n s.info.currentVersionCode,\n s.info.remoteVersionCode\n ),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(downloadFailedMsg)\n }\n }\n }) { Text(stringResource(R.string.settings_update_action_update)) }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n stringResource(R.string.settings_update_downloading, (s.progress * 100).toInt()),\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(stringResource(R.string.settings_update_opening_installer), style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n stringResource(R.string.settings_update_up_to_date),\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_recheck)) }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_retry)) }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\nprivate fun stageTypeLabel(type: StageType): Int = app.nock.android.ui.components.stageTypeLabel(type)\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Box(\n modifier = Modifier\n .size(40.dp)\n .clip(CircleShape)\n .background(MaterialTheme.colorScheme.surfaceContainerHigh),\n contentAlignment = Alignment.Center\n ) {\n Icon(\n imageVector = app.nock.android.ui.components.stageIcon(stage.type),\n contentDescription = null,\n modifier = Modifier.size(20.dp),\n tint = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, onEditGroup: (Long) -> Unit, onAddGroup: () -> Unit) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, onClick = { onEditGroup(g.id) })\n HorizontalDivider()\n }\n TextButton(onClick = onAddGroup) {\n Icon(Icons.Filled.Add, contentDescription = null)\n Spacer(Modifier.width(4.dp))\n Text(stringResource(R.string.add))\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, onClick: () -> Unit) {\n Row(\n verticalAlignment = Alignment.CenterVertically,\n modifier = Modifier\n .fillMaxWidth()\n .clickable(onClick = onClick)\n .padding(vertical = 8.dp)\n ) {\n app.nock.android.ui.components.GroupAvatar(g, size = 28.dp)\n Spacer(Modifier.width(12.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = onClick) {\n Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))\n }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, context: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n var c by remember(context) { mutableStateOf(context) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(16.dp))\n Text(\n stringResource(R.string.settings_deepseek_context_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c,\n onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_deepseek_context)) },\n placeholder = { Text(stringResource(R.string.settings_deepseek_context_placeholder)) },\n singleLine = false,\n minLines = 3,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u, c) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n\n@Composable\nprivate fun AlarmHistorySection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n var logText by remember { mutableStateOf(vm.alarmHistoryDump()) }\n val copiedMsg = stringResource(R.string.settings_alarm_history_copied)\n\n SectionCard(stringResource(R.string.settings_alarm_history_title)) {\n Text(\n stringResource(R.string.settings_alarm_history_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n OutlinedButton(onClick = { logText = vm.alarmHistoryDump() }) {\n Text(stringResource(R.string.settings_alarm_history_refresh))\n }\n Button(onClick = {\n val snapshot = vm.alarmHistoryDump()\n logText = snapshot\n clipboard.setText(AnnotatedString(snapshot))\n scope.launch { snackbarHostState.showSnackbar(copiedMsg) }\n }) {\n Text(stringResource(R.string.settings_alarm_history_copy))\n }\n OutlinedButton(onClick = {\n vm.clearAlarmHistory()\n logText = \"\"\n }) {\n Text(stringResource(R.string.settings_alarm_history_clear))\n }\n }\n Spacer(Modifier.height(12.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 360.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.settings_alarm_history_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n }\n}\n"}, "b065b3dd7fd122028f8c8c8fb1e1cca5679c7bc8": {"t": 1785755967.0774922, "n": 27072, "h": "7a599e12e7aa2569", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.util.Log\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n@Composable\nfun SettingsScreen(\n onEditGroup: (Long) -> Unit = {},\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp)\n ) {\n LanguageSection(vm)\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n GroupsSection(state.groups, onEditGroup, onAddGroup = { onEditGroup(0L) })\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, state.deepSeekContext, vm)\n VoiceDiagnosticsSection(vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n UpdateSection()\n }\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n val downloadFailedMsg = stringResource(R.string.settings_update_download_failed)\n SectionCard(stringResource(R.string.settings_update_title)) {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(stringResource(R.string.settings_update_checking), style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(R.string.settings_update_available), fontWeight = FontWeight.SemiBold)\n Text(\n stringResource(\n R.string.settings_update_build_change,\n s.info.currentVersionCode,\n s.info.remoteVersionCode\n ),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(downloadFailedMsg)\n }\n }\n }) { Text(stringResource(R.string.settings_update_action_update)) }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n stringResource(R.string.settings_update_downloading, (s.progress * 100).toInt()),\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(stringResource(R.string.settings_update_opening_installer), style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n stringResource(R.string.settings_update_up_to_date),\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_recheck)) }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_retry)) }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\nprivate fun stageTypeLabel(type: StageType): Int = app.nock.android.ui.components.stageTypeLabel(type)\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Box(\n modifier = Modifier\n .size(40.dp)\n .clip(CircleShape)\n .background(MaterialTheme.colorScheme.surfaceContainerHigh),\n contentAlignment = Alignment.Center\n ) {\n Icon(\n imageVector = app.nock.android.ui.components.stageIcon(stage.type),\n contentDescription = null,\n modifier = Modifier.size(20.dp),\n tint = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, onEditGroup: (Long) -> Unit, onAddGroup: () -> Unit) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, onClick = { onEditGroup(g.id) })\n HorizontalDivider()\n }\n TextButton(onClick = onAddGroup) {\n Icon(Icons.Filled.Add, contentDescription = null)\n Spacer(Modifier.width(4.dp))\n Text(stringResource(R.string.add))\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, onClick: () -> Unit) {\n Row(\n verticalAlignment = Alignment.CenterVertically,\n modifier = Modifier\n .fillMaxWidth()\n .clickable(onClick = onClick)\n .padding(vertical = 8.dp)\n ) {\n app.nock.android.ui.components.GroupAvatar(g, size = 28.dp)\n Spacer(Modifier.width(12.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = onClick) {\n Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))\n }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, context: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n var c by remember(context) { mutableStateOf(context) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(16.dp))\n Text(\n stringResource(R.string.settings_deepseek_context_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c,\n onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_deepseek_context)) },\n placeholder = { Text(stringResource(R.string.settings_deepseek_context_placeholder)) },\n singleLine = false,\n minLines = 3,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u, c) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n\n@Composable\nprivate fun VoiceDiagnosticsSection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n var logText by remember { mutableStateOf(vm.voiceLogDump()) }\n val copiedMsg = stringResource(R.string.settings_voice_log_copied)\n\n SectionCard(stringResource(R.string.settings_voice_log_title)) {\n Text(\n stringResource(R.string.settings_voice_log_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n OutlinedButton(onClick = { logText = vm.voiceLogDump() }) {\n Text(stringResource(R.string.settings_voice_log_refresh))\n }\n Button(onClick = {\n val snapshot = vm.voiceLogDump()\n logText = snapshot\n clipboard.setText(AnnotatedString(snapshot))\n scope.launch { snackbarHostState.showSnackbar(copiedMsg) }\n }) {\n Text(stringResource(R.string.settings_voice_log_copy))\n }\n OutlinedButton(onClick = {\n vm.clearVoiceLog()\n logText = \"\"\n }) {\n Text(stringResource(R.string.settings_voice_log_clear))\n }\n }\n Spacer(Modifier.height(12.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 360.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.settings_voice_log_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n }\n}\n"}, "8a803db6a66023b48a818b61c90699fc7d0b0999": {"t": 1785755968.6572149, "n": 26303, "h": "b1aefec45c36ba34", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.util.Log\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n@Composable\nfun SettingsScreen(\n onEditGroup: (Long) -> Unit = {},\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp)\n ) {\n LanguageSection(vm)\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n GroupsSection(state.groups, onEditGroup, onAddGroup = { onEditGroup(0L) })\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, vm)\n VoiceDiagnosticsSection(vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n UpdateSection()\n }\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n val downloadFailedMsg = stringResource(R.string.settings_update_download_failed)\n SectionCard(stringResource(R.string.settings_update_title)) {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(stringResource(R.string.settings_update_checking), style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(R.string.settings_update_available), fontWeight = FontWeight.SemiBold)\n Text(\n stringResource(\n R.string.settings_update_build_change,\n s.info.currentVersionCode,\n s.info.remoteVersionCode\n ),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(downloadFailedMsg)\n }\n }\n }) { Text(stringResource(R.string.settings_update_action_update)) }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n stringResource(R.string.settings_update_downloading, (s.progress * 100).toInt()),\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(stringResource(R.string.settings_update_opening_installer), style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n stringResource(R.string.settings_update_up_to_date),\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_recheck)) }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(stringResource(R.string.settings_update_retry)) }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\nprivate fun stageTypeLabel(type: StageType): Int = app.nock.android.ui.components.stageTypeLabel(type)\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Box(\n modifier = Modifier\n .size(40.dp)\n .clip(CircleShape)\n .background(MaterialTheme.colorScheme.surfaceContainerHigh),\n contentAlignment = Alignment.Center\n ) {\n Icon(\n imageVector = app.nock.android.ui.components.stageIcon(stage.type),\n contentDescription = null,\n modifier = Modifier.size(20.dp),\n tint = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, onEditGroup: (Long) -> Unit, onAddGroup: () -> Unit) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, onClick = { onEditGroup(g.id) })\n HorizontalDivider()\n }\n TextButton(onClick = onAddGroup) {\n Icon(Icons.Filled.Add, contentDescription = null)\n Spacer(Modifier.width(4.dp))\n Text(stringResource(R.string.add))\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, onClick: () -> Unit) {\n Row(\n verticalAlignment = Alignment.CenterVertically,\n modifier = Modifier\n .fillMaxWidth()\n .clickable(onClick = onClick)\n .padding(vertical = 8.dp)\n ) {\n app.nock.android.ui.components.GroupAvatar(g, size = 28.dp)\n Spacer(Modifier.width(12.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = onClick) {\n Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))\n }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n\n@Composable\nprivate fun VoiceDiagnosticsSection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n var logText by remember { mutableStateOf(vm.voiceLogDump()) }\n val copiedMsg = stringResource(R.string.settings_voice_log_copied)\n\n SectionCard(stringResource(R.string.settings_voice_log_title)) {\n Text(\n stringResource(R.string.settings_voice_log_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n OutlinedButton(onClick = { logText = vm.voiceLogDump() }) {\n Text(stringResource(R.string.settings_voice_log_refresh))\n }\n Button(onClick = {\n val snapshot = vm.voiceLogDump()\n logText = snapshot\n clipboard.setText(AnnotatedString(snapshot))\n scope.launch { snackbarHostState.showSnackbar(copiedMsg) }\n }) {\n Text(stringResource(R.string.settings_voice_log_copy))\n }\n OutlinedButton(onClick = {\n vm.clearVoiceLog()\n logText = \"\"\n }) {\n Text(stringResource(R.string.settings_voice_log_clear))\n }\n }\n Spacer(Modifier.height(12.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 360.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.settings_voice_log_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n }\n}\n"}, "0ba1b3cb56ef2a1c2c7c066ee2009c30752f6e88": {"t": 1785755970.6816092, "n": 25732, "h": "e771c9f81fcaf184", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.util.Log\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n@Composable\nfun SettingsScreen(\n onEditGroup: (Long) -> Unit = {},\n vm: SettingsViewModel = hiltViewModel(),\n) {\n val state by vm.state.collectAsState()\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp)\n ) {\n LanguageSection(vm)\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n GroupsSection(state.groups, onEditGroup, onAddGroup = { onEditGroup(0L) })\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, vm)\n VoiceDiagnosticsSection(vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n UpdateSection()\n }\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n SectionCard(\"App update\") {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(\"Checking for updates…\", style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(\"Update available\", fontWeight = FontWeight.SemiBold)\n Text(\n \"build ${s.info.currentVersionCode} → ${s.info.remoteVersionCode}\",\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(\"Download failed\")\n }\n }\n }) { Text(\"Update\") }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n \"Downloading… ${(s.progress * 100).toInt()}%\",\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(\"Opening installer…\", style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n \"You're up to date\",\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(\"Re-check\") }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(\"Retry\") }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\nprivate fun stageTypeLabel(type: StageType): Int = app.nock.android.ui.components.stageTypeLabel(type)\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Box(\n modifier = Modifier\n .size(40.dp)\n .clip(CircleShape)\n .background(MaterialTheme.colorScheme.surfaceContainerHigh),\n contentAlignment = Alignment.Center\n ) {\n Icon(\n imageVector = app.nock.android.ui.components.stageIcon(stage.type),\n contentDescription = null,\n modifier = Modifier.size(20.dp),\n tint = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, onEditGroup: (Long) -> Unit, onAddGroup: () -> Unit) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, onClick = { onEditGroup(g.id) })\n HorizontalDivider()\n }\n TextButton(onClick = onAddGroup) {\n Icon(Icons.Filled.Add, contentDescription = null)\n Spacer(Modifier.width(4.dp))\n Text(stringResource(R.string.add))\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, onClick: () -> Unit) {\n Row(\n verticalAlignment = Alignment.CenterVertically,\n modifier = Modifier\n .fillMaxWidth()\n .clickable(onClick = onClick)\n .padding(vertical = 8.dp)\n ) {\n app.nock.android.ui.components.GroupAvatar(g, size = 28.dp)\n Spacer(Modifier.width(12.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = onClick) {\n Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))\n }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n\n@Composable\nprivate fun VoiceDiagnosticsSection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n var logText by remember { mutableStateOf(vm.voiceLogDump()) }\n val copiedMsg = stringResource(R.string.settings_voice_log_copied)\n\n SectionCard(stringResource(R.string.settings_voice_log_title)) {\n Text(\n stringResource(R.string.settings_voice_log_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n OutlinedButton(onClick = { logText = vm.voiceLogDump() }) {\n Text(stringResource(R.string.settings_voice_log_refresh))\n }\n Button(onClick = {\n val snapshot = vm.voiceLogDump()\n logText = snapshot\n clipboard.setText(AnnotatedString(snapshot))\n scope.launch { snackbarHostState.showSnackbar(copiedMsg) }\n }) {\n Text(stringResource(R.string.settings_voice_log_copy))\n }\n OutlinedButton(onClick = {\n vm.clearVoiceLog()\n logText = \"\"\n }) {\n Text(stringResource(R.string.settings_voice_log_clear))\n }\n }\n Spacer(Modifier.height(12.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 360.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.settings_voice_log_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n }\n}\n"}, "c52ed95d9397e25e45b91a12c29c24d97f676f4b": {"t": 1785755972.400706, "n": 27195, "h": "849bae08d90f31a4", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.util.Log\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n@Composable\nfun SettingsScreen(vm: SettingsViewModel = hiltViewModel()) {\n val state by vm.state.collectAsState()\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp)\n ) {\n LanguageSection(vm)\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n GroupsSection(state.groups, vm)\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, vm)\n VoiceDiagnosticsSection(vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n UpdateSection()\n }\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n SectionCard(\"App update\") {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(\"Checking for updates…\", style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(\"Update available\", fontWeight = FontWeight.SemiBold)\n Text(\n \"build ${s.info.currentVersionCode} → ${s.info.remoteVersionCode}\",\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(\"Download failed\")\n }\n }\n }) { Text(\"Update\") }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n \"Downloading… ${(s.progress * 100).toInt()}%\",\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(\"Opening installer…\", style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n \"You're up to date\",\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(\"Re-check\") }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(\"Retry\") }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\n@StringRes\nprivate fun stageTypeLabel(type: StageType): Int = when (type) {\n StageType.SILENT -> R.string.stage_type_silent\n StageType.ALARM_VIBRATE -> R.string.stage_type_alarm_vibrate\n StageType.ALARM -> R.string.stage_type_alarm\n StageType.TELEGRAM -> R.string.stage_type_telegram\n}\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Box(\n modifier = Modifier\n .size(40.dp)\n .clip(CircleShape)\n .background(MaterialTheme.colorScheme.surfaceContainerHigh),\n contentAlignment = Alignment.Center\n ) {\n Icon(\n imageVector = app.nock.android.ui.components.stageIcon(stage.type),\n contentDescription = null,\n modifier = Modifier.size(20.dp),\n tint = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, vm: SettingsViewModel) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, vm)\n HorizontalDivider()\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, vm: SettingsViewModel) {\n var editing by remember { mutableStateOf(false) }\n Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {\n Box(\n modifier = Modifier.size(20.dp).clip(CircleShape).background(Color(g.color))\n )\n Spacer(Modifier.width(8.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = { editing = !editing }) { Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit)) }\n }\n if (editing) {\n GroupOverrideEditor(g, vm) { editing = false }\n }\n}\n\n@Composable\nprivate fun GroupOverrideEditor(g: Group, vm: SettingsViewModel, onClose: () -> Unit) {\n var mirror by remember(g.id) { mutableStateOf(g.telegramSilentMirror) }\n var override by remember(g.id) { mutableStateOf(g.overrideChain) }\n\n Column(modifier = Modifier.padding(start = 28.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Switch(checked = mirror, onCheckedChange = {\n mirror = it\n vm.updateGroup(g.copy(telegramSilentMirror = it))\n })\n Spacer(Modifier.width(8.dp))\n Text(stringResource(R.string.settings_mirror_silent_telegram))\n }\n Row(verticalAlignment = Alignment.CenterVertically) {\n Switch(checked = override != null, onCheckedChange = { on ->\n override = if (on) app.nock.android.domain.model.DefaultChain.CHAIN else null\n vm.updateGroup(g.copy(overrideChain = override))\n })\n Spacer(Modifier.width(8.dp))\n Text(stringResource(R.string.settings_custom_chain_toggle))\n }\n override?.let { chain ->\n Text(stringResource(R.string.settings_override_chain), style = MaterialTheme.typography.labelMedium)\n StageChainSection(chain = chain, onChange = {\n override = it\n vm.updateGroup(g.copy(overrideChain = it))\n })\n }\n TextButton(onClick = onClose) { Text(stringResource(R.string.settings_done_editing_group)) }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n\n@Composable\nprivate fun VoiceDiagnosticsSection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n var logText by remember { mutableStateOf(vm.voiceLogDump()) }\n val copiedMsg = stringResource(R.string.settings_voice_log_copied)\n\n SectionCard(stringResource(R.string.settings_voice_log_title)) {\n Text(\n stringResource(R.string.settings_voice_log_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n OutlinedButton(onClick = { logText = vm.voiceLogDump() }) {\n Text(stringResource(R.string.settings_voice_log_refresh))\n }\n Button(onClick = {\n val snapshot = vm.voiceLogDump()\n logText = snapshot\n clipboard.setText(AnnotatedString(snapshot))\n scope.launch { snackbarHostState.showSnackbar(copiedMsg) }\n }) {\n Text(stringResource(R.string.settings_voice_log_copy))\n }\n OutlinedButton(onClick = {\n vm.clearVoiceLog()\n logText = \"\"\n }) {\n Text(stringResource(R.string.settings_voice_log_clear))\n }\n }\n Spacer(Modifier.height(12.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 360.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.settings_voice_log_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n }\n}\n"}, "eea60ecd32c6e5dbcb2575393baf149b461591c7": {"t": 1785755974.060456, "n": 26627, "h": "7de7d3c0eb77e166", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.util.Log\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n@Composable\nfun SettingsScreen(vm: SettingsViewModel = hiltViewModel()) {\n val state by vm.state.collectAsState()\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp)\n ) {\n LanguageSection(vm)\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n GroupsSection(state.groups, vm)\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, vm)\n VoiceDiagnosticsSection(vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n UpdateSection()\n }\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n SectionCard(\"App update\") {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(\"Checking for updates…\", style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(\"Update available\", fontWeight = FontWeight.SemiBold)\n Text(\n \"build ${s.info.currentVersionCode} → ${s.info.remoteVersionCode}\",\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(\"Download failed\")\n }\n }\n }) { Text(\"Update\") }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n \"Downloading… ${(s.progress * 100).toInt()}%\",\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(\"Opening installer…\", style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n \"You're up to date\",\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(\"Re-check\") }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(\"Retry\") }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\n@StringRes\nprivate fun stageTypeLabel(type: StageType): Int = when (type) {\n StageType.SILENT -> R.string.stage_type_silent\n StageType.ALARM_VIBRATE -> R.string.stage_type_alarm_vibrate\n StageType.ALARM -> R.string.stage_type_alarm\n StageType.TELEGRAM -> R.string.stage_type_telegram\n}\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, vm: SettingsViewModel) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, vm)\n HorizontalDivider()\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, vm: SettingsViewModel) {\n var editing by remember { mutableStateOf(false) }\n Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {\n Box(\n modifier = Modifier.size(20.dp).clip(CircleShape).background(Color(g.color))\n )\n Spacer(Modifier.width(8.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = { editing = !editing }) { Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit)) }\n }\n if (editing) {\n GroupOverrideEditor(g, vm) { editing = false }\n }\n}\n\n@Composable\nprivate fun GroupOverrideEditor(g: Group, vm: SettingsViewModel, onClose: () -> Unit) {\n var mirror by remember(g.id) { mutableStateOf(g.telegramSilentMirror) }\n var override by remember(g.id) { mutableStateOf(g.overrideChain) }\n\n Column(modifier = Modifier.padding(start = 28.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Switch(checked = mirror, onCheckedChange = {\n mirror = it\n vm.updateGroup(g.copy(telegramSilentMirror = it))\n })\n Spacer(Modifier.width(8.dp))\n Text(stringResource(R.string.settings_mirror_silent_telegram))\n }\n Row(verticalAlignment = Alignment.CenterVertically) {\n Switch(checked = override != null, onCheckedChange = { on ->\n override = if (on) app.nock.android.domain.model.DefaultChain.CHAIN else null\n vm.updateGroup(g.copy(overrideChain = override))\n })\n Spacer(Modifier.width(8.dp))\n Text(stringResource(R.string.settings_custom_chain_toggle))\n }\n override?.let { chain ->\n Text(stringResource(R.string.settings_override_chain), style = MaterialTheme.typography.labelMedium)\n StageChainSection(chain = chain, onChange = {\n override = it\n vm.updateGroup(g.copy(overrideChain = it))\n })\n }\n TextButton(onClick = onClose) { Text(stringResource(R.string.settings_done_editing_group)) }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n\n@Composable\nprivate fun VoiceDiagnosticsSection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n var logText by remember { mutableStateOf(vm.voiceLogDump()) }\n val copiedMsg = stringResource(R.string.settings_voice_log_copied)\n\n SectionCard(stringResource(R.string.settings_voice_log_title)) {\n Text(\n stringResource(R.string.settings_voice_log_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n OutlinedButton(onClick = { logText = vm.voiceLogDump() }) {\n Text(stringResource(R.string.settings_voice_log_refresh))\n }\n Button(onClick = {\n val snapshot = vm.voiceLogDump()\n logText = snapshot\n clipboard.setText(AnnotatedString(snapshot))\n scope.launch { snackbarHostState.showSnackbar(copiedMsg) }\n }) {\n Text(stringResource(R.string.settings_voice_log_copy))\n }\n OutlinedButton(onClick = {\n vm.clearVoiceLog()\n logText = \"\"\n }) {\n Text(stringResource(R.string.settings_voice_log_clear))\n }\n }\n Spacer(Modifier.height(12.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 360.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.settings_voice_log_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n }\n}\n"}, "44e2a411d379ce6bb30accdd87fea87251f8e09a": {"t": 1785755975.5158944, "n": 26613, "h": "6b15d4ca6cbfb63e", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.util.Log\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n@Composable\nfun SettingsScreen(vm: SettingsViewModel = hiltViewModel()) {\n val state by vm.state.collectAsState()\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp)\n ) {\n LanguageSection(vm)\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n GroupsSection(state.groups, vm)\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, vm)\n VoiceDiagnosticsSection(vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n UpdateSection()\n }\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n SectionCard(\"App update\") {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(\"Checking for updates…\", style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(\"Update available\", fontWeight = FontWeight.SemiBold)\n Text(\n \"build ${s.info.currentVersionCode} → ${s.info.remoteVersionCode}\",\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(\"Download failed\")\n }\n }\n }) { Text(\"Update\") }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n \"Downloading… ${(s.progress * 100).toInt()}%\",\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(\"Opening installer…\", style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n \"You're up to date\",\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(\"Re-check\") }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(\"Retry\") }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\n@StringRes\nprivate fun stageTypeLabel(type: StageType): Int = when (type) {\n StageType.SILENT -> R.string.stage_type_silent\n StageType.NORMAL -> R.string.stage_type_normal\n StageType.ALARM -> R.string.stage_type_alarm\n StageType.TELEGRAM -> R.string.stage_type_telegram\n}\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, vm: SettingsViewModel) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, vm)\n HorizontalDivider()\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, vm: SettingsViewModel) {\n var editing by remember { mutableStateOf(false) }\n Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {\n Box(\n modifier = Modifier.size(20.dp).clip(CircleShape).background(Color(g.color))\n )\n Spacer(Modifier.width(8.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = { editing = !editing }) { Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit)) }\n }\n if (editing) {\n GroupOverrideEditor(g, vm) { editing = false }\n }\n}\n\n@Composable\nprivate fun GroupOverrideEditor(g: Group, vm: SettingsViewModel, onClose: () -> Unit) {\n var mirror by remember(g.id) { mutableStateOf(g.telegramSilentMirror) }\n var override by remember(g.id) { mutableStateOf(g.overrideChain) }\n\n Column(modifier = Modifier.padding(start = 28.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Switch(checked = mirror, onCheckedChange = {\n mirror = it\n vm.updateGroup(g.copy(telegramSilentMirror = it))\n })\n Spacer(Modifier.width(8.dp))\n Text(stringResource(R.string.settings_mirror_silent_telegram))\n }\n Row(verticalAlignment = Alignment.CenterVertically) {\n Switch(checked = override != null, onCheckedChange = { on ->\n override = if (on) app.nock.android.domain.model.DefaultChain.CHAIN else null\n vm.updateGroup(g.copy(overrideChain = override))\n })\n Spacer(Modifier.width(8.dp))\n Text(stringResource(R.string.settings_custom_chain_toggle))\n }\n override?.let { chain ->\n Text(stringResource(R.string.settings_override_chain), style = MaterialTheme.typography.labelMedium)\n StageChainSection(chain = chain, onChange = {\n override = it\n vm.updateGroup(g.copy(overrideChain = it))\n })\n }\n TextButton(onClick = onClose) { Text(stringResource(R.string.settings_done_editing_group)) }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n\n@Composable\nprivate fun VoiceDiagnosticsSection(vm: SettingsViewModel) {\n val clipboard = LocalClipboardManager.current\n val scope = rememberCoroutineScope()\n val snackbarHostState = remember { SnackbarHostState() }\n var logText by remember { mutableStateOf(vm.voiceLogDump()) }\n val copiedMsg = stringResource(R.string.settings_voice_log_copied)\n\n SectionCard(stringResource(R.string.settings_voice_log_title)) {\n Text(\n stringResource(R.string.settings_voice_log_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(12.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n OutlinedButton(onClick = { logText = vm.voiceLogDump() }) {\n Text(stringResource(R.string.settings_voice_log_refresh))\n }\n Button(onClick = {\n val snapshot = vm.voiceLogDump()\n logText = snapshot\n clipboard.setText(AnnotatedString(snapshot))\n scope.launch { snackbarHostState.showSnackbar(copiedMsg) }\n }) {\n Text(stringResource(R.string.settings_voice_log_copy))\n }\n OutlinedButton(onClick = {\n vm.clearVoiceLog()\n logText = \"\"\n }) {\n Text(stringResource(R.string.settings_voice_log_clear))\n }\n }\n Spacer(Modifier.height(12.dp))\n Surface(\n color = MaterialTheme.colorScheme.surfaceVariant,\n shape = MaterialTheme.shapes.small,\n modifier = Modifier.fillMaxWidth()\n ) {\n Box(\n modifier = Modifier\n .heightIn(max = 360.dp)\n .verticalScroll(rememberScrollState())\n .padding(8.dp)\n ) {\n Text(\n text = logText.ifBlank { stringResource(R.string.settings_voice_log_empty) },\n style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),\n color = MaterialTheme.colorScheme.onSurfaceVariant\n )\n }\n }\n SnackbarHost(hostState = snackbarHostState, modifier = Modifier.fillMaxWidth())\n }\n}\n"}, "3b3cd08ff3dc89e0390749b24f1a60bbe660bc87": {"t": 1785755977.1701825, "n": 24091, "h": "6bbbdeaac4308e6e", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.util.Log\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n@Composable\nfun SettingsScreen(vm: SettingsViewModel = hiltViewModel()) {\n val state by vm.state.collectAsState()\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp)\n ) {\n LanguageSection(vm)\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n GroupsSection(state.groups, vm)\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n DeepSeekSection(state.deepSeekApiKey, state.deepSeekModel, state.deepSeekBaseUrl, vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n UpdateSection()\n }\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n SectionCard(\"App update\") {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(\"Checking for updates…\", style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(\"Update available\", fontWeight = FontWeight.SemiBold)\n Text(\n \"build ${s.info.currentVersionCode} → ${s.info.remoteVersionCode}\",\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(\"Download failed\")\n }\n }\n }) { Text(\"Update\") }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n \"Downloading… ${(s.progress * 100).toInt()}%\",\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(\"Opening installer…\", style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n \"You're up to date\",\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(\"Re-check\") }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(\"Retry\") }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\n@StringRes\nprivate fun stageTypeLabel(type: StageType): Int = when (type) {\n StageType.SILENT -> R.string.stage_type_silent\n StageType.NORMAL -> R.string.stage_type_normal\n StageType.ALARM -> R.string.stage_type_alarm\n StageType.TELEGRAM -> R.string.stage_type_telegram\n}\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, vm: SettingsViewModel) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, vm)\n HorizontalDivider()\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, vm: SettingsViewModel) {\n var editing by remember { mutableStateOf(false) }\n Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {\n Box(\n modifier = Modifier.size(20.dp).clip(CircleShape).background(Color(g.color))\n )\n Spacer(Modifier.width(8.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = { editing = !editing }) { Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit)) }\n }\n if (editing) {\n GroupOverrideEditor(g, vm) { editing = false }\n }\n}\n\n@Composable\nprivate fun GroupOverrideEditor(g: Group, vm: SettingsViewModel, onClose: () -> Unit) {\n var mirror by remember(g.id) { mutableStateOf(g.telegramSilentMirror) }\n var override by remember(g.id) { mutableStateOf(g.overrideChain) }\n\n Column(modifier = Modifier.padding(start = 28.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Switch(checked = mirror, onCheckedChange = {\n mirror = it\n vm.updateGroup(g.copy(telegramSilentMirror = it))\n })\n Spacer(Modifier.width(8.dp))\n Text(stringResource(R.string.settings_mirror_silent_telegram))\n }\n Row(verticalAlignment = Alignment.CenterVertically) {\n Switch(checked = override != null, onCheckedChange = { on ->\n override = if (on) app.nock.android.domain.model.DefaultChain.CHAIN else null\n vm.updateGroup(g.copy(overrideChain = override))\n })\n Spacer(Modifier.width(8.dp))\n Text(stringResource(R.string.settings_custom_chain_toggle))\n }\n override?.let { chain ->\n Text(stringResource(R.string.settings_override_chain), style = MaterialTheme.typography.labelMedium)\n StageChainSection(chain = chain, onChange = {\n override = it\n vm.updateGroup(g.copy(overrideChain = it))\n })\n }\n TextButton(onClick = onClose) { Text(stringResource(R.string.settings_done_editing_group)) }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DeepSeekSection(apiKey: String, model: String, baseUrl: String, vm: SettingsViewModel) {\n var k by remember(apiKey) { mutableStateOf(apiKey) }\n var m by remember(model) { mutableStateOf(model) }\n var u by remember(baseUrl) { mutableStateOf(baseUrl) }\n SectionCard(stringResource(R.string.settings_deepseek_title)) {\n Text(\n stringResource(R.string.settings_deepseek_help),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = k,\n onValueChange = { k = it },\n label = { Text(stringResource(R.string.settings_deepseek_api_key)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = m,\n onValueChange = { m = it },\n label = { Text(stringResource(R.string.settings_deepseek_model)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = u,\n onValueChange = { u = it },\n label = { Text(stringResource(R.string.settings_deepseek_base_url)) },\n singleLine = true,\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Button(onClick = { vm.setDeepSeek(k, m, u) }) { Text(stringResource(R.string.save)) }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n"}, "7f6cdb4eaf68449bf780d08eb527888a12e99c6c": {"t": 1785755978.6218596, "n": 22412, "h": "a390f6e5c53967ba", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.util.Log\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalLifecycleOwner\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n@Composable\nfun SettingsScreen(vm: SettingsViewModel = hiltViewModel()) {\n val state by vm.state.collectAsState()\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp)\n ) {\n LanguageSection(vm)\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n GroupsSection(state.groups, vm)\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n UpdateSection()\n }\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n // The system installer is a separate activity: when the user cancels (or\n // even completes) it, our activity resumes. If we left the UI parked on\n // \"Opening installer…\" the user could never retry. Re-check on resume so\n // the section returns to either Available (cancelled) or UpToDate (installed).\n val lifecycleOwner = LocalLifecycleOwner.current\n DisposableEffect(lifecycleOwner) {\n val observer = LifecycleEventObserver { _, event ->\n if (event == Lifecycle.Event.ON_RESUME && updateState is UpdateState.Installing) {\n scope.launch { doCheck() }\n }\n }\n lifecycleOwner.lifecycle.addObserver(observer)\n onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }\n }\n\n SectionCard(\"App update\") {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(\"Checking for updates…\", style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(\"Update available\", fontWeight = FontWeight.SemiBold)\n Text(\n \"build ${s.info.currentVersionCode} → ${s.info.remoteVersionCode}\",\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(\"Download failed\")\n }\n }\n }) { Text(\"Update\") }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n \"Downloading… ${(s.progress * 100).toInt()}%\",\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(\"Opening installer…\", style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n \"You're up to date\",\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(\"Re-check\") }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(\"Retry\") }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\n@StringRes\nprivate fun stageTypeLabel(type: StageType): Int = when (type) {\n StageType.SILENT -> R.string.stage_type_silent\n StageType.NORMAL -> R.string.stage_type_normal\n StageType.ALARM -> R.string.stage_type_alarm\n StageType.TELEGRAM -> R.string.stage_type_telegram\n}\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, vm: SettingsViewModel) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, vm)\n HorizontalDivider()\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, vm: SettingsViewModel) {\n var editing by remember { mutableStateOf(false) }\n Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {\n Box(\n modifier = Modifier.size(20.dp).clip(CircleShape).background(Color(g.color))\n )\n Spacer(Modifier.width(8.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = { editing = !editing }) { Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit)) }\n }\n if (editing) {\n GroupOverrideEditor(g, vm) { editing = false }\n }\n}\n\n@Composable\nprivate fun GroupOverrideEditor(g: Group, vm: SettingsViewModel, onClose: () -> Unit) {\n var mirror by remember(g.id) { mutableStateOf(g.telegramSilentMirror) }\n var override by remember(g.id) { mutableStateOf(g.overrideChain) }\n\n Column(modifier = Modifier.padding(start = 28.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Switch(checked = mirror, onCheckedChange = {\n mirror = it\n vm.updateGroup(g.copy(telegramSilentMirror = it))\n })\n Spacer(Modifier.width(8.dp))\n Text(stringResource(R.string.settings_mirror_silent_telegram))\n }\n Row(verticalAlignment = Alignment.CenterVertically) {\n Switch(checked = override != null, onCheckedChange = { on ->\n override = if (on) app.nock.android.domain.model.DefaultChain.CHAIN else null\n vm.updateGroup(g.copy(overrideChain = override))\n })\n Spacer(Modifier.width(8.dp))\n Text(stringResource(R.string.settings_custom_chain_toggle))\n }\n override?.let { chain ->\n Text(stringResource(R.string.settings_override_chain), style = MaterialTheme.typography.labelMedium)\n StageChainSection(chain = chain, onChange = {\n override = it\n vm.updateGroup(g.copy(overrideChain = it))\n })\n }\n TextButton(onClick = onClose) { Text(stringResource(R.string.settings_done_editing_group)) }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n"}, "ad40317899fe6eda3630568e0575acabab53567f": {"t": 1785755980.2365005, "n": 21506, "h": "df45fd18ca8f5f10", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.util.Log\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n@Composable\nfun SettingsScreen(vm: SettingsViewModel = hiltViewModel()) {\n val state by vm.state.collectAsState()\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp)\n ) {\n LanguageSection(vm)\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n GroupsSection(state.groups, vm)\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n UpdateSection()\n }\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n SectionCard(\"App update\") {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(\"Checking for updates…\", style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(\"Update available\", fontWeight = FontWeight.SemiBold)\n Text(\n \"build ${s.info.currentVersionCode} → ${s.info.remoteVersionCode}\",\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(\"Download failed\")\n }\n }\n }) { Text(\"Update\") }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n \"Downloading… ${(s.progress * 100).toInt()}%\",\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(\"Opening installer…\", style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n \"You're up to date\",\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(\"Re-check\") }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(\"Retry\") }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\n@StringRes\nprivate fun stageTypeLabel(type: StageType): Int = when (type) {\n StageType.SILENT -> R.string.stage_type_silent\n StageType.NORMAL -> R.string.stage_type_normal\n StageType.ALARM -> R.string.stage_type_alarm\n StageType.TELEGRAM -> R.string.stage_type_telegram\n}\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, vm: SettingsViewModel) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, vm)\n HorizontalDivider()\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, vm: SettingsViewModel) {\n var editing by remember { mutableStateOf(false) }\n Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {\n Box(\n modifier = Modifier.size(20.dp).clip(CircleShape).background(Color(g.color))\n )\n Spacer(Modifier.width(8.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = { editing = !editing }) { Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit)) }\n }\n if (editing) {\n GroupOverrideEditor(g, vm) { editing = false }\n }\n}\n\n@Composable\nprivate fun GroupOverrideEditor(g: Group, vm: SettingsViewModel, onClose: () -> Unit) {\n var mirror by remember(g.id) { mutableStateOf(g.telegramSilentMirror) }\n var override by remember(g.id) { mutableStateOf(g.overrideChain) }\n\n Column(modifier = Modifier.padding(start = 28.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Switch(checked = mirror, onCheckedChange = {\n mirror = it\n vm.updateGroup(g.copy(telegramSilentMirror = it))\n })\n Spacer(Modifier.width(8.dp))\n Text(stringResource(R.string.settings_mirror_silent_telegram))\n }\n Row(verticalAlignment = Alignment.CenterVertically) {\n Switch(checked = override != null, onCheckedChange = { on ->\n override = if (on) app.nock.android.domain.model.DefaultChain.CHAIN else null\n vm.updateGroup(g.copy(overrideChain = override))\n })\n Spacer(Modifier.width(8.dp))\n Text(stringResource(R.string.settings_custom_chain_toggle))\n }\n override?.let { chain ->\n Text(stringResource(R.string.settings_override_chain), style = MaterialTheme.typography.labelMedium)\n StageChainSection(chain = chain, onChange = {\n override = it\n vm.updateGroup(g.copy(overrideChain = it))\n })\n }\n TextButton(onClick = onClose) { Text(stringResource(R.string.settings_done_editing_group)) }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n if (result.resultCode == Activity.RESULT_CANCELED && result.data == null) {\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_cancelled))\n return@rememberLauncherForActivityResult\n }\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc ->\n vm.setDriveEmail(acc.email)\n vm.setDriveStatus(null)\n }\n .onFailure { e ->\n val code = (e as? com.google.android.gms.common.api.ApiException)?.statusCode\n val detail = if (code != null) \"code $code: ${e.message ?: \"\"}\" else (e.message ?: e::class.simpleName ?: \"unknown\")\n Log.w(\"DriveSignIn\", \"Google sign-in failed: $detail\", e)\n vm.setDriveStatus(ctx.getString(R.string.status_drive_sign_in_failed, detail))\n }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n // Sign out first so a cached account with a stale/invalid grant doesn't make the\n // picker close with no observable effect.\n client.signOut().addOnCompleteListener {\n launcher.launch(client.signInIntent)\n }\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n"}, "59ae772d61fc618777d73c5283d5396465ef9b7e": {"t": 1785755981.5443974, "n": 20505, "h": "5b3b6089d06beb72", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n@Composable\nfun SettingsScreen(vm: SettingsViewModel = hiltViewModel()) {\n val state by vm.state.collectAsState()\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp)\n ) {\n LanguageSection(vm)\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n GroupsSection(state.groups, vm)\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n UpdateSection()\n }\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n SectionCard(\"App update\") {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(\"Checking for updates…\", style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(\"Update available\", fontWeight = FontWeight.SemiBold)\n Text(\n \"build ${s.info.currentVersionCode} → ${s.info.remoteVersionCode}\",\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(\"Download failed\")\n }\n }\n }) { Text(\"Update\") }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n \"Downloading… ${(s.progress * 100).toInt()}%\",\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(\"Opening installer…\", style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n \"You're up to date\",\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(\"Re-check\") }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(\"Retry\") }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection(vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n vm.syncSeedGroupNames()\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\n@StringRes\nprivate fun stageTypeLabel(type: StageType): Int = when (type) {\n StageType.SILENT -> R.string.stage_type_silent\n StageType.NORMAL -> R.string.stage_type_normal\n StageType.ALARM -> R.string.stage_type_alarm\n StageType.TELEGRAM -> R.string.stage_type_telegram\n}\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, vm: SettingsViewModel) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, vm)\n HorizontalDivider()\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, vm: SettingsViewModel) {\n var editing by remember { mutableStateOf(false) }\n Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {\n Box(\n modifier = Modifier.size(20.dp).clip(CircleShape).background(Color(g.color))\n )\n Spacer(Modifier.width(8.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = { editing = !editing }) { Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit)) }\n }\n if (editing) {\n GroupOverrideEditor(g, vm) { editing = false }\n }\n}\n\n@Composable\nprivate fun GroupOverrideEditor(g: Group, vm: SettingsViewModel, onClose: () -> Unit) {\n var mirror by remember(g.id) { mutableStateOf(g.telegramSilentMirror) }\n var override by remember(g.id) { mutableStateOf(g.overrideChain) }\n\n Column(modifier = Modifier.padding(start = 28.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Switch(checked = mirror, onCheckedChange = {\n mirror = it\n vm.updateGroup(g.copy(telegramSilentMirror = it))\n })\n Spacer(Modifier.width(8.dp))\n Text(stringResource(R.string.settings_mirror_silent_telegram))\n }\n Row(verticalAlignment = Alignment.CenterVertically) {\n Switch(checked = override != null, onCheckedChange = { on ->\n override = if (on) app.nock.android.domain.model.DefaultChain.CHAIN else null\n vm.updateGroup(g.copy(overrideChain = override))\n })\n Spacer(Modifier.width(8.dp))\n Text(stringResource(R.string.settings_custom_chain_toggle))\n }\n override?.let { chain ->\n Text(stringResource(R.string.settings_override_chain), style = MaterialTheme.typography.labelMedium)\n StageChainSection(chain = chain, onChange = {\n override = it\n vm.updateGroup(g.copy(overrideChain = it))\n })\n }\n TextButton(onClick = onClose) { Text(stringResource(R.string.settings_done_editing_group)) }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc -> vm.setDriveEmail(acc.email) }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n launcher.launch(client.signInIntent)\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n"}, "a1a53b47e02c587d44e9dcc84e9dfd622869386d": {"t": 1785755982.8829074, "n": 20434, "h": "60529916c7288adf", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.R\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.ui.LocaleHelper\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\nimport java.util.Locale\n\n@Composable\nfun SettingsScreen(vm: SettingsViewModel = hiltViewModel()) {\n val state by vm.state.collectAsState()\n Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp)\n ) {\n LanguageSection()\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n GroupsSection(state.groups, vm)\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n UpdateSection()\n }\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n SectionCard(\"App update\") {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(\"Checking for updates…\", style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(\"Update available\", fontWeight = FontWeight.SemiBold)\n Text(\n \"build ${s.info.currentVersionCode} → ${s.info.remoteVersionCode}\",\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(\"Download failed\")\n }\n }\n }) { Text(\"Update\") }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n \"Downloading… ${(s.progress * 100).toInt()}%\",\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(\"Opening installer…\", style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n \"You're up to date\",\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(\"Re-check\") }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(\"Retry\") }\n }\n }\n }\n}\n\n@Composable\nprivate fun LanguageSection() {\n val ctx = LocalContext.current\n val currentTag = LocaleHelper.getLanguageTag(ctx)\n val options = listOf(\n \"\" to R.string.language_system_default,\n \"en\" to R.string.language_english,\n \"de\" to R.string.language_german,\n )\n SectionCard(stringResource(R.string.settings_language_title)) {\n androidx.compose.foundation.layout.FlowRow {\n options.forEach { (tag, labelRes) ->\n FilterChip(\n selected = tag == currentTag,\n onClick = {\n if (tag == currentTag) return@FilterChip\n LocaleHelper.setLanguageTag(ctx, tag)\n (ctx as? Activity)?.recreate()\n },\n label = { Text(stringResource(labelRes)) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n}\n\n@StringRes\nprivate fun stageTypeLabel(type: StageType): Int = when (type) {\n StageType.SILENT -> R.string.stage_type_silent\n StageType.NORMAL -> R.string.stage_type_normal\n StageType.ALARM -> R.string.stage_type_alarm\n StageType.TELEGRAM -> R.string.stage_type_telegram\n}\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(stringResource(R.string.settings_escalation_chain_title)) {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(stringResource(R.string.settings_add_stage), style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(stringResource(stageTypeLabel(t))) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = stringResource(R.string.settings_repeat_interval_label),\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Column(modifier = Modifier.weight(1f)) {\n Text(stringResource(stageTypeLabel(stage.type)), fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(stringResource(R.string.settings_offset_label)) },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.remove)) }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, vm: SettingsViewModel) {\n SectionCard(stringResource(R.string.settings_groups_title)) {\n groups.forEach { g ->\n GroupRow(g, vm)\n HorizontalDivider()\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, vm: SettingsViewModel) {\n var editing by remember { mutableStateOf(false) }\n Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {\n Box(\n modifier = Modifier.size(20.dp).clip(CircleShape).background(Color(g.color))\n )\n Spacer(Modifier.width(8.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n stringResource(if (g.overrideChain != null) R.string.settings_group_custom_chain else R.string.settings_group_default_chain),\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = { editing = !editing }) { Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit)) }\n }\n if (editing) {\n GroupOverrideEditor(g, vm) { editing = false }\n }\n}\n\n@Composable\nprivate fun GroupOverrideEditor(g: Group, vm: SettingsViewModel, onClose: () -> Unit) {\n var mirror by remember(g.id) { mutableStateOf(g.telegramSilentMirror) }\n var override by remember(g.id) { mutableStateOf(g.overrideChain) }\n\n Column(modifier = Modifier.padding(start = 28.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Switch(checked = mirror, onCheckedChange = {\n mirror = it\n vm.updateGroup(g.copy(telegramSilentMirror = it))\n })\n Spacer(Modifier.width(8.dp))\n Text(stringResource(R.string.settings_mirror_silent_telegram))\n }\n Row(verticalAlignment = Alignment.CenterVertically) {\n Switch(checked = override != null, onCheckedChange = { on ->\n override = if (on) app.nock.android.domain.model.DefaultChain.CHAIN else null\n vm.updateGroup(g.copy(overrideChain = override))\n })\n Spacer(Modifier.width(8.dp))\n Text(stringResource(R.string.settings_custom_chain_toggle))\n }\n override?.let { chain ->\n Text(stringResource(R.string.settings_override_chain), style = MaterialTheme.typography.labelMedium)\n StageChainSection(chain = chain, onChange = {\n override = it\n vm.updateGroup(g.copy(overrideChain = it))\n })\n }\n TextButton(onClick = onClose) { Text(stringResource(R.string.settings_done_editing_group)) }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(stringResource(R.string.settings_telegram_title)) {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(stringResource(R.string.settings_telegram_token)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(stringResource(R.string.settings_telegram_chat_id)) },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(stringResource(R.string.save)) }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(stringResource(R.string.settings_test_send)) }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc -> vm.setDriveEmail(acc.email) }\n }\n SectionCard(stringResource(R.string.settings_drive_title)) {\n if (email == null) {\n Text(stringResource(R.string.settings_not_signed_in))\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n launcher.launch(client.signInIntent)\n }) { Text(stringResource(R.string.sign_in)) }\n } else {\n Text(stringResource(R.string.settings_signed_in_as, email))\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(stringResource(R.string.settings_push_now)) }\n OutlinedButton(onClick = vm::syncPull) { Text(stringResource(R.string.settings_pull_now)) }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(stringResource(R.string.sign_out)) }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\", Locale.getDefault())\n Text(\n stringResource(\n R.string.settings_last_sync,\n LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt)\n ),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n"}, "fe75f3b5cab54932221088bdde81ff26f719a32b": {"t": 1785755984.8363326, "n": 18002, "h": "47df1c2a33ef5bb6", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material.icons.outlined.SystemUpdate\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport kotlinx.coroutines.launch\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport app.nock.android.update.UpdateManager\nimport app.nock.android.update.UpdateResult\nimport app.nock.android.update.UpdateState\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\n\n@Composable\nfun SettingsScreen(vm: SettingsViewModel = hiltViewModel()) {\n val state by vm.state.collectAsState()\n Scaffold(topBar = { TopAppBar(title = { Text(\"Settings\") }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp)\n ) {\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n GroupsSection(state.groups, vm)\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n UpdateSection()\n }\n }\n}\n\n@Composable\nprivate fun UpdateSection() {\n val context = LocalContext.current\n val updateManager = remember { UpdateManager(context) }\n val scope = rememberCoroutineScope()\n var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }\n\n suspend fun doCheck() {\n updateState = UpdateState.Checking\n updateState = when (val result = updateManager.checkForUpdate()) {\n is UpdateResult.Success ->\n if (result.info.hasUpdate) UpdateState.Available(result.info)\n else UpdateState.UpToDate\n is UpdateResult.Failure -> UpdateState.Error(result.error)\n }\n }\n\n LaunchedEffect(Unit) { doCheck() }\n\n SectionCard(\"App update\") {\n when (val s = updateState) {\n UpdateState.Idle, UpdateState.Checking -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n CircularProgressIndicator(\n modifier = Modifier.size(16.dp),\n strokeWidth = 2.dp\n )\n Spacer(Modifier.width(12.dp))\n Text(\"Checking for updates…\", style = MaterialTheme.typography.bodyMedium)\n }\n }\n is UpdateState.Available -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Icon(\n imageVector = Icons.Outlined.SystemUpdate,\n contentDescription = null,\n tint = MaterialTheme.colorScheme.primary\n )\n Spacer(Modifier.width(12.dp))\n Column(modifier = Modifier.weight(1f)) {\n Text(\"Update available\", fontWeight = FontWeight.SemiBold)\n Text(\n \"build ${s.info.currentVersionCode} → ${s.info.remoteVersionCode}\",\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n }\n Button(onClick = {\n scope.launch {\n updateState = UpdateState.Downloading(0f)\n val apk = updateManager.downloadUpdate { progress ->\n updateState = UpdateState.Downloading(progress)\n }\n if (apk != null) {\n updateState = UpdateState.Installing\n updateManager.installApk(apk)\n } else {\n updateState = UpdateState.Error(\"Download failed\")\n }\n }\n }) { Text(\"Update\") }\n }\n }\n is UpdateState.Downloading -> {\n Text(\n \"Downloading… ${(s.progress * 100).toInt()}%\",\n style = MaterialTheme.typography.bodyMedium\n )\n Spacer(Modifier.height(8.dp))\n LinearProgressIndicator(\n progress = s.progress,\n modifier = Modifier.fillMaxWidth()\n )\n }\n UpdateState.Installing -> {\n Text(\"Opening installer…\", style = MaterialTheme.typography.bodyMedium)\n }\n UpdateState.UpToDate -> {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Text(\n \"You're up to date\",\n style = MaterialTheme.typography.bodyMedium,\n modifier = Modifier.weight(1f)\n )\n TextButton(onClick = { scope.launch { doCheck() } }) { Text(\"Re-check\") }\n }\n }\n is UpdateState.Error -> {\n Text(\n s.message,\n style = MaterialTheme.typography.bodyMedium,\n color = MaterialTheme.colorScheme.error\n )\n Spacer(Modifier.height(8.dp))\n OutlinedButton(onClick = { scope.launch { doCheck() } }) { Text(\"Retry\") }\n }\n }\n }\n}\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(\"Escalation chain (global default)\") {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(\"Add stage:\", style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(t.name) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = \"Last-stage repeat interval (minutes)\",\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Column(modifier = Modifier.weight(1f)) {\n Text(stage.type.name, fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(\"Offset (min)\") },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = \"Up\") }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = \"Down\") }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = \"Remove\") }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, vm: SettingsViewModel) {\n SectionCard(\"Groups\") {\n groups.forEach { g ->\n GroupRow(g, vm)\n HorizontalDivider()\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, vm: SettingsViewModel) {\n var editing by remember { mutableStateOf(false) }\n Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {\n Box(\n modifier = Modifier.size(20.dp).clip(CircleShape).background(Color(g.color))\n )\n Spacer(Modifier.width(8.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n if (g.overrideChain != null) \"custom chain\" else \"default chain\",\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = { editing = !editing }) { Icon(Icons.Filled.Edit, contentDescription = \"Edit\") }\n }\n if (editing) {\n GroupOverrideEditor(g, vm) { editing = false }\n }\n}\n\n@Composable\nprivate fun GroupOverrideEditor(g: Group, vm: SettingsViewModel, onClose: () -> Unit) {\n var mirror by remember(g.id) { mutableStateOf(g.telegramSilentMirror) }\n var override by remember(g.id) { mutableStateOf(g.overrideChain) }\n\n Column(modifier = Modifier.padding(start = 28.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Switch(checked = mirror, onCheckedChange = {\n mirror = it\n vm.updateGroup(g.copy(telegramSilentMirror = it))\n })\n Spacer(Modifier.width(8.dp))\n Text(\"Mirror silent stage to Telegram\")\n }\n Row(verticalAlignment = Alignment.CenterVertically) {\n Switch(checked = override != null, onCheckedChange = { on ->\n override = if (on) app.nock.android.domain.model.DefaultChain.CHAIN else null\n vm.updateGroup(g.copy(overrideChain = override))\n })\n Spacer(Modifier.width(8.dp))\n Text(\"Custom escalation chain\")\n }\n override?.let { chain ->\n Text(\"Override chain\", style = MaterialTheme.typography.labelMedium)\n StageChainSection(chain = chain, onChange = {\n override = it\n vm.updateGroup(g.copy(overrideChain = it))\n })\n }\n TextButton(onClick = onClose) { Text(\"Done editing group\") }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(\"Telegram bot\") {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(\"Bot token\") },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(\"Chat ID\") },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(\"Save\") }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(\"Test send\") }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc -> vm.setDriveEmail(acc.email) }\n }\n SectionCard(\"Google Drive (App Folder)\") {\n if (email == null) {\n Text(\"Not signed in.\")\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n launcher.launch(client.signInIntent)\n }) { Text(\"Sign in\") }\n } else {\n Text(\"Signed in as $email\")\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(\"Push now\") }\n OutlinedButton(onClick = vm::syncPull) { Text(\"Pull now\") }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(\"Sign out\") }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\")\n Text(\n \"Last sync: \" + LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n"}, "d7c68558bc13b226949f3b0363b5c609e6cb333c": {"t": 1785755986.3974304, "n": 13460, "h": "5d4068d97872f347", "c": "@file:OptIn(\n androidx.compose.foundation.layout.ExperimentalLayoutApi::class,\n androidx.compose.material3.ExperimentalMaterial3Api::class\n)\n\npackage app.nock.android.ui.settings\n\nimport android.app.Activity\nimport android.content.Intent\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.ArrowDownward\nimport androidx.compose.material.icons.filled.ArrowUpward\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.Edit\nimport androidx.compose.material3.*\nimport androidx.compose.runtime.*\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport app.nock.android.domain.model.EscalationChain\nimport app.nock.android.domain.model.Group\nimport app.nock.android.domain.model.StageConfig\nimport app.nock.android.domain.model.StageType\nimport com.google.android.gms.auth.api.signin.GoogleSignIn\nimport com.google.android.gms.auth.api.signin.GoogleSignInOptions\nimport com.google.android.gms.common.api.Scope\nimport com.google.api.services.drive.DriveScopes\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\n\n@Composable\nfun SettingsScreen(vm: SettingsViewModel = hiltViewModel()) {\n val state by vm.state.collectAsState()\n Scaffold(topBar = { TopAppBar(title = { Text(\"Settings\") }) }) { padding ->\n Column(\n modifier = Modifier\n .fillMaxSize()\n .padding(padding)\n .padding(16.dp)\n .verticalScroll(rememberScrollState()),\n verticalArrangement = Arrangement.spacedBy(24.dp)\n ) {\n state.chain?.let {\n StageChainSection(chain = it, onChange = vm::setChain)\n }\n GroupsSection(state.groups, vm)\n TelegramSection(state.telegramToken, state.telegramChat, state.telegramStatus, vm)\n DriveSection(state.driveEmail, state.driveLastSyncMs, state.driveStatus, vm)\n }\n }\n}\n\n@Composable\nprivate fun StageChainSection(chain: EscalationChain, onChange: (EscalationChain) -> Unit) {\n var local by remember(chain) { mutableStateOf(chain) }\n SectionCard(\"Escalation chain (global default)\") {\n local.stages.forEachIndexed { idx, stage ->\n StageRow(\n stage = stage,\n canMoveUp = idx > 0,\n canMoveDown = idx < local.stages.lastIndex,\n onMoveUp = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx - 1]; list[idx - 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onMoveDown = {\n val list = local.stages.toMutableList()\n val tmp = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = tmp\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onOffsetChange = { newOff ->\n val list = local.stages.toMutableList()\n list[idx] = list[idx].copy(offsetMs = newOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n onRemove = {\n if (local.stages.size > 1) {\n val list = local.stages.toMutableList().also { it.removeAt(idx) }\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n }\n }\n )\n }\n val missing = StageType.values().toSet() - local.stages.map { it.type }.toSet()\n if (missing.isNotEmpty()) {\n Spacer(Modifier.height(8.dp))\n Text(\"Add stage:\", style = MaterialTheme.typography.labelMedium)\n androidx.compose.foundation.layout.FlowRow {\n missing.forEach { t ->\n AssistChip(\n onClick = {\n val nextOff = (local.stages.lastOrNull()?.offsetMs ?: 0L) + 5 * 60_000L\n val list = local.stages + StageConfig(t, nextOff)\n local = EscalationChain(list, local.repeatIntervalMs); onChange(local)\n },\n label = { Text(t.name) },\n modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)\n )\n }\n }\n }\n Spacer(Modifier.height(8.dp))\n IntervalField(\n label = \"Last-stage repeat interval (minutes)\",\n valueMin = (local.repeatIntervalMs / 60_000L).toInt()\n ) { newMin ->\n local = local.copy(repeatIntervalMs = newMin * 60_000L); onChange(local)\n }\n }\n}\n\n@Composable\nprivate fun StageRow(\n stage: StageConfig,\n canMoveUp: Boolean,\n canMoveDown: Boolean,\n onMoveUp: () -> Unit,\n onMoveDown: () -> Unit,\n onOffsetChange: (Long) -> Unit,\n onRemove: () -> Unit,\n) {\n Row(\n modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Column(modifier = Modifier.weight(1f)) {\n Text(stage.type.name, fontWeight = FontWeight.SemiBold)\n val mins = (stage.offsetMs / 60_000L).toInt()\n OutlinedTextField(\n value = mins.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> onOffsetChange(v * 60_000L) } },\n label = { Text(\"Offset (min)\") },\n modifier = Modifier.width(160.dp).padding(top = 4.dp)\n )\n }\n IconButton(onClick = onMoveUp, enabled = canMoveUp) { Icon(Icons.Filled.ArrowUpward, contentDescription = \"Up\") }\n IconButton(onClick = onMoveDown, enabled = canMoveDown) { Icon(Icons.Filled.ArrowDownward, contentDescription = \"Down\") }\n IconButton(onClick = onRemove) { Icon(Icons.Filled.Close, contentDescription = \"Remove\") }\n }\n}\n\n@Composable\nprivate fun IntervalField(label: String, valueMin: Int, onChange: (Int) -> Unit) {\n OutlinedTextField(\n value = valueMin.toString(),\n onValueChange = { it.toIntOrNull()?.let { v -> if (v >= 1) onChange(v) } },\n label = { Text(label) },\n modifier = Modifier.fillMaxWidth()\n )\n}\n\n@Composable\nprivate fun GroupsSection(groups: List<Group>, vm: SettingsViewModel) {\n SectionCard(\"Groups\") {\n groups.forEach { g ->\n GroupRow(g, vm)\n HorizontalDivider()\n }\n }\n}\n\n@Composable\nprivate fun GroupRow(g: Group, vm: SettingsViewModel) {\n var editing by remember { mutableStateOf(false) }\n Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {\n Box(\n modifier = Modifier.size(20.dp).clip(CircleShape).background(Color(g.color))\n )\n Spacer(Modifier.width(8.dp))\n Text(g.name, modifier = Modifier.weight(1f))\n Text(\n if (g.overrideChain != null) \"custom chain\" else \"default chain\",\n style = MaterialTheme.typography.bodySmall,\n color = MaterialTheme.colorScheme.outline\n )\n IconButton(onClick = { editing = !editing }) { Icon(Icons.Filled.Edit, contentDescription = \"Edit\") }\n }\n if (editing) {\n GroupOverrideEditor(g, vm) { editing = false }\n }\n}\n\n@Composable\nprivate fun GroupOverrideEditor(g: Group, vm: SettingsViewModel, onClose: () -> Unit) {\n var mirror by remember(g.id) { mutableStateOf(g.telegramSilentMirror) }\n var override by remember(g.id) { mutableStateOf(g.overrideChain) }\n\n Column(modifier = Modifier.padding(start = 28.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {\n Row(verticalAlignment = Alignment.CenterVertically) {\n Switch(checked = mirror, onCheckedChange = {\n mirror = it\n vm.updateGroup(g.copy(telegramSilentMirror = it))\n })\n Spacer(Modifier.width(8.dp))\n Text(\"Mirror silent stage to Telegram\")\n }\n Row(verticalAlignment = Alignment.CenterVertically) {\n Switch(checked = override != null, onCheckedChange = { on ->\n override = if (on) app.nock.android.domain.model.DefaultChain.CHAIN else null\n vm.updateGroup(g.copy(overrideChain = override))\n })\n Spacer(Modifier.width(8.dp))\n Text(\"Custom escalation chain\")\n }\n override?.let { chain ->\n Text(\"Override chain\", style = MaterialTheme.typography.labelMedium)\n StageChainSection(chain = chain, onChange = {\n override = it\n vm.updateGroup(g.copy(overrideChain = it))\n })\n }\n TextButton(onClick = onClose) { Text(\"Done editing group\") }\n }\n}\n\n@Composable\nprivate fun TelegramSection(token: String, chat: String, status: String?, vm: SettingsViewModel) {\n var t by remember(token) { mutableStateOf(token) }\n var c by remember(chat) { mutableStateOf(chat) }\n SectionCard(\"Telegram bot\") {\n OutlinedTextField(\n value = t, onValueChange = { t = it },\n label = { Text(\"Bot token\") },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n OutlinedTextField(\n value = c, onValueChange = { c = it },\n label = { Text(\"Chat ID\") },\n modifier = Modifier.fillMaxWidth()\n )\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = { vm.setTelegram(t, c) }) { Text(\"Save\") }\n OutlinedButton(onClick = { vm.setTelegram(t, c); vm.testTelegram() }) { Text(\"Test send\") }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun DriveSection(email: String?, lastSyncMs: Long?, status: String?, vm: SettingsViewModel) {\n val ctx = LocalContext.current\n val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)\n runCatching { task.getResult(com.google.android.gms.common.api.ApiException::class.java) }\n .onSuccess { acc -> vm.setDriveEmail(acc.email) }\n }\n SectionCard(\"Google Drive (App Folder)\") {\n if (email == null) {\n Text(\"Not signed in.\")\n Spacer(Modifier.height(8.dp))\n Button(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .requestScopes(Scope(DriveScopes.DRIVE_APPDATA))\n .build()\n val client = GoogleSignIn.getClient(ctx, gso)\n launcher.launch(client.signInIntent)\n }) { Text(\"Sign in\") }\n } else {\n Text(\"Signed in as $email\")\n Spacer(Modifier.height(8.dp))\n Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {\n Button(onClick = vm::syncPush) { Text(\"Push now\") }\n OutlinedButton(onClick = vm::syncPull) { Text(\"Pull now\") }\n TextButton(onClick = {\n val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).build()\n GoogleSignIn.getClient(ctx, gso).signOut()\n vm.setDriveEmail(null)\n }) { Text(\"Sign out\") }\n }\n if (lastSyncMs != null) {\n Spacer(Modifier.height(8.dp))\n val fmt = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm\")\n Text(\n \"Last sync: \" + LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMs), ZoneId.systemDefault()).format(fmt),\n style = MaterialTheme.typography.bodySmall\n )\n }\n }\n if (status != null) {\n Spacer(Modifier.height(8.dp))\n Text(status, style = MaterialTheme.typography.bodySmall)\n }\n }\n}\n\n@Composable\nprivate fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {\n ElevatedCard(modifier = Modifier.fillMaxWidth()) {\n Column(modifier = Modifier.padding(16.dp)) {\n Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)\n Spacer(Modifier.height(12.dp))\n content()\n }\n }\n}\n"}}, "app/src/main/java/app/nock/android/data/SettingsRepository.kt": {"62435763959e697b61607d4ea76b9ca03d580950": {"t": 1785745734.0245764, "n": 2680, "h": "8e956266aea6de1f", "c": "package app.nock.android.data\n\nimport app.nock.android.data.dao.SettingsDao\nimport app.nock.android.data.entity.SettingsEntity\nimport app.nock.android.data.json.ChainJson\nimport app.nock.android.domain.model.DefaultChain\nimport app.nock.android.domain.model.EscalationChain\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.map\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\n@Singleton\nclass SettingsRepository @Inject constructor(\n private val dao: SettingsDao\n) {\n companion object {\n const val KEY_STAGE_CHAIN = \"stage_chain\"\n const val KEY_TELEGRAM_TOKEN = \"telegram_token\"\n const val KEY_TELEGRAM_CHAT = \"telegram_chat\"\n const val KEY_ALARM_SOUND = \"alarm_sound_uri\"\n const val KEY_PREALARM_SOUND = \"prealarm_sound_uri\"\n const val KEY_DRIVE_ACCOUNT = \"drive_account\"\n const val KEY_DRIVE_LAST_SYNC_MS = \"drive_last_sync_ms\"\n const val KEY_DRIVE_LAST_REMOTE_MS = \"drive_last_remote_ms\"\n const val KEY_DEEPSEEK_API_KEY = \"deepseek_api_key\"\n const val KEY_DEEPSEEK_MODEL = \"deepseek_model\"\n const val KEY_DEEPSEEK_BASE_URL = \"deepseek_base_url\"\n const val KEY_DEEPSEEK_CONTEXT = \"deepseek_context\"\n\n // Calendar trip alarms\n const val KEY_TRIPS_ENABLED = \"trips_enabled\"\n const val KEY_TOMTOM_KEY = \"tomtom_key\"\n const val KEY_TRIP_HOME_ADDRESS = \"trip_home_address\"\n const val KEY_TRIP_HOME_LAT = \"trip_home_lat\"\n const val KEY_TRIP_HOME_LON = \"trip_home_lon\"\n const val KEY_TRIP_BUFFER_MIN = \"trip_buffer_min\"\n const val KEY_TRIP_TRAVEL_MODE = \"trip_travel_mode\"\n // CSV of CalendarContract calendar ids the user opted in to. Only events\n // from these calendars become trip alarms; empty/unset means none.\n const val KEY_TRIP_CALENDAR_IDS = \"trip_calendar_ids\"\n\n const val DEFAULT_DEEPSEEK_MODEL = \"deepseek-v4-flash\"\n const val DEFAULT_DEEPSEEK_BASE_URL = \"https://api.deepseek.com\"\n }\n\n suspend fun getStageChain(): EscalationChain {\n val v = dao.get(KEY_STAGE_CHAIN) ?: return DefaultChain.CHAIN\n return runCatching { ChainJson.decode(v) }.getOrDefault(DefaultChain.CHAIN)\n }\n\n suspend fun setStageChain(c: EscalationChain) {\n dao.set(SettingsEntity(KEY_STAGE_CHAIN, ChainJson.encode(c)))\n }\n\n suspend fun get(key: String): String? = dao.get(key)\n suspend fun set(key: String, value: String) = dao.set(SettingsEntity(key, value))\n suspend fun clear(key: String) = dao.delete(key)\n\n fun observeAll(): Flow<Map<String, String>> = dao.observeAll().map { items ->\n items.associate { it.key to it.value }\n }\n}\n"}, "798d969d6f84b3433aae3f3fab30b95aa2176685": {"t": 1785755943.689089, "n": 2680, "h": "8e956266aea6de1f", "c": "package app.nock.android.data\n\nimport app.nock.android.data.dao.SettingsDao\nimport app.nock.android.data.entity.SettingsEntity\nimport app.nock.android.data.json.ChainJson\nimport app.nock.android.domain.model.DefaultChain\nimport app.nock.android.domain.model.EscalationChain\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.map\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\n@Singleton\nclass SettingsRepository @Inject constructor(\n private val dao: SettingsDao\n) {\n companion object {\n const val KEY_STAGE_CHAIN = \"stage_chain\"\n const val KEY_TELEGRAM_TOKEN = \"telegram_token\"\n const val KEY_TELEGRAM_CHAT = \"telegram_chat\"\n const val KEY_ALARM_SOUND = \"alarm_sound_uri\"\n const val KEY_PREALARM_SOUND = \"prealarm_sound_uri\"\n const val KEY_DRIVE_ACCOUNT = \"drive_account\"\n const val KEY_DRIVE_LAST_SYNC_MS = \"drive_last_sync_ms\"\n const val KEY_DRIVE_LAST_REMOTE_MS = \"drive_last_remote_ms\"\n const val KEY_DEEPSEEK_API_KEY = \"deepseek_api_key\"\n const val KEY_DEEPSEEK_MODEL = \"deepseek_model\"\n const val KEY_DEEPSEEK_BASE_URL = \"deepseek_base_url\"\n const val KEY_DEEPSEEK_CONTEXT = \"deepseek_context\"\n\n // Calendar trip alarms\n const val KEY_TRIPS_ENABLED = \"trips_enabled\"\n const val KEY_TOMTOM_KEY = \"tomtom_key\"\n const val KEY_TRIP_HOME_ADDRESS = \"trip_home_address\"\n const val KEY_TRIP_HOME_LAT = \"trip_home_lat\"\n const val KEY_TRIP_HOME_LON = \"trip_home_lon\"\n const val KEY_TRIP_BUFFER_MIN = \"trip_buffer_min\"\n const val KEY_TRIP_TRAVEL_MODE = \"trip_travel_mode\"\n // CSV of CalendarContract calendar ids the user opted in to. Only events\n // from these calendars become trip alarms; empty/unset means none.\n const val KEY_TRIP_CALENDAR_IDS = \"trip_calendar_ids\"\n\n const val DEFAULT_DEEPSEEK_MODEL = \"deepseek-v4-flash\"\n const val DEFAULT_DEEPSEEK_BASE_URL = \"https://api.deepseek.com\"\n }\n\n suspend fun getStageChain(): EscalationChain {\n val v = dao.get(KEY_STAGE_CHAIN) ?: return DefaultChain.CHAIN\n return runCatching { ChainJson.decode(v) }.getOrDefault(DefaultChain.CHAIN)\n }\n\n suspend fun setStageChain(c: EscalationChain) {\n dao.set(SettingsEntity(KEY_STAGE_CHAIN, ChainJson.encode(c)))\n }\n\n suspend fun get(key: String): String? = dao.get(key)\n suspend fun set(key: String, value: String) = dao.set(SettingsEntity(key, value))\n suspend fun clear(key: String) = dao.delete(key)\n\n fun observeAll(): Flow<Map<String, String>> = dao.observeAll().map { items ->\n items.associate { it.key to it.value }\n }\n}\n"}, "4809b188e7bda181ca7bcdb8d91e078cfde16927": {"t": 1785755945.0493965, "n": 2620, "h": "505cb4cf852ebe4e", "c": "package app.nock.android.data\n\nimport app.nock.android.data.dao.SettingsDao\nimport app.nock.android.data.entity.SettingsEntity\nimport app.nock.android.data.json.ChainJson\nimport app.nock.android.domain.model.DefaultChain\nimport app.nock.android.domain.model.EscalationChain\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.map\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\n@Singleton\nclass SettingsRepository @Inject constructor(\n private val dao: SettingsDao\n) {\n companion object {\n const val KEY_STAGE_CHAIN = \"stage_chain\"\n const val KEY_TELEGRAM_TOKEN = \"telegram_token\"\n const val KEY_TELEGRAM_CHAT = \"telegram_chat\"\n const val KEY_ALARM_SOUND = \"alarm_sound_uri\"\n const val KEY_DRIVE_ACCOUNT = \"drive_account\"\n const val KEY_DRIVE_LAST_SYNC_MS = \"drive_last_sync_ms\"\n const val KEY_DRIVE_LAST_REMOTE_MS = \"drive_last_remote_ms\"\n const val KEY_DEEPSEEK_API_KEY = \"deepseek_api_key\"\n const val KEY_DEEPSEEK_MODEL = \"deepseek_model\"\n const val KEY_DEEPSEEK_BASE_URL = \"deepseek_base_url\"\n const val KEY_DEEPSEEK_CONTEXT = \"deepseek_context\"\n\n // Calendar trip alarms\n const val KEY_TRIPS_ENABLED = \"trips_enabled\"\n const val KEY_TOMTOM_KEY = \"tomtom_key\"\n const val KEY_TRIP_HOME_ADDRESS = \"trip_home_address\"\n const val KEY_TRIP_HOME_LAT = \"trip_home_lat\"\n const val KEY_TRIP_HOME_LON = \"trip_home_lon\"\n const val KEY_TRIP_BUFFER_MIN = \"trip_buffer_min\"\n const val KEY_TRIP_TRAVEL_MODE = \"trip_travel_mode\"\n // CSV of CalendarContract calendar ids the user opted in to. Only events\n // from these calendars become trip alarms; empty/unset means none.\n const val KEY_TRIP_CALENDAR_IDS = \"trip_calendar_ids\"\n\n const val DEFAULT_DEEPSEEK_MODEL = \"deepseek-v4-flash\"\n const val DEFAULT_DEEPSEEK_BASE_URL = \"https://api.deepseek.com\"\n }\n\n suspend fun getStageChain(): EscalationChain {\n val v = dao.get(KEY_STAGE_CHAIN) ?: return DefaultChain.CHAIN\n return runCatching { ChainJson.decode(v) }.getOrDefault(DefaultChain.CHAIN)\n }\n\n suspend fun setStageChain(c: EscalationChain) {\n dao.set(SettingsEntity(KEY_STAGE_CHAIN, ChainJson.encode(c)))\n }\n\n suspend fun get(key: String): String? = dao.get(key)\n suspend fun set(key: String, value: String) = dao.set(SettingsEntity(key, value))\n suspend fun clear(key: String) = dao.delete(key)\n\n fun observeAll(): Flow<Map<String, String>> = dao.observeAll().map { items ->\n items.associate { it.key to it.value }\n }\n}\n"}, "ac7dc67aad55bf476b10ffaf5f669f7eb6c89393": {"t": 1785755946.7658143, "n": 2595, "h": "1f82eebd6be79010", "c": "package app.nock.android.data\n\nimport app.nock.android.data.dao.SettingsDao\nimport app.nock.android.data.entity.SettingsEntity\nimport app.nock.android.data.json.ChainJson\nimport app.nock.android.domain.model.DefaultChain\nimport app.nock.android.domain.model.EscalationChain\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.map\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\n@Singleton\nclass SettingsRepository @Inject constructor(\n private val dao: SettingsDao\n) {\n companion object {\n const val KEY_STAGE_CHAIN = \"stage_chain\"\n const val KEY_TELEGRAM_TOKEN = \"telegram_token\"\n const val KEY_TELEGRAM_CHAT = \"telegram_chat\"\n const val KEY_ALARM_SOUND = \"alarm_sound_uri\"\n const val KEY_DRIVE_ACCOUNT = \"drive_account\"\n const val KEY_DRIVE_LAST_SYNC_MS = \"drive_last_sync_ms\"\n const val KEY_DRIVE_LAST_REMOTE_MS = \"drive_last_remote_ms\"\n const val KEY_DEEPSEEK_API_KEY = \"deepseek_api_key\"\n const val KEY_DEEPSEEK_MODEL = \"deepseek_model\"\n const val KEY_DEEPSEEK_BASE_URL = \"deepseek_base_url\"\n const val KEY_DEEPSEEK_CONTEXT = \"deepseek_context\"\n\n // Calendar trip alarms\n const val KEY_TRIPS_ENABLED = \"trips_enabled\"\n const val KEY_TOMTOM_KEY = \"tomtom_key\"\n const val KEY_TRIP_HOME_ADDRESS = \"trip_home_address\"\n const val KEY_TRIP_HOME_LAT = \"trip_home_lat\"\n const val KEY_TRIP_HOME_LON = \"trip_home_lon\"\n const val KEY_TRIP_BUFFER_MIN = \"trip_buffer_min\"\n const val KEY_TRIP_TRAVEL_MODE = \"trip_travel_mode\"\n // CSV of CalendarContract calendar ids to import located events from.\n // Empty/unset means \"all visible calendars\".\n const val KEY_TRIP_CALENDAR_IDS = \"trip_calendar_ids\"\n\n const val DEFAULT_DEEPSEEK_MODEL = \"deepseek-v4-flash\"\n const val DEFAULT_DEEPSEEK_BASE_URL = \"https://api.deepseek.com\"\n }\n\n suspend fun getStageChain(): EscalationChain {\n val v = dao.get(KEY_STAGE_CHAIN) ?: return DefaultChain.CHAIN\n return runCatching { ChainJson.decode(v) }.getOrDefault(DefaultChain.CHAIN)\n }\n\n suspend fun setStageChain(c: EscalationChain) {\n dao.set(SettingsEntity(KEY_STAGE_CHAIN, ChainJson.encode(c)))\n }\n\n suspend fun get(key: String): String? = dao.get(key)\n suspend fun set(key: String, value: String) = dao.set(SettingsEntity(key, value))\n suspend fun clear(key: String) = dao.delete(key)\n\n fun observeAll(): Flow<Map<String, String>> = dao.observeAll().map { items ->\n items.associate { it.key to it.value }\n }\n}\n"}, "21c89a091549d77fb8036079580ea992a493bfa8": {"t": 1785755947.9873552, "n": 2037, "h": "fb2ed7c6fb0aa419", "c": "package app.nock.android.data\n\nimport app.nock.android.data.dao.SettingsDao\nimport app.nock.android.data.entity.SettingsEntity\nimport app.nock.android.data.json.ChainJson\nimport app.nock.android.domain.model.DefaultChain\nimport app.nock.android.domain.model.EscalationChain\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.map\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\n@Singleton\nclass SettingsRepository @Inject constructor(\n private val dao: SettingsDao\n) {\n companion object {\n const val KEY_STAGE_CHAIN = \"stage_chain\"\n const val KEY_TELEGRAM_TOKEN = \"telegram_token\"\n const val KEY_TELEGRAM_CHAT = \"telegram_chat\"\n const val KEY_ALARM_SOUND = \"alarm_sound_uri\"\n const val KEY_PREALARM_SOUND = \"prealarm_sound_uri\"\n const val KEY_DRIVE_ACCOUNT = \"drive_account\"\n const val KEY_DRIVE_LAST_SYNC_MS = \"drive_last_sync_ms\"\n const val KEY_DRIVE_LAST_REMOTE_MS = \"drive_last_remote_ms\"\n const val KEY_DEEPSEEK_API_KEY = \"deepseek_api_key\"\n const val KEY_DEEPSEEK_MODEL = \"deepseek_model\"\n const val KEY_DEEPSEEK_BASE_URL = \"deepseek_base_url\"\n const val KEY_DEEPSEEK_CONTEXT = \"deepseek_context\"\n\n const val DEFAULT_DEEPSEEK_MODEL = \"deepseek-v4-flash\"\n const val DEFAULT_DEEPSEEK_BASE_URL = \"https://api.deepseek.com\"\n }\n\n suspend fun getStageChain(): EscalationChain {\n val v = dao.get(KEY_STAGE_CHAIN) ?: return DefaultChain.CHAIN\n return runCatching { ChainJson.decode(v) }.getOrDefault(DefaultChain.CHAIN)\n }\n\n suspend fun setStageChain(c: EscalationChain) {\n dao.set(SettingsEntity(KEY_STAGE_CHAIN, ChainJson.encode(c)))\n }\n\n suspend fun get(key: String): String? = dao.get(key)\n suspend fun set(key: String, value: String) = dao.set(SettingsEntity(key, value))\n suspend fun clear(key: String) = dao.delete(key)\n\n fun observeAll(): Flow<Map<String, String>> = dao.observeAll().map { items ->\n items.associate { it.key to it.value }\n }\n}\n"}, "b065b3dd7fd122028f8c8c8fb1e1cca5679c7bc8": {"t": 1785755949.4028525, "n": 1977, "h": "1b2d0982a04adaf0", "c": "package app.nock.android.data\n\nimport app.nock.android.data.dao.SettingsDao\nimport app.nock.android.data.entity.SettingsEntity\nimport app.nock.android.data.json.ChainJson\nimport app.nock.android.domain.model.DefaultChain\nimport app.nock.android.domain.model.EscalationChain\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.map\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\n@Singleton\nclass SettingsRepository @Inject constructor(\n private val dao: SettingsDao\n) {\n companion object {\n const val KEY_STAGE_CHAIN = \"stage_chain\"\n const val KEY_TELEGRAM_TOKEN = \"telegram_token\"\n const val KEY_TELEGRAM_CHAT = \"telegram_chat\"\n const val KEY_ALARM_SOUND = \"alarm_sound_uri\"\n const val KEY_DRIVE_ACCOUNT = \"drive_account\"\n const val KEY_DRIVE_LAST_SYNC_MS = \"drive_last_sync_ms\"\n const val KEY_DRIVE_LAST_REMOTE_MS = \"drive_last_remote_ms\"\n const val KEY_DEEPSEEK_API_KEY = \"deepseek_api_key\"\n const val KEY_DEEPSEEK_MODEL = \"deepseek_model\"\n const val KEY_DEEPSEEK_BASE_URL = \"deepseek_base_url\"\n const val KEY_DEEPSEEK_CONTEXT = \"deepseek_context\"\n\n const val DEFAULT_DEEPSEEK_MODEL = \"deepseek-v4-flash\"\n const val DEFAULT_DEEPSEEK_BASE_URL = \"https://api.deepseek.com\"\n }\n\n suspend fun getStageChain(): EscalationChain {\n val v = dao.get(KEY_STAGE_CHAIN) ?: return DefaultChain.CHAIN\n return runCatching { ChainJson.decode(v) }.getOrDefault(DefaultChain.CHAIN)\n }\n\n suspend fun setStageChain(c: EscalationChain) {\n dao.set(SettingsEntity(KEY_STAGE_CHAIN, ChainJson.encode(c)))\n }\n\n suspend fun get(key: String): String? = dao.get(key)\n suspend fun set(key: String, value: String) = dao.set(SettingsEntity(key, value))\n suspend fun clear(key: String) = dao.delete(key)\n\n fun observeAll(): Flow<Map<String, String>> = dao.observeAll().map { items ->\n items.associate { it.key to it.value }\n }\n}\n"}, "3b3cd08ff3dc89e0390749b24f1a60bbe660bc87": {"t": 1785755950.8039978, "n": 1917, "h": "b0ac44f9094897ef", "c": "package app.nock.android.data\n\nimport app.nock.android.data.dao.SettingsDao\nimport app.nock.android.data.entity.SettingsEntity\nimport app.nock.android.data.json.ChainJson\nimport app.nock.android.domain.model.DefaultChain\nimport app.nock.android.domain.model.EscalationChain\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.map\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\n@Singleton\nclass SettingsRepository @Inject constructor(\n private val dao: SettingsDao\n) {\n companion object {\n const val KEY_STAGE_CHAIN = \"stage_chain\"\n const val KEY_TELEGRAM_TOKEN = \"telegram_token\"\n const val KEY_TELEGRAM_CHAT = \"telegram_chat\"\n const val KEY_ALARM_SOUND = \"alarm_sound_uri\"\n const val KEY_DRIVE_ACCOUNT = \"drive_account\"\n const val KEY_DRIVE_LAST_SYNC_MS = \"drive_last_sync_ms\"\n const val KEY_DRIVE_LAST_REMOTE_MS = \"drive_last_remote_ms\"\n const val KEY_DEEPSEEK_API_KEY = \"deepseek_api_key\"\n const val KEY_DEEPSEEK_MODEL = \"deepseek_model\"\n const val KEY_DEEPSEEK_BASE_URL = \"deepseek_base_url\"\n\n const val DEFAULT_DEEPSEEK_MODEL = \"deepseek-v4-flash\"\n const val DEFAULT_DEEPSEEK_BASE_URL = \"https://api.deepseek.com\"\n }\n\n suspend fun getStageChain(): EscalationChain {\n val v = dao.get(KEY_STAGE_CHAIN) ?: return DefaultChain.CHAIN\n return runCatching { ChainJson.decode(v) }.getOrDefault(DefaultChain.CHAIN)\n }\n\n suspend fun setStageChain(c: EscalationChain) {\n dao.set(SettingsEntity(KEY_STAGE_CHAIN, ChainJson.encode(c)))\n }\n\n suspend fun get(key: String): String? = dao.get(key)\n suspend fun set(key: String, value: String) = dao.set(SettingsEntity(key, value))\n suspend fun clear(key: String) = dao.delete(key)\n\n fun observeAll(): Flow<Map<String, String>> = dao.observeAll().map { items ->\n items.associate { it.key to it.value }\n }\n}\n"}, "e69e46729d9e1b8c537ed8aba881b8197a838111": {"t": 1785755952.2587783, "n": 1602, "h": "ef8a199b67968dc4", "c": "package app.nock.android.data\n\nimport app.nock.android.data.dao.SettingsDao\nimport app.nock.android.data.entity.SettingsEntity\nimport app.nock.android.data.json.ChainJson\nimport app.nock.android.domain.model.DefaultChain\nimport app.nock.android.domain.model.EscalationChain\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.map\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\n@Singleton\nclass SettingsRepository @Inject constructor(\n private val dao: SettingsDao\n) {\n companion object {\n const val KEY_STAGE_CHAIN = \"stage_chain\"\n const val KEY_TELEGRAM_TOKEN = \"telegram_token\"\n const val KEY_TELEGRAM_CHAT = \"telegram_chat\"\n const val KEY_ALARM_SOUND = \"alarm_sound_uri\"\n const val KEY_DRIVE_ACCOUNT = \"drive_account\"\n const val KEY_DRIVE_LAST_SYNC_MS = \"drive_last_sync_ms\"\n const val KEY_DRIVE_LAST_REMOTE_MS = \"drive_last_remote_ms\"\n }\n\n suspend fun getStageChain(): EscalationChain {\n val v = dao.get(KEY_STAGE_CHAIN) ?: return DefaultChain.CHAIN\n return runCatching { ChainJson.decode(v) }.getOrDefault(DefaultChain.CHAIN)\n }\n\n suspend fun setStageChain(c: EscalationChain) {\n dao.set(SettingsEntity(KEY_STAGE_CHAIN, ChainJson.encode(c)))\n }\n\n suspend fun get(key: String): String? = dao.get(key)\n suspend fun set(key: String, value: String) = dao.set(SettingsEntity(key, value))\n suspend fun clear(key: String) = dao.delete(key)\n\n fun observeAll(): Flow<Map<String, String>> = dao.observeAll().map { items ->\n items.associate { it.key to it.value }\n }\n}\n"}}}}