diff -r 137a5da55ed9 -r bedda51b88eb ui/src/main/java/com/geekorum/aboutoss/ui/OpenSourceLicenseScreen.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/ui/src/main/java/com/geekorum/aboutoss/ui/OpenSourceLicenseScreen.kt Thu Apr 13 17:11:33 2023 -0400 @@ -0,0 +1,177 @@ +/* + * AboutOss is a utility library to retrieve and display + * opensource licenses in Android applications. + * + * Copyright (C) 2023 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 . + */ +package com.geekorum.aboutoss.ui + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.UrlAnnotation +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withAnnotation +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri + +@Composable +fun OpenSourceLicenseScreen( + viewModel: OpenSourceLicensesViewModel, + dependency: String, + onBackClick: () -> Unit, +) { + val context = LocalContext.current + val license by viewModel.getLicenseDependency(dependency).collectAsState("") + OpenSourceLicenseScreen( + dependency = dependency, + license = license, + onBackClick = onBackClick, + onUrlClick = { + viewModel.openLinkInBrowser(context, it) + }, + onUrlsFound = { + val uris = it.map { uri -> uri.toUri() } + viewModel.mayLaunchUrl(*uris.toTypedArray()) + } + ) +} + +@OptIn(ExperimentalLayoutApi::class, ExperimentalTextApi::class) +@Composable +fun OpenSourceLicenseScreen( + dependency: String, + license: String, + onBackClick: () -> Unit, + onUrlClick: (String) -> Unit, + onUrlsFound: (List) -> Unit, +) { + val linkifiedLicense = linkifyText(text = license) + LaunchedEffect(linkifiedLicense) { + val uris = + linkifiedLicense.getUrlAnnotations(0, linkifiedLicense.length).map { it.item.url } + onUrlsFound(uris) + } + + val scrollState = rememberScrollState() + val hasScrolled by remember { + derivedStateOf { scrollState.value > 0 } + } + val topBarElevation by animateDpAsState( + if (hasScrolled) 4.dp else 0.dp + ) + Scaffold(topBar = { + TopAppBar(title = { Text(dependency, overflow = TextOverflow.Ellipsis, maxLines = 1) }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + Icons.Default.ArrowBack, + contentDescription = null + ) + } + }, + elevation = topBarElevation + ) + }) { paddingValues -> + val layoutResult = remember { mutableStateOf(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(horizontal = 16.dp) + .fillMaxSize() + .then(pressIndicator) + .verticalScroll(scrollState) + .padding(paddingValues) + .consumeWindowInsets(paddingValues), + onTextLayout = { + layoutResult.value = it + } + ) + } +} + +/** + * https://regexr.com/37i6s + */ +private val UrlRegexp = """https?://(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_+.~#?&/=]*)""".toRegex() + +@OptIn(ExperimentalTextApi::class) +@Composable +private fun linkifyText(text: String): AnnotatedString { + val style = SpanStyle( + color = MaterialTheme.colors.secondary, + textDecoration = TextDecoration.Underline + ) + return remember(text, style) { + buildAnnotatedString { + var currentIdx = 0 + for (match in UrlRegexp.findAll(text)) { + if (currentIdx < match.range.first) { + append(text.substring(currentIdx, match.range.first)) + } + val url = text.substring(match.range) + withAnnotation(UrlAnnotation(url)) { + withStyle(style) { + append(url) + } + } + currentIdx = match.range.last + 1 + } + append(text.substring(currentIdx)) + } + } +}