diff --git a/biz/detail/src/main/AndroidManifest.xml b/biz/detail/src/main/AndroidManifest.xml index 990c4aa05..08fddbef7 100644 --- a/biz/detail/src/main/AndroidManifest.xml +++ b/biz/detail/src/main/AndroidManifest.xml @@ -2,7 +2,9 @@ - + diff --git a/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/ui/Color.kt b/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/ui/Color.kt new file mode 100644 index 000000000..12d3ee839 --- /dev/null +++ b/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/ui/Color.kt @@ -0,0 +1,10 @@ +@file:Suppress("MagicNumber") + +package io.goooler.demoapp.detail.ui + +import androidx.compose.ui.graphics.Color + +val Navy = Color(0xFF073042) +val Blue = Color(0xFF4285F4) +val LightBlue = Color(0xFFD7EFFE) +val Chartreuse = Color(0xFFEFF7CF) diff --git a/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/ui/DetailPage.kt b/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/ui/DetailPage.kt deleted file mode 100644 index bdc359e41..000000000 --- a/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/ui/DetailPage.kt +++ /dev/null @@ -1,131 +0,0 @@ -package io.goooler.demoapp.detail.ui - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -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.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Share -import androidx.compose.material.icons.filled.Star -import androidx.compose.material.pullrefresh.PullRefreshIndicator -import androidx.compose.material.pullrefresh.pullRefresh -import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.runtime.Composable -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.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import io.goooler.demoapp.common.util.getQuantityString -import io.goooler.demoapp.common.util.showToast -import io.goooler.demoapp.detail.R -import io.goooler.demoapp.detail.model.RepoDetailModel - -@OptIn(ExperimentalMaterialApi::class) -@Composable -fun DetailPageWithSwipeRefresh( - isRefreshing: Boolean, - onRefresh: () -> Unit, - model: RepoDetailModel, - modifier: Modifier = Modifier, - onForkClick: () -> Unit, -) { - val refreshState = rememberPullRefreshState(refreshing = isRefreshing, onRefresh = onRefresh) - - MaterialTheme { - Box(modifier = Modifier.pullRefresh(state = refreshState)) { - DetailPage(model = model, onForkClick = onForkClick) - PullRefreshIndicator(isRefreshing, refreshState, Modifier.align(Alignment.TopCenter)) - } - } -} - -@Composable -fun DetailPage( - model: RepoDetailModel, - modifier: Modifier = Modifier, - onForkClick: () -> Unit, -) { - var isDescExpanded by remember { mutableStateOf(false) } - - Column( - modifier = Modifier - .padding(8.dp) - .fillMaxSize() - .verticalScroll(rememberScrollState()), - ) { - Text( - text = model.fullName, - style = MaterialTheme.typography.h5, - maxLines = 1, - ) - Spacer(modifier = Modifier.height(5.dp)) - Text( - text = model.description, - style = MaterialTheme.typography.body1, - maxLines = if (isDescExpanded) Int.MAX_VALUE else 2, - modifier = Modifier.clickable { - isDescExpanded = !isDescExpanded - }, - ) - Spacer(modifier = Modifier.height(5.dp)) - Row { - Button(onClick = { - R.plurals.detail_star_count_tip.getQuantityString(model.starsCount)?.showToast() - }) { - Icon( - Icons.Filled.Star, - contentDescription = "Star", - modifier = Modifier.size(ButtonDefaults.IconSize), - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(model.starsCount.toString()) - } - Spacer(modifier = Modifier.width(20.dp)) - Button(onClick = onForkClick) { - Icon( - Icons.Filled.Share, - contentDescription = "Fork", - modifier = Modifier.size(ButtonDefaults.IconSize), - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(model.forksCount.toString()) - } - } - Spacer(modifier = Modifier.height(5.dp)) - } -} - -@Preview -@Composable -private fun DetailPagePreview() { - @Suppress("MagicNumber") - val model = RepoDetailModel( - "Compose/Demo", - "Jetpack Compose is Android’s modern toolkit for building native UI. " + - "It simplifies and accelerates UI development on Android. " + - "Quickly bring your app to life with less code, powerful tools, and intuitive Kotlin APIs.", - "Apache", - 99, - 1, - 2, - ) - DetailPage(model) {} -} diff --git a/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/ui/DetailScreen.kt b/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/ui/DetailScreen.kt new file mode 100644 index 000000000..2faeff51b --- /dev/null +++ b/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/ui/DetailScreen.kt @@ -0,0 +1,185 @@ +package io.goooler.demoapp.detail.ui + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshContainer +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import io.goooler.demoapp.common.util.getQuantityString +import io.goooler.demoapp.common.util.showToast +import io.goooler.demoapp.detail.R +import io.goooler.demoapp.detail.model.RepoDetailModel +import io.goooler.demoapp.detail.vm.DetailViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DetailScreenWithSwipeRefresh( + modifier: Modifier = Modifier, + vm: DetailViewModel = viewModel(), +) { + val model by vm.repoDetailModel.collectAsState() + val isRefreshing by vm.isRefreshing.collectAsState() + val refreshState = rememberPullToRefreshState() + + if (refreshState.isRefreshing) { + LaunchedEffect(true) { + // fetch something + vm.refresh() + if (!isRefreshing) { + refreshState.endRefresh() + } + } + } + + Surface( + color = MaterialTheme.colorScheme.surface, + modifier = modifier.padding(horizontal = 10.dp), + ) { + Box(Modifier.nestedScroll(refreshState.nestedScrollConnection)) { + DetailList(model, vm::fork) + + PullToRefreshContainer( + modifier = Modifier.align(Alignment.TopCenter), + state = refreshState, + ) + } + } +} + +@Composable +private fun DetailList( + model: RepoDetailModel, + onForkClick: () -> Unit = {}, +) { + LazyColumn { + @Suppress("MagicNumber") + val models = List(10) { model } + items(models) { model -> + DetailCard(model = model, onForkClick = onForkClick) + } + } +} + +@Composable +private fun DetailCard( + model: RepoDetailModel, + modifier: Modifier = Modifier, + onForkClick: () -> Unit = {}, +) { + var isDescExpanded by rememberSaveable { mutableStateOf(false) } + + Card( + modifier = modifier.padding(8.dp), + ) { + Column( + modifier = Modifier + .padding(12.dp) + .animateContentSize( + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow, + ), + ), + ) { + Text( + text = model.fullName, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + maxLines = 1, + ) + Spacer(modifier = Modifier.height(5.dp)) + Text( + text = model.description, + style = MaterialTheme.typography.bodyLarge, + maxLines = if (isDescExpanded) Int.MAX_VALUE else 1, + modifier = Modifier.clickable { + isDescExpanded = !isDescExpanded + }, + ) + Spacer(modifier = Modifier.height(5.dp)) + Row { + Button( + modifier = Modifier.weight(1f), + onClick = { + R.plurals.detail_star_count_tip.getQuantityString(model.starsCount)?.showToast() + }, + ) { + Icon( + Icons.Filled.Star, + contentDescription = "Star", + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(model.starsCount.toString()) + } + Spacer(modifier = Modifier.width(20.dp)) + Button( + modifier = Modifier.weight(1f), + onClick = onForkClick, + ) { + Icon( + Icons.Filled.Share, + contentDescription = "Fork", + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(model.forksCount.toString()) + } + } + Spacer(modifier = Modifier.height(5.dp)) + } + } +} + +@PreviewDemo +@Composable +private fun DetailListPreview() { + @Suppress("MagicNumber") + val model = RepoDetailModel( + "Compose/Demo", + "Jetpack Compose is Android’s modern toolkit for building native UI. " + + "It simplifies and accelerates UI development on Android. " + + "Quickly bring your app to life with less code, powerful tools, and intuitive Kotlin APIs.", + "Apache", + 99, + 1, + 2, + ) + DetailList(model) +} diff --git a/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/ui/RepoDetailActivity.kt b/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/ui/RepoDetailActivity.kt index 5d6efed7b..53288750c 100644 --- a/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/ui/RepoDetailActivity.kt +++ b/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/ui/RepoDetailActivity.kt @@ -3,8 +3,8 @@ package io.goooler.demoapp.detail.ui import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.viewModels -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier import io.goooler.demoapp.base.core.BaseActivity import io.goooler.demoapp.detail.vm.DetailViewModel @@ -15,15 +15,15 @@ class RepoDetailActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - intent.getStringExtra(FULL_NAME)?.let { - vm.fullName = it - vm.refresh() - } + vm.fullName = intent.getStringExtra(FULL_NAME) ?: "Goooler/DemoApp" + vm.refresh() setContent { - val model by vm.repoDetailModel.collectAsState() - val isRefreshing by vm.isRefreshing.collectAsState() - DetailPageWithSwipeRefresh(isRefreshing, vm::refresh, model, onForkClick = vm::fork) + DemoScaffold { innerPadding -> + DetailScreenWithSwipeRefresh( + modifier = Modifier.padding(innerPadding), + ) + } } } diff --git a/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/ui/Theme.kt b/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/ui/Theme.kt new file mode 100644 index 000000000..2a503cde1 --- /dev/null +++ b/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/ui/Theme.kt @@ -0,0 +1,115 @@ +package io.goooler.demoapp.detail.ui + +import android.app.Activity +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +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.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + surface = Blue, + onSurface = Navy, + primary = Navy, + onPrimary = Chartreuse, +) + +private val LightColorScheme = lightColorScheme( + surface = Blue, + onSurface = Color.White, + primary = LightBlue, + onPrimary = Navy, +) + +@Composable +fun DemoTheme( + 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 + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content, + ) +} + +@Preview( + name = "Light", + showSystemUi = true, + showBackground = true, +) +@Preview( + name = "Dark", + showSystemUi = true, + showBackground = true, + uiMode = UI_MODE_NIGHT_YES, +) +annotation class PreviewDemo + +@Composable +fun DemoScaffold( + modifier: Modifier = Modifier, + content: @Composable (PaddingValues) -> Unit, +) { + DemoTheme { + Scaffold( + modifier = modifier.fillMaxSize(), + content = content, + ) + } +} + +@Composable +fun PaddingValues.copy( + layoutDirection: LayoutDirection = LocalLayoutDirection.current, + start: Dp = calculateStartPadding(layoutDirection), + top: Dp = calculateTopPadding(), + end: Dp = calculateEndPadding(layoutDirection), + bottom: Dp = calculateBottomPadding(), +) = PaddingValues( + start = start, + top = top, + end = end, + bottom = bottom, +) diff --git a/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/ui/Type.kt b/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/ui/Type.kt new file mode 100644 index 000000000..ad5aa5934 --- /dev/null +++ b/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/ui/Type.kt @@ -0,0 +1,34 @@ +package io.goooler.demoapp.detail.ui + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) diff --git a/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/vm/DetailViewModel.kt b/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/vm/DetailViewModel.kt index 442ac95ca..e3cd1cebb 100644 --- a/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/vm/DetailViewModel.kt +++ b/biz/detail/src/main/kotlin/io/goooler/demoapp/detail/vm/DetailViewModel.kt @@ -20,14 +20,12 @@ class DetailViewModel : BaseViewModel() { lateinit var fullName: String - val repoDetailModel: StateFlow - get() = _repoDetailModel.asStateFlow() - val isRefreshing: StateFlow - get() = _isRefreshing.asStateFlow() + val repoDetailModel: StateFlow = _repoDetailModel.asStateFlow() + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() fun refresh() { viewModelScope.launch { - _isRefreshing.emit(true) + _isRefreshing.value = true repository.getRepoDetail(fullName).let { repoDetail = RepoDetailModel( it.fullName, @@ -39,7 +37,7 @@ class DetailViewModel : BaseViewModel() { ) } _repoDetailModel.value = repoDetail - _isRefreshing.emit(false) + _isRefreshing.value = false } } diff --git a/biz/main/src/main/kotlin/io/goooler/demoapp/main/vm/MainHomeViewModel.kt b/biz/main/src/main/kotlin/io/goooler/demoapp/main/vm/MainHomeViewModel.kt index 78f796794..4dc90187c 100644 --- a/biz/main/src/main/kotlin/io/goooler/demoapp/main/vm/MainHomeViewModel.kt +++ b/biz/main/src/main/kotlin/io/goooler/demoapp/main/vm/MainHomeViewModel.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onCompletion @@ -26,7 +27,7 @@ class MainHomeViewModel : BaseViewModel() { private val repository = MainCommonRepository() private val _title = MutableStateFlow("") - val title: StateFlow = _title + val title: StateFlow = _title.asStateFlow() private var countdownJob: Job? = null diff --git a/detekt.yml b/detekt.yml index ea5074944..b5ad4386c 100644 --- a/detekt.yml +++ b/detekt.yml @@ -128,7 +128,7 @@ complexity: threshold: 600 LongMethod: active: true - threshold: 60 + threshold: 70 LongParameterList: active: true functionThreshold: 6 @@ -740,7 +740,7 @@ style: UnusedPrivateMember: active: true allowedNames: '' - ignoreAnnotated: [ 'Preview' ] + ignoreAnnotated: [ 'Preview*' ] UnusedPrivateProperty: active: true allowedNames: '_|ignored|expected|serialVersionUID' diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 87d0d8da1..7771da54a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,7 +36,7 @@ androidX-compose-bom = "androidx.compose:compose-bom:2024.06.00" androidX-compose-ui = { module = "androidx.compose.ui:ui" } androidX-compose-tooling = { module = "androidx.compose.ui:ui-tooling" } androidX-compose-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } -androidX-compose-material = { module = "androidx.compose.material:material" } +androidX-compose-material3 = { module = "androidx.compose.material3:material3" } androidX-compose-foundation = { module = "androidx.compose.foundation:foundation" } androidX-compose-icons-core = { module = "androidx.compose.material:material-icons-core" } androidX-compose-icons-extended = { module = "androidx.compose.material:material-icons-extended" } @@ -53,7 +53,8 @@ androidX-coordinatorLayout = "androidx.coordinatorlayout:coordinatorlayout:1.2.0 androidX-core = "androidx.core:core-ktx:1.13.1" androidX-fragment = "androidx.fragment:fragment-ktx:1.8.1" androidX-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "androidX-lifecycle" } -androidX-lifecycle-viewModel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidX-lifecycle" } +androidX-lifecycle-viewModel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidX-lifecycle" } +androidX-lifecycle-viewModel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidX-lifecycle" } androidX-paging = "androidx.paging:paging-runtime:3.3.0" androidX-recyclerView = "androidx.recyclerview:recyclerview:1.3.2" androidX-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidX-room" } @@ -98,13 +99,14 @@ composeRules = "io.nlopez.compose.rules:detekt:0.4.5" androidX-compose = [ "androidX-compose-ui", "androidX-compose-tooling-preview", - "androidX-compose-material", + "androidX-compose-material3", "androidX-compose-foundation", "androidX-compose-icons-core", - "androidX-compose-icons-extended" + "androidX-compose-icons-extended", + "androidX-lifecycle-viewModel-compose", ] androidX-lifecycle = [ - "androidX-lifecycle-viewModel", + "androidX-lifecycle-viewModel-ktx", "androidX-lifecycle-service" ] androidX-room = [