diff --git a/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/ui/MainActivity.kt b/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/ui/MainActivity.kt index e2d84904e..51088a045 100644 --- a/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/ui/MainActivity.kt +++ b/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/ui/MainActivity.kt @@ -37,6 +37,7 @@ import de.tum.informatics.www1.artemis.native_app.core.ui.LocalLinkOpener import de.tum.informatics.www1.artemis.native_app.core.ui.LocalWindowSizeClassProvider import de.tum.informatics.www1.artemis.native_app.core.ui.WindowSizeClassProvider import de.tum.informatics.www1.artemis.native_app.core.ui.alert.TextAlertDialog +import de.tum.informatics.www1.artemis.native_app.core.ui.markdown.link_resolving.LocalMarkdownLinkResolver import de.tum.informatics.www1.artemis.native_app.core.ui.remote_images.LocalArtemisImageProvider import de.tum.informatics.www1.artemis.native_app.feature.courseregistration.courseRegistration import de.tum.informatics.www1.artemis.native_app.feature.courseregistration.navigateToCourseRegistration @@ -255,7 +256,8 @@ class MainActivity : AppCompatActivity(), CompositionLocalProvider( LocalWindowSizeClassProvider provides windowSizeClassProvider, LocalLinkOpener provides linkOpener, - LocalArtemisImageProvider provides koinInject() + LocalArtemisImageProvider provides koinInject(), + LocalMarkdownLinkResolver provides koinInject() ) { // Use jetpack compose navigation for the navigation logic. NavHost(navController = navController, startDestination = startDestination) { diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 6e3c5708a..209a76bfc 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -32,11 +32,12 @@ dependencies { debugApi(libs.coil.compose.core) debugApi(libs.coil.test) api(libs.accompanist.webview) + api(libs.noties.markwon.core) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.coroutines.android) + implementation(libs.androidx.browser) - implementation(libs.noties.markwon.core) implementation(libs.noties.markwon.ext.strikethrough) implementation(libs.noties.markwon.ext.tables) implementation(libs.noties.markwon.html) diff --git a/core/ui/src/main/AndroidManifest.xml b/core/ui/src/main/AndroidManifest.xml new file mode 100644 index 000000000..0fdec3e1a --- /dev/null +++ b/core/ui/src/main/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + + + \ No newline at end of file diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/compose/ArtemisPdfView.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/compose/ArtemisPdfView.kt new file mode 100644 index 000000000..75f63f356 --- /dev/null +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/compose/ArtemisPdfView.kt @@ -0,0 +1,251 @@ +package de.tum.informatics.www1.artemis.native_app.core.ui.compose + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.tum.informatics.www1.artemis.native_app.core.ui.R +import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.PdfFile +import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.HorizontalPdfView +import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.VerticalPdfView +import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.state.HorizontalPdfReaderState +import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.state.PdfReaderState +import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.state.VerticalPdfReaderState +import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.state.rememberHorizontalPdfReaderState +import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.state.rememberVerticalPdfReaderState +import java.net.UnknownHostException + +@Composable +fun ArtemisPdfView( + modifier: Modifier, + pdfFile: PdfFile, + dismiss: () -> Unit +) { + val isVertical = remember { mutableStateOf(true) } + val context = LocalContext.current + + val verticalPdfState = rememberVerticalPdfReaderState( + pdfFile = pdfFile, + isZoomEnabled = true, + ) + val horizontalPdfState = rememberHorizontalPdfReaderState( + pdfFile = pdfFile, + isZoomEnabled = true, + ) + + var pdfState = if (isVertical.value) verticalPdfState else horizontalPdfState + + val showMenu = remember { mutableStateOf(false) } + + if (pdfState.mError != null) { + val errorMessage = when (pdfState.mError) { + is UnknownHostException -> { + R.string.pdf_view_error_no_internet + } + else -> { + R.string.pdf_view_error_loading + } + } + + Toast.makeText( + LocalContext.current, + stringResource(id = errorMessage), + Toast.LENGTH_LONG + ).show() + + dismiss() + } + + Box( + modifier = modifier.padding(8.dp), + ) { + Column { + pdfState.file?.let { + Text( + text = pdfFile.filename ?: it.name, + modifier = Modifier.padding(8.dp), + style = MaterialTheme.typography.titleMedium + ) + } + if (isVertical.value) { + VerticalPdfView( + state = pdfState as VerticalPdfReaderState, + modifier = Modifier + .background(color = MaterialTheme.colorScheme.surface) + .fillMaxSize() + ) + } else { + HorizontalPdfView( + state = pdfState as HorizontalPdfReaderState, + modifier = Modifier + .fillMaxSize() + .background( + color = MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.medium + ) + ) + } + } + + Column( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp) + ) { + if (showMenu.value) { + Spacer(modifier = Modifier.height(16.dp)) + } + + FloatingActionButton( + onClick = { showMenu.value = !showMenu.value }, + modifier = Modifier + ) { + Icon(imageVector = Icons.Default.MoreHoriz, contentDescription = null) + } + + PdfActionDropDownMenu( + modifier = Modifier, + isExpanded = showMenu.value, + onDismissRequest = { showMenu.value = false }, + downloadPdf = { + showMenu.value = false + pdfState.file?.let { pdfFile.downloadPdf(context) } + }, + sharePdf = { + showMenu.value = false + pdfState.file?.let { pdfFile.sharePdf(context, it) } + }, + toggleViewMode = { + showMenu.value = false + pdfState = if (isVertical.value) horizontalPdfState else verticalPdfState + isVertical.value = !isVertical.value + } + ) + } + + if (!pdfState.isLoaded) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + progress = { pdfState.loadPercent / 100f }, + modifier = Modifier.height(48.dp), + color = MaterialTheme.colorScheme.primary + ) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopStart) + .padding(top = 54.dp, end = 4.dp) + .padding(16.dp), + contentAlignment = Alignment.TopEnd + ) { + PageCountText( + modifier = Modifier, + pdfState = pdfState + ) + } + } +} + +@Composable +private fun PageCountText( + modifier: Modifier, + pdfState: PdfReaderState +) { + Box( + modifier = modifier + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + shape = MaterialTheme.shapes.medium + ) + .padding(8.dp) + ) { + Text( + text = stringResource( + R.string.pdf_view_page_count, + pdfState.currentPage, + pdfState.pdfPageCount + ), + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Composable +private fun PdfActionDropDownMenu( + modifier: Modifier, + isExpanded: Boolean, + onDismissRequest: () -> Unit, + downloadPdf: () -> Unit, + sharePdf: () -> Unit, + toggleViewMode: () -> Unit, +) { + DropdownMenu( + modifier = Modifier, + expanded = isExpanded, + onDismissRequest = onDismissRequest + ) { + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Default.Download, + contentDescription = null + ) + }, + text = { Text(stringResource(R.string.pdf_view_download_menu_item)) }, + onClick = downloadPdf + ) + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Default.Share, + contentDescription = null + ) + }, + text = { Text(stringResource(R.string.pdf_view_share_menu_item)) }, + onClick = sharePdf + ) + DropdownMenuItem( + leadingIcon = { + Icon( + modifier = Modifier.height(19.dp), + painter = painterResource(id = R.drawable.rotate), + contentDescription = null + ) + }, + text = { Text(stringResource(R.string.pdf_view_rotate_menu_item)) }, + onClick = toggleViewMode + ) + } +} \ No newline at end of file diff --git a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ArtemisWebView.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/compose/ArtemisWebView.kt similarity index 58% rename from feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ArtemisWebView.kt rename to core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/compose/ArtemisWebView.kt index 7da78614e..e7e73ee63 100644 --- a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ArtemisWebView.kt +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/compose/ArtemisWebView.kt @@ -1,4 +1,4 @@ -package de.tum.informatics.www1.artemis.native_app.feature.exerciseview +package de.tum.informatics.www1.artemis.native_app.core.ui.compose import android.annotation.SuppressLint import android.content.Context @@ -14,93 +14,16 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.google.accompanist.web.AccompanistWebViewClient -import com.google.accompanist.web.WebContent import com.google.accompanist.web.WebView import com.google.accompanist.web.WebViewState -import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ResultTemplateStatus -import io.ktor.http.* - -@Composable -internal fun getProblemStatementWebViewState( - serverUrl: String, - courseId: Long?, - exerciseId: Long?, - participationId: Long? -): WebViewState? { - val url by remember(serverUrl, courseId, exerciseId) { - derivedStateOf { - if (courseId != null && exerciseId != null) { - URLBuilder(serverUrl).apply { - appendPathSegments( - "courses", - courseId.toString(), - "exercises", - exerciseId.toString(), - "problem-statement" - ) - - if (participationId != null) { - appendPathSegments(participationId.toString()) - } - } - .buildString() - } else null - } - } - - return remember(url) { - derivedStateOf { - url?.let { - WebViewState(WebContent.Url(url = it)) - } - } - }.value -} - -@Composable -internal fun getFeedbackViewWebViewState( - serverUrl: String, - courseId: Long, - exerciseId: Long, - participationId: Long, - resultId: Long, - templateStatus: ResultTemplateStatus -): WebViewState { - val url by remember(serverUrl, courseId, exerciseId, resultId, templateStatus) { - derivedStateOf { - URLBuilder(serverUrl).apply { - appendPathSegments( - "courses", - courseId.toString(), - "exercises", - exerciseId.toString(), - "feedback", - participationId.toString(), - resultId.toString(), - (templateStatus == ResultTemplateStatus.Missing).toString() - ) - }.buildString() - } - } - - return remember(url) { - derivedStateOf { - url.let { - WebViewState(WebContent.Url(url = it)) - } - } - }.value -} @SuppressLint("SetJavaScriptEnabled") @Composable -internal fun ArtemisWebView( +fun ArtemisWebView( modifier: Modifier, webViewState: WebViewState, webView: WebView?, @@ -183,4 +106,6 @@ private class ThemeClient( super.onPageFinished(view, url) } -} \ No newline at end of file +} + + diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/compose/LinkBottomSheet.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/compose/LinkBottomSheet.kt new file mode 100644 index 000000000..4e00a3494 --- /dev/null +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/compose/LinkBottomSheet.kt @@ -0,0 +1,102 @@ +package de.tum.informatics.www1.artemis.native_app.core.ui.compose + +import android.annotation.SuppressLint +import android.webkit.WebView +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +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 com.google.accompanist.web.WebContent +import com.google.accompanist.web.WebViewState +import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.PdfFile + +enum class LinkBottomSheetState { + PDFVIEWSTATE, + WEBVIEWSTATE +} + +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun LinkBottomSheet( + modifier: Modifier, + serverUrl: String, + authToken: String, + link: String, + fileName: String?, + state: LinkBottomSheetState, + onDismissRequest: () -> Unit +) { + var webView: WebView? by remember { mutableStateOf(null) } + val webViewState = getWebViewState(link) + + ModalBottomSheet( + modifier = modifier.padding( + top = WindowInsets.systemBars.asPaddingValues().calculateTopPadding(), + ), + onDismissRequest = onDismissRequest + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .padding(8.dp) + ) { + when (state) { + LinkBottomSheetState.PDFVIEWSTATE -> { + val pdfFile = PdfFile(link, authToken, fileName) + ArtemisPdfView( + modifier = Modifier.fillMaxSize(), + pdfFile = pdfFile, + dismiss = onDismissRequest + ) + } + + LinkBottomSheetState.WEBVIEWSTATE -> { + // The lazy column is needed to support scrolling + LazyColumn( + modifier = Modifier + .fillMaxHeight(), + state = rememberLazyListState() + ) { + item { + ArtemisWebView( + modifier = Modifier + .fillMaxHeight() + .padding(8.dp), + webViewState = webViewState, + webView = webView, + setWebView = { webView = it }, + serverUrl = serverUrl, + authToken = authToken + ) + } + } + } + } + } + } +} + +@Composable +private fun getWebViewState( + link: String +): WebViewState { + return remember(link) { + derivedStateOf { + WebViewState(WebContent.Url(url = link)) + } + }.value +} \ No newline at end of file diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/LocalMarkwon.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/LocalMarkwon.kt index faddae433..782c4a884 100644 --- a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/LocalMarkwon.kt +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/LocalMarkwon.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext +import de.tum.informatics.www1.artemis.native_app.core.ui.markdown.link_resolving.LocalMarkdownLinkResolver import de.tum.informatics.www1.artemis.native_app.core.ui.remote_images.LocalArtemisImageProvider import io.noties.markwon.Markwon @@ -15,11 +16,12 @@ val LocalMarkwon: ProvidableCompositionLocal = @Composable fun ProvideMarkwon(content: @Composable () -> Unit) { val imageLoader = LocalArtemisImageProvider.current.rememberArtemisImageLoader() + val linkResolver = LocalMarkdownLinkResolver.current.rememberMarkdownLinkResolver() val context = LocalContext.current val imageWidth = context.resources.displayMetrics.widthPixels - val markdownRender: Markwon = remember(imageLoader) { - createMarkdownRender(context, imageLoader, imageWidth) + val markdownRender: Markwon = remember(imageLoader, linkResolver) { + createMarkdownRender(context, imageLoader, linkResolver, imageWidth) } CompositionLocalProvider(LocalMarkwon provides markdownRender) { diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/MarkdownText.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/MarkdownText.kt index 7dc0cb905..f63527f1d 100644 --- a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/MarkdownText.kt +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/MarkdownText.kt @@ -38,7 +38,9 @@ import de.tum.informatics.www1.artemis.native_app.core.common.R import de.tum.informatics.www1.artemis.native_app.core.common.markdown.ArtemisMarkdownTransformer import de.tum.informatics.www1.artemis.native_app.core.common.markdown.TYPE_ICON_RESOURCE_PATH import io.noties.markwon.AbstractMarkwonPlugin +import io.noties.markwon.LinkResolver import io.noties.markwon.Markwon +import io.noties.markwon.MarkwonConfiguration import io.noties.markwon.core.MarkwonTheme import io.noties.markwon.ext.strikethrough.StrikethroughPlugin import io.noties.markwon.ext.tables.TablePlugin @@ -93,14 +95,15 @@ fun MarkdownText( onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, imageLoader: ImageLoader? = null, + linkResolver: LinkResolver? = null ) { val defaultColor: Color = LocalContentColor.current val context: Context = LocalContext.current val localMarkwon = LocalMarkwon.current val imageWidth = context.resources.displayMetrics.widthPixels - val markdownRender: Markwon = localMarkwon ?: remember(imageLoader) { - createMarkdownRender(context, imageLoader, imageWidth) + val markdownRender: Markwon = localMarkwon ?: remember(imageLoader, linkResolver) { + createMarkdownRender(context, imageLoader, linkResolver, imageWidth) } val markdownTransformer = LocalMarkdownTransformer.current @@ -224,7 +227,7 @@ private fun TextView.applyStyleAndColor( } } -fun createMarkdownRender(context: Context, imageLoader: ImageLoader?, imageWidth: Int): Markwon { +fun createMarkdownRender(context: Context, imageLoader: ImageLoader?, linkResolver: LinkResolver?, imageWidth: Int): Markwon { val imagePlugin: CoilImagesPlugin? = if (imageLoader != null) { CoilImagesPlugin.create( @@ -268,6 +271,13 @@ fun createMarkdownRender(context: Context, imageLoader: ImageLoader?, imageWidth if (imagePlugin != null) { usePlugin(imagePlugin) } + if (linkResolver != null) { + usePlugin(object : AbstractMarkwonPlugin() { + override fun configureConfiguration(builder: MarkwonConfiguration.Builder) { + builder.linkResolver(linkResolver) + } + }) + } } .build() } \ No newline at end of file diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/link_resolving/MarkdownLinkResolver.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/link_resolving/MarkdownLinkResolver.kt new file mode 100644 index 000000000..8d2dbcdcb --- /dev/null +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/link_resolving/MarkdownLinkResolver.kt @@ -0,0 +1,12 @@ +package de.tum.informatics.www1.artemis.native_app.core.ui.markdown.link_resolving + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import io.noties.markwon.LinkResolver + +val LocalMarkdownLinkResolver = compositionLocalOf { error("No MarkdownLinkResolver provided") } + +interface MarkdownLinkResolver { + @Composable + fun rememberMarkdownLinkResolver(): LinkResolver +} \ No newline at end of file diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/link_resolving/MarkdownLinkResolverImpl.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/link_resolving/MarkdownLinkResolverImpl.kt new file mode 100644 index 000000000..1be2c74fc --- /dev/null +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/link_resolving/MarkdownLinkResolverImpl.kt @@ -0,0 +1,79 @@ +package de.tum.informatics.www1.artemis.native_app.core.ui.markdown.link_resolving + +import android.content.Context +import android.view.View +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import de.tum.informatics.www1.artemis.native_app.core.datastore.AccountService +import de.tum.informatics.www1.artemis.native_app.core.datastore.ServerConfigurationService +import de.tum.informatics.www1.artemis.native_app.core.datastore.authToken +import de.tum.informatics.www1.artemis.native_app.core.ui.LinkOpener +import de.tum.informatics.www1.artemis.native_app.core.ui.LocalLinkOpener +import de.tum.informatics.www1.artemis.native_app.core.ui.compose.LinkBottomSheet +import de.tum.informatics.www1.artemis.native_app.core.ui.compose.LinkBottomSheetState +import io.noties.markwon.LinkResolver + +class MarkdownLinkResolverImpl( + private val accountService: AccountService, + private val serverConfigurationService: ServerConfigurationService, +): MarkdownLinkResolver { + @Composable + override fun rememberMarkdownLinkResolver(): LinkResolver { + val serverUrl by serverConfigurationService.serverUrl.collectAsState(initial = "") + val authToken by accountService.authToken.collectAsState(initial = "") + val context = LocalContext.current + val localLinkOpener = LocalLinkOpener.current + + val (bottomSheetLink, setLinkToShow) = remember { mutableStateOf(null) } + val (bottomSheetState, setBottomSheetState) = remember { mutableStateOf(LinkBottomSheetState.WEBVIEWSTATE) } + + if (bottomSheetLink != null) { + val filename = if (bottomSheetState == LinkBottomSheetState.PDFVIEWSTATE) bottomSheetLink.substringAfterLast("/") else null + LinkBottomSheet( + modifier = Modifier.fillMaxSize(), + serverUrl = serverUrl, + authToken = authToken, + link = bottomSheetLink, + fileName = filename, + state = bottomSheetState, + onDismissRequest = { setLinkToShow(null) } + ) + } + + return remember(context, localLinkOpener, authToken, serverUrl) { + BaseMarkdownLinkResolver(context, localLinkOpener, serverUrl, setLinkToShow, setBottomSheetState) + } + } +} + +class BaseMarkdownLinkResolver( + private val context: Context, + private val linkOpener: LinkOpener, + private val serverUrl: String = "", + private val showModalBottomSheet: (String) -> Unit, + private val setBottomSheetState: (LinkBottomSheetState) -> Unit +) : LinkResolver { + override fun resolve(view: View, link: String) { + when { + link.endsWith(".pdf") -> { + setBottomSheetState(LinkBottomSheetState.PDFVIEWSTATE) + showModalBottomSheet(link) + } + // TODO: open Artemis link in a Modal Bottom Sheet webview to attach session cookie (https://github.com/ls1intum/artemis-android/issues/245) +// link.startsWith(serverUrl) -> { +// setBottomSheetState(LinkBottomSheetState.WEBVIEWSTATE) +// showModalBottomSheet(link) +// } + else -> { + linkOpener.openLink(link) + } + } + } +} + diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/Notice.md b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/Notice.md new file mode 100644 index 000000000..912e972f6 --- /dev/null +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/Notice.md @@ -0,0 +1,19 @@ +### Disclaimer + +The files included in this package have been heavily influenced by: + +https://github.com/GRizzi91/bouquet + +Copyright [2022] [Graziano Rizzi] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/PdfFile.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/PdfFile.kt new file mode 100644 index 000000000..659b4640d --- /dev/null +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/PdfFile.kt @@ -0,0 +1,201 @@ +package de.tum.informatics.www1.artemis.native_app.core.ui.pdf + +import android.app.DownloadManager +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Environment +import android.os.ParcelFileDescriptor +import android.util.Log +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.core.content.ContextCompat.getString +import androidx.core.content.FileProvider +import de.tum.informatics.www1.artemis.native_app.core.ui.R +import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.PdfRendering +import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.state.PdfReaderState +import io.ktor.http.HttpHeaders +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.net.UnknownHostException +import java.util.Date + + +class PdfFile( + val link: String, + val authToken: String, + val filename: String? = null +) { + + private fun generateFileName(): String = "${Date().time}.pdf" + + fun load( + coroutineScope: CoroutineScope, + context: Context, + state: PdfReaderState, + width: Int, + height: Int, + portrait: Boolean + ) { + val client = OkHttpClient() + val request = Request.Builder() + .url(link) + .header(HttpHeaders.Cookie, "jwt=$authToken") + .build() + + runCatching { + if (state.isLoaded) { + coroutineScope.launch(Dispatchers.IO) { + runCatching { + val pFD = + ParcelFileDescriptor.open( + state.mFile, + ParcelFileDescriptor.MODE_READ_ONLY + ) + state.pdfRender = + PdfRendering(pFD, width, height, portrait) + }.onFailure { + state.mError = it + } + } + } else { + coroutineScope.launch(Dispatchers.IO) { + runCatching { + + val bufferSize = 8192 + var downloaded = 0 + val file = File(context.cacheDir, filename ?: generateFileName()) + val response = client.newCall(request).execute() + if (!response.isSuccessful) { + state.mError = Exception("Failed to download PDF: ${response.code}") + Log.e("PdfView", "Failed to download PDF: ${response.code}") + } + + val byteStream = response.body?.byteStream() + byteStream.use { input -> + file.outputStream().use { output -> + val totalBytes = response.body?.contentLength() + var data = ByteArray(bufferSize) + var count = input?.read(data) + while (count != -1) { + if (totalBytes != null) { + if (totalBytes > 0) { + downloaded += bufferSize + state.mLoadPercent = + (downloaded * (100 / totalBytes.toFloat())).toInt() + } + } + if (count != null) { + output.write(data, 0, count) + } + data = ByteArray(bufferSize) + count = input?.read(data) + } + } + } + val pFD = ParcelFileDescriptor.open( + file, + ParcelFileDescriptor.MODE_READ_ONLY + ) + state.pdfRender = + PdfRendering(pFD, width, height, portrait) + state.mFile = file + }.onFailure { + state.mError = it + } + } + } + }.onFailure { + state.mError = it + } + } + + fun downloadPdf(context: Context) { + try { + val downloadManager: DownloadManager = + context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val downloadUri = Uri.parse(link) + + downloadManager + .enqueue( + DownloadManager.Request(downloadUri) + .addRequestHeader(HttpHeaders.Cookie, "jwt=$authToken") + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setTitle( + filename ?: generateFileName() + ) + .setDestinationInExternalPublicDir( + Environment.DIRECTORY_DOWNLOADS, + downloadUri.lastPathSegment + ) + ) + + Toast.makeText( + context, + getString(context, R.string.pdf_view_downloading_toast), + Toast.LENGTH_SHORT + ).show() + + } catch (e: Exception) { + val errorMessage = when (e) { + is UnknownHostException -> { + R.string.pdf_view_error_no_internet + } + else -> { + R.string.pdf_view_error_downloading + } + } + Toast.makeText(context, getString(context, errorMessage), Toast.LENGTH_LONG) + .show() + e.printStackTrace() + } + } + + fun sharePdf(context: Context, pdfFile: File) { + val pdfUri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + pdfFile + ) + + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "application/pdf" + clipData = ClipData( + "pdf", + arrayOf("application/pdf"), + ClipData.Item(pdfUri) + ) + putExtra(Intent.EXTRA_STREAM, pdfUri) // to support sharing to older applications + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity( + Intent.createChooser( + shareIntent, + getString(context, R.string.pdf_view_share_title) + ) + ) + } +} + +@Composable +internal fun PdfImage( + bitmap: () -> ImageBitmap, + contentDescription: String = "", +) { + Image( + bitmap = bitmap(), + contentDescription = contentDescription, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth() + ) +} \ No newline at end of file diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/PdfRendering.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/PdfRendering.kt new file mode 100644 index 000000000..db32aa180 --- /dev/null +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/PdfRendering.kt @@ -0,0 +1,157 @@ +package de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.pdf.PdfRenderer +import android.os.ParcelFileDescriptor +import androidx.core.graphics.createBitmap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal class PdfRendering( + private val fileDescriptor: ParcelFileDescriptor, + val width: Int, + val height: Int, + private val portrait: Boolean +) { + private val pdfRenderer = PdfRenderer(fileDescriptor) + val pageCount get() = pdfRenderer.pageCount + private val mutex: Mutex = Mutex() + private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + val pageLists: List = List(pdfRenderer.pageCount) { + Page( + mutex = mutex, + index = it, + pdfRenderer = pdfRenderer, + coroutineScope = coroutineScope, + width = width, + height = height, + portrait = portrait + ) + } + + fun close() { + coroutineScope.launch { + pageLists.forEach { + it.job?.cancelAndJoin() + it.recycle() + } + pdfRenderer.close() + fileDescriptor.close() + } + } + + class Page( + val mutex: Mutex, + val index: Int, + val pdfRenderer: PdfRenderer, + val coroutineScope: CoroutineScope, + width: Int, + height: Int, + portrait: Boolean + ) { + val dimension = pdfRenderer.openPage(index).use { + if (portrait) { + val h = it.height * (width.toFloat() / it.width) + val dim = Dimension( + height = h.toInt(), + width = width + ) + dim + } else { + val w = it.width * (height.toFloat() / it.height) + val dim = Dimension( + height = height, + width = w.toInt() + ) + dim + } + } + + var job: Job? = null + + val stateFlow = MutableStateFlow( + PageContentInt.BlankPage( + width = dimension.width, + height = dimension.height + ) + ) + + private var isLoaded = false + + fun load() { + if (!isLoaded) { + job = coroutineScope.launch { + mutex.withLock { + val newBitmap: Bitmap + pdfRenderer.openPage(index).use { currentPage -> + newBitmap = createBlankBitmap( + width = dimension.width, + height = dimension.height + ) + currentPage.render( + newBitmap, + null, + null, + PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY + ) + } + isLoaded = true + stateFlow.emit(PageContentInt.PageContent(newBitmap)) + } + } + } + } + + fun recycle() { + isLoaded = false + val oldBitmap = stateFlow.value as? PageContentInt.PageContent + stateFlow.tryEmit( + PageContentInt.BlankPage( + width = dimension.width, + height = dimension.height + ) + ) + oldBitmap?.bitmap?.recycle() + } + + private fun createBlankBitmap( + width: Int, + height: Int + ): Bitmap { + return createBitmap( + width, + height, + Bitmap.Config.ARGB_8888 + ).apply { + val canvas = Canvas(this) + canvas.drawColor(android.graphics.Color.WHITE) + canvas.drawBitmap(this, 0f, 0f, null) + } + } + + data class Dimension( + val height: Int, + val width: Int + ) + } +} + +sealed interface PageContentInt { + data class PageContent( + val bitmap: Bitmap + ) : PageContentInt + + data class BlankPage( + val width: Int, + val height: Int + ) : PageContentInt +} \ No newline at end of file diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/PdfView.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/PdfView.kt new file mode 100644 index 000000000..49996424a --- /dev/null +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/PdfView.kt @@ -0,0 +1,319 @@ +package de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.PdfImage +import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.state.HorizontalPdfReaderState +import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.state.VerticalPdfReaderState +import kotlinx.coroutines.launch + +@Composable +fun VerticalPdfView( + state: VerticalPdfReaderState, + modifier: Modifier +) { + BoxWithConstraints( + modifier = modifier, + contentAlignment = Alignment.TopCenter + ) { + val ctx = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val lazyState = state.lazyState + val pdfFile = state.pdfFile + + DisposableEffect(key1 = Unit) { + pdfFile.load( + coroutineScope, + ctx, + state, + constraints.maxWidth, + constraints.maxHeight, + true + ) + onDispose { + state.close() + } + } + state.pdfRender?.let { pdf -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .tapToZoomVertical(state, constraints), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + state = lazyState + ) { + items(pdf.pageCount) { + val pageContent = pdf.pageLists[it].stateFlow.collectAsState().value + DisposableEffect(key1 = Unit) { + pdf.pageLists[it].load() + onDispose { + // This caused issues and has therefore been temporarily disabled + //pdf.pageLists[it].recycle() + } + } + when (pageContent) { + is PageContentInt.PageContent -> { + PdfImage( + bitmap = { pageContent.bitmap.asImageBitmap() } + ) + } + + is PageContentInt.BlankPage -> BlackPage( + width = pageContent.width, + height = pageContent.height + ) + } + } + } + } + } +} + +@Composable +fun HorizontalPdfView( + state: HorizontalPdfReaderState, + modifier: Modifier +) { + BoxWithConstraints( + modifier = modifier, + contentAlignment = Alignment.TopCenter + ) { + val ctx = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val pdfFile = state.pdfFile + + DisposableEffect(key1 = Unit) { + pdfFile.load( + coroutineScope, + ctx, + state, + constraints.maxWidth, + constraints.maxHeight, + constraints.maxHeight > constraints.maxWidth + ) + onDispose { + state.close() + } + } + val pagerState = rememberPagerState( state.currentPage - 1 ) { state.pdfPageCount } + + state.pdfRender?.let { pdf -> + HorizontalPager( + modifier = Modifier + .fillMaxSize() + .tapToZoomHorizontal(state, constraints), + state = pagerState, + userScrollEnabled = state.scale == 1f + ) { page -> + val pageContent = pdf.pageLists[page].stateFlow.collectAsState().value + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage }.collect { page -> + state.currentPage = page + 1 + } + snapshotFlow { pagerState.isScrollInProgress }.collect { isScrolling -> + state.isScrolling = isScrolling + } + } + + DisposableEffect(key1 = Unit) { + pdf.pageLists[page].load() + onDispose { + pdf.pageLists[page].recycle() + } + } + when (pageContent) { + is PageContentInt.PageContent -> { + PdfImage( + bitmap = { pageContent.bitmap.asImageBitmap() } + ) + } + + is PageContentInt.BlankPage -> BlackPage( + width = pageContent.width, + height = pageContent.height + ) + } + } + } + } +} + +fun Modifier.tapToZoomVertical( + state: VerticalPdfReaderState, + constraints: Constraints +): Modifier = composed( + inspectorInfo = debugInspectorInfo { + name = "verticalTapToZoom" + properties["state"] = state + } +) { + val coroutineScope = rememberCoroutineScope() + this + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { tapCenter -> + if (!state.isZoomEnabled) return@detectTapGestures + if (state.mScale > 1.0f) { + state.mScale = 1.0f + state.offset = Offset(0f, 0f) + } else { + state.mScale = 3.0f + val center = Pair(constraints.maxWidth / 2, constraints.maxHeight / 2) + val xDiff = (tapCenter.x - center.first) * state.scale + val yDiff = ((tapCenter.y - center.second) * state.scale).coerceIn( + minimumValue = -(center.second * 2f), + maximumValue = (center.second * 2f) + ) + state.offset = Offset(-xDiff, -yDiff) + } + } + ) + } + .pointerInput(Unit) { + detectTransformGestures(true) { centroid, pan, zoom, rotation -> + val pair = if (pan.y > 0) { + if (state.lazyState.canScrollBackward) { + Pair(0f, pan.y) + } else { + Pair(pan.y, 0f) + } + } else { + if (state.lazyState.canScrollForward) { + Pair(0f, pan.y) + } else { + Pair(pan.y, 0f) + } + } + val nOffset = if (state.scale > 1f) { + val maxT = (constraints.maxWidth * state.scale) - constraints.maxWidth + val maxY = (constraints.maxHeight * state.scale) - constraints.maxHeight + Offset( + x = (state.offset.x + pan.x).coerceIn( + minimumValue = (-maxT / 2) * 1.3f, + maximumValue = (maxT / 2) * 1.3f + ), + y = (state.offset.y + pair.first).coerceIn( + minimumValue = (-maxY / 2), + maximumValue = (maxY / 2) + ) + ) + } else { + Offset(0f, 0f) + } + state.offset = nOffset + coroutineScope.launch { + state.lazyState.scrollBy((-pair.second / state.scale)) + } + } + } + .graphicsLayer { + scaleX = state.scale + scaleY = state.scale + translationX = state.offset.x + translationY = state.offset.y + } +} + +fun Modifier.tapToZoomHorizontal( + state: HorizontalPdfReaderState, + constraints: Constraints +): Modifier = composed( + inspectorInfo = debugInspectorInfo { + name = "horizontalTapToZoom" + properties["state"] = state + } +) { + this + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { tapCenter -> + if (!state.isZoomEnabled) return@detectTapGestures + if (state.mScale > 1.0f) { + state.mScale = 1.0f + state.offset = Offset(0f, 0f) + } else { + state.mScale = 3.0f + val center = Pair(constraints.maxWidth / 2, constraints.maxHeight / 2) + val xDiff = (tapCenter.x - center.first) * state.scale + val yDiff = ((tapCenter.y - center.second) * state.scale).coerceIn( + minimumValue = -(center.second * 2f), + maximumValue = (center.second * 2f) + ) + state.offset = Offset(-xDiff, -yDiff) + } + } + ) + } + .pointerInput(Unit) { + detectTransformGestures(true) { centroid, pan, zoom, rotation -> + val nOffset = if (state.scale > 1f) { + val maxT = (constraints.maxWidth * state.scale) - constraints.maxWidth + val maxY = (constraints.maxHeight * state.scale) - constraints.maxHeight + Offset( + x = (state.offset.x + pan.x).coerceIn( + minimumValue = (-maxT / 2) * 1.3f, + maximumValue = (maxT / 2) * 1.3f + ), + y = (state.offset.y + pan.y).coerceIn( + minimumValue = (-maxY / 2) * 1.3f, + maximumValue = (maxY / 2) * 1.3f + ) + ) + } else { + Offset(0f, 0f) + } + state.offset = nOffset + } + } + .graphicsLayer { + scaleX = state.scale + scaleY = state.scale + translationX = state.offset.x + translationY = state.offset.y + } +} + +@Composable +fun BlackPage( + width: Int, + height: Int +) { + Box( + modifier = Modifier + .size( + width = width.dp, + height = height.dp + ) + .background(color = Color.White) + ) +} \ No newline at end of file diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/state/HorizontalPdfReaderState.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/state/HorizontalPdfReaderState.kt new file mode 100644 index 000000000..cf4d03a29 --- /dev/null +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/state/HorizontalPdfReaderState.kt @@ -0,0 +1,46 @@ +package de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.state + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.PdfFile + +class HorizontalPdfReaderState( + pdfFile: PdfFile, + isZoomEnabled: Boolean = false +) : PdfReaderState(pdfFile, isZoomEnabled) { + + companion object { + val Saver: Saver = listSaver( + save = { state -> + listOf( + state.pdfFile.link, + state.pdfFile.authToken, + state.pdfFile.filename, + state.isZoomEnabled + ) + }, + restore = { restoredList -> + val pdfFile = PdfFile(restoredList[0] as String, restoredList[1] as String, restoredList[2] as String?) + val isZoomEnabled = restoredList[3] as Boolean + + HorizontalPdfReaderState( + pdfFile = pdfFile, + isZoomEnabled = isZoomEnabled + ) + } + ) + } +} + +@Composable +fun rememberHorizontalPdfReaderState( + pdfFile: PdfFile, + isZoomEnabled: Boolean = true, +): HorizontalPdfReaderState { + return rememberSaveable(saver = HorizontalPdfReaderState.Saver) { + HorizontalPdfReaderState(pdfFile, isZoomEnabled) + } +} + diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/state/PdfReaderState.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/state/PdfReaderState.kt new file mode 100644 index 000000000..ec8cd90b9 --- /dev/null +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/state/PdfReaderState.kt @@ -0,0 +1,55 @@ +package de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.state + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.PdfFile +import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.PdfRendering +import java.io.File + +abstract class PdfReaderState( + val pdfFile: PdfFile, + isZoomEnabled: Boolean = false +) { + internal var mError by mutableStateOf(null) + val error: Throwable? + get() = mError + + private var mIsZoomEnable by mutableStateOf(isZoomEnabled) + val isZoomEnabled: Boolean + get() = mIsZoomEnable + + internal var mScale by mutableFloatStateOf(1f) + val scale: Float + get() = mScale + + internal var offset by mutableStateOf(Offset(0f, 0f)) + + internal var mFile by mutableStateOf(null) + val file: File? + get() = mFile + + internal var pdfRender by mutableStateOf(null) + + internal var mLoadPercent by mutableIntStateOf(0) + val loadPercent: Int + get() = mLoadPercent + + val pdfPageCount: Int + get() = pdfRender?.pageCount ?: 0 + + open var currentPage by mutableIntStateOf(1) + + val isLoaded + get() = mFile != null + + open var isScrolling by mutableStateOf(false) + + fun close() { + pdfRender?.close() + pdfRender = null + } +} \ No newline at end of file diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/state/VerticalPdfReaderState.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/state/VerticalPdfReaderState.kt new file mode 100644 index 000000000..f0c226915 --- /dev/null +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/pdf/render/state/VerticalPdfReaderState.kt @@ -0,0 +1,78 @@ +package de.tum.informatics.www1.artemis.native_app.core.ui.pdf.render.state + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import de.tum.informatics.www1.artemis.native_app.core.ui.pdf.PdfFile + +class VerticalPdfReaderState( + pdfFile: PdfFile, + isZoomEnabled: Boolean = false, +) : PdfReaderState(pdfFile, isZoomEnabled) { + + internal var lazyState: LazyListState = LazyListState() + private set + + override var currentPage: Int = currentPage() + get() = currentPage() + + override var isScrolling: Boolean = lazyState.isScrollInProgress + + private fun currentPage(): Int { + return pdfRender?.let { pdfRender -> + val currentMinIndex = lazyState.firstVisibleItemIndex + var lastVisibleIndex = currentMinIndex + var totalVisiblePortion = + (pdfRender.pageLists[currentMinIndex].dimension.height * scale) - lazyState.firstVisibleItemScrollOffset + for (i in currentMinIndex + 1 until pdfPageCount) { + val newTotalVisiblePortion = + totalVisiblePortion + (pdfRender.pageLists[i].dimension.height * scale) + if (newTotalVisiblePortion <= pdfRender.height) { + lastVisibleIndex = i + totalVisiblePortion = newTotalVisiblePortion + } else { + break + } + } + lastVisibleIndex + 1 + } ?: 0 + } + + companion object { + val Saver: Saver = listSaver( + save = { state -> + listOf( + state.pdfFile.link, + state.pdfFile.authToken, + state.pdfFile.filename, + state.isZoomEnabled, + state.lazyState.firstVisibleItemIndex, + state.lazyState.firstVisibleItemScrollOffset + ) + }, + restore = { restoredList -> + VerticalPdfReaderState( + PdfFile(restoredList[0] as String, restoredList[1] as String, restoredList[2] as String?), + restoredList[3] as Boolean, + ).apply { + lazyState = LazyListState( + firstVisibleItemIndex = restoredList[4] as Int, + firstVisibleItemScrollOffset = restoredList[5] as Int + ) + } + } + ) + } +} + +@Composable +fun rememberVerticalPdfReaderState( + pdfFile: PdfFile, + isZoomEnabled: Boolean = true, +): VerticalPdfReaderState { + return rememberSaveable(saver = VerticalPdfReaderState.Saver) { + VerticalPdfReaderState(pdfFile, isZoomEnabled) + } +} diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/ui_module.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/ui_module.kt index 8ad2090fa..35fd00843 100644 --- a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/ui_module.kt +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/ui_module.kt @@ -1,9 +1,12 @@ package de.tum.informatics.www1.artemis.native_app.core.ui +import de.tum.informatics.www1.artemis.native_app.core.ui.markdown.link_resolving.MarkdownLinkResolver +import de.tum.informatics.www1.artemis.native_app.core.ui.markdown.link_resolving.MarkdownLinkResolverImpl import de.tum.informatics.www1.artemis.native_app.core.ui.remote_images.ArtemisImageProvider import de.tum.informatics.www1.artemis.native_app.core.ui.remote_images.impl.ArtemisImageProviderImpl import org.koin.dsl.module val uiModule = module { single { ArtemisImageProviderImpl(get(), get()) } + single { MarkdownLinkResolverImpl(get(), get()) } } \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/rotate.xml b/core/ui/src/main/res/drawable/rotate.xml new file mode 100644 index 000000000..bca1172a0 --- /dev/null +++ b/core/ui/src/main/res/drawable/rotate.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/core/ui/src/main/res/values/pdf_viewer_strings.xml b/core/ui/src/main/res/values/pdf_viewer_strings.xml new file mode 100644 index 000000000..d1acd8363 --- /dev/null +++ b/core/ui/src/main/res/values/pdf_viewer_strings.xml @@ -0,0 +1,17 @@ + + + + Share PDF + Download PDF + Toggle View Mode + + Page %1d/%2d + + Downloading to Downloads folder... + Share PDF using + + Error while loading PDF + Error: No internet connection + Error while downloading PDF + + \ No newline at end of file diff --git a/core/ui/src/main/res/xml/file_paths.xml b/core/ui/src/main/res/xml/file_paths.xml new file mode 100644 index 000000000..d2e2c8dea --- /dev/null +++ b/core/ui/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ExerciseWebViewUtils.kt b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ExerciseWebViewUtils.kt new file mode 100644 index 000000000..ee2b30aff --- /dev/null +++ b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ExerciseWebViewUtils.kt @@ -0,0 +1,83 @@ +package de.tum.informatics.www1.artemis.native_app.feature.exerciseview + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import com.google.accompanist.web.WebContent +import com.google.accompanist.web.WebViewState +import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ResultTemplateStatus +import io.ktor.http.URLBuilder +import io.ktor.http.appendPathSegments + +@Composable +internal fun getProblemStatementWebViewState( + serverUrl: String, + courseId: Long?, + exerciseId: Long?, + participationId: Long? +): WebViewState? { + val url by remember(serverUrl, courseId, exerciseId) { + derivedStateOf { + if (courseId != null && exerciseId != null) { + URLBuilder(serverUrl).apply { + appendPathSegments( + "courses", + courseId.toString(), + "exercises", + exerciseId.toString(), + "problem-statement" + ) + + if (participationId != null) { + appendPathSegments(participationId.toString()) + } + } + .buildString() + } else null + } + } + + return remember(url) { + derivedStateOf { + url?.let { + WebViewState(WebContent.Url(url = it)) + } + } + }.value +} + +@Composable +internal fun getFeedbackViewWebViewState( + serverUrl: String, + courseId: Long, + exerciseId: Long, + participationId: Long, + resultId: Long, + templateStatus: ResultTemplateStatus +): WebViewState { + val url by remember(serverUrl, courseId, exerciseId, resultId, templateStatus) { + derivedStateOf { + URLBuilder(serverUrl).apply { + appendPathSegments( + "courses", + courseId.toString(), + "exercises", + exerciseId.toString(), + "feedback", + participationId.toString(), + resultId.toString(), + (templateStatus == ResultTemplateStatus.Missing).toString() + ) + }.buildString() + } + } + + return remember(url) { + derivedStateOf { + url.let { + WebViewState(WebContent.Url(url = it)) + } + } + }.value +} \ No newline at end of file diff --git a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/overview/ExerciseOverviewTab.kt b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/overview/ExerciseOverviewTab.kt index 3ba65fbb0..12a2ef7ae 100644 --- a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/overview/ExerciseOverviewTab.kt +++ b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/overview/ExerciseOverviewTab.kt @@ -32,6 +32,7 @@ import com.google.accompanist.web.WebViewState import de.tum.informatics.www1.artemis.native_app.core.model.exercise.Exercise import de.tum.informatics.www1.artemis.native_app.core.model.exercise.QuizExercise import de.tum.informatics.www1.artemis.native_app.core.model.exercise.currentUserPoints +import de.tum.informatics.www1.artemis.native_app.core.ui.compose.ArtemisWebView import de.tum.informatics.www1.artemis.native_app.core.ui.date.getRelativeTime import de.tum.informatics.www1.artemis.native_app.core.ui.date.hasPassed import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ExerciseActions @@ -39,7 +40,6 @@ import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ExerciseCateg import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ExerciseInfoChip import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ExerciseInfoChipTextHorizontalPadding import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ExercisePointsDecimalFormat -import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.ArtemisWebView import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.R import kotlinx.datetime.Instant diff --git a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/participate/textexercise/TextExerciseParticipationScreen.kt b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/participate/textexercise/TextExerciseParticipationScreen.kt index 15cc73b6d..931abb3b3 100644 --- a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/participate/textexercise/TextExerciseParticipationScreen.kt +++ b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/participate/textexercise/TextExerciseParticipationScreen.kt @@ -41,9 +41,9 @@ import de.tum.informatics.www1.artemis.native_app.core.model.exercise.Exercise import de.tum.informatics.www1.artemis.native_app.core.model.exercise.participation.Participation import de.tum.informatics.www1.artemis.native_app.core.model.exercise.participation.isInitializationAfterDueDate import de.tum.informatics.www1.artemis.native_app.core.model.exercise.submission.Result +import de.tum.informatics.www1.artemis.native_app.core.ui.compose.ArtemisWebView import de.tum.informatics.www1.artemis.native_app.core.ui.date.isInFuture import de.tum.informatics.www1.artemis.native_app.core.ui.getWindowSizeClass -import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.ArtemisWebView import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.R import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.getProblemStatementWebViewState diff --git a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/viewresult/ViewResultScreen.kt b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/viewresult/ViewResultScreen.kt index 03cf98a73..20a1fdac8 100644 --- a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/viewresult/ViewResultScreen.kt +++ b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/viewresult/ViewResultScreen.kt @@ -22,9 +22,9 @@ import de.tum.informatics.www1.artemis.native_app.core.data.join import de.tum.informatics.www1.artemis.native_app.core.model.exercise.latestParticipation import de.tum.informatics.www1.artemis.native_app.core.ui.Spacings import de.tum.informatics.www1.artemis.native_app.core.ui.common.EmptyDataStateUi +import de.tum.informatics.www1.artemis.native_app.core.ui.compose.ArtemisWebView import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.LocalTemplateStatusProvider import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ProvideDefaultExerciseTemplateStatus -import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.ArtemisWebView import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.ExerciseViewModel import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.R import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.getFeedbackViewWebViewState