diff --git a/.github/workflows/CompareScreenshot.yml b/.github/workflows/CompareScreenshot.yml index 8b8a4c363..7bfe68dad 100644 --- a/.github/workflows/CompareScreenshot.yml +++ b/.github/workflows/CompareScreenshot.yml @@ -33,7 +33,7 @@ jobs: workflow: UnitTest.yml branch: main - - run: ./gradlew compareRoborazziDebug compareRoborazziDevDebug --stacktrace -Pscreenshot + - run: ./gradlew compareRoborazziDebug compareRoborazziDevRelease --stacktrace -Pscreenshot - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 if: ${{ always() }} diff --git a/app-android/src/main/java/io/github/droidkaigi/confsched2023/AppModule.kt b/app-android/src/main/java/io/github/droidkaigi/confsched2023/AppModule.kt index 06b477e0d..9c46cc9ad 100644 --- a/app-android/src/main/java/io/github/droidkaigi/confsched2023/AppModule.kt +++ b/app-android/src/main/java/io/github/droidkaigi/confsched2023/AppModule.kt @@ -1,10 +1,15 @@ package io.github.droidkaigi.confsched2023 +import android.content.Context import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import io.github.droidkaigi.confsched2023.data.di.AppAndroidBuildConfig +import io.github.droidkaigi.confsched2023.data.di.AppAndroidOssLicenseConfig +import io.github.droidkaigi.confsched2023.data.osslicense.OssLicenseDataSource +import io.github.droidkaigi.confsched2023.license.DefaultOssLicenseDataSource import io.github.droidkaigi.confsched2023.model.BuildConfigProvider import javax.inject.Singleton @@ -15,6 +20,13 @@ class AppModule { @Singleton @AppAndroidBuildConfig fun provideBuildConfigProvider(): BuildConfigProvider = AppBuildConfigProvider() + + @Provides + @Singleton + @AppAndroidOssLicenseConfig + fun provideOssLicenseDataSourceProvider( + @ApplicationContext context: Context, + ): OssLicenseDataSource = DefaultOssLicenseDataSource(context) } class AppBuildConfigProvider( diff --git a/app-android/src/main/java/io/github/droidkaigi/confsched2023/KaigiApp.kt b/app-android/src/main/java/io/github/droidkaigi/confsched2023/KaigiApp.kt index e56f4623a..75282bbc2 100644 --- a/app-android/src/main/java/io/github/droidkaigi/confsched2023/KaigiApp.kt +++ b/app-android/src/main/java/io/github/droidkaigi/confsched2023/KaigiApp.kt @@ -31,7 +31,11 @@ import co.touchlab.kermit.Logger import com.google.android.gms.oss.licenses.OssLicensesMenuActivity import io.github.droidkaigi.confsched2023.about.aboutScreenRoute import io.github.droidkaigi.confsched2023.about.navigateAboutScreen +import io.github.droidkaigi.confsched2023.about.navigateOssLicenseDetailScreen +import io.github.droidkaigi.confsched2023.about.navigateOssLicenseScreen import io.github.droidkaigi.confsched2023.about.nestedAboutScreen +import io.github.droidkaigi.confsched2023.about.ossLicenseDetailScreen +import io.github.droidkaigi.confsched2023.about.ossLicenseScreen import io.github.droidkaigi.confsched2023.achievements.achievementsScreenRoute import io.github.droidkaigi.confsched2023.achievements.navigateAchievementsScreen import io.github.droidkaigi.confsched2023.achievements.nestedAchievementsScreen @@ -125,6 +129,19 @@ private fun KaigiNavHost( onBackClick = navController::popBackStack, onStaffClick = externalNavController::navigate, ) + ossLicenseScreen( + onLicenseClick = { license -> + navController.navigateOssLicenseDetailScreen(license) + }, + onUpClick = { + navController.navigateUp() + }, + ) + ossLicenseDetailScreen( + onUpClick = { + navController.navigateUp() + }, + ) // For KMP, we are not using navigation abstraction for contributors screen composable(contributorsScreenRoute) { val lifecycleOwner = LocalLifecycleOwner.current @@ -171,7 +188,7 @@ private fun NavGraphBuilder.mainScreen( Sponsors -> navController.navigateSponsorsScreen() CodeOfConduct -> { externalNavController.navigate(url = "$portalBaseUrl/about/code-of-conduct") } Contributors -> navController.navigate(contributorsScreenRoute) - License -> externalNavController.navigateToLicenseScreen() + License -> navController.navigateOssLicenseScreen() Medium -> externalNavController.navigate(url = "https://medium.com/droidkaigi") PrivacyPolicy -> { externalNavController.navigate(url = "$portalBaseUrl/about/privacy") diff --git a/app-android/src/main/java/io/github/droidkaigi/confsched2023/license/DefaultOssLicenseDataSource.kt b/app-android/src/main/java/io/github/droidkaigi/confsched2023/license/DefaultOssLicenseDataSource.kt new file mode 100644 index 000000000..189f18a4f --- /dev/null +++ b/app-android/src/main/java/io/github/droidkaigi/confsched2023/license/DefaultOssLicenseDataSource.kt @@ -0,0 +1,102 @@ +package io.github.droidkaigi.confsched2023.license + +import android.content.Context +import io.github.droidkaigi.confsched2023.R +import io.github.droidkaigi.confsched2023.data.osslicense.OssLicenseDataSource +import io.github.droidkaigi.confsched2023.model.License +import io.github.droidkaigi.confsched2023.model.OssLicenseGroup +import io.github.droidkaigi.confsched2023.ui.Inject +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okio.BufferedSource +import okio.buffer +import okio.source + +public class DefaultOssLicenseDataSource @Inject constructor( + private val context: Context, +) : OssLicenseDataSource { + + override suspend fun license(): List { + return withContext(context = Dispatchers.IO) { + readLicenses() + .groupByCategory() + .map { + OssLicenseGroup( + title = it.key, + licenses = it.value, + ) + } + .toPersistentList() + } + } + + private fun readLicenses(): List { + val licenseData = readLicensesFile().toRowList() + return readLicensesMetaFile().toRowList() + .map { + val (position, name) = it.split(' ', limit = 2) + val (offset, length) = position.split(':').map { it.toInt() } + + val id = name.replace(' ', '-') + val licensesText = kotlin.runCatching { + licenseData.subList(offset, offset + length).joinToString() + }.getOrNull() ?: "" + + License( + id = id, + name = name, + licensesText = licensesText, + ) + } + .distinctBy { it.id } + } + + private fun List.groupByCategory(): Map> { + val categoryList = listOf( + "Android Support", + "Android Datastore", + "Android ", + "Compose UI", + "Compose Material3", + "Compose ", + "AndroidX lifecycle", + "AndroidX ", + "Kotlin", + "Dagger", + "Firebase", + "Ktorfit", + "okhttp", + "ktor", + ) + return groupBy { license -> + categoryList.firstOrNull { + license.name.startsWith( + prefix = it, + ignoreCase = true, + ) + } ?: "etc" + } + } + + private fun readLicensesMetaFile(): BufferedSource { + return context.resources.openRawResource(R.raw.third_party_license_metadata) + .source() + .buffer() + } + + private fun readLicensesFile(): BufferedSource { + return context.resources.openRawResource(R.raw.third_party_licenses) + .source() + .buffer() + } + + private fun BufferedSource.toRowList(): List { + val list: MutableList = mutableListOf() + while (true) { + val line = readUtf8Line() ?: break + list.add(line) + } + return list + } +} diff --git a/core/data/src/androidMain/kotlin/io/github/droidkaigi/confsched2023/data/di/BuildConfigProviderModule.kt b/core/data/src/androidMain/kotlin/io/github/droidkaigi/confsched2023/data/di/BuildConfigProviderModule.kt index c2f0f659f..4ab45e157 100644 --- a/core/data/src/androidMain/kotlin/io/github/droidkaigi/confsched2023/data/di/BuildConfigProviderModule.kt +++ b/core/data/src/androidMain/kotlin/io/github/droidkaigi/confsched2023/data/di/BuildConfigProviderModule.kt @@ -5,7 +5,12 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import io.github.droidkaigi.confsched2023.data.osslicense.OssLicenseDataSource import io.github.droidkaigi.confsched2023.model.BuildConfigProvider +import io.github.droidkaigi.confsched2023.model.License +import io.github.droidkaigi.confsched2023.model.OssLicenseGroup +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf import java.util.Optional import javax.inject.Qualifier import javax.inject.Singleton @@ -13,6 +18,9 @@ import javax.inject.Singleton @Qualifier public annotation class AppAndroidBuildConfig +@Qualifier +public annotation class AppAndroidOssLicenseConfig + @Module @InstallIn(SingletonComponent::class) public class BuildConfigProviderModule { @@ -25,6 +33,16 @@ public class BuildConfigProviderModule { } else { EmptyBuildConfigProvider } + + @Provides + @Singleton + public fun provideOssLicenseRepositoryProvider( + @AppAndroidOssLicenseConfig ossLicenseDataSourceOptional: Optional, + ): OssLicenseDataSource = if (ossLicenseDataSourceOptional.isPresent) { + ossLicenseDataSourceOptional.get() + } else { + EmptyOssLicenseDataSource + } } @InstallIn(SingletonComponent::class) @@ -35,7 +53,32 @@ public abstract class AppAndroidBuildConfigModule { public abstract fun bindBuildConfigProvider(): BuildConfigProvider } +@InstallIn(SingletonComponent::class) +@Module +public abstract class AppAndroidOssLicenseModule { + @BindsOptionalOf + @AppAndroidOssLicenseConfig + public abstract fun bindOssLicenseDataStoreProvider(): OssLicenseDataSource +} + private object EmptyBuildConfigProvider : BuildConfigProvider { override val versionName: String = "" override val debugBuild: Boolean = false } + +private object EmptyOssLicenseDataSource : OssLicenseDataSource { + override suspend fun license(): PersistentList { + return persistentListOf( + OssLicenseGroup( + "dummy", + listOf( + License( + "id", + "name", + "license text", + ), + ), + ), + ) + } +} diff --git a/core/data/src/androidMain/kotlin/io/github/droidkaigi/confsched2023/data/osslicense/OssLicenseRepositoryModule.kt b/core/data/src/androidMain/kotlin/io/github/droidkaigi/confsched2023/data/osslicense/OssLicenseRepositoryModule.kt new file mode 100644 index 000000000..e6656bf89 --- /dev/null +++ b/core/data/src/androidMain/kotlin/io/github/droidkaigi/confsched2023/data/osslicense/OssLicenseRepositoryModule.kt @@ -0,0 +1,22 @@ +package io.github.droidkaigi.confsched2023.data.osslicense + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import io.github.droidkaigi.confsched2023.model.OssLicenseRepository +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +public class OssLicenseRepositoryModule { + @Provides + @Singleton + public fun provideOssLicenseRepository( + ossLicenseDataSource: OssLicenseDataSource, + ): OssLicenseRepository { + return DefaultOssLicenseRepository( + ossLicenseDataSource, + ) + } +} diff --git a/core/data/src/commonMain/kotlin/io/github/droidkaigi/confsched2023/data/osslicense/DefaultOssLicenseRepository.kt b/core/data/src/commonMain/kotlin/io/github/droidkaigi/confsched2023/data/osslicense/DefaultOssLicenseRepository.kt new file mode 100644 index 000000000..03103a290 --- /dev/null +++ b/core/data/src/commonMain/kotlin/io/github/droidkaigi/confsched2023/data/osslicense/DefaultOssLicenseRepository.kt @@ -0,0 +1,26 @@ +package io.github.droidkaigi.confsched2023.data.osslicense + +import io.github.droidkaigi.confsched2023.model.OssLicenseGroup +import io.github.droidkaigi.confsched2023.model.OssLicenseRepository +import io.github.droidkaigi.confsched2023.ui.Inject +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.onStart + +public class DefaultOssLicenseRepository @Inject constructor( + private val ossLicenseDataSource: OssLicenseDataSource, +) : OssLicenseRepository { + + private val ossLicenseStateFlow = + MutableStateFlow>(persistentListOf()) + + override fun licenseData(): Flow> { + return ossLicenseStateFlow.onStart { + val ossLicenseData = ossLicenseDataSource.license().toPersistentList() + ossLicenseStateFlow.emit(ossLicenseData) + } + } +} diff --git a/core/data/src/commonMain/kotlin/io/github/droidkaigi/confsched2023/data/osslicense/OssLicenseDataSource.kt b/core/data/src/commonMain/kotlin/io/github/droidkaigi/confsched2023/data/osslicense/OssLicenseDataSource.kt new file mode 100644 index 000000000..d6db168fd --- /dev/null +++ b/core/data/src/commonMain/kotlin/io/github/droidkaigi/confsched2023/data/osslicense/OssLicenseDataSource.kt @@ -0,0 +1,7 @@ +package io.github.droidkaigi.confsched2023.data.osslicense + +import io.github.droidkaigi.confsched2023.model.OssLicenseGroup + +public interface OssLicenseDataSource { + public suspend fun license(): List +} diff --git a/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2023/model/License.kt b/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2023/model/License.kt new file mode 100644 index 000000000..bc0ee8a90 --- /dev/null +++ b/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2023/model/License.kt @@ -0,0 +1,12 @@ +package io.github.droidkaigi.confsched2023.model + +data class License( + val id: String, + val name: String, + val licensesText: String, +) + +data class OssLicenseGroup( + val title: String, + val licenses: List, +) diff --git a/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2023/model/OssLicenseRepository.kt b/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2023/model/OssLicenseRepository.kt new file mode 100644 index 000000000..1d4b7e748 --- /dev/null +++ b/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2023/model/OssLicenseRepository.kt @@ -0,0 +1,8 @@ +package io.github.droidkaigi.confsched2023.model + +import kotlinx.collections.immutable.PersistentList +import kotlinx.coroutines.flow.Flow + +public interface OssLicenseRepository { + public fun licenseData(): Flow> +} diff --git a/core/testing/src/main/java/io/github/droidkaigi/confsched2023/testing/robot/OssLicenseDetailScreenRobot.kt b/core/testing/src/main/java/io/github/droidkaigi/confsched2023/testing/robot/OssLicenseDetailScreenRobot.kt new file mode 100644 index 000000000..84f11f1cd --- /dev/null +++ b/core/testing/src/main/java/io/github/droidkaigi/confsched2023/testing/robot/OssLicenseDetailScreenRobot.kt @@ -0,0 +1,49 @@ +package io.github.droidkaigi.confsched2023.testing.robot + +import androidx.compose.ui.test.isRoot +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import com.github.takahirom.roborazzi.captureRoboImage +import io.github.droidkaigi.confsched2023.about.OssLicenseDetailScreen +import io.github.droidkaigi.confsched2023.designsystem.theme.KaigiTheme +import io.github.droidkaigi.confsched2023.testing.RobotTestRule +import io.github.droidkaigi.confsched2023.testing.coroutines.runTestWithLogging +import kotlinx.coroutines.test.TestDispatcher +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +class OssLicenseDetailScreenRobot @Inject constructor( + private val testDispatcher: TestDispatcher, +) { + @Inject lateinit var robotTestRule: RobotTestRule + private lateinit var composeTestRule: AndroidComposeTestRule<*, *> + operator fun invoke( + block: OssLicenseDetailScreenRobot.() -> Unit, + ) { + runTestWithLogging(timeout = 30.seconds) { + this@OssLicenseDetailScreenRobot.composeTestRule = robotTestRule.composeTestRule + block() + } + } + + fun setupOssLicenseDetailScreenContent() { + composeTestRule.setContent { + KaigiTheme { + OssLicenseDetailScreen( + onUpClick = {}, + ) + } + } + waitUntilIdle() + } + + fun checkScreenCapture() { + composeTestRule + .onNode(isRoot()) + .captureRoboImage() + } + + fun waitUntilIdle() { + composeTestRule.waitForIdle() + testDispatcher.scheduler.advanceUntilIdle() + } +} diff --git a/core/testing/src/main/java/io/github/droidkaigi/confsched2023/testing/robot/OssLicenseScreenRobot.kt b/core/testing/src/main/java/io/github/droidkaigi/confsched2023/testing/robot/OssLicenseScreenRobot.kt new file mode 100644 index 000000000..344693721 --- /dev/null +++ b/core/testing/src/main/java/io/github/droidkaigi/confsched2023/testing/robot/OssLicenseScreenRobot.kt @@ -0,0 +1,50 @@ +package io.github.droidkaigi.confsched2023.testing.robot + +import androidx.compose.ui.test.isRoot +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import com.github.takahirom.roborazzi.captureRoboImage +import io.github.droidkaigi.confsched2023.about.OssLicenseScreen +import io.github.droidkaigi.confsched2023.designsystem.theme.KaigiTheme +import io.github.droidkaigi.confsched2023.testing.RobotTestRule +import io.github.droidkaigi.confsched2023.testing.coroutines.runTestWithLogging +import kotlinx.coroutines.test.TestDispatcher +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +class OssLicenseScreenRobot @Inject constructor( + private val testDispatcher: TestDispatcher, +) { + @Inject lateinit var robotTestRule: RobotTestRule + private lateinit var composeTestRule: AndroidComposeTestRule<*, *> + operator fun invoke( + block: OssLicenseScreenRobot.() -> Unit, + ) { + runTestWithLogging(timeout = 30.seconds) { + this@OssLicenseScreenRobot.composeTestRule = robotTestRule.composeTestRule + block() + } + } + + fun setupOssLicenseScreenContent() { + composeTestRule.setContent { + KaigiTheme { + OssLicenseScreen( + onLicenseClick = {}, + onUpClick = {}, + ) + } + } + waitUntilIdle() + } + + fun checkScreenCapture() { + composeTestRule + .onNode(isRoot()) + .captureRoboImage() + } + + fun waitUntilIdle() { + composeTestRule.waitForIdle() + testDispatcher.scheduler.advanceUntilIdle() + } +} diff --git a/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/AboutStrings.kt b/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/AboutStrings.kt index f1e01528b..2e174bdc8 100644 --- a/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/AboutStrings.kt +++ b/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/AboutStrings.kt @@ -21,6 +21,7 @@ sealed class AboutStrings : Strings(Bindings) { data object OthersTitle : AboutStrings() data object CodeOfConduct : AboutStrings() data object License : AboutStrings() + data object LicenseScreenTitle : AboutStrings() data object PrivacyPolicy : AboutStrings() data object AppVersion : AboutStrings() data object LicenceDescription : AboutStrings() @@ -42,6 +43,7 @@ sealed class AboutStrings : Strings(Bindings) { OthersTitle -> "Others" CodeOfConduct -> "行動規範" License -> "ライセンス" + LicenseScreenTitle -> "ライセンス" PrivacyPolicy -> "プライバシーポリシー" AppVersion -> "アプリバージョン" LicenceDescription -> "The Android robot is reproduced or modified from work created and shared by Google and used according to terms described in the Creative Commons 3.0 Attribution License." @@ -63,6 +65,7 @@ sealed class AboutStrings : Strings(Bindings) { OthersTitle -> bindings.defaultBinding(item, bindings) CodeOfConduct -> "Code Of Conduct" License -> "License" + LicenseScreenTitle -> "License" PrivacyPolicy -> "Privacy Policy" AppVersion -> "App Version" LicenceDescription -> bindings.defaultBinding(item, bindings) diff --git a/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/OssLicenseDetailScreen.kt b/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/OssLicenseDetailScreen.kt new file mode 100644 index 000000000..f870cdfcc --- /dev/null +++ b/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/OssLicenseDetailScreen.kt @@ -0,0 +1,90 @@ +package io.github.droidkaigi.confsched2023.about + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import io.github.droidkaigi.confsched2023.model.License + +const val ossLicenseDetailScreenRouteNameArgument = "name" +const val ossLicenseDetailScreenRoute = "osslicense" +fun NavGraphBuilder.ossLicenseDetailScreen(onUpClick: () -> Unit) { + composable("$ossLicenseDetailScreenRoute/{$ossLicenseDetailScreenRouteNameArgument}") { + OssLicenseDetailScreen( + onUpClick = onUpClick, + ) + } +} + +fun NavController.navigateOssLicenseDetailScreen( + license: License, +) { + navigate("$ossLicenseDetailScreenRoute/${license.id}") +} + +data class OssLicenseDetailScreenUiState( + val ossLicense: License? = null, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OssLicenseDetailScreen( + modifier: Modifier = Modifier, + viewModel: OssLicenseDetailViewModel = hiltViewModel(), + onUpClick: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsState() + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text(text = "OSS ライセンス") + }, + navigationIcon = { + IconButton(onClick = { onUpClick() }) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "back button", + ) + } + }, + ) + }, + ) { paddingValues -> + Box(modifier = Modifier.padding(paddingValues)) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 8.dp), + ) { + uiState.ossLicense?.run { + Text( + text = this.licensesText, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } +} diff --git a/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/OssLicenseDetailViewModel.kt b/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/OssLicenseDetailViewModel.kt new file mode 100644 index 000000000..e2c768712 --- /dev/null +++ b/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/OssLicenseDetailViewModel.kt @@ -0,0 +1,43 @@ +package io.github.droidkaigi.confsched2023.about + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.droidkaigi.confsched2023.model.OssLicenseGroup +import io.github.droidkaigi.confsched2023.model.OssLicenseRepository +import io.github.droidkaigi.confsched2023.ui.buildUiState +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class OssLicenseDetailViewModel @Inject constructor( + private val ossLicenseRepository: OssLicenseRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val licenseName = savedStateHandle.getStateFlow( + ossLicenseDetailScreenRouteNameArgument, + "", + ) + + private val licenseStateFlow: StateFlow> = + ossLicenseRepository.licenseData() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = persistentListOf(), + ) + + internal val uiState: StateFlow = + buildUiState(licenseStateFlow) { licenses -> + val license = licenses + .flatMap { it.licenses } + .firstOrNull { it.id == licenseName.value } + OssLicenseDetailScreenUiState(license) + } +} diff --git a/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/OssLicenseScreen.kt b/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/OssLicenseScreen.kt new file mode 100644 index 000000000..91c6d54ae --- /dev/null +++ b/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/OssLicenseScreen.kt @@ -0,0 +1,139 @@ +package io.github.droidkaigi.confsched2023.about + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import io.github.droidkaigi.confsched2023.model.License +import io.github.droidkaigi.confsched2023.model.OssLicenseGroup +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf + +const val ossLicenseScreenRoute = "osslicense" +fun NavGraphBuilder.ossLicenseScreen( + onLicenseClick: (License) -> Unit, + onUpClick: () -> Unit, +) { + composable(ossLicenseScreenRoute) { + OssLicenseScreen(onLicenseClick = onLicenseClick, onUpClick = onUpClick) + } +} + +fun NavController.navigateOssLicenseScreen() { + navigate(ossLicenseScreenRoute) +} + +data class OssLicenseScreenUiState( + val ossLicense: PersistentList = persistentListOf(), +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OssLicenseScreen( + onLicenseClick: (License) -> Unit, + onUpClick: () -> Unit, + modifier: Modifier = Modifier, + viewModel: OssLicenseViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsState() + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text(text = AboutStrings.LicenseScreenTitle.asString()) + }, + navigationIcon = { + IconButton(onClick = { onUpClick() }) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "back button", + ) + } + }, + ) + }, + ) { paddingValues -> + Box(modifier = Modifier.padding(paddingValues)) { + OssLicenseScreen( + uiState = uiState, + onLicenseClick = onLicenseClick, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun OssLicenseScreen( + uiState: OssLicenseScreenUiState, + onLicenseClick: (License) -> Unit, +) { + LazyColumn(modifier = Modifier.padding(horizontal = 8.dp)) { + items(items = uiState.ossLicense) { group -> + var expand by remember { + mutableStateOf(false) + } + ElevatedCard( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + onClick = { expand = !expand }, + ) { + Column { + Spacer(modifier = Modifier.height(8.dp)) + Text( + modifier = Modifier.padding(start = 8.dp), + text = group.title, + style = MaterialTheme.typography.headlineMedium, + ) + AnimatedContent(targetState = expand, label = "") { targetState -> + if (targetState) { + Column { + group.licenses.forEach { license -> + key(license.name) { + TextButton(onClick = { onLicenseClick(license) }) { + Text(text = license.name) + } + } + } + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + } +} diff --git a/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/OssLicenseViewModel.kt b/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/OssLicenseViewModel.kt new file mode 100644 index 000000000..59fd8f154 --- /dev/null +++ b/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/OssLicenseViewModel.kt @@ -0,0 +1,33 @@ +package io.github.droidkaigi.confsched2023.about + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.droidkaigi.confsched2023.model.OssLicenseGroup +import io.github.droidkaigi.confsched2023.model.OssLicenseRepository +import io.github.droidkaigi.confsched2023.ui.buildUiState +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class OssLicenseViewModel @Inject constructor( + ossLicenseRepository: OssLicenseRepository, +) : ViewModel() { + + private val licenseStateFlow: StateFlow> = + ossLicenseRepository.licenseData() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = persistentListOf(), + ) + + internal val uiState: StateFlow = + buildUiState(licenseStateFlow) { licenses -> + OssLicenseScreenUiState(ossLicense = licenses) + } +} diff --git a/feature/about/src/test/java/io/github/droidkaigi/confsched2023/OssLicenseDetailScreenTest.kt b/feature/about/src/test/java/io/github/droidkaigi/confsched2023/OssLicenseDetailScreenTest.kt new file mode 100644 index 000000000..f071ea926 --- /dev/null +++ b/feature/about/src/test/java/io/github/droidkaigi/confsched2023/OssLicenseDetailScreenTest.kt @@ -0,0 +1,41 @@ +package io.github.droidkaigi.confsched2023 + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.droidkaigi.confsched2023.testing.HiltTestActivity +import io.github.droidkaigi.confsched2023.testing.RobotTestRule +import io.github.droidkaigi.confsched2023.testing.category.ScreenshotTests +import io.github.droidkaigi.confsched2023.testing.robot.OssLicenseDetailScreenRobot +import org.junit.Rule +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import javax.inject.Inject + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@HiltAndroidTest +@Config( + qualifiers = RobolectricDeviceQualifiers.NexusOne, +) +class OssLicenseDetailScreenTest { + + @get:Rule + @BindValue val robotTestRule: RobotTestRule = RobotTestRule(this) + + @Inject + lateinit var ossLicenseDetailScreenRobot: OssLicenseDetailScreenRobot + + @Test + @Category(ScreenshotTests::class) + fun checkLaunchShot() { + ossLicenseDetailScreenRobot { + setupOssLicenseDetailScreenContent() + checkScreenCapture() + } + } +} diff --git a/feature/about/src/test/java/io/github/droidkaigi/confsched2023/OssLicenseScreenTest.kt b/feature/about/src/test/java/io/github/droidkaigi/confsched2023/OssLicenseScreenTest.kt new file mode 100644 index 000000000..f9e6eab3f --- /dev/null +++ b/feature/about/src/test/java/io/github/droidkaigi/confsched2023/OssLicenseScreenTest.kt @@ -0,0 +1,41 @@ +package io.github.droidkaigi.confsched2023 + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.droidkaigi.confsched2023.testing.HiltTestActivity +import io.github.droidkaigi.confsched2023.testing.RobotTestRule +import io.github.droidkaigi.confsched2023.testing.category.ScreenshotTests +import io.github.droidkaigi.confsched2023.testing.robot.OssLicenseScreenRobot +import org.junit.Rule +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import javax.inject.Inject + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@HiltAndroidTest +@Config( + qualifiers = RobolectricDeviceQualifiers.NexusOne, +) +class OssLicenseScreenTest { + + @get:Rule + @BindValue val robotTestRule: RobotTestRule = RobotTestRule(this) + + @Inject + lateinit var ossLicenseScreenRobot: OssLicenseScreenRobot + + @Test + @Category(ScreenshotTests::class) + fun checkLaunchShot() { + ossLicenseScreenRobot { + setupOssLicenseScreenContent() + checkScreenCapture() + } + } +}