Added settings screen

This commit is contained in:
OmniaX-Dev 2026-07-06 06:09:29 +02:00
parent cae8f99830
commit 35b77c75fe
13 changed files with 596 additions and 39 deletions

View file

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

View file

@ -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<ListRoute> {
RecordingListScreen(
vm = vm,
onOpenSettings = { navController.navigate(SettingsRoute) }
)
}
composable<SettingsRoute> {
SettingsScreen(
vm = vm,
onBack = { navController.popBackStack() }
)
}
}
}
}
}
}

View file

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

View file

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

View file

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

View file

@ -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<PlaybackState> = _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
}
}
}

View file

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

View file

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

View file

@ -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<PlaybackState> = 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> = _repeatMode.asStateFlow()
@ -76,7 +81,42 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
*/
private var shuffleOrder: List<String> = 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<Boolean> = _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<Long?>(null)
val cacheSizeBytes: StateFlow<Long?> = _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>(SyncState.Syncing)
val syncState: StateFlow<SyncState> = _syncState.asStateFlow()
@ -114,7 +154,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
// ── User-controlled filters ────────────────────────────────────────
// -- User-controlled filters --
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
@ -136,7 +176,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
private val _hiddenYears = MutableStateFlow<Set<Int>>(emptySet())
val hiddenYears: StateFlow<Set<Int>> = _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<List<Recording>> = 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<List<Recording>> =
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.

View file

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

View file

@ -7,6 +7,7 @@
<string name="cd_shuffle">Aleatorio</string>
<string name="cd_repeat">Repetir</string>
<string name="cd_search_filter">Buscar y filtrar</string>
<string name="cd_more_options">M\u00E1s opciones</string>
<!-- Sync states -->
<string name="syncing">Sincronizando\u2026</string>
@ -56,4 +57,20 @@
<string name="sort_title_az">T\u00EDtulo A \u2192 Z</string>
<string name="sort_longest">M\u00E1s largas</string>
<string name="sort_shortest">M\u00E1s cortas</string>
<!-- Settings -->
<string name="settings_title">Ajustes</string>
<string name="cd_back">Atr\u00E1s</string>
<string name="settings_section_notifications">Notificaciones</string>
<string name="settings_notifications_title">Avisarme de nuevas grabaciones</string>
<string name="settings_notifications_subtitle">Recibe una notificaci\u00F3n cuando se a\u00F1adan nuevas grabaciones</string>
<string name="settings_section_storage">Almacenamiento</string>
<string name="settings_clear_cache_title">Borrar grabaciones en cach\u00E9</string>
<string name="settings_clear_cache_subtitle">Usando %1$s</string>
<string name="settings_clear_cache_confirm">Las grabaciones descargadas se eliminar\u00E1n de este dispositivo. Se volver\u00E1n a descargar la pr\u00F3xima vez que las reproduzcas.</string>
<string name="settings_clear_badges_title">Borrar todas las etiquetas \u201CNUEVO\u201D</string>
<string name="settings_clear_badges_subtitle">Quita la etiqueta NUEVO de todas las grabaciones</string>
<string name="settings_clear_badges_confirm">Se quitar\u00E1 la etiqueta NUEVO de todas las grabaciones.</string>
<string name="settings_clear">Borrar</string>
<string name="settings_cancel">Cancelar</string>
</resources>

View file

@ -7,6 +7,7 @@
<string name="cd_shuffle">Casuale</string>
<string name="cd_repeat">Ripeti</string>
<string name="cd_search_filter">Cerca e filtra</string>
<string name="cd_more_options">Altre opzioni</string>
<!-- Sync states -->
<string name="syncing">Sincronizzazione\u2026</string>
@ -56,4 +57,20 @@
<string name="sort_title_az">Titolo A \u2192 Z</string>
<string name="sort_longest">Pi\u00F9 lunghi</string>
<string name="sort_shortest">Pi\u00F9 brevi</string>
<!-- Settings -->
<string name="settings_title">Impostazioni</string>
<string name="cd_back">Indietro</string>
<string name="settings_section_notifications">Notifiche</string>
<string name="settings_notifications_title">Avvisami delle nuove registrazioni</string>
<string name="settings_notifications_subtitle">Ricevi una notifica quando vengono aggiunte nuove registrazioni</string>
<string name="settings_section_storage">Archiviazione</string>
<string name="settings_clear_cache_title">Cancella registrazioni in cache</string>
<string name="settings_clear_cache_subtitle">In uso %1$s</string>
<string name="settings_clear_cache_confirm">Le registrazioni scaricate verranno rimosse da questo dispositivo. Verranno scaricate di nuovo la prossima volta che le riproduci.</string>
<string name="settings_clear_badges_title">Cancella tutte le etichette \u201CNUOVO\u201D</string>
<string name="settings_clear_badges_subtitle">Rimuovi l\u2019etichetta NUOVO da tutte le registrazioni</string>
<string name="settings_clear_badges_confirm">L\u2019etichetta NUOVO verr\u00E0 rimossa da tutte le registrazioni.</string>
<string name="settings_clear">Cancella</string>
<string name="settings_cancel">Annulla</string>
</resources>

View file

@ -7,6 +7,7 @@
<string name="cd_shuffle">Shuffle</string>
<string name="cd_repeat">Repeat</string>
<string name="cd_search_filter">Search &amp; Filter</string>
<string name="cd_more_options">More options</string>
<!-- Sync states -->
<string name="syncing">Syncing\u2026</string>
@ -56,4 +57,20 @@
<string name="sort_title_az">Title A \u2192 Z</string>
<string name="sort_longest">Longest first</string>
<string name="sort_shortest">Shortest first</string>
<!-- Settings -->
<string name="settings_title">Settings</string>
<string name="cd_back">Back</string>
<string name="settings_section_notifications">Notifications</string>
<string name="settings_notifications_title">Notify me about new recordings</string>
<string name="settings_notifications_subtitle">Get a notification when new recordings are added</string>
<string name="settings_section_storage">Storage</string>
<string name="settings_clear_cache_title">Clear cached recordings</string>
<string name="settings_clear_cache_subtitle">Using %1$s</string>
<string name="settings_clear_cache_confirm">Downloaded recordings will be removed from this device. They\'ll download again next time you play them.</string>
<string name="settings_clear_badges_title">Clear all \u201CNEW\u201D badges</string>
<string name="settings_clear_badges_subtitle">Remove the NEW label from every recording</string>
<string name="settings_clear_badges_confirm">The NEW badge will be removed from all recordings.</string>
<string name="settings_clear">Clear</string>
<string name="settings_cancel">Cancel</string>
</resources>