From 6d5ded27cd413a5d2e413603f2d5f4b1d7552e46 Mon Sep 17 00:00:00 2001 From: Joel Kanyi Date: Thu, 11 Jun 2026 18:35:20 +0300 Subject: [PATCH] add settings screen with re-pair, lyrics prefs, server info (#84, #114) Adds a Settings screen reachable via a gear icon on the Folders header. Follows the MVI screen skill (UiState + UiEvent + UiEffect + ViewModel + stateless ScreenContent + ObserverAsEvent for effects). What the screen shows: - Account section with the connected server URL and a Re-pair device button. Tapping it signs out and routes to QR scan. - Lyrics section with three toggles bound to AppSettingsRepository: the online lyrics search master switch, auto-search when a track has no lyrics, and the option to upgrade unsynced lyrics to a synced version found online. - About section with the app version. Auth changes to support sign out: - AuthRepository.signOut() clears tokens, base URL, the stored user record, and the in-memory holders. - BaseUrlDao.clearBaseUrl and UserDao.clearLoggedInUser are now suspend so they don't error from a coroutine context. - AuthTokensDataStore.clear wipes the auth datastore. Fixes for null base URL issues that surfaced while testing the sign out flow: - storeBaseUrl normalizes the URL (trimEnd('/') + "/") so it ends with a single slash regardless of input. Previously each call appended another slash, producing legacy DB rows like https://host/// over time. getBaseUrl also normalizes on read so existing bad rows self heal on next launch. - storeBaseUrl now updates BaseUrlHolder in memory the same way storeAuthTokens does, so the first request after pairing has the URL. - getFreshTokensFromServer early returns if base URL or refresh token is null. The TokenRefreshWorker fires on schedule and was hitting http://default/null/auth/refresh in the window between sign out and sign in. - DataFolderRepository.getPagingContent returns an empty flow if base URL is null instead of constructing a PagingSource with "nullfolder" baked into its URL. Closes #84 and #114. The user profile fetch (showing name/email instead of just server URL) is a separate concern, AuthViewModel has a TODO for it and the LogInResult doesn't carry user info yet. --- app/build.gradle.kts | 1 + .../presentation/navigator/CoreNavigator.kt | 7 + .../presentation/navigator/NavGraphs.kt | 2 + .../data/datastore/AuthTokensDataStore.kt | 4 + .../data/repository/DataAuthRepository.kt | 26 +- .../auth/domain/repository/AuthRepository.kt | 2 + .../database/data/dao/BaseUrlDao.kt | 2 +- .../swingmusic/database/data/dao/UserDao.kt | 3 + .../presentation/navigator/CommonNavigator.kt | 2 + .../data/repository/DataFolderRepository.kt | 7 + .../presentation/screen/FoldersAndTracks.kt | 41 ++- feature/settings/build.gradle.kts | 4 + .../presentation/event/SettingsUiEffect.kt | 7 + .../presentation/event/SettingsUiEvent.kt | 9 + .../presentation/screen/SettingsScreen.kt | 311 ++++++++++++++++++ .../presentation/state/SettingsUiState.kt | 16 + .../viewmodel/SettingsViewModel.kt | 131 ++++++++ 17 files changed, 560 insertions(+), 15 deletions(-) create mode 100644 feature/settings/src/main/java/com/android/swingmusic/settings/presentation/event/SettingsUiEffect.kt create mode 100644 feature/settings/src/main/java/com/android/swingmusic/settings/presentation/event/SettingsUiEvent.kt create mode 100644 feature/settings/src/main/java/com/android/swingmusic/settings/presentation/screen/SettingsScreen.kt create mode 100644 feature/settings/src/main/java/com/android/swingmusic/settings/presentation/state/SettingsUiState.kt create mode 100644 feature/settings/src/main/java/com/android/swingmusic/settings/presentation/viewmodel/SettingsViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5436c498..31859383 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -80,6 +80,7 @@ dependencies { implementation(project(":feature:artist")) implementation(project(":feature:album")) implementation(project(":feature:search")) + implementation(project(":feature:settings")) // Common Feature implementation(project(":feature:common")) diff --git a/app/src/main/java/com/android/swingmusic/presentation/navigator/CoreNavigator.kt b/app/src/main/java/com/android/swingmusic/presentation/navigator/CoreNavigator.kt index 1b226173..a916ce74 100644 --- a/app/src/main/java/com/android/swingmusic/presentation/navigator/CoreNavigator.kt +++ b/app/src/main/java/com/android/swingmusic/presentation/navigator/CoreNavigator.kt @@ -12,6 +12,7 @@ import com.android.swingmusic.home.presentation.destinations.HomeDestination import com.android.swingmusic.player.presentation.screen.destinations.LyricsScreenDestination import com.android.swingmusic.player.presentation.screen.destinations.QueueScreenDestination import com.android.swingmusic.search.presentation.screen.destinations.ViewAllSearchResultsDestination +import com.android.swingmusic.settings.presentation.screen.destinations.SettingsScreenDestination import com.ramcosta.composedestinations.navigation.navigate class CoreNavigator( @@ -152,4 +153,10 @@ class CoreNavigator( launchSingleTop = true } } + + override fun gotoSettings() { + navController.navigate(SettingsScreenDestination) { + launchSingleTop = true + } + } } diff --git a/app/src/main/java/com/android/swingmusic/presentation/navigator/NavGraphs.kt b/app/src/main/java/com/android/swingmusic/presentation/navigator/NavGraphs.kt index 6a6d06c5..d76dbe71 100644 --- a/app/src/main/java/com/android/swingmusic/presentation/navigator/NavGraphs.kt +++ b/app/src/main/java/com/android/swingmusic/presentation/navigator/NavGraphs.kt @@ -14,6 +14,7 @@ import com.android.swingmusic.player.presentation.screen.destinations.NowPlaying import com.android.swingmusic.player.presentation.screen.destinations.QueueScreenDestination import com.android.swingmusic.search.presentation.screen.destinations.SearchScreenDestination import com.android.swingmusic.search.presentation.screen.destinations.ViewAllSearchResultsDestination +import com.android.swingmusic.settings.presentation.screen.destinations.SettingsScreenDestination import com.ramcosta.composedestinations.spec.DestinationSpec import com.ramcosta.composedestinations.spec.NavGraphSpec import com.ramcosta.composedestinations.spec.Route @@ -49,6 +50,7 @@ object NavGraphs { ViewAllScreenOnArtistDestination, ArtistInfoScreenDestination, ViewAllSearchResultsDestination, + SettingsScreenDestination, ) return (preAuthDestSpec + pastAuthDestSpec).associateBy { it.route } diff --git a/auth/src/main/java/com/android/swingmusic/auth/data/datastore/AuthTokensDataStore.kt b/auth/src/main/java/com/android/swingmusic/auth/data/datastore/AuthTokensDataStore.kt index c3755d14..61f50f6c 100644 --- a/auth/src/main/java/com/android/swingmusic/auth/data/datastore/AuthTokensDataStore.kt +++ b/auth/src/main/java/com/android/swingmusic/auth/data/datastore/AuthTokensDataStore.kt @@ -49,4 +49,8 @@ class AuthTokensDataStore @Inject constructor( data[MAX_AGE] = maxAge } } + + suspend fun clear() { + context.dataStore.edit { it.clear() } + } } diff --git a/auth/src/main/java/com/android/swingmusic/auth/data/repository/DataAuthRepository.kt b/auth/src/main/java/com/android/swingmusic/auth/data/repository/DataAuthRepository.kt index a6f06db4..9fa1a1db 100644 --- a/auth/src/main/java/com/android/swingmusic/auth/data/repository/DataAuthRepository.kt +++ b/auth/src/main/java/com/android/swingmusic/auth/data/repository/DataAuthRepository.kt @@ -46,16 +46,21 @@ class DataAuthRepository @Inject constructor( override suspend fun getBaseUrl(): String? { if (BaseUrlHolder.baseUrl == null) { BaseUrlHolder.baseUrl = withContext(Dispatchers.IO) { - baseUrlDao.getBaseUrl()?.toModel()?.url + baseUrlDao.getBaseUrl()?.toModel()?.url?.normalizeBaseUrl() } } return BaseUrlHolder.baseUrl } override suspend fun storeBaseUrl(url: String) { - baseUrlDao.insertBaseUrl(BaseUrl(url = "$url/").toEntity()) // BASE_URL must end with '/' + val normalized = url.normalizeBaseUrl() + baseUrlDao.insertBaseUrl(BaseUrl(url = normalized).toEntity()) + BaseUrlHolder.baseUrl = normalized } + // BASE_URL must end with a single '/'. Idempotent against legacy values with 0, 1, or many trailing slashes. + private fun String.normalizeBaseUrl(): String = trimEnd('/') + "/" + override suspend fun getAccessToken(): String? { if (AuthTokenHolder.accessToken == null) { AuthTokenHolder.accessToken = authTokensDataStore.accessToken.firstOrNull() @@ -84,8 +89,14 @@ class DataAuthRepository @Inject constructor( val refreshToken = getRefreshToken() val baseUrl = BaseUrlHolder.baseUrl ?: getBaseUrl() + if (baseUrl.isNullOrBlank() || refreshToken.isNullOrBlank()) { + // Not signed in. Skip the refresh attempt so we don't fire requests + // like http://default/null/auth/refresh while between sessions. + return null + } + val result = authApiService.refreshTokens( - url = "$baseUrl/auth/refresh", + url = "${baseUrl.trimEnd('/')}/auth/refresh", bearerRefreshToken = "Bearer $refreshToken" ) @@ -219,4 +230,13 @@ class DataAuthRepository @Inject constructor( } } } + + override suspend fun signOut() { + authTokensDataStore.clear() + baseUrlDao.clearBaseUrl() + userDao.clearLoggedInUser() + AuthTokenHolder.accessToken = null + AuthTokenHolder.refreshToken = null + BaseUrlHolder.baseUrl = null + } } diff --git a/auth/src/main/java/com/android/swingmusic/auth/domain/repository/AuthRepository.kt b/auth/src/main/java/com/android/swingmusic/auth/domain/repository/AuthRepository.kt index 09e3b57e..13722bb1 100644 --- a/auth/src/main/java/com/android/swingmusic/auth/domain/repository/AuthRepository.kt +++ b/auth/src/main/java/com/android/swingmusic/auth/domain/repository/AuthRepository.kt @@ -49,4 +49,6 @@ interface AuthRepository { fun processQrCodeData(encoded: String): Pair suspend fun logInWithQrCode(url: String, pairCode: String): Flow> + + suspend fun signOut() } diff --git a/database/src/main/java/com/android/swingmusic/database/data/dao/BaseUrlDao.kt b/database/src/main/java/com/android/swingmusic/database/data/dao/BaseUrlDao.kt index 86798974..92a66bd6 100644 --- a/database/src/main/java/com/android/swingmusic/database/data/dao/BaseUrlDao.kt +++ b/database/src/main/java/com/android/swingmusic/database/data/dao/BaseUrlDao.kt @@ -16,5 +16,5 @@ interface BaseUrlDao { suspend fun getBaseUrl(): BaseUrlEntity? @Query("DELETE FROM base_url") - fun clearBaseUrl() + suspend fun clearBaseUrl() } diff --git a/database/src/main/java/com/android/swingmusic/database/data/dao/UserDao.kt b/database/src/main/java/com/android/swingmusic/database/data/dao/UserDao.kt index bba04dc5..786ed7ab 100644 --- a/database/src/main/java/com/android/swingmusic/database/data/dao/UserDao.kt +++ b/database/src/main/java/com/android/swingmusic/database/data/dao/UserDao.kt @@ -14,4 +14,7 @@ interface UserDao { @Query("SELECT * FROM logged_in_user LIMIT 1") suspend fun getLoggedInUser(): UserEntity? + + @Query("DELETE FROM logged_in_user") + suspend fun clearLoggedInUser() } diff --git a/feature/common/src/main/java/com/android/swingmusic/common/presentation/navigator/CommonNavigator.kt b/feature/common/src/main/java/com/android/swingmusic/common/presentation/navigator/CommonNavigator.kt index ee7f7ccb..6973a3a0 100644 --- a/feature/common/src/main/java/com/android/swingmusic/common/presentation/navigator/CommonNavigator.kt +++ b/feature/common/src/main/java/com/android/swingmusic/common/presentation/navigator/CommonNavigator.kt @@ -27,4 +27,6 @@ interface CommonNavigator { fun gotoLyrics() + fun gotoSettings() + } diff --git a/feature/folder/src/main/java/com/android/swingmusic/folder/data/repository/DataFolderRepository.kt b/feature/folder/src/main/java/com/android/swingmusic/folder/data/repository/DataFolderRepository.kt index a308d2ca..a31f1d99 100644 --- a/feature/folder/src/main/java/com/android/swingmusic/folder/data/repository/DataFolderRepository.kt +++ b/feature/folder/src/main/java/com/android/swingmusic/folder/data/repository/DataFolderRepository.kt @@ -91,6 +91,13 @@ class DataFolderRepository @Inject constructor( val accessToken = AuthTokenHolder.accessToken ?: authRepository.getAccessToken() val baseUrl = BaseUrlHolder.baseUrl ?: authRepository.getBaseUrl() + if (baseUrl.isNullOrBlank()) { + // Not signed in yet (or just signed out). Returning an empty flow avoids + // capturing a null base into the PagingSource and firing requests to + // urls like http://default/nullfolder. + return emptyFlow() + } + return Pager( config = PagingConfig(enablePlaceholders = false, pageSize = 20, prefetchDistance = 1), pagingSourceFactory = { diff --git a/feature/folder/src/main/java/com/android/swingmusic/folder/presentation/screen/FoldersAndTracks.kt b/feature/folder/src/main/java/com/android/swingmusic/folder/presentation/screen/FoldersAndTracks.kt index ec1ae69a..b4b8238a 100644 --- a/feature/folder/src/main/java/com/android/swingmusic/folder/presentation/screen/FoldersAndTracks.kt +++ b/feature/folder/src/main/java/com/android/swingmusic/folder/presentation/screen/FoldersAndTracks.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -18,10 +19,12 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold @@ -96,7 +99,8 @@ private fun FoldersAndTracks( isManualRefreshing: Boolean, onManualRefreshingChange: (Boolean) -> Unit, getSavedScroll: (path: String) -> Pair?, - onSaveScroll: (path: String, index: Int, offset: Int) -> Unit + onSaveScroll: (path: String, index: Int, offset: Int) -> Unit, + onClickSettings: () -> Unit ) { val sheetState = rememberModalBottomSheetState() val scope = rememberCoroutineScope() @@ -190,15 +194,29 @@ private fun FoldersAndTracks( Scaffold( modifier = Modifier.padding(it), topBar = { - Text( - modifier = Modifier.padding( - top = 16.dp, - start = 16.dp, - bottom = 8.dp - ), - text = "Folders", - style = MaterialTheme.typography.headlineMedium - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + top = 16.dp, + start = 16.dp, + end = 8.dp, + bottom = 8.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = "Folders", + style = MaterialTheme.typography.headlineMedium + ) + IconButton(onClick = onClickSettings) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Settings" + ) + } + } } ) { paddingValues -> Surface( @@ -690,7 +708,8 @@ fun FoldersAndTracksScreen( getSavedScroll = { path -> foldersViewModel.getScrollPosition(path) }, onSaveScroll = { path, index, offset -> foldersViewModel.saveScrollPosition(path, index, offset) - } + }, + onClickSettings = { navigator.gotoSettings() } ) } } diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index f8173ae1..d2d5d9ad 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -46,6 +46,10 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.ktx) // Project Core implementation(project(":core")) + implementation(project(":auth")) + implementation(project(":database")) + implementation(project(":uicomponent")) + implementation(project(":feature:common")) // Compose diff --git a/feature/settings/src/main/java/com/android/swingmusic/settings/presentation/event/SettingsUiEffect.kt b/feature/settings/src/main/java/com/android/swingmusic/settings/presentation/event/SettingsUiEffect.kt new file mode 100644 index 00000000..2d2be338 --- /dev/null +++ b/feature/settings/src/main/java/com/android/swingmusic/settings/presentation/event/SettingsUiEffect.kt @@ -0,0 +1,7 @@ +package com.android.swingmusic.settings.presentation.event + +internal sealed class SettingsUiEffect { + data object NavigateBack : SettingsUiEffect() + data object NavigateToQrScan : SettingsUiEffect() + data class ShowSnackBar(val message: String) : SettingsUiEffect() +} diff --git a/feature/settings/src/main/java/com/android/swingmusic/settings/presentation/event/SettingsUiEvent.kt b/feature/settings/src/main/java/com/android/swingmusic/settings/presentation/event/SettingsUiEvent.kt new file mode 100644 index 00000000..ad8d2364 --- /dev/null +++ b/feature/settings/src/main/java/com/android/swingmusic/settings/presentation/event/SettingsUiEvent.kt @@ -0,0 +1,9 @@ +package com.android.swingmusic.settings.presentation.event + +internal sealed interface SettingsUiEvent { + data object OnBackPressed : SettingsUiEvent + data object OnClickRePairDevice : SettingsUiEvent + data class OnToggleUseLyricsPlugin(val enabled: Boolean) : SettingsUiEvent + data class OnToggleLyricsAutoDownload(val enabled: Boolean) : SettingsUiEvent + data class OnToggleLyricsOverrideUnsynced(val enabled: Boolean) : SettingsUiEvent +} diff --git a/feature/settings/src/main/java/com/android/swingmusic/settings/presentation/screen/SettingsScreen.kt b/feature/settings/src/main/java/com/android/swingmusic/settings/presentation/screen/SettingsScreen.kt new file mode 100644 index 00000000..df2a912c --- /dev/null +++ b/feature/settings/src/main/java/com/android/swingmusic/settings/presentation/screen/SettingsScreen.kt @@ -0,0 +1,311 @@ +package com.android.swingmusic.settings.presentation.screen + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +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.CircularProgressIndicator +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.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.android.swingmusic.common.presentation.navigator.CommonNavigator +import com.android.swingmusic.settings.presentation.event.SettingsUiEffect +import com.android.swingmusic.settings.presentation.event.SettingsUiEvent +import com.android.swingmusic.settings.presentation.state.SettingsUiState +import com.android.swingmusic.settings.presentation.viewmodel.SettingsViewModel +import com.android.swingmusic.uicomponent.presentation.theme.SwingMusicTheme +import com.android.swingmusic.uicomponent.presentation.util.ObserverAsEvent +import com.ramcosta.composedestinations.annotation.Destination +import kotlinx.coroutines.launch + +@Destination +@Composable +internal fun SettingsScreen( + navigator: CommonNavigator, + modifier: Modifier = Modifier, + viewModel: SettingsViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + val context = LocalContext.current + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + ObserverAsEvent(viewModel.uiEffect) { effect -> + when (effect) { + SettingsUiEffect.NavigateBack -> navigator.navigateBack() + SettingsUiEffect.NavigateToQrScan -> navigator.gotoLoginWithQrCode() + is SettingsUiEffect.ShowSnackBar -> { + scope.launch { snackbarHostState.showSnackbar(effect.message) } + } + } + } + + SettingsScreenContent( + uiState = uiState, + onEvent = viewModel::onEvent, + snackbarHost = { SnackbarHost(snackbarHostState) }, + modifier = modifier + ) +} + +@Composable +private fun SettingsScreenContent( + uiState: SettingsUiState, + onEvent: (SettingsUiEvent) -> Unit, + snackbarHost: @Composable () -> Unit, + modifier: Modifier = Modifier +) { + Scaffold( + modifier = modifier, + snackbarHost = snackbarHost, + topBar = { + SettingsTopBar( + onBack = { onEvent(SettingsUiEvent.OnBackPressed) } + ) + } + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + ProfileSection( + uiState = uiState, + onClickRePair = { onEvent(SettingsUiEvent.OnClickRePairDevice) } + ) + + HorizontalDivider() + + LyricsSection( + uiState = uiState, + onTogglePlugin = { onEvent(SettingsUiEvent.OnToggleUseLyricsPlugin(it)) }, + onToggleAuto = { onEvent(SettingsUiEvent.OnToggleLyricsAutoDownload(it)) }, + onToggleOverride = { onEvent(SettingsUiEvent.OnToggleLyricsOverrideUnsynced(it)) } + ) + + HorizontalDivider() + + AboutSection(uiState = uiState) + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +@Composable +private fun SettingsTopBar(onBack: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = "Settings", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + } +} + +@Composable +private fun ProfileSection( + uiState: SettingsUiState, + onClickRePair: () -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionLabel(text = "Account") + + if (uiState.serverUrl.isNotBlank()) { + Text( + text = "Server", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + Text( + text = uiState.serverUrl, + style = MaterialTheme.typography.bodyMedium + ) + } else { + Text( + text = "No server connected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + TextButton( + onClick = onClickRePair, + enabled = !uiState.isSigningOut + ) { + Text( + text = if (uiState.isSigningOut) "Signing out..." else "Re-pair device" + ) + } + } +} + +@Composable +private fun LyricsSection( + uiState: SettingsUiState, + onTogglePlugin: (Boolean) -> Unit, + onToggleAuto: (Boolean) -> Unit, + onToggleOverride: (Boolean) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionLabel(text = "Lyrics") + + SettingToggleRow( + label = "Online lyrics search", + description = "Let the app fetch lyrics from online sources when they aren't on your server", + checked = uiState.useLyricsPlugin, + onCheckedChange = onTogglePlugin + ) + + SettingToggleRow( + label = "Auto-search when missing", + description = "Search online automatically when a track has no lyrics. Otherwise the lyrics screen shows a Search online button you can tap", + checked = uiState.lyricsAutoDownload, + onCheckedChange = onToggleAuto, + enabled = uiState.useLyricsPlugin + ) + + SettingToggleRow( + label = "Upgrade unsynced lyrics", + description = "If a track only has plain text lyrics, look online for a version with timestamps that highlight each line as the song plays", + checked = uiState.lyricsOverrideUnsynced, + onCheckedChange = onToggleOverride, + enabled = uiState.useLyricsPlugin + ) + } +} + +@Composable +private fun AboutSection(uiState: SettingsUiState) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + SectionLabel(text = "About") + Text( + text = if (uiState.appVersion.isNotBlank()) { + "Version ${uiState.appVersion}" + } else { + "Swing Music" + }, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text.uppercase(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) +} + +@Composable +private fun SettingToggleRow( + label: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 12.dp) + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + } + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy( + alpha = if (enabled) 0.7f else 0.3f + ) + ) + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled + ) + } +} + +@Preview +@Composable +private fun SettingsScreenContentPreview() { + SwingMusicTheme { + SettingsScreenContent( + uiState = SettingsUiState( + serverUrl = "https://swingmx.local/", + appVersion = "0.42.0", + useLyricsPlugin = true, + lyricsAutoDownload = true, + lyricsOverrideUnsynced = false + ), + onEvent = {}, + snackbarHost = {} + ) + } +} diff --git a/feature/settings/src/main/java/com/android/swingmusic/settings/presentation/state/SettingsUiState.kt b/feature/settings/src/main/java/com/android/swingmusic/settings/presentation/state/SettingsUiState.kt new file mode 100644 index 00000000..88c95b81 --- /dev/null +++ b/feature/settings/src/main/java/com/android/swingmusic/settings/presentation/state/SettingsUiState.kt @@ -0,0 +1,16 @@ +package com.android.swingmusic.settings.presentation.state + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class SettingsUiState( + val serverUrl: String = "", + val appVersion: String = "", + + val useLyricsPlugin: Boolean = false, + val lyricsAutoDownload: Boolean = false, + val lyricsOverrideUnsynced: Boolean = false, + + val isSigningOut: Boolean = false, + val signOutError: String? = null +) diff --git a/feature/settings/src/main/java/com/android/swingmusic/settings/presentation/viewmodel/SettingsViewModel.kt b/feature/settings/src/main/java/com/android/swingmusic/settings/presentation/viewmodel/SettingsViewModel.kt new file mode 100644 index 00000000..77e0cdbb --- /dev/null +++ b/feature/settings/src/main/java/com/android/swingmusic/settings/presentation/viewmodel/SettingsViewModel.kt @@ -0,0 +1,131 @@ +package com.android.swingmusic.settings.presentation.viewmodel + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.swingmusic.auth.domain.repository.AuthRepository +import com.android.swingmusic.settings.domain.repository.AppSettingsRepository +import com.android.swingmusic.settings.presentation.event.SettingsUiEffect +import com.android.swingmusic.settings.presentation.event.SettingsUiEvent +import com.android.swingmusic.settings.presentation.state.SettingsUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +internal class SettingsViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val appSettingsRepository: AppSettingsRepository, + @ApplicationContext private val context: Context +) : ViewModel() { + + private val _uiState = MutableStateFlow(SettingsUiState()) + val uiState = _uiState.asStateFlow() + + private val _uiEffect = Channel(capacity = Channel.UNLIMITED) + val uiEffect = _uiEffect.receiveAsFlow() + + init { + loadServerUrl() + observeLyricsPrefs() + loadAppVersion() + } + + fun onEvent(event: SettingsUiEvent) { + when (event) { + SettingsUiEvent.OnBackPressed -> { + _uiEffect.trySend(SettingsUiEffect.NavigateBack) + } + + SettingsUiEvent.OnClickRePairDevice -> signOutAndReturnToQrScan() + + is SettingsUiEvent.OnToggleUseLyricsPlugin -> updatePref { + appSettingsRepository.setUseLyricsPlugin(event.enabled) + } + + is SettingsUiEvent.OnToggleLyricsAutoDownload -> updatePref { + appSettingsRepository.setLyricsAutoDownload(event.enabled) + } + + is SettingsUiEvent.OnToggleLyricsOverrideUnsynced -> updatePref { + appSettingsRepository.setLyricsOverrideUnsynced(event.enabled) + } + } + } + + private fun loadServerUrl() { + viewModelScope.launch { + val serverUrl = runCatching { authRepository.getBaseUrl().orEmpty() } + .onFailure { Timber.tag("SETTINGS").e(it) } + .getOrDefault("") + _uiState.update { it.copy(serverUrl = serverUrl) } + } + } + + private fun observeLyricsPrefs() { + viewModelScope.launch { + val useLyricsPlugin = appSettingsRepository.useLyricsPlugin.first() + val autoDownload = appSettingsRepository.lyricsAutoDownload.first() + val overrideUnsynced = appSettingsRepository.lyricsOverrideUnsynced.first() + _uiState.update { + it.copy( + useLyricsPlugin = useLyricsPlugin, + lyricsAutoDownload = autoDownload, + lyricsOverrideUnsynced = overrideUnsynced + ) + } + } + } + + private fun loadAppVersion() { + val version = runCatching { + val info = context.packageManager.getPackageInfo(context.packageName, 0) + info.versionName.orEmpty() + }.getOrDefault("") + _uiState.update { it.copy(appVersion = version) } + } + + private fun updatePref(block: suspend () -> Unit) { + viewModelScope.launch { + runCatching { block() } + .onFailure { Timber.tag("SETTINGS").e(it) } + observeLyricsPrefs() + } + } + + private fun signOutAndReturnToQrScan() { + if (_uiState.value.isSigningOut) return + _uiState.update { it.copy(isSigningOut = true, signOutError = null) } + viewModelScope.launch { + val result = runCatching { authRepository.signOut() } + result.fold( + onSuccess = { + _uiState.update { it.copy(isSigningOut = false) } + _uiEffect.trySend(SettingsUiEffect.NavigateToQrScan) + }, + onFailure = { error -> + Timber.tag("SETTINGS").e(error, "signOut failed") + _uiState.update { + it.copy( + isSigningOut = false, + signOutError = error.message ?: "Failed to sign out" + ) + } + _uiEffect.trySend( + SettingsUiEffect.ShowSnackBar( + error.message ?: "Failed to sign out" + ) + ) + } + ) + } + } +}