--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/ui/material3/src/commonMain/kotlin/com/geekorum/aboutoss/ui/material3/AdaptiveOpenSourceDependenciesScreen.kt Sun Apr 27 15:02:04 2025 -0400
@@ -0,0 +1,477 @@
+/*
+ * AboutOss is an utility library to retrieve and display
+ * opensource licenses in Android applications.
+ *
+ * Copyright (C) 2023-2025 by Frederic-Charles Barthelery.
+ *
+ * This file is part of AboutOss.
+ *
+ * AboutOss is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AboutOss is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AboutOss. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.geekorum.aboutoss.ui.material3
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+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.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
+import androidx.compose.material3.adaptive.layout.AnimatedPane
+import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
+import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
+import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
+import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldValue
+import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
+import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
+import androidx.compose.material3.rememberTopAppBarState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.backhandler.BackHandler
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.text.ExperimentalTextApi
+import androidx.compose.ui.text.TextLayoutResult
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.geekorum.aboutoss.common.generated.resources.title_oss_licenses
+import com.geekorum.aboutoss.ui.common.OpenSourceLicensesViewModel
+import com.geekorum.aboutoss.ui.common.rememberBrowserLauncher
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.stringResource
+import org.jetbrains.compose.ui.tooling.preview.Preview
+import com.geekorum.aboutoss.common.generated.resources.Res as CommonRes
+
+
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+@Composable
+fun AdaptiveOpenSourceDependenciesScreen(
+ openSourceLicensesViewModel: OpenSourceLicensesViewModel,
+ navigateUp: () -> Unit
+) {
+ val dependencies by openSourceLicensesViewModel.dependenciesList.collectAsStateWithLifecycle(emptyList())
+ val browserLauncher = rememberBrowserLauncher()
+ val coroutineScope = rememberCoroutineScope()
+ val onUrlsFound: (List<String>) -> Unit = {
+ browserLauncher.mayLaunchUrl(*it.toTypedArray())
+ }
+ val onUrlClick: (String) -> Unit = {
+ browserLauncher.launchUrl(it)
+ }
+
+ AdaptiveOpenSourceDependenciesScreen(
+ modifier = Modifier.background(MaterialTheme.colorScheme.surface),
+ dependenciesListPane = {
+ AdaptiveOpenSourceDependenciesListPane(
+ isSinglePane = isSinglePane,
+ dependencies = dependencies,
+ selectedDependency = selectedDependency,
+ onDependencyClick = {
+ coroutineScope.launch {
+ showLicenseDetails(it)
+ }
+ },
+ onUpClick = navigateUp,
+ )
+ },
+ dependencyLicensePane = {dependency ->
+ if (dependency != null) {
+ val license by openSourceLicensesViewModel.getLicenseDependency(dependency).collectAsStateWithLifecycle("")
+ AdaptiveOpenSourceLicensePane(
+ isSinglePane = isSinglePane,
+ dependency = dependency,
+ license = license,
+ onUpClick = {
+ coroutineScope.launch {
+ navigateBack()
+ }
+ },
+ onUrlClick = onUrlClick,
+ onUrlsFound = onUrlsFound,
+ )
+ }
+ }
+ )
+}
+
+@OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalComposeUiApi::class)
+@Composable
+fun AdaptiveOpenSourceDependenciesScreen(
+ dependenciesListPane: @Composable OpenSourcePaneScope.() -> Unit,
+ dependencyLicensePane: @Composable OpenSourcePaneScope.(dependency: String?) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val navigator = rememberListDetailPaneScaffoldNavigator<String>()
+ val coroutineScope = rememberCoroutineScope()
+ BackHandler(navigator.canNavigateBack()) {
+ coroutineScope.launch {
+ navigator.navigateBack()
+ }
+ }
+
+ val scope = remember(navigator) { DefaultOpenSourcePaneScope(navigator) }
+ ListDetailPaneScaffold(
+ modifier = modifier,
+ directive = navigator.scaffoldDirective,
+ scaffoldState = navigator.scaffoldState,
+ listPane = {
+ AnimatedPane {
+ scope.dependenciesListPane()
+ }
+ },
+ detailPane = {
+ AnimatedPane {
+ val dependency = navigator.currentDestination?.contentKey
+ scope.dependencyLicensePane(dependency)
+ }
+ }
+ )
+}
+
+
+/**
+ * Display the list of dependencies used in the application
+ *
+ * @param isSinglePane if only a single pane is visible
+ * @param dependencies the list of dependencies
+ * @param selectedDependency the currently selected dependency
+ * @param onDependencyClick lambda to execute on click on one dependency item
+ * @param onUpClick lambda to execute on click on the up arrow
+ */
+@Composable
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+fun AdaptiveOpenSourceDependenciesListPane(
+ isSinglePane: Boolean,
+ dependencies: List<String>,
+ selectedDependency: String?,
+ onDependencyClick: (String) -> Unit,
+ onUpClick: () -> Unit,
+) {
+ if (isSinglePane) {
+ OpenSourceDependenciesListScreen(
+ dependencies,
+ onDependencyClick = onDependencyClick,
+ onUpClick = onUpClick
+ )
+ } else {
+ OpenSourceDependenciesListPane(
+ dependencies,
+ selectedDependency = selectedDependency,
+ onDependencyClick = onDependencyClick,
+ onUpClick = onUpClick
+ )
+ }
+}
+
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun OpenSourceDependenciesListPane(
+ dependencies: List<String>,
+ selectedDependency: String?,
+ onDependencyClick: (String) -> Unit,
+ onUpClick: () -> Unit
+) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(CommonRes.string.title_oss_licenses)) },
+ navigationIcon = {
+ IconButton(onClick = onUpClick) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = null
+ )
+ }
+ },
+ )
+ }
+ ) {
+ LazyColumn(Modifier.fillMaxSize(), contentPadding = it) {
+ items(dependencies) { dependency ->
+ Column {
+ val colors = if (selectedDependency == dependency) {
+ ListItemDefaults.colors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ headlineColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ leadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ overlineColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ disabledHeadlineColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ disabledLeadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ disabledTrailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ )
+ } else {
+ ListItemDefaults.colors()
+ }
+ ListItem(
+ modifier = Modifier.clickable(onClick = { onDependencyClick(dependency) }),
+ colors = colors,
+ headlineContent = {
+ Text(dependency, overflow = TextOverflow.Ellipsis, maxLines = 1)
+ }
+ )
+ HorizontalDivider(Modifier.padding(horizontal = 16.dp))
+ }
+ }
+ }
+ }
+}
+
+
+/**
+ * Display the opensource license of a dependency
+ *
+ * @param isSinglePane if only a single pane is visible
+ * @param dependency the dependency
+ * @param license the opensource license text
+ * @param onUpClick lambda to execute on click on the navigate up button
+ * @param onUrlClick lambda to execute on click on a url
+ * @param onUrlsFound lambda to execute when all urls in the license have been found
+ */
+@Composable
+fun AdaptiveOpenSourceLicensePane(
+ isSinglePane: Boolean,
+ dependency: String,
+ license: String,
+ onUpClick: () -> Unit,
+ onUrlClick: (String) -> Unit,
+ onUrlsFound: (List<String>) -> Unit,
+) {
+ if (isSinglePane) {
+ OpenSourceLicenseScreen(
+ dependency = dependency,
+ license = license,
+ onUpClick = onUpClick,
+ onUrlsFound = onUrlsFound,
+ onUrlClick = onUrlClick
+ )
+ } else {
+ OpenSourceLicensePane(
+ dependency = dependency,
+ license = license,
+ onUrlsFound = onUrlsFound,
+ onUrlClick = onUrlClick
+ )
+ }
+}
+
+/**
+ * Display the opensource license of a dependency
+ *
+ * @param dependency the dependency
+ * @param license the opensource license text
+ * @param onUrlClick lambda to execute on click on a url
+ * @param onUrlsFound lambda to execute when all urls in the license have been found
+ */
+@OptIn(ExperimentalLayoutApi::class, ExperimentalTextApi::class, ExperimentalMaterial3Api::class)
+@Composable
+private fun OpenSourceLicensePane(
+ dependency: String,
+ license: String,
+ onUrlClick: (String) -> Unit,
+ onUrlsFound: (List<String>) -> Unit,
+) {
+ val linkifiedLicense = linkifyText(text = license)
+ LaunchedEffect(linkifiedLicense) {
+ val uris =
+ linkifiedLicense.getUrlAnnotations(0, linkifiedLicense.length).map { it.item.url }
+ onUrlsFound(uris)
+ }
+
+ Surface(
+ shape = MaterialTheme.shapes.large,
+ modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Vertical))
+ .padding(end = 24.dp)
+ ) {
+ val scrollState = rememberScrollState()
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
+ Scaffold(
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ topBar = {
+ TopAppBar(
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ ),
+ scrollBehavior = scrollBehavior,
+ title = { Text(dependency, overflow = TextOverflow.Ellipsis, maxLines = 1) }
+ )
+ }
+ ) { paddingValues ->
+ val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
+ val pressIndicator = Modifier.pointerInput(layoutResult, linkifiedLicense) {
+ detectTapGestures { pos ->
+ layoutResult.value?.let { layoutResult ->
+ val posWithScroll = pos.copy(y = pos.y + scrollState.value)
+ val offset = layoutResult.getOffsetForPosition(posWithScroll)
+ linkifiedLicense.getUrlAnnotations(start = offset, end = offset)
+ .firstOrNull()?.let { annotation ->
+ onUrlClick(annotation.item.url)
+ }
+ }
+ }
+ }
+
+ Text(
+ linkifiedLicense,
+ modifier = Modifier
+ .padding(paddingValues)
+ .consumeWindowInsets(paddingValues)
+ .padding(horizontal = 16.dp)
+ .fillMaxSize()
+ .then(pressIndicator)
+ .verticalScroll(scrollState),
+ onTextLayout = {
+ layoutResult.value = it
+ }
+ )
+ }
+ }
+}
+
+@Stable
+interface OpenSourcePaneScope {
+ val isSinglePane: Boolean
+ val selectedDependency: String?
+
+ suspend fun showLicenseDetails(dependency: String)
+
+ suspend fun navigateBack()
+}
+
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+private class DefaultOpenSourcePaneScope(
+ val navigator: ThreePaneScaffoldNavigator<String>,
+) : OpenSourcePaneScope {
+ override val isSinglePane: Boolean
+ get() = navigator.scaffoldState.targetState.isSinglePane()
+
+ override val selectedDependency: String?
+ get() = navigator.currentDestination?.contentKey
+
+ override suspend fun showLicenseDetails(dependency: String) {
+ navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, dependency)
+ }
+
+ override suspend fun navigateBack() {
+ navigator.navigateBack()
+ }
+
+}
+
+
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+private fun ThreePaneScaffoldValue.isSinglePane(): Boolean {
+ return primary == PaneAdaptedValue.Expanded && secondary == PaneAdaptedValue.Hidden && tertiary == PaneAdaptedValue.Hidden
+}
+
+
+@Preview
+@Composable
+private fun PreviewOpenSourceLicensePane() {
+ var singlePane by remember { mutableStateOf(false) }
+ Box(Modifier.fillMaxSize()) {
+ AdaptiveOpenSourceLicensePane(
+ singlePane,
+ "Apache HttpCommons",
+ "Apache 2.0",
+ onUrlsFound = {},
+ onUrlClick = {},
+ onUpClick = {}
+ )
+ Button(modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
+ onClick = {
+ singlePane = !singlePane
+ }) {
+ Text("Toggle single pane")
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+@Preview
+@Composable
+private fun PreviewBrowsingPanes() {
+ Surface {
+ val dependencies = List(20) {
+ "Dep $it"
+ }
+ val licenses = dependencies.associate { it to "license of $it" }
+ val navigator = rememberListDetailPaneScaffoldNavigator<String>()
+ val coroutineScope = rememberCoroutineScope()
+ AdaptiveOpenSourceDependenciesScreen(
+ dependenciesListPane = {
+ OpenSourceDependenciesListPane(
+ dependencies,
+ selectedDependency = navigator.currentDestination?.contentKey,
+ onDependencyClick = {
+ coroutineScope.launch {
+ navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, it)
+ }
+ },
+ onUpClick = {}
+ )
+ },
+ dependencyLicensePane = { dependency ->
+ if (dependency != null) {
+ OpenSourceLicensePane(
+ dependency,
+ licenses[dependency] ?: "",
+ onUrlClick = {},
+ onUrlsFound = {})
+ }
+ }
+ )
+ }
+}
\ No newline at end of file