From 35b77c75feb58ebe05b5639daa171dd32f0509d9 Mon Sep 17 00:00:00 2001 From: OmniaX-Dev Date: Mon, 6 Jul 2026 06:09:29 +0200 Subject: [PATCH] Added settings screen --- app/build.gradle.kts | 1 + .../pianotimeline/MainActivity.kt | 88 +++++- .../pianotimeline/data/MusicRepository.kt | 22 +- .../pianotimeline/data/SettingsStore.kt | 15 + .../pianotimeline/data/local/RecordingDao.kt | 4 + .../pianotimeline/player/PlayerManager.kt | 17 +- .../pianotimeline/ui/RecordingItem.kt | 4 +- .../pianotimeline/ui/RecordingListScreen.kt | 31 ++- .../ui/RecordingListViewModel.kt | 141 ++++++++-- .../pianotimeline/ui/SettingsScreen.kt | 261 ++++++++++++++++++ app/src/main/res/values-es/strings.xml | 17 ++ app/src/main/res/values-it/strings.xml | 17 ++ app/src/main/res/values/strings.xml | 17 ++ 13 files changed, 596 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/com/keylightpiano/pianotimeline/ui/SettingsScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index de4b6d1..dff82a9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -93,4 +93,5 @@ dependencies { implementation(libs.androidx.compose.material.icons.core) implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.navigation:navigation-compose:2.9.0") } diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/MainActivity.kt b/app/src/main/java/com/keylightpiano/pianotimeline/MainActivity.kt index d526f9a..7a369c7 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/MainActivity.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/MainActivity.kt @@ -7,14 +7,43 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController import com.keylightpiano.pianotimeline.ui.RecordingListScreen import com.keylightpiano.pianotimeline.ui.RecordingListViewModel +import com.keylightpiano.pianotimeline.ui.SettingsScreen import com.keylightpiano.pianotimeline.ui.theme.PianoTimelineTheme +import kotlinx.serialization.Serializable + +/** + * Type-safe navigation routes. Kotlin-serialization-backed destinations + * (Navigation-Compose 2.8+), so adding arguments later is just adding fields. + */ +@Serializable +object ListRoute + +@Serializable +object SettingsRoute + +// Standard Android forward/back slide feel: the incoming screen slides in from +// the right while the outgoing one slides partway out to the left, both with a +// short fade. On back (pop), the directions reverse. 300ms matches the platform +// default duration closely enough to feel native. +private const val NAV_ANIM_MS = 300 class MainActivity : ComponentActivity() { // Hoist the ViewModel to the Activity so deep-link intents (which arrive here) - // can be forwarded to it. The Compose screen uses this same instance. + // can be forwarded to it. Both Compose screens share this same instance. private val vm: RecordingListViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { @@ -27,7 +56,62 @@ class MainActivity : ComponentActivity() { setContent { // Force dark mode regardless of system setting PianoTimelineTheme(darkTheme = true) { - RecordingListScreen(vm = vm) + val navController = rememberNavController() + + // Opaque themed backdrop BEHIND the NavHost. During a screen + // transition Compose does not stack the two destinations -- it + // plays exit then enter -- so mid-animation there's a gap where + // neither fully covers the host. Without this, the window's + // default (white) background shows through as a flash. Filling + // the host with the theme background makes that gap dark instead. + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + NavHost( + navController = navController, + startDestination = ListRoute, + // Forward navigation (A -> B): new screen enters from the right. + enterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(NAV_ANIM_MS) + ) + fadeIn(tween(NAV_ANIM_MS)) + }, + exitTransition = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(NAV_ANIM_MS) + ) + fadeOut(tween(NAV_ANIM_MS)) + }, + // Back navigation (pop B -> A): directions reverse. + popEnterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(NAV_ANIM_MS) + ) + fadeIn(tween(NAV_ANIM_MS)) + }, + popExitTransition = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(NAV_ANIM_MS) + ) + fadeOut(tween(NAV_ANIM_MS)) + } + ) { + composable { + RecordingListScreen( + vm = vm, + onOpenSettings = { navController.navigate(SettingsRoute) } + ) + } + composable { + SettingsScreen( + vm = vm, + onBack = { navController.popBackStack() } + ) + } + } + } } } } diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/MusicRepository.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/MusicRepository.kt index 90e4f5b..15f7880 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/data/MusicRepository.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/MusicRepository.kt @@ -6,6 +6,7 @@ import com.keylightpiano.pianotimeline.data.local.RecordingDao import com.keylightpiano.pianotimeline.data.local.toDomainOrNull import com.keylightpiano.pianotimeline.data.remote.MusicNetwork import com.keylightpiano.pianotimeline.domain.Recording +import com.keylightpiano.pianotimeline.player.MediaCache import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -69,12 +70,31 @@ class MusicRepository(context: Context) { dao.setNew(id, true) } + /** Clear the "new" badge from every recording (Settings action). */ + suspend fun clearAllNew() = withContext(Dispatchers.IO) { + dao.clearAllNew() + } + + /** + * Total bytes currently held in the ExoPlayer disk cache. Backed by the real + * SimpleCache (MediaCache), same source of truth as `isFullyCached`. + */ + suspend fun cacheSizeBytes(context: Context): Long = withContext(Dispatchers.IO) { + MediaCache.cacheSizeBytes(context) + } + /** * Clear all cached audio/midi files from disk and reset paths in the database. * Used by the "clear cache" setting. + * + * Clears BOTH the ExoPlayer SimpleCache (where streamed audio actually lands) + * and the legacy on-disk cache dirs, then resets the Room path columns. */ suspend fun clearAllCache(context: Context) = withContext(Dispatchers.IO) { - // Delete physical files + // Clear the real media cache (ExoPlayer SimpleCache). + MediaCache.clear(context) + + // Delete legacy physical files, if any remain from earlier caching schemes. val cacheDir = audioCacheDir(context) if (cacheDir.exists()) { cacheDir.deleteRecursively() diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/SettingsStore.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/SettingsStore.kt index b432b44..ee9366b 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/data/SettingsStore.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/SettingsStore.kt @@ -29,6 +29,7 @@ class SettingsStore(private val context: Context) { val KEY_REPEAT = stringPreferencesKey("repeat_mode") val KEY_LAST_TRACK_ID = stringPreferencesKey("last_track_id") val KEY_LAST_POSITION_MS = longPreferencesKey("last_position_ms") + val KEY_NOTIFICATIONS = booleanPreferencesKey("notifications_enabled") } /** Read the stored shuffle flag (default false). */ @@ -85,4 +86,18 @@ class SettingsStore(private val context: Context) { it.remove(KEY_LAST_POSITION_MS) } } + + /** + * "Notify me about new recordings" toggle. Currently just a stored flag — + * the actual notification delivery (WorkManager/FCM) isn't wired up yet, so + * reading/writing this is a no-op beyond persistence. Default off. + */ + suspend fun getNotificationsEnabled(): Boolean { + val prefs = context.userPrefsStore.data.first() + return prefs[KEY_NOTIFICATIONS] ?: false + } + + suspend fun setNotificationsEnabled(enabled: Boolean) { + context.userPrefsStore.edit { it[KEY_NOTIFICATIONS] = enabled } + } } diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/local/RecordingDao.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/local/RecordingDao.kt index 7b34d78..9769b72 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/data/local/RecordingDao.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/local/RecordingDao.kt @@ -63,6 +63,10 @@ interface RecordingDao { /** Clear the "new" flag for a recording (called when its play button is first pressed). */ @Query("UPDATE recordings SET isNew = 0 WHERE id = :id") suspend fun clearNew(id: String) + + /** Clear the "new" flag on every recording (from the Settings screen). */ + @Query("UPDATE recordings SET isNew = 0") + suspend fun clearAllNew() } data class IdAndSize(val id: String, val fileSize: Long) diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/player/PlayerManager.kt b/app/src/main/java/com/keylightpiano/pianotimeline/player/PlayerManager.kt index 5ee5d8e..ff29bea 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/player/PlayerManager.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/player/PlayerManager.kt @@ -34,7 +34,12 @@ import kotlinx.coroutines.launch class PlayerManager( private val context: Context, private val scope: CoroutineScope, - private val onTrackEnded: () -> Unit = {} + private val onTrackEnded: () -> Unit = {}, + // Fired when a track finishes playing all the way through, which means it has + // now been streamed-and-cached end to end. The ViewModel uses this to refresh + // the "downloaded" glyphs without waiting for an app restart. Best-effort: it + // signals "the cache probably changed, re-check", not a guarantee. + private val onCacheMayHaveChanged: () -> Unit = {} ) { private val _state = MutableStateFlow(PlaybackState()) val state: StateFlow = _state.asStateFlow() @@ -65,6 +70,10 @@ class PlayerManager( if (playbackState == Player.STATE_READY) startPositionLoop() if (playbackState == Player.STATE_ENDED) { stopPositionLoop() + // The track played to the end, so its bytes are now fully in the + // disk cache. Let the ViewModel know before advancing, so the + // just-finished recording can show its "downloaded" glyph. + onCacheMayHaveChanged() onTrackEnded() } } @@ -81,7 +90,7 @@ class PlayerManager( */ fun playOrToggle(recordingId: String, url: String) { if (recordingId == currentId) { - // Same track — toggle play/pause. + // Same track -- toggle play/pause. if (player.isPlaying) player.pause() else player.play() return } @@ -98,7 +107,7 @@ class PlayerManager( } /** - * Load a track and seek to [positionMs] but do NOT start playing — used to + * Load a track and seek to [positionMs] but do NOT start playing -- used to * restore the last session on launch, Spotify-style (comes back paused). * ExoPlayer defers the seek until the item is prepared enough to honor it, * so calling prepare() then seekTo() before playback is safe. @@ -149,7 +158,7 @@ class PlayerManager( positionJob = scope.launch { while (true) { updateState() - delay(200) // 5x/sec — smooth enough for a progress bar + delay(200) // 5x/sec -- smooth enough for a progress bar } } } diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingItem.kt b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingItem.kt index d391042..9b28942 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingItem.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingItem.kt @@ -19,7 +19,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Block -import androidx.compose.material.icons.filled.DownloadDone +import androidx.compose.material.icons.filled.DownloadForOffline import androidx.compose.material.icons.filled.MusicNote import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.outlined.StarBorder @@ -206,7 +206,7 @@ fun RecordingItem( // full audio is cached (playable offline). if (recording.isCached) { Icon( - imageVector = Icons.Filled.DownloadDone, + imageVector = Icons.Filled.DownloadForOffline, contentDescription = stringResource(R.string.cd_downloaded), tint = NotesGreen, modifier = Modifier.size(14.dp) diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListScreen.kt b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListScreen.kt index b03859d..14cbf5e 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListScreen.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator @@ -65,15 +66,15 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.keylightpiano.pianotimeline.R import com.keylightpiano.pianotimeline.domain.Recording @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun RecordingListScreen( - modifier: Modifier = Modifier, - vm: RecordingListViewModel = viewModel() + vm: RecordingListViewModel, + onOpenSettings: () -> Unit, + modifier: Modifier = Modifier ) { val syncState by vm.syncState.collectAsStateWithLifecycle() val recordings by vm.recordings.collectAsStateWithLifecycle() @@ -94,6 +95,7 @@ fun RecordingListScreen( var showSortMenu by remember { mutableStateOf(false) } var showArtistFilter by rememberSaveable { mutableStateOf(false) } var showYearFilter by rememberSaveable { mutableStateOf(false) } + var showOverflowMenu by remember { mutableStateOf(false) } val listState = rememberLazyListState() @@ -213,6 +215,29 @@ fun RecordingListScreen( else MaterialTheme.colorScheme.onSurfaceVariant ) } + + // Overflow (three dots) → Settings + Box { + IconButton(onClick = { showOverflowMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.cd_more_options), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + DropdownMenu( + expanded = showOverflowMenu, + onDismissRequest = { showOverflowMenu = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.settings_title)) }, + onClick = { + showOverflowMenu = false + onOpenSettings() + } + ) + } + } }, scrollBehavior = scrollBehavior ) diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListViewModel.kt b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListViewModel.kt index c500bc0..aa539e9 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListViewModel.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListViewModel.kt @@ -37,12 +37,17 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { private val repo = MusicRepository(app) private val syncManager = SyncManager(app) private val settings = SettingsStore(app) - private val playerManager = PlayerManager(app, viewModelScope, onTrackEnded = { advanceToNext() }) + private val playerManager = PlayerManager( + app, + viewModelScope, + onTrackEnded = { advanceToNext() }, + onCacheMayHaveChanged = { bumpCacheGeneration() } + ) /** Playback state the UI observes (which card is playing, position, etc). */ val playbackState: StateFlow = playerManager.state - // ── Connectivity ─────────────────────────────────────────────────── + // -- Connectivity -- private val networkMonitor = NetworkMonitor(app) @@ -60,7 +65,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { .debounce(800L) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), networkMonitor.isOnlineNow()) - // ── Continuous play: repeat + shuffle ────────────────────────────── + // -- Continuous play: repeat + shuffle -- private val _repeatMode = MutableStateFlow(RepeatMode.OFF) val repeatMode: StateFlow = _repeatMode.asStateFlow() @@ -76,7 +81,42 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { */ private var shuffleOrder: List = emptyList() - // ── Deep-link signals the UI observes ────────────────────────────── + // -- Cache freshness signal -- + + /** + * Bumped whenever something changes the on-disk cache in a way the recording + * list needs to reflect: "clear cache" (via clearCache) and a track finishing + * playback (via the player's onCacheMayHaveChanged, since a fully-played track + * is now fully cached). The `recordings` flow folds this in, so incrementing + * it forces `isCached` to be recomputed and the "downloaded" glyphs to update + * immediately -- no app restart. + */ + private val _cacheGeneration = MutableStateFlow(0) + + /** Nudge the recordings flow to re-check on-disk cache state. */ + private fun bumpCacheGeneration() { + _cacheGeneration.value += 1 + } + + // -- Settings state -- + + /** + * "Notify me about new recordings" toggle. Persisted, but currently a no-op + * beyond storage -- notification delivery isn't wired up yet. The Settings UI + * observes this to render the switch state. + */ + private val _notificationsEnabled = MutableStateFlow(false) + val notificationsEnabled: StateFlow = _notificationsEnabled.asStateFlow() + + /** + * Cached-media size in bytes, shown on the Settings screen. Null = "not yet + * computed" (Settings hasn't asked for it, or a refresh is in flight). The + * Settings screen calls refreshCacheSize() when it opens. + */ + private val _cacheSizeBytes = MutableStateFlow(null) + val cacheSizeBytes: StateFlow = _cacheSizeBytes.asStateFlow() + + // -- Deep-link signals the UI observes -- /** * Set to a recording id when an incoming link should make the list scroll @@ -99,13 +139,13 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { fun consumeVisualizationRequest() { _openVisualizationFor.value = null } - // ── Share URLs (used by the long-press menu) ─────────────────────── + // -- Share URLs (used by the long-press menu) -- fun audioShareUrl(recording: Recording): String = repo.audioUrl(recording) fun midiShareUrl(recording: Recording): String? = repo.midiUrl(recording) - // ── Sync state ───────────────────────────────────────────────────── + // -- Sync state -- private val _syncState = MutableStateFlow(SyncState.Syncing) val syncState: StateFlow = _syncState.asStateFlow() @@ -114,7 +154,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { private val _isRefreshing = MutableStateFlow(false) val isRefreshing: StateFlow = _isRefreshing.asStateFlow() - // ── User-controlled filters ──────────────────────────────────────── + // -- User-controlled filters -- private val _searchQuery = MutableStateFlow("") val searchQuery: StateFlow = _searchQuery.asStateFlow() @@ -136,7 +176,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { private val _hiddenYears = MutableStateFlow>(emptySet()) val hiddenYears: StateFlow> = _hiddenYears.asStateFlow() - // ── Derived: all unique grouping artists, for the filter UI ──────── + // -- Derived: all unique grouping artists, for the filter UI -- /** All recordings straight from Room (unfiltered). */ private val allRecordings = repo.observeRecordings() @@ -163,7 +203,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { .sortedDescending() } - // ── The final filtered + sorted list the UI renders ──────────────── + // -- The final filtered + sorted list the UI renders -- private val filteredRecordings: kotlinx.coroutines.flow.Flow> = combine( allRecordings, @@ -177,7 +217,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { .filterBySearch(query) .sortedWith(sort.comparator()) // Split into favorites and the rest. Favorites sit at the top, ordered by - // WHEN they were favorited (oldest first → newest favorite at the bottom + // WHEN they were favorited (oldest first -> newest favorite at the bottom // of the group), which reads as insertion order. Non-favorites keep the // user's chosen sort. val (favorites, others) = filteredSorted.partition { it.isFavorite } @@ -186,13 +226,14 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { /** * The list the UI renders. On top of the filtered/sorted list we stamp each - * recording's live `isCached` flag — derived from the real ExoPlayer disk + * recording's live `isCached` flag -- derived from the real ExoPlayer disk * cache (MediaCache), not the unused Room column. Recomputed whenever - * connectivity flips (isOnline) so "playable offline" is fresh when it - * matters. The map runs on IO because it touches cache metadata on disk. + * connectivity flips (isOnline) OR the cache generation is bumped (e.g. after + * "clear cache"), so "playable offline" stays fresh. The map runs on IO + * because it touches cache metadata on disk. */ val recordings: StateFlow> = - combine(filteredRecordings, isOnline) { list, _ -> + combine(filteredRecordings, isOnline, _cacheGeneration) { list, _, _ -> list.map { rec -> val cached = MediaCache.isFullyCached(app, repo.audioUrl(rec)) if (cached == rec.isCached) rec else rec.copy(isCached = cached) @@ -201,7 +242,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { .flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - // ── Init: run sync on creation ───────────────────────────────────── + // -- Init: run sync on creation -- init { // Restore persisted playback preferences (shuffle / repeat) before sync. @@ -210,10 +251,11 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { _repeatMode.value = runCatching { RepeatMode.valueOf(settings.getRepeatModeName()) }.getOrDefault(RepeatMode.OFF) + _notificationsEnabled.value = settings.getNotificationsEnabled() } // Restore the last-played track (PAUSED, Spotify-style) once the list is - // available so we can resolve the id → URL. Fires only once. + // available so we can resolve the id -> URL. Fires only once. viewModelScope.launch { val savedId = settings.getLastTrackId() ?: return@launch val savedPos = settings.getLastPositionMs() @@ -224,7 +266,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { playerManager.preparePaused(match.id, repo.audioUrl(match), savedPos) } else { // Track no longer exists (removed on the server, or storage cleared) - // — drop the stale pointer so we don't keep trying. + // -- drop the stale pointer so we don't keep trying. settings.clearLastPlayed() } } @@ -253,7 +295,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { } viewModelScope.launch { - // No connection → don't even attempt a network sync. Show whatever is + // No connection -> don't even attempt a network sync. Show whatever is // cached in Room if we have it; otherwise surface an offline error. if (!isOnline.value) { _syncState.value = @@ -276,7 +318,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { } } - // ── Public actions the UI calls ──────────────────────────────────── + // -- Public actions the UI calls -- fun setSearchQuery(query: String) { _searchQuery.value = query @@ -328,6 +370,51 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { } } + // -- Settings actions -- + + /** + * Toggle the "notify on new recordings" preference. Persists the flag only -- + * no delivery mechanism is wired up yet. When notifications are actually + * implemented, the permission-request flow (POST_NOTIFICATIONS on Android 13+) + * will be triggered from the UI layer, not here, since it needs an Activity. + */ + fun setNotificationsEnabled(enabled: Boolean) { + _notificationsEnabled.value = enabled + viewModelScope.launch { settings.setNotificationsEnabled(enabled) } + } + + /** Recompute the cached-media size shown on the Settings screen. */ + fun refreshCacheSize() { + viewModelScope.launch { + _cacheSizeBytes.value = repo.cacheSizeBytes(getApplication()) + } + } + + /** + * Clear all cached recordings from disk, then refresh the displayed size and + * bump the cache generation so the list drops its "downloaded" glyphs + * immediately. Playback is stopped first because SimpleCache can't be cleared + * while a player is actively reading from it. + */ + fun clearCache() { + viewModelScope.launch { + // Stop any active playback so the cache isn't being read during clear. + playerManager.stop() + settings.clearLastPlayed() + repo.clearAllCache(getApplication()) + _cacheSizeBytes.value = repo.cacheSizeBytes(getApplication()) + // Force the recordings flow to recompute isCached now (not on restart). + bumpCacheGeneration() + } + } + + /** Clear the "new" badge from every recording (Settings action). */ + fun clearAllNewBadges() { + viewModelScope.launch { + repo.clearAllNew() + } + } + /** * Called when a recording's play button is pressed. Clears its "new" badge, * then plays (or toggles pause/resume if it's already the active track). @@ -355,7 +442,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { viewModelScope.launch { settings.clearLastPlayed() } } - // ── Deep-link handling ───────────────────────────────────────────── + // -- Deep-link handling -- /** A link that arrived before the recording list was ready, retried later. */ private var pendingLink: DeepLinkParser.ParsedLink? = null @@ -408,7 +495,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { } /** - * Manual "next" button — behaves like a natural track-end advance, + * Manual "next" button -- behaves like a natural track-end advance, * honoring shuffle and repeat-all, but a manual skip should always move on * even under Repeat One (you don't want the button to just replay). */ @@ -468,7 +555,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { return prevId?.let { id -> visible.firstOrNull { it.id == id } } } - // ── Repeat / shuffle controls ────────────────────────────────────── + // -- Repeat / shuffle controls -- fun cycleRepeatMode() { val next = when (_repeatMode.value) { @@ -535,7 +622,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { val url = repo.audioUrl(nextRecording) playerManager.playOrToggle(nextRecording.id, url) } else { - // End of queue with Repeat Off → stop. + // End of queue with Repeat Off -> stop. playerManager.stop() } } @@ -561,7 +648,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { val nextId = when { idx != -1 && idx + 1 < shuffleOrder.size -> shuffleOrder[idx + 1] _repeatMode.value == RepeatMode.ALL -> { - // Completed a pass — reshuffle for the next one. + // Completed a pass -- reshuffle for the next one. rebuildShuffleOrder(startId = null) shuffleOrder.firstOrNull() } @@ -582,7 +669,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { */ fun refresh() { if (_isRefreshing.value) return // ignore overlapping pulls - // Offline → there's nothing to fetch. Bail immediately so the pull + // Offline -> there's nothing to fetch. Bail immediately so the pull // indicator doesn't spin against a network call that can only fail. if (!isOnline.value) return _isRefreshing.value = true @@ -614,7 +701,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { } } -// ── State types ──────────────────────────────────────────────────────── +// -- State types -- sealed interface SyncState { data object Syncing : SyncState @@ -625,7 +712,7 @@ sealed interface SyncState { /** Three-state repeat, like a normal audio player. */ enum class RepeatMode { OFF, ALL, ONE } -// ── Private helpers ──────────────────────────────────────────────────── +// -- Private helpers -- /** * Case-insensitive, token-based search. diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/ui/SettingsScreen.kt b/app/src/main/java/com/keylightpiano/pianotimeline/ui/SettingsScreen.kt new file mode 100644 index 0000000..2e35a3b --- /dev/null +++ b/app/src/main/java/com/keylightpiano/pianotimeline/ui/SettingsScreen.kt @@ -0,0 +1,261 @@ +package com.keylightpiano.pianotimeline.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.keylightpiano.pianotimeline.R +import java.util.Locale + +private val AccentGreen = Color(0xFF70D66B) + +/** + * Settings screen. Utility surface — deliberately quiet, matching the app's + * existing Material 3 dark theme and green accent rather than introducing a new + * visual language. + * + * Shares the same RecordingListViewModel as the list screen so preferences and + * cache actions operate on the one source of truth. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + vm: RecordingListViewModel, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val notificationsEnabled by vm.notificationsEnabled.collectAsStateWithLifecycle() + val cacheSizeBytes by vm.cacheSizeBytes.collectAsStateWithLifecycle() + + // Compute the cache size once when the screen first appears. + LaunchedEffect(Unit) { vm.refreshCacheSize() } + + var showClearCacheDialog by remember { mutableStateOf(false) } + var showClearBadgesDialog by remember { mutableStateOf(false) } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.settings_title), fontWeight = FontWeight.Bold) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back) + ) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // ── Notifications ────────────────────────────────────────── + SettingsSection(title = stringResource(R.string.settings_section_notifications)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_notifications_title), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(R.string.settings_notifications_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(Modifier.width(12.dp)) + Switch( + checked = notificationsEnabled, + onCheckedChange = { vm.setNotificationsEnabled(it) }, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = AccentGreen + ) + ) + } + } + + // ── Storage ──────────────────────────────────────────────── + SettingsSection(title = stringResource(R.string.settings_section_storage)) { + SettingsClickableRow( + title = stringResource(R.string.settings_clear_cache_title), + subtitle = stringResource( + R.string.settings_clear_cache_subtitle, + formatBytes(cacheSizeBytes) + ), + onClick = { showClearCacheDialog = true } + ) + HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)) + SettingsClickableRow( + title = stringResource(R.string.settings_clear_badges_title), + subtitle = stringResource(R.string.settings_clear_badges_subtitle), + onClick = { showClearBadgesDialog = true } + ) + } + } + } + + // ── Confirmation dialogs ─────────────────────────────────────────── + + if (showClearCacheDialog) { + AlertDialog( + onDismissRequest = { showClearCacheDialog = false }, + title = { Text(stringResource(R.string.settings_clear_cache_title)) }, + text = { Text(stringResource(R.string.settings_clear_cache_confirm)) }, + confirmButton = { + TextButton(onClick = { + vm.clearCache() + showClearCacheDialog = false + }) { + Text(stringResource(R.string.settings_clear), color = AccentGreen) + } + }, + dismissButton = { + TextButton(onClick = { showClearCacheDialog = false }) { + Text(stringResource(R.string.settings_cancel)) + } + } + ) + } + + if (showClearBadgesDialog) { + AlertDialog( + onDismissRequest = { showClearBadgesDialog = false }, + title = { Text(stringResource(R.string.settings_clear_badges_title)) }, + text = { Text(stringResource(R.string.settings_clear_badges_confirm)) }, + confirmButton = { + TextButton(onClick = { + vm.clearAllNewBadges() + showClearBadgesDialog = false + }) { + Text(stringResource(R.string.settings_clear), color = AccentGreen) + } + }, + dismissButton = { + TextButton(onClick = { showClearBadgesDialog = false }) { + Text(stringResource(R.string.settings_cancel)) + } + } + ) + } +} + +/** A titled group of settings rows, wrapped in a card. */ +@Composable +private fun SettingsSection( + title: String, + content: @Composable () -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + color = AccentGreen, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 4.dp) + ) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + content() + } + } +} + +/** A tappable row with a title + subtitle. */ +@Composable +private fun SettingsClickableRow( + title: String, + subtitle: String, + onClick: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** + * Human-readable byte size. Null (not-yet-computed) shows an em dash. + * Uses binary units (KB/MB/GB) matching how Android's own storage screens read. + */ +private fun formatBytes(bytes: Long?): String { + if (bytes == null) return "—" + if (bytes <= 0L) return "0 MB" + val kb = 1024.0 + val mb = kb * 1024 + val gb = mb * 1024 + return when { + bytes >= gb -> String.format(Locale.getDefault(), "%.1f GB", bytes / gb) + bytes >= mb -> String.format(Locale.getDefault(), "%.1f MB", bytes / mb) + bytes >= kb -> String.format(Locale.getDefault(), "%.0f KB", bytes / kb) + else -> "$bytes B" + } +} diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index f79611d..9a4c2ad 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -7,6 +7,7 @@ Aleatorio Repetir Buscar y filtrar + M\u00E1s opciones Sincronizando\u2026 @@ -56,4 +57,20 @@ T\u00EDtulo A \u2192 Z M\u00E1s largas M\u00E1s cortas + + + Ajustes + Atr\u00E1s + Notificaciones + Avisarme de nuevas grabaciones + Recibe una notificaci\u00F3n cuando se a\u00F1adan nuevas grabaciones + Almacenamiento + Borrar grabaciones en cach\u00E9 + Usando %1$s + Las grabaciones descargadas se eliminar\u00E1n de este dispositivo. Se volver\u00E1n a descargar la pr\u00F3xima vez que las reproduzcas. + Borrar todas las etiquetas \u201CNUEVO\u201D + Quita la etiqueta NUEVO de todas las grabaciones + Se quitar\u00E1 la etiqueta NUEVO de todas las grabaciones. + Borrar + Cancelar diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index ead7fa9..7c7ea39 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -7,6 +7,7 @@ Casuale Ripeti Cerca e filtra + Altre opzioni Sincronizzazione\u2026 @@ -56,4 +57,20 @@ Titolo A \u2192 Z Pi\u00F9 lunghi Pi\u00F9 brevi + + + Impostazioni + Indietro + Notifiche + Avvisami delle nuove registrazioni + Ricevi una notifica quando vengono aggiunte nuove registrazioni + Archiviazione + Cancella registrazioni in cache + In uso %1$s + Le registrazioni scaricate verranno rimosse da questo dispositivo. Verranno scaricate di nuovo la prossima volta che le riproduci. + Cancella tutte le etichette \u201CNUOVO\u201D + Rimuovi l\u2019etichetta NUOVO da tutte le registrazioni + L\u2019etichetta NUOVO verr\u00E0 rimossa da tutte le registrazioni. + Cancella + Annulla diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6737f37..7746af7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,6 +7,7 @@ Shuffle Repeat Search & Filter + More options Syncing\u2026 @@ -56,4 +57,20 @@ Title A \u2192 Z Longest first Shortest first + + + Settings + Back + Notifications + Notify me about new recordings + Get a notification when new recordings are added + Storage + Clear cached recordings + Using %1$s + Downloaded recordings will be removed from this device. They\'ll download again next time you play them. + Clear all \u201CNEW\u201D badges + Remove the NEW label from every recording + The NEW badge will be removed from all recordings. + Clear + Cancel