diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9a0977f..d014ee8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -64,6 +64,7 @@ android { buildFeatures { compose = true + buildConfig = true } composeOptions { @@ -85,6 +86,7 @@ dependencies { implementation("androidx.compose.ui:ui:1.4.3") implementation("androidx.compose.ui:ui-graphics:1.4.3") implementation("androidx.compose.ui:ui-tooling-preview:1.4.3") + implementation("androidx.compose.material:material:1.4.3") implementation("androidx.compose.material3:material3:1.1.1") debugImplementation("androidx.compose.ui:ui-tooling:1.4.3") diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/authentication/AuthenticationScreen.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/authentication/AuthenticationScreen.kt index 031f85e..a1b30b6 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/authentication/AuthenticationScreen.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/authentication/AuthenticationScreen.kt @@ -2,7 +2,8 @@ package com.pixelized.rplexicon.ui.screens.authentication import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -18,7 +19,6 @@ import androidx.compose.runtime.State import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString @@ -38,17 +38,21 @@ import kotlinx.coroutines.CoroutineScope @Composable fun AuthenticationScreen( - viewModel: AuthenticationViewModel = hiltViewModel() + authenticationVM: AuthenticationViewModel = hiltViewModel(), + versionVM: VersionViewModel = hiltViewModel(), ) { val screen = LocalScreenNavHost.current val activity = LocalActivity.current - val state = viewModel.rememberAuthenticationState() + val state = authenticationVM.rememberAuthenticationState() Surface { AuthenticationScreenContent( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .padding(all = 16.dp), + version = versionVM.version, onGoogleSignIn = { - viewModel.signIn(activity = activity) + authenticationVM.signIn(activity = activity) }, ) @@ -74,34 +78,27 @@ fun HandleAuthenticationState( @Composable private fun AuthenticationScreenContent( modifier: Modifier = Modifier, + version: VersionViewModel.Version, onGoogleSignIn: () -> Unit, ) { - Box( + Column( modifier = modifier, - contentAlignment = Alignment.BottomCenter, + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.Bottom), + horizontalAlignment = Alignment.End, ) { Button( modifier = Modifier .padding(all = 16.dp) .fillMaxWidth(), - colors = ButtonDefaults.outlinedButtonColors(), + colors = ButtonDefaults.buttonColors(), onClick = onGoogleSignIn, ) { Text(text = rememeberGoogleStringResource()) } - } -} -@Composable -private fun rememberBackgroundGradient(): Brush { - val colorScheme = MaterialTheme.colorScheme - return remember { - Brush.verticalGradient( - colors = listOf( - colorScheme.surface.copy(alpha = 0.2f), - colorScheme.surface.copy(alpha = 0.5f), - colorScheme.surface.copy(alpha = 1.0f), - ) + Text( + style = MaterialTheme.typography.labelSmall, + text = version.toText(), ) } } @@ -175,8 +172,14 @@ private fun rememeberGoogleStringResource(): AnnotatedString { @Preview(uiMode = UI_MODE_NIGHT_YES) private fun AuthenticationScreenContentPreview() { LexiconTheme { - AuthenticationScreenContent( - onGoogleSignIn = { }, - ) + Surface { + AuthenticationScreenContent( + modifier = Modifier + .fillMaxSize() + .padding(all = 16.dp), + version = VersionViewModel.Version(R.string.app_name, "0.0.0", "0"), + onGoogleSignIn = { }, + ) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/authentication/VersionViewModel.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/authentication/VersionViewModel.kt new file mode 100644 index 0000000..92705d3 --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/authentication/VersionViewModel.kt @@ -0,0 +1,34 @@ +package com.pixelized.rplexicon.ui.screens.authentication + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.ViewModel +import com.pixelized.rplexicon.BuildConfig +import com.pixelized.rplexicon.R +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class VersionViewModel @Inject constructor() : ViewModel() { + + val version = Version( + appName = R.string.app_name, + version = BuildConfig.VERSION_NAME, + code = BuildConfig.VERSION_CODE.toString() + ) + + @Stable + data class Version( + @StringRes val appName: Int, + val version: String, + val code: String, + ) { + @Composable + @Stable + fun toText(): String { + return "${stringResource(id = appName)} ${version}.${code}" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/detail/CharacterDetailViewModel.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/detail/CharacterDetailViewModel.kt index 80f767a..2662f3b 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/detail/CharacterDetailViewModel.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/detail/CharacterDetailViewModel.kt @@ -21,7 +21,7 @@ class CharacterDetailViewModel @Inject constructor( val source = repository.data.value[savedStateHandle.characterDetailArgument.id] return CharacterDetailUio( name = source.name, - diminutive = source.diminutive?.let { "./ $it" }, + diminutive = source.diminutive?.takeIf { it.isNotBlank() }?.let { "./ $it" }, gender = when (source.gender) { Lexicon.Gender.MALE -> "homme" Lexicon.Gender.FEMALE -> "femme" diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconScreen.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconScreen.kt index 420fe67..3566bf7 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconScreen.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconScreen.kt @@ -7,13 +7,23 @@ import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.PullRefreshState +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable @@ -21,11 +31,14 @@ import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.pixelized.rplexicon.LocalSnack +import com.pixelized.rplexicon.R import com.pixelized.rplexicon.ui.navigation.LocalScreenNavHost import com.pixelized.rplexicon.ui.navigation.navigateToCharacterDetail import com.pixelized.rplexicon.ui.screens.lexicon.LexiconErrorUio.Default @@ -43,15 +56,28 @@ sealed class LexiconErrorUio { object Default : LexiconErrorUio() } +@OptIn(ExperimentalMaterialApi::class) @Composable fun LexiconScreen( viewModel: LexiconViewModel = hiltViewModel(), ) { + val scope = rememberCoroutineScope() val screen = LocalScreenNavHost.current + val refresh = rememberPullRefreshState( + refreshing = viewModel.isLoading.value, + onRefresh = { + scope.launch { + viewModel.fetchLexicon() + } + }, + ) + Surface { LexiconScreenContent( items = viewModel.items, + refresh = refresh, + refreshing = viewModel.isLoading, onItem = { screen.navigateToCharacterDetail(id = it.id) }, @@ -66,23 +92,49 @@ fun LexiconScreen( } } +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @Composable private fun LexiconScreenContent( modifier: Modifier = Modifier, + refresh: PullRefreshState, + refreshing: State, items: State>, onItem: (LexiconItemUio) -> Unit, ) { - LazyColumn( + Scaffold( modifier = modifier, - contentPadding = PaddingValues(vertical = 8.dp), + topBar = { + TopAppBar( + title = { + Text(text = stringResource(id = R.string.app_name)) + }, + ) + } ) { - items(items = items.value) { - LexiconItem( + Box( + modifier = Modifier.padding(paddingValues = it), + contentAlignment = Alignment.TopCenter, + ) { + LazyColumn( modifier = Modifier - .clickable { onItem(it) } - .fillMaxWidth() - .padding(vertical = 8.dp, horizontal = 16.dp), - item = it, + .fillMaxSize() + .pullRefresh(state = refresh), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + items(items = items.value) { item -> + LexiconItem( + modifier = Modifier + .clickable { onItem(item) } + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 16.dp), + item = item, + ) + } + } + + PullRefreshIndicator( + refreshing = refreshing.value, + state = refresh, ) } } @@ -116,6 +168,7 @@ fun HandleError( } } +@OptIn(ExperimentalMaterialApi::class) @Composable @Preview(uiMode = UI_MODE_NIGHT_NO) @Preview(uiMode = UI_MODE_NIGHT_YES) @@ -124,6 +177,11 @@ private fun LexiconScreenContentPreview() { Surface { LexiconScreenContent( modifier = Modifier.fillMaxSize(), + refresh = rememberPullRefreshState( + refreshing = false, + onRefresh = {}, + ), + refreshing = remember { mutableStateOf(false) }, items = remember { mutableStateOf( listOf( diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconViewModel.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconViewModel.kt index a27328e..de970b1 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconViewModel.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconViewModel.kt @@ -9,7 +9,6 @@ import com.google.api.client.googleapis.extensions.android.gms.auth.UserRecovera import com.pixelized.rplexicon.model.Lexicon import com.pixelized.rplexicon.repository.LexiconRepository import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch @@ -20,6 +19,9 @@ class LexiconViewModel @Inject constructor( private val repository: LexiconRepository, ) : ViewModel() { + private val _isLoading = mutableStateOf(false) + val isLoading: State get() = _isLoading + private val _items = mutableStateOf>(emptyList()) val items: State> get() = _items @@ -52,6 +54,7 @@ class LexiconViewModel @Inject constructor( suspend fun fetchLexicon() { try { + _isLoading.value = true repository.fetchLexicon() } // user need to accept OAuth2 permission. @@ -64,6 +67,10 @@ class LexiconViewModel @Inject constructor( Log.e(TAG, exception.message, exception) _error.emit(LexiconErrorUio.Default) } + // clean the laoding state + finally { + _isLoading.value = false + } } companion object { diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/theme/Theme.kt b/app/src/main/java/com/pixelized/rplexicon/ui/theme/Theme.kt index 9700121..ce3bba8 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/theme/Theme.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/theme/Theme.kt @@ -1,17 +1,13 @@ package com.pixelized.rplexicon.ui.theme import android.app.Activity -import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat @@ -25,42 +21,32 @@ private val LightColorScheme = lightColorScheme( primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40 - - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ ) @Composable fun LexiconTheme( darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, content: @Composable () -> Unit ) { val colorScheme = when { -// dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { -// val context = LocalContext.current -// if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) -// } - darkTheme -> DarkColorScheme else -> LightColorScheme } -// val view = LocalView.current -// if (!view.isInEditMode) { -// SideEffect { -// val window = (view.context as Activity).window -// window.statusBarColor = colorScheme.primary.toArgb() -// WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme -// } -// } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + colorScheme.background.toArgb().let { + window.statusBarColor = it + window.navigationBarColor = it + } + WindowCompat.getInsetsController(window, view).let { + it.isAppearanceLightStatusBars = !darkTheme + it.isAppearanceLightNavigationBars = !darkTheme + } + } + } MaterialTheme( colorScheme = colorScheme,