diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 983c34585..f14705a66 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -160,7 +160,6 @@ dependencies { implementation(projects.features.languages) implementation(projects.features.media) implementation(projects.features.navigationSuite) - implementation(projects.features.pdf) implementation(projects.features.resource) implementation(projects.features.settings) implementation(projects.features.share) @@ -172,6 +171,7 @@ dependencies { implementation(projects.services.lessons.impl) implementation(projects.services.media.impl) implementation(projects.services.media.ui) + implementation(projects.services.pdf.impl) implementation(projects.services.prefs.impl) implementation(projects.services.resources.impl) implementation(projects.services.storage.impl) diff --git a/app/src/main/java/com/cryart/sabbathschool/ui/home/HomeActivity.kt b/app/src/main/java/com/cryart/sabbathschool/ui/home/HomeActivity.kt index 1a9dad747..8041f4f81 100644 --- a/app/src/main/java/com/cryart/sabbathschool/ui/home/HomeActivity.kt +++ b/app/src/main/java/com/cryart/sabbathschool/ui/home/HomeActivity.kt @@ -23,9 +23,9 @@ package com.cryart.sabbathschool.ui.home import android.os.Bundle -import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.remember @@ -47,7 +47,7 @@ import ss.services.circuit.impl.interceptor.AndroidSupportingInterceptor import javax.inject.Inject @AndroidEntryPoint -class HomeActivity : ComponentActivity() { +class HomeActivity : AppCompatActivity() { @Inject lateinit var circuit: Circuit diff --git a/common/design/src/main/res/values/colors.xml b/common/design/src/main/res/values/colors.xml index 277b5466a..35b1aae40 100644 --- a/common/design/src/main/res/values/colors.xml +++ b/common/design/src/main/res/values/colors.xml @@ -38,6 +38,7 @@ #FFFFFF #000000 #66000000 + #FDF4E6 #80D7D7D7 #EFEFEF diff --git a/common/translations/src/main/res/values/strings.xml b/common/translations/src/main/res/values/strings.xml index 99492f295..e003ff45e 100644 --- a/common/translations/src/main/res/values/strings.xml +++ b/common/translations/src/main/res/values/strings.xml @@ -151,4 +151,5 @@ Share Downloading… Off + Annotations \ No newline at end of file diff --git a/features/document/build.gradle.kts b/features/document/build.gradle.kts index 5db54e568..21e6d5279 100644 --- a/features/document/build.gradle.kts +++ b/features/document/build.gradle.kts @@ -45,11 +45,14 @@ ksp { } dependencies { + implementation(libs.androidx.activity.compose) implementation(libs.coil.compose) implementation(libs.google.hilt.android) implementation(libs.joda.time) implementation(libs.kotlinx.collectionsImmutable) + implementation(libs.nutrient) implementation(libs.timber) + implementation(projects.common.design) implementation(projects.common.designCompose) implementation(projects.common.misc) implementation(projects.common.translations) diff --git a/features/document/src/main/kotlin/ss/document/DocumentPresenter.kt b/features/document/src/main/kotlin/ss/document/DocumentPresenter.kt index 2b88d7217..c465050ae 100644 --- a/features/document/src/main/kotlin/ss/document/DocumentPresenter.kt +++ b/features/document/src/main/kotlin/ss/document/DocumentPresenter.kt @@ -23,14 +23,11 @@ package ss.document import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.Snapshot import androidx.core.net.toUri -import app.ss.models.PDFAux import app.ss.models.media.AudioFile import app.ss.models.media.SSVideo import com.slack.circuit.codegen.annotations.CircuitInject @@ -66,7 +63,6 @@ import ss.document.producer.UserInputStateProducer import ss.document.segment.producer.SegmentOverlayStateProducer import ss.libraries.circuit.navigation.DocumentScreen import ss.libraries.circuit.navigation.ExpandedAudioPlayerScreen -import ss.libraries.circuit.navigation.PdfScreen import ss.libraries.circuit.navigation.ResourceScreen import ss.libraries.media.api.MediaNavigation import ss.libraries.media.api.SSMediaPlayer @@ -75,7 +71,6 @@ import ss.libraries.media.model.SSMediaItem import ss.libraries.media.model.extensions.NONE_PLAYING import ss.libraries.media.service.MusicService import ss.libraries.media.service.VideoService -import ss.libraries.pdf.api.PdfReader import ss.misc.DateHelper import ss.resources.api.ResourcesRepository import ss.services.media.ui.PlaybackConnection @@ -93,7 +88,6 @@ class DocumentPresenter @AssistedInject constructor( private val readerStyleStateProducer: ReaderStyleStateProducer, private val segmentOverlayStateProducer: SegmentOverlayStateProducer, private val userInputStateProducer: UserInputStateProducer, - private val pdfReader: PdfReader, private val playbackConnection: PlaybackConnection, private val mediaNavigation: MediaNavigation, private val mediaPlayer: SSMediaPlayer, @@ -111,8 +105,6 @@ class DocumentPresenter @AssistedInject constructor( val resourceDocument = response - LaunchedEffect(resourceDocument) { checkPdfOnlySegment(resourceDocument) } - val actionsState = resourceDocument?.let { actionsProducer( navigator = navigator, @@ -292,37 +284,6 @@ class DocumentPresenter @AssistedInject constructor( } ?: firstOrNull() } - private fun checkPdfOnlySegment(resourceDocument: ResourceDocument?) { - val document = resourceDocument ?: return - val segments = document.segments ?: return - val blocks = segments.flatMap { it.blocks.orEmpty() } - val pdfs = segments.flatMap { it.pdf.orEmpty() } - - if (blocks.isEmpty() && pdfs.isNotEmpty()) { - val pdfs = segments.flatMap { it.pdf.orEmpty() } - val screen = PdfScreen( - documentId = document.id, - resourceId = document.resourceId, - resourceIndex = document.resourceIndex, - documentIndex = document.index, - segmentId = null, - pdfs = pdfs.map { - PDFAux( - id = it.id, - src = it.src, - title = it.title, - target = it.target, - targetIndex = it.targetIndex, - ) - }, - ) - Snapshot.withMutableSnapshot { - navigator.pop() - navigator.goTo(IntentScreen(pdfReader.launchIntent(screen))) - } - } - } - private fun BlockItem.Video.toSSVideo( resource: Resource?, document: ResourceDocument?, diff --git a/features/document/src/main/kotlin/ss/document/DocumentScreenUi.kt b/features/document/src/main/kotlin/ss/document/DocumentScreenUi.kt index d6f4bb127..5a4a4b8bf 100644 --- a/features/document/src/main/kotlin/ss/document/DocumentScreenUi.kt +++ b/features/document/src/main/kotlin/ss/document/DocumentScreenUi.kt @@ -291,11 +291,10 @@ private fun State.showTopBar(collapsed: Boolean): Boolean = when (this) { is State.Success -> when (selectedSegment?.type) { SegmentType.VIDEO -> false SegmentType.STORY -> collapsed - SegmentType.PDF, SegmentType.UNKNOWN, SegmentType.BLOCK, - null -> true + SegmentType.PDF -> false } } diff --git a/features/document/src/main/kotlin/ss/document/components/DocumentTopAppBar.kt b/features/document/src/main/kotlin/ss/document/components/DocumentTopAppBar.kt index 8f5a8de3d..6ddb979ae 100644 --- a/features/document/src/main/kotlin/ss/document/components/DocumentTopAppBar.kt +++ b/features/document/src/main/kotlin/ss/document/components/DocumentTopAppBar.kt @@ -42,7 +42,6 @@ import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -52,6 +51,7 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable @@ -73,14 +73,13 @@ import app.ss.design.compose.extensions.haptics.LocalSsHapticFeedback import app.ss.design.compose.theme.SsTheme import app.ss.design.compose.widget.icon.IconBox import app.ss.design.compose.widget.icon.IconButtonResSlot -import app.ss.design.compose.widget.icon.IconButtonSlot import app.ss.design.compose.widget.icon.Icons import io.adventech.blockkit.model.resource.Segment import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import ss.misc.DateHelper -import androidx.compose.material.icons.Icons as MaterialIcons import app.ss.translations.R as L10nR +import com.pspdfkit.R as PspdfR import ss.document.R as DocumentR import ss.libraries.media.resources.R as MediaR @@ -113,6 +112,21 @@ enum class DocumentTopAppBarAction( iconRes = DocumentR.drawable.ic_text_format, title = L10nR.string.ss_settings_display_options, primary = false, + ), + Annotations( + iconRes = DocumentR.drawable.ic_pdf_annotations, + title = L10nR.string.ss_annotations, + primary = true, + ), + Outline( + iconRes = DocumentR.drawable.ic_pdf_bookmark, + title = PspdfR.string.pspdf__activity_menu_outline, + primary = true, + ), + Settings( + iconRes = DocumentR.drawable.ic_pdf_settings, + title = PspdfR.string.pspdf__activity_menu_settings, + primary = true, ) } @@ -126,11 +140,21 @@ internal fun DocumentTopAppBar( contentColor: Color = SsTheme.colors.primaryForeground, scrollBehavior: TopAppBarScrollBehavior? = null, actions: ImmutableList = persistentListOf(), + colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent, + ), onNavBack: () -> Unit = {}, onActionClick: (DocumentTopAppBarAction) -> Unit = {}, ) { val hapticFeedback = LocalSsHapticFeedback.current var expanded by remember { mutableStateOf(false) } + + val primaryActions = remember(actions) { actions.filter { it.primary } } + val nonPrimaryActions = remember(actions) { actions.filter { !it.primary } } + val visibleActions = remember(primaryActions) { primaryActions.take(2) } + val overflowActions = remember(primaryActions, nonPrimaryActions) { primaryActions.drop(2) + nonPrimaryActions } + Box( modifier = Modifier .fillMaxWidth() @@ -144,7 +168,7 @@ internal fun DocumentTopAppBar( shape = RoundedCornerShape(16.dp), containerColor = SsTheme.colors.primaryBackground, ) { - actions.filter { !it.primary }.forEach { action -> + overflowActions.forEach { action -> DropdownMenuItem( text = { Text( @@ -219,7 +243,8 @@ internal fun DocumentTopAppBar( }, actions = { buildList { - actions.filter { it.primary }.forEach { action -> + // Add the visible primary actions (up to 2) + visibleActions.forEach { action -> add( IconButtonResSlot( iconRes = action.iconRes, @@ -228,10 +253,12 @@ internal fun DocumentTopAppBar( ) ) } - if (actions.any { !it.primary }) { + + // Add the "More" icon if there are any secondary or overflowed primary actions + if (overflowActions.isNotEmpty()) { add( - IconButtonSlot( - imageVector = MaterialIcons.Rounded.MoreVert, + IconButtonResSlot( + iconRes = DocumentR.drawable.ic_more_vert, contentDescription = stringResource(L10nR.string.ss_more), onClick = { expanded = true @@ -242,12 +269,7 @@ internal fun DocumentTopAppBar( } }.forEach { icon -> val iconColor by topAppBarContentColor(collapsible, collapsed, contentColor) - val onClick = (icon as? IconButtonSlot)?.onClick ?: (icon as? IconButtonResSlot)?.onClick - IconButton( - onClick = { - onClick?.invoke() - }, - ) { + IconButton(onClick = { icon.onClick() }) { IconBox( icon = icon, contentColor = iconColor, @@ -256,10 +278,7 @@ internal fun DocumentTopAppBar( } }, scrollBehavior = scrollBehavior, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent, - scrolledContainerColor = Color.Transparent, - ) + colors = colors ) } diff --git a/features/document/src/main/kotlin/ss/document/di/BindingsModule.kt b/features/document/src/main/kotlin/ss/document/di/BindingsModule.kt index 133f81bbe..0acaf16ab 100644 --- a/features/document/src/main/kotlin/ss/document/di/BindingsModule.kt +++ b/features/document/src/main/kotlin/ss/document/di/BindingsModule.kt @@ -34,6 +34,8 @@ import ss.document.producer.TopAppbarActionsProducer import ss.document.producer.TopAppbarActionsProducerImpl import ss.document.producer.UserInputStateProducer import ss.document.producer.UserInputStateProducerImpl +import ss.document.segment.components.pdf.PdfTopAppBarStateProducer +import ss.document.segment.components.pdf.PdfTopAppBarStateProducerImpl import ss.document.segment.producer.OverlayStateProducerImpl import ss.document.segment.producer.SegmentOverlayStateProducer @@ -54,4 +56,7 @@ internal abstract class BindingsModule { @Binds internal abstract fun bindUserInputStateProducer(impl: UserInputStateProducerImpl): UserInputStateProducer + + @Binds + internal abstract fun bindPdfTopAppBarStateProducer(impl: PdfTopAppBarStateProducerImpl): PdfTopAppBarStateProducer } diff --git a/features/document/src/main/kotlin/ss/document/producer/TopAppbarActionsProducer.kt b/features/document/src/main/kotlin/ss/document/producer/TopAppbarActionsProducer.kt index 98f060446..6f3664900 100644 --- a/features/document/src/main/kotlin/ss/document/producer/TopAppbarActionsProducer.kt +++ b/features/document/src/main/kotlin/ss/document/producer/TopAppbarActionsProducer.kt @@ -27,14 +27,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import app.ss.models.PDFAux import com.slack.circuit.retained.produceRetainedState import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.Navigator -import com.slack.circuitx.android.IntentScreen import dagger.Lazy import io.adventech.blockkit.model.resource.Segment import io.adventech.blockkit.model.resource.SegmentType @@ -52,7 +50,6 @@ import ss.libraries.circuit.navigation.AudioPlayerScreen import ss.libraries.circuit.navigation.PdfScreen import ss.libraries.circuit.navigation.ShareOptionsScreen import ss.libraries.circuit.navigation.VideosScreen -import ss.libraries.pdf.api.PdfReader import ss.resources.api.ResourcesRepository import javax.inject.Inject @@ -92,7 +89,6 @@ interface TopAppbarActionsProducer { internal class TopAppbarActionsProducerImpl @Inject constructor( private val repository: ResourcesRepository, - private val pdfReader: PdfReader, private val shareIntentHelper: Lazy, ) : TopAppbarActionsProducer { @@ -118,7 +114,7 @@ internal class TopAppbarActionsProducerImpl @Inject constructor( if (segment?.type == SegmentType.PDF) return@produceRetainedState value = repository.pdf(resourceIndex, documentIndex).getOrNull().orEmpty() } - val actions = remember(audio, video, pdfs, segment, shareOptions) { + val actions = rememberRetained(audio, video, pdfs, segment, shareOptions) { buildList { if (audio.isNotEmpty()) { add(DocumentTopAppBarAction.Audio) @@ -184,7 +180,7 @@ internal class TopAppbarActionsProducerImpl @Inject constructor( ) }, ) - navigator.goTo(IntentScreen(pdfReader.launchIntent(screen))) + navigator.goTo(screen) } DocumentTopAppBarAction.DisplayOptions -> { bottomSheetState = BottomSheet( @@ -217,6 +213,7 @@ internal class TopAppbarActionsProducerImpl @Inject constructor( } } } + else -> Unit } } } diff --git a/features/document/src/main/kotlin/ss/document/segment/components/pdf/PdfTopAppBarStateProducer.kt b/features/document/src/main/kotlin/ss/document/segment/components/pdf/PdfTopAppBarStateProducer.kt new file mode 100644 index 000000000..39c03290a --- /dev/null +++ b/features/document/src/main/kotlin/ss/document/segment/components/pdf/PdfTopAppBarStateProducer.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026. Adventech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package ss.document.segment.components.pdf + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import app.ss.models.media.MediaAvailability +import com.slack.circuit.retained.produceRetainedState +import com.slack.circuit.runtime.CircuitUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import ss.document.components.DocumentTopAppBarAction +import ss.libraries.circuit.navigation.PdfScreen +import ss.resources.api.ResourcesRepository +import javax.inject.Inject + +@Stable +interface PdfTopAppBarStateProducer { + @Composable + operator fun invoke(screen: PdfScreen): PdfTopAppBarState +} + +@Immutable +data class PdfTopAppBarState( + val actions: ImmutableList, +): CircuitUiState + +class PdfTopAppBarStateProducerImpl @Inject constructor( + private val resourcesRepository: ResourcesRepository, +) : PdfTopAppBarStateProducer { + @Composable + override fun invoke(screen: PdfScreen): PdfTopAppBarState { + val mediaAvailability by rememberMediaAvailability(screen) + + val actions by produceRetainedState(persistentListOf(), mediaAvailability) { + value = buildList { + if (mediaAvailability.audio) { + add(DocumentTopAppBarAction.Audio) + } + if (mediaAvailability.video) { + add(DocumentTopAppBarAction.Video) + } + add(DocumentTopAppBarAction.Annotations) + add(DocumentTopAppBarAction.Outline) + add(DocumentTopAppBarAction.Settings) + }.toImmutableList() + } + + return PdfTopAppBarState(actions) + } + + @Composable + private fun rememberMediaAvailability(screen: PdfScreen): State = produceRetainedState(MediaAvailability()) { + val documentIndex = screen.documentIndex + val resourceIndex = screen.resourceIndex + + val audioDeferred = contentDeferred { resourcesRepository.audio(resourceIndex, documentIndex) } + val videoDeferred = contentDeferred { resourcesRepository.video(resourceIndex, documentIndex) } + + value = MediaAvailability( + audio = audioDeferred.await(), + video = videoDeferred.await(), + ) + } + + private fun CoroutineScope.contentDeferred(content: suspend () -> Result>): Deferred { + return async { content().getOrDefault(emptyList()).isNotEmpty() } + } +} diff --git a/features/document/src/main/kotlin/ss/document/segment/components/pdf/PdfUi.kt b/features/document/src/main/kotlin/ss/document/segment/components/pdf/PdfUi.kt new file mode 100644 index 000000000..c347aa3e9 --- /dev/null +++ b/features/document/src/main/kotlin/ss/document/segment/components/pdf/PdfUi.kt @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2026. Adventech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package ss.document.segment.components.pdf + +import android.content.Context +import androidx.activity.compose.LocalActivity +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.Modifier +import androidx.compose.ui.layout.onVisibilityChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import app.ss.design.compose.widget.scaffold.LocalNavbarController +import com.pspdfkit.annotations.AnnotationType +import com.pspdfkit.configuration.activity.PdfActivityConfiguration +import com.pspdfkit.configuration.activity.ThumbnailBarMode +import com.pspdfkit.configuration.activity.UserInterfaceViewMode +import com.pspdfkit.configuration.page.PageFitMode +import com.pspdfkit.configuration.settings.SettingsMenuItemType +import com.pspdfkit.configuration.sharing.ShareFeatures +import com.pspdfkit.document.PdfDocument +import com.pspdfkit.jetpack.compose.interactors.DocumentListener +import com.pspdfkit.jetpack.compose.interactors.DocumentState +import com.pspdfkit.jetpack.compose.interactors.getDefaultDocumentManager +import com.pspdfkit.jetpack.compose.interactors.rememberDocumentState +import com.pspdfkit.jetpack.compose.views.DocumentView +import io.adventech.blockkit.model.input.PDFAuxAnnotations +import io.adventech.blockkit.ui.style.LocalReaderStyle +import io.adventech.blockkit.ui.style.background +import io.adventech.blockkit.ui.style.primaryForeground +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.launch +import ss.document.components.DocumentTopAppBar +import ss.document.components.DocumentTopAppBarAction +import ss.libraries.pdf.api.LocalFile +import java.util.EnumSet +import com.pspdfkit.R as PspdfR + +@Composable +fun PdfUi( + document: PdfDocumentState, + topAppBarState: PdfTopAppBarState, + config: PdfReaderConfig, + modifier: Modifier = Modifier, + title: @Composable () -> Unit = { Text(document.file.title) }, + eventSink: (ReadPdfEvent) -> Unit = {}, +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val documentUri = document.file.uri + val pdfActivityConfiguration = rememberPdfConfiguration(context, document.file, config) + var loadedDocument by remember { mutableStateOf(null) } + + val documentState = rememberDocumentState(documentUri, pdfActivityConfiguration) + + val bottomPadding by animateDpAsState(if (LocalNavbarController.current.enabled) 120.dp else 0.dp) + + Column(modifier = modifier.fillMaxSize()) { + PdfTopAppBar( + title = title, + state = topAppBarState, + documentState = documentState, + eventSink = eventSink, + ) + + val activity = LocalActivity.current as? FragmentActivity + + DocumentView( + documentState = documentState, + modifier = Modifier + .weight(1f) + .padding(bottom = bottomPadding) + .onVisibilityChanged { visible -> + if (!visible) { // Save configuration once the document is not visible + val pdfFragment = activity?.supportFragmentManager?.findPdfFragment() + coroutineScope.launch { + val annotations = pdfFragment?.document?.annotationProvider?.getAllAnnotationsOfType(allowedAnnotations.toSet()) + pdfFragment?.let { + eventSink( + ReadPdfEvent.OnDocumentHidden( + config = it.configuration, + annotations = annotations, + pdfId = document.pdfId, + ) + ) + } + } + } + }, + documentManager = getDefaultDocumentManager( + documentListener = DocumentListener(onDocumentLoaded = { pdfDoc -> + // set annotations + coroutineScope.launch { pdfDoc.loadAnnotations(document.annotations) } + + loadedDocument = pdfDoc + }), + ), + ) + } + + LaunchedEffect(document.annotations, loadedDocument) { + loadedDocument?.loadAnnotations(document.annotations) + } +} + +private suspend fun PdfDocument.loadAnnotations(annotations: ImmutableList) { + with(annotationProvider) { + // Remove existing + val existingAnnotations = annotationProvider + .getAllAnnotationsOfType(allowedAnnotations.toSet()) + existingAnnotations.forEach { removeAnnotationFromPage(it) } + + // Add annotations + annotations.flatMap { it.annotations } + .forEach { createAnnotationFromInstantJson(it) } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PdfTopAppBar( + title: @Composable () -> Unit, + state: PdfTopAppBarState, + documentState: DocumentState, + eventSink: (ReadPdfEvent) -> Unit, + modifier: Modifier = Modifier, +) { + val readerTheme = LocalReaderStyle.current.theme + + DocumentTopAppBar( + title = title, + modifier = modifier, + collapsed = true, + contentColor = readerTheme.primaryForeground(), + actions = state.actions, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = readerTheme.background(), + navigationIconContentColor = readerTheme.primaryForeground(), + actionIconContentColor = readerTheme.primaryForeground(), + titleContentColor = readerTheme.primaryForeground(), + ), + onNavBack = { eventSink(ReadPdfEvent.OnNavBack) }, + onActionClick = { action -> + when (action) { + DocumentTopAppBarAction.Annotations -> documentState.toggleView(PspdfR.id.pspdf__menu_option_edit_annotations) + DocumentTopAppBarAction.Outline -> documentState.toggleView(PspdfR.id.pspdf__menu_option_outline) + DocumentTopAppBarAction.Settings -> documentState.toggleView(PspdfR.id.pspdf__menu_option_settings) + else -> eventSink(ReadPdfEvent.OnTopAppBarAction(action)) + } + } + ) +} + +@Composable +private fun rememberPdfConfiguration( + context: Context, + file: LocalFile, + config: PdfReaderConfig, +) = remember(file, config) { + val excludedAnnotationTypes = ArrayList(EnumSet.allOf(AnnotationType::class.java)) + allowedAnnotations.forEach { excludedAnnotationTypes.remove(it) } + + PdfActivityConfiguration + .Builder(context) + .setUserInterfaceViewMode(UserInterfaceViewMode.USER_INTERFACE_VIEW_MODE_VISIBLE) + .defaultToolbarEnabled(false) + .title(file.title) + .scrollMode(config.scrollMode) + .layoutMode(config.layoutMode) + .scrollDirection(config.scrollDirection) + .themeMode(config.themeMode) + .fitMode(PageFitMode.FIT_TO_WIDTH) + .animateScrollOnEdgeTaps(true) + .excludedAnnotationTypes(excludedAnnotationTypes) + .setEnabledShareFeatures(EnumSet.noneOf(ShareFeatures::class.java)) + .setThumbnailBarMode(ThumbnailBarMode.THUMBNAIL_BAR_MODE_NONE) + .setSettingsMenuItems(EnumSet.allOf(SettingsMenuItemType::class.java)) + .build() +} + +private val allowedAnnotations = listOf( + AnnotationType.HIGHLIGHT, + AnnotationType.INK, + AnnotationType.NOTE, + AnnotationType.WATERMARK, + AnnotationType.STRIKEOUT, + AnnotationType.FREETEXT, + AnnotationType.UNDERLINE, +) + +// I know :-( +private fun FragmentManager.findPdfFragment(): com.pspdfkit.ui.PdfFragment? { + for (fragment in fragments) { + if (fragment is com.pspdfkit.ui.PdfFragment) return fragment + + // Recursively search child fragments + val child = fragment.childFragmentManager.findPdfFragment() + if (child != null) return child + } + return null +} diff --git a/features/document/src/main/kotlin/ss/document/segment/components/pdf/ReadPdfPresenter.kt b/features/document/src/main/kotlin/ss/document/segment/components/pdf/ReadPdfPresenter.kt new file mode 100644 index 000000000..4517d7deb --- /dev/null +++ b/features/document/src/main/kotlin/ss/document/segment/components/pdf/ReadPdfPresenter.kt @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2026. Adventech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package ss.document.segment.components.pdf + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.pspdfkit.annotations.Annotation +import com.pspdfkit.configuration.page.PageLayoutMode +import com.pspdfkit.configuration.page.PageScrollDirection +import com.pspdfkit.configuration.page.PageScrollMode +import com.pspdfkit.configuration.theming.ThemeMode +import com.slack.circuit.codegen.annotations.CircuitInject +import com.slack.circuit.foundation.NavEvent +import com.slack.circuit.foundation.onNavEvent +import com.slack.circuit.retained.produceRetainedState +import com.slack.circuit.retained.rememberRetained +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.presenter.Presenter +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.components.SingletonComponent +import io.adventech.blockkit.model.input.PDFAuxAnnotations +import io.adventech.blockkit.model.input.UserInput +import io.adventech.blockkit.model.input.UserInputRequest +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import ss.document.components.DocumentTopAppBarAction +import ss.foundation.coroutines.DispatcherProvider +import ss.libraries.circuit.navigation.AudioPlayerScreen +import ss.libraries.circuit.navigation.ExpandedAudioPlayerScreen +import ss.libraries.circuit.navigation.PdfScreen +import ss.libraries.circuit.navigation.VideosScreen +import ss.libraries.pdf.api.LocalFile +import ss.libraries.pdf.api.PdfReader +import ss.libraries.pdf.api.PdfReaderPrefs +import ss.resources.api.ResourcesRepository +import timber.log.Timber + +class ReadPdfPresenter @AssistedInject constructor( + @Assisted private val navigator: Navigator, + @Assisted private val screen: PdfScreen, + private val pdfReader: PdfReader, + private val pdfReaderPrefs: PdfReaderPrefs, + private val resourcesRepository: ResourcesRepository, + private val topAppBarStateProducer: PdfTopAppBarStateProducer, + private val dispatcherProvider: DispatcherProvider, +) : Presenter { + + @Composable + override fun present(): ReadPdfState { + val documents by rememberFiles() + val annotationsMap by rememberDocumentAnnotations() + val topAppBarState = topAppBarStateProducer(screen) + val config by rememberPdfReaderConfig() + var overlayState by rememberRetained { mutableStateOf(ReadPdfOverlayState.None) } + val documentsState = rememberRetained(documents, annotationsMap, config) { + documents.mapIndexed { index, file -> + val pdfId = screen.pdfs.getOrNull(index)?.id.orEmpty() + PdfDocumentState( + pdfId = pdfId, + file = file, + annotations = annotationsMap.getOrDefault(index, emptyList()).toImmutableList(), + ) + }.toImmutableList() + } + + fun showAudioScreen() { + overlayState = ReadPdfOverlayState.BottomSheet( + screen = AudioPlayerScreen(resourceId = screen.resourceId, segmentId = screen.segmentId), + skipPartiallyExpanded = true, + onResult = { _ -> overlayState = ReadPdfOverlayState.None } + ) + } + + fun showVideoScreen() { + overlayState = ReadPdfOverlayState.BottomSheet( + screen = VideosScreen(documentIndex = screen.documentIndex, documentId = screen.documentId), + skipPartiallyExpanded = true, + onResult = { _ -> overlayState = ReadPdfOverlayState.None } + ) + } + + return when { + documents.isNotEmpty() -> ReadPdfState.Success( + documents = documentsState, + topAppBarState = topAppBarState, + config = config, + overlayState = overlayState, + eventSink = { event -> + when (event) { + ReadPdfEvent.OnNavBack -> navigator.pop() + is ReadPdfEvent.OnNavEvent -> { + when (val navEvent = event.event) { + is NavEvent.GoTo -> { + if (navEvent.screen is ExpandedAudioPlayerScreen) { + showAudioScreen() + } else { + navigator.goTo(navEvent.screen) + } + } + + else -> navigator.onNavEvent(navEvent) + } + } + + is ReadPdfEvent.OnTopAppBarAction -> { + when (event.action) { + DocumentTopAppBarAction.Audio -> showAudioScreen() + DocumentTopAppBarAction.Video -> showVideoScreen() + // Everything else is not handled here + else -> Unit + } + } + + is ReadPdfEvent.OnDocumentHidden -> { + pdfReaderPrefs.saveConfiguration(event.config) + + event.annotations?.let { saveAnnotations(pdfId = event.pdfId, annotations = it) } + } + } + }, + ) + else -> ReadPdfState.Loading + } + } + + @Composable + private fun rememberFiles(): State> = produceRetainedState>(persistentListOf()) { + val result = pdfReader.downloadFiles(screen.pdfs) + value = if (result.isSuccess) { + result.getOrDefault(emptyList()).toImmutableList() + } else { + persistentListOf() + } + } + + @Composable + private fun rememberPdfReaderConfig(): State = produceRetainedState( + PdfReaderConfig( + scrollMode = PageScrollMode.CONTINUOUS, + layoutMode = PageLayoutMode.SINGLE, + scrollDirection = PageScrollDirection.VERTICAL, + themeMode = ThemeMode.DEFAULT, + ) + ) { + combine( + pdfReaderPrefs.scrollMode(), + pdfReaderPrefs.pageLayoutMode(), + pdfReaderPrefs.scrollDirection(), + pdfReaderPrefs.themeMode() + ) { scrollMode, layoutMode, scrollDirection, themeMode -> + PdfReaderConfig( + scrollMode = scrollMode, + layoutMode = layoutMode, + scrollDirection = scrollDirection, + themeMode = themeMode, + ) + }.collect { value = it } + } + + @Composable + private fun rememberDocumentAnnotations(): State>> = produceRetainedState(emptyMap()) { + resourcesRepository.documentInput(screen.documentId) + .map { userInputs -> + userInputs.asSequence() + .mapNotNull { it as? UserInput.Annotation } + .toList() + } + .map { input -> + val inputByPdfId = input.groupBy { it.pdfId } + val pdfs = screen.pdfs + pdfs.mapIndexed { index, pdf -> + index to (inputByPdfId[pdf.id]?.flatMap { it.data } ?: emptyList()) + }.toMap() + } + .catch { Timber.e(it) } + .flowOn(dispatcherProvider.default) + .collect { value = it } + } + + private fun saveAnnotations(pdfId: String, annotations: List) { + if (pdfId.isEmpty()) return + + val documentId = screen.documentId + val syncAnnotations = annotations.toSync() + + val userInput = UserInputRequest.Annotation( + blockId = pdfId, + pdfId = pdfId, + data = syncAnnotations + ) + + resourcesRepository.saveDocumentInput(documentId, userInput) + } + + private fun List.toSync(): List { + val groupedAnnotations = groupBy { it.pageIndex } + return groupedAnnotations.map { (pageIndex, list) -> + val annotations = list.map { it.toInstantJson() }.filter(::isValidInstantJson) + PDFAuxAnnotations(pageIndex, annotations) + } + } + + + private fun isValidInstantJson(json: String) = json != "null" + + @CircuitInject(PdfScreen::class, SingletonComponent::class) + @AssistedFactory + interface Factory { + fun create(navigator: Navigator, screen: PdfScreen): ReadPdfPresenter + } +} diff --git a/features/document/src/main/kotlin/ss/document/segment/components/pdf/ReadPdfState.kt b/features/document/src/main/kotlin/ss/document/segment/components/pdf/ReadPdfState.kt new file mode 100644 index 000000000..abd9beef7 --- /dev/null +++ b/features/document/src/main/kotlin/ss/document/segment/components/pdf/ReadPdfState.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026. Adventech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package ss.document.segment.components.pdf + +import android.content.Context +import androidx.compose.runtime.Immutable +import com.pspdfkit.annotations.Annotation +import com.pspdfkit.configuration.PdfConfiguration +import com.pspdfkit.configuration.page.PageLayoutMode +import com.pspdfkit.configuration.page.PageScrollDirection +import com.pspdfkit.configuration.page.PageScrollMode +import com.pspdfkit.configuration.theming.ThemeMode +import com.slack.circuit.foundation.NavEvent +import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.screen.Screen +import io.adventech.blockkit.model.input.PDFAuxAnnotations +import kotlinx.collections.immutable.ImmutableList +import ss.document.components.DocumentTopAppBarAction +import ss.libraries.circuit.overlay.BottomSheetOverlay +import ss.libraries.pdf.api.LocalFile + +sealed interface ReadPdfState : CircuitUiState { + data object Loading : ReadPdfState + + data class Success( + val documents: ImmutableList, + val config: PdfReaderConfig, + val topAppBarState: PdfTopAppBarState, + val overlayState: ReadPdfOverlayState, + val eventSink: (ReadPdfEvent) -> Unit, + ) : ReadPdfState +} + +sealed interface ReadPdfEvent { + data object OnNavBack : ReadPdfEvent + data class OnNavEvent(val event: NavEvent, val context: Context) : ReadPdfEvent + data class OnTopAppBarAction(val action: DocumentTopAppBarAction): ReadPdfEvent + data class OnDocumentHidden( + val config: PdfConfiguration, + val annotations: List?, + val pdfId: String, + ) : ReadPdfEvent +} + +sealed interface ReadPdfOverlayState : CircuitUiState { + + data object None : ReadPdfOverlayState + + /** Overlay state for a bottom sheet. */ + @Immutable + data class BottomSheet( + val screen: Screen, + val skipPartiallyExpanded: Boolean, + val onResult: (BottomSheetOverlay.Result) -> Unit, + ) : ReadPdfOverlayState +} + +@Immutable +data class PdfDocumentState( + val pdfId: String, + val file: LocalFile, + val annotations: ImmutableList, +) + +@Immutable +data class PdfReaderConfig( + val scrollMode: PageScrollMode, + val layoutMode: PageLayoutMode, + val scrollDirection: PageScrollDirection, + val themeMode: ThemeMode, +) + diff --git a/features/document/src/main/kotlin/ss/document/segment/components/pdf/ReadPdfUi.kt b/features/document/src/main/kotlin/ss/document/segment/components/pdf/ReadPdfUi.kt new file mode 100644 index 000000000..6513b7931 --- /dev/null +++ b/features/document/src/main/kotlin/ss/document/segment/components/pdf/ReadPdfUi.kt @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2026. Adventech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package ss.document.segment.components.pdf + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import app.ss.design.compose.extensions.haptics.LocalSsHapticFeedback +import app.ss.design.compose.theme.SsTheme +import app.ss.design.compose.widget.icon.IconBox +import app.ss.design.compose.widget.icon.Icons +import com.slack.circuit.codegen.annotations.CircuitInject +import com.slack.circuit.foundation.CircuitContent +import com.slack.circuit.foundation.NavEvent +import com.slack.circuit.overlay.ContentWithOverlays +import com.slack.circuit.overlay.OverlayEffect +import dagger.hilt.components.SingletonComponent +import io.adventech.blockkit.ui.style.LocalReaderStyle +import io.adventech.blockkit.ui.style.primaryForeground +import kotlinx.coroutines.launch +import ss.libraries.circuit.navigation.PdfScreen +import ss.libraries.circuit.overlay.BottomSheetOverlay + +@CircuitInject(PdfScreen::class, SingletonComponent::class) +@Composable +fun ReadPdfUi(state: ReadPdfState, modifier: Modifier = Modifier) { + when (state) { + ReadPdfState.Loading -> Surface { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + is ReadPdfState.Success -> ReadPdfSuccessUi(state, modifier) + } +} + +@Composable +private fun ReadPdfSuccessUi(state: ReadPdfState.Success, modifier: Modifier = Modifier) { + val pagerState = rememberPagerState( + pageCount = { state.documents.size }, + ) + val readerStyle = LocalReaderStyle.current + val coroutineScope = rememberCoroutineScope() + val hapticFeedback = LocalSsHapticFeedback.current + val context = LocalContext.current + + HorizontalPager( + state = pagerState, + modifier = modifier.fillMaxSize(), + verticalAlignment = Alignment.Top, + beyondViewportPageCount = 0, + ) { page -> + val document = state.documents[page] + var expanded by remember { mutableStateOf(false) } + + PdfUi( + document = document, + topAppBarState = state.topAppBarState, + config = state.config, + modifier = Modifier, + title = { + val hasMultipleDocs = state.documents.size > 1 + Row( + modifier = if (hasMultipleDocs) { + Modifier + .sizeIn(minHeight = 48.dp) + .padding(horizontal = 6.dp, vertical = 2.dp) + .clip(RoundedCornerShape(12.dp)) + .clickable { + expanded = true + hapticFeedback.performClick() + } + .padding(horizontal = 12.dp, vertical = 12.dp) + } else { + Modifier + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = document.file.title, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (hasMultipleDocs) { + + IconBox( + icon = Icons.ArrowDropDown, + contentColor = readerStyle.theme.primaryForeground(), + ) + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + state.documents.forEachIndexed { index, doc -> + DropdownMenuItem( + text = { Text(doc.file.title) }, + onClick = { + expanded = false + coroutineScope.launch { pagerState.animateScrollToPage(index) } + } + ) + } + } + } + } + }, + eventSink = state.eventSink, + ) + } + + ReadPdfOverlay(state.overlayState) { state.eventSink(ReadPdfEvent.OnNavEvent(it, context)) } +} + +@Composable +private fun ReadPdfOverlay(state: ReadPdfOverlayState, onNavEvent: (event: NavEvent) -> Unit,) { + OverlayEffect(state::class.simpleName) { + when (state) { + is ReadPdfOverlayState.BottomSheet -> state.onResult( + show(BottomSheetOverlay( + skipPartiallyExpanded = state.skipPartiallyExpanded, + ) { + ContentWithOverlays { + CircuitContent( + screen = state.screen, + onNavEvent = onNavEvent, + ) + } + }) + ) + ReadPdfOverlayState.None -> Unit + } + } +} + +@PreviewLightDark +@Composable +private fun PreviewLoading() { + SsTheme { ReadPdfUi(ReadPdfState.Loading) } +} diff --git a/features/pdf/src/main/res/color/ss_pdf_item_tint.xml b/features/document/src/main/res/color/ss_pdf_item_tint.xml similarity index 100% rename from features/pdf/src/main/res/color/ss_pdf_item_tint.xml rename to features/document/src/main/res/color/ss_pdf_item_tint.xml diff --git a/features/document/src/main/res/color/ss_pdf_item_tint_custom.xml b/features/document/src/main/res/color/ss_pdf_item_tint_custom.xml new file mode 100644 index 000000000..fc8d080f0 --- /dev/null +++ b/features/document/src/main/res/color/ss_pdf_item_tint_custom.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/features/document/src/main/res/color/ss_pdf_item_tint_dark.xml b/features/document/src/main/res/color/ss_pdf_item_tint_dark.xml new file mode 100644 index 000000000..3831dd171 --- /dev/null +++ b/features/document/src/main/res/color/ss_pdf_item_tint_dark.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/features/document/src/main/res/color/ss_pdf_item_tint_light.xml b/features/document/src/main/res/color/ss_pdf_item_tint_light.xml new file mode 100644 index 000000000..695222812 --- /dev/null +++ b/features/document/src/main/res/color/ss_pdf_item_tint_light.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/features/document/src/main/res/color/ss_pdf_item_tint_sepia.xml b/features/document/src/main/res/color/ss_pdf_item_tint_sepia.xml new file mode 100644 index 000000000..6edf3933a --- /dev/null +++ b/features/document/src/main/res/color/ss_pdf_item_tint_sepia.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/features/pdf/src/main/res/color/ss_pdf_tab_color.xml b/features/document/src/main/res/color/ss_pdf_tab_color.xml similarity index 100% rename from features/pdf/src/main/res/color/ss_pdf_tab_color.xml rename to features/document/src/main/res/color/ss_pdf_tab_color.xml diff --git a/features/pdf/src/main/res/color/ss_pdf_text_color.xml b/features/document/src/main/res/color/ss_pdf_text_color.xml similarity index 100% rename from features/pdf/src/main/res/color/ss_pdf_text_color.xml rename to features/document/src/main/res/color/ss_pdf_text_color.xml diff --git a/features/document/src/main/res/drawable/ic_more_vert.xml b/features/document/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 000000000..966e8e94b --- /dev/null +++ b/features/document/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/pdf/src/main/res/drawable/ic_pdf_annotations.xml b/features/document/src/main/res/drawable/ic_pdf_annotations.xml similarity index 100% rename from features/pdf/src/main/res/drawable/ic_pdf_annotations.xml rename to features/document/src/main/res/drawable/ic_pdf_annotations.xml diff --git a/features/pdf/src/main/res/drawable/ic_pdf_bookmark.xml b/features/document/src/main/res/drawable/ic_pdf_bookmark.xml similarity index 100% rename from features/pdf/src/main/res/drawable/ic_pdf_bookmark.xml rename to features/document/src/main/res/drawable/ic_pdf_bookmark.xml diff --git a/features/pdf/src/main/res/drawable/ic_pdf_settings.xml b/features/document/src/main/res/drawable/ic_pdf_settings.xml similarity index 100% rename from features/pdf/src/main/res/drawable/ic_pdf_settings.xml rename to features/document/src/main/res/drawable/ic_pdf_settings.xml diff --git a/features/pdf/src/main/res/values-night/colors.xml b/features/document/src/main/res/values-night/colors.xml similarity index 83% rename from features/pdf/src/main/res/values-night/colors.xml rename to features/document/src/main/res/values-night/colors.xml index 3b66fa058..65b7b6d7d 100644 --- a/features/pdf/src/main/res/values-night/colors.xml +++ b/features/document/src/main/res/values-night/colors.xml @@ -1,5 +1,5 @@ @color/ss_theme_color_background + + + #FFFFFF + #F9FAFB + #001328 + + #000000 + #1f262e + #F9FAFB + + #FDF4E6 + #E8DAC4 + #3E3634 + + @color/ss_pdf_item_tint \ No newline at end of file diff --git a/features/document/src/main/res/values/themes.xml b/features/document/src/main/res/values/themes.xml new file mode 100644 index 000000000..463367363 --- /dev/null +++ b/features/document/src/main/res/values/themes.xml @@ -0,0 +1,191 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/features/media/build.gradle.kts b/features/media/build.gradle.kts index 44e68eeb4..99c6da3c3 100644 --- a/features/media/build.gradle.kts +++ b/features/media/build.gradle.kts @@ -45,11 +45,9 @@ dependencies { implementation(libs.androidx.activity) implementation(libs.androidx.appcompat) implementation(libs.androidx.constraintlayout) - implementation(libs.androidx.core) implementation(libs.androidx.lifecycle.extensions) implementation(libs.coil.compose) implementation(libs.google.hilt.android) - implementation(libs.google.material) implementation(libs.kotlin.coroutines.guava) implementation(libs.timber) implementation(projects.common.core) diff --git a/features/media/src/main/kotlin/app/ss/media/playback/PlaybackConnectionImpl.kt b/features/media/src/main/kotlin/app/ss/media/playback/PlaybackConnectionImpl.kt index 302356d79..0be0ad929 100644 --- a/features/media/src/main/kotlin/app/ss/media/playback/PlaybackConnectionImpl.kt +++ b/features/media/src/main/kotlin/app/ss/media/playback/PlaybackConnectionImpl.kt @@ -56,7 +56,7 @@ import timber.log.Timber internal class PlaybackConnectionImpl( private val context: Context, private val serviceComponent: ComponentName, - coroutineScope: CoroutineScope = ProcessLifecycleOwner.Companion.get().lifecycleScope + coroutineScope: CoroutineScope = ProcessLifecycleOwner.get().lifecycleScope ) : PlaybackConnection, CoroutineScope by coroutineScope { override val isConnected = MutableStateFlow(false) diff --git a/features/media/src/main/kotlin/app/ss/media/playback/PlaybackViewModel.kt b/features/media/src/main/kotlin/app/ss/media/playback/PlaybackViewModel.kt deleted file mode 100644 index a1ba2fb0d..000000000 --- a/features/media/src/main/kotlin/app/ss/media/playback/PlaybackViewModel.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright (c) 2023. Adventech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -package app.ss.media.playback - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import ss.libraries.media.model.extensions.id -import ss.services.media.ui.PlaybackConnection -import javax.inject.Inject - -@HiltViewModel -class PlaybackViewModel @Inject constructor( - val playbackConnection: PlaybackConnection -) : ViewModel() { - - init { - viewModelScope.launch { - playbackConnection.isConnected.collect { connected -> - if (connected) { - val state = playbackConnection.playbackState.first() - val nowPlaying = playbackConnection.nowPlaying.first() - if (!state.isPlaying && nowPlaying.id.isNotEmpty()) { - playbackConnection.stop() - } - } - } - } - } -} diff --git a/features/media/src/main/kotlin/app/ss/media/playback/ui/nowPlaying/AudioPlayerScreenUi.kt b/features/media/src/main/kotlin/app/ss/media/playback/ui/nowPlaying/AudioPlayerScreenUi.kt index a585c504d..18105bbf7 100644 --- a/features/media/src/main/kotlin/app/ss/media/playback/ui/nowPlaying/AudioPlayerScreenUi.kt +++ b/features/media/src/main/kotlin/app/ss/media/playback/ui/nowPlaying/AudioPlayerScreenUi.kt @@ -51,8 +51,6 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import app.ss.design.compose.theme.SsTheme import app.ss.design.compose.widget.icon.IconBox import app.ss.design.compose.widget.icon.IconSlot @@ -83,41 +81,6 @@ fun AudioPlayerScreenUi(state: AudioPlayerState, modifier: Modifier = Modifier) } } -@Composable -internal fun NowPlayingScreen( - viewModel: NowPlayingViewModel = viewModel(), - isDraggable: (Boolean) -> Unit = {} -) { - val playbackConnection = viewModel.playbackConnection - val playbackState by playbackConnection.playbackState - .collectAsStateWithLifecycle() - val nowPlaying by viewModel.nowPlayingAudio - .collectAsStateWithLifecycle() - val playbackQueue by playbackConnection.playbackQueue - .collectAsStateWithLifecycle() - val playbackSpeed by playbackConnection.playbackSpeed - .collectAsStateWithLifecycle() - val playbackProgressState by playbackConnection.playbackProgress - .collectAsStateWithLifecycle() - val nowPlayingAudio = if (nowPlaying.id.isEmpty()) { - playbackQueue.currentAudio ?: nowPlaying - } else { - nowPlaying - } - - NowPlayingScreen( - spec = NowPlayingScreenSpec( - nowPlayingAudio, - playbackQueue, - playbackState, - playbackProgressState, - playbackConnection, - playbackSpeed, - isDraggable - ) - ) -} - @Composable internal fun NowPlayingScreen( spec: NowPlayingScreenSpec, diff --git a/features/media/src/main/kotlin/app/ss/media/playback/ui/nowPlaying/NowPlayingDetail.kt b/features/media/src/main/kotlin/app/ss/media/playback/ui/nowPlaying/NowPlayingDetail.kt index 91a3db5d3..ea7e83bdc 100644 --- a/features/media/src/main/kotlin/app/ss/media/playback/ui/nowPlaying/NowPlayingDetail.kt +++ b/features/media/src/main/kotlin/app/ss/media/playback/ui/nowPlaying/NowPlayingDetail.kt @@ -26,7 +26,6 @@ import android.view.MotionEvent import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope -import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.core.Spring @@ -61,7 +60,7 @@ import app.ss.models.media.AudioFile import ss.services.media.ui.spec.PlaybackQueueSpec import ss.services.media.ui.spec.toSpec -@OptIn(ExperimentalComposeUiApi::class, ExperimentalSharedTransitionApi::class) +@OptIn(ExperimentalComposeUiApi::class) @Composable internal fun NowPlayingDetail( spec: NowPlayingScreenSpec, @@ -147,7 +146,6 @@ internal fun NowPlayingDetail( } } -@OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun RowContent( nowPlayingAudio: AudioFile, @@ -188,7 +186,6 @@ private fun RowContent( } } -@OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun ColumnContent( nowPlayingAudio: AudioFile, diff --git a/features/media/src/main/kotlin/app/ss/media/playback/ui/nowPlaying/NowPlayingFragment.kt b/features/media/src/main/kotlin/app/ss/media/playback/ui/nowPlaying/NowPlayingFragment.kt deleted file mode 100644 index fc3a222cb..000000000 --- a/features/media/src/main/kotlin/app/ss/media/playback/ui/nowPlaying/NowPlayingFragment.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright (c) 2021. Adventech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -package app.ss.media.playback.ui.nowPlaying - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed -import androidx.fragment.app.FragmentManager -import app.ss.design.compose.extensions.surface.BottomSheetSurface -import com.cryart.design.base.TransparentBottomSheetFragment -import com.google.android.material.bottomsheet.BottomSheetDialog -import dagger.hilt.android.AndroidEntryPoint -import ss.misc.SSConstants - -@AndroidEntryPoint -class NowPlayingFragment : TransparentBottomSheetFragment() { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed) - setContent { - BottomSheetSurface { - NowPlayingScreen( - isDraggable = { isDraggable -> - (dialog as? BottomSheetDialog)?.behavior?.isDraggable = isDraggable - } - ) - } - } - } - } -} - -fun FragmentManager.showNowPlaying( - lessonIndex: String?, - readIndex: String? = null -) { - val fragment = NowPlayingFragment().apply { - arguments = Bundle().apply { - putString(SSConstants.SS_LESSON_INDEX_EXTRA, lessonIndex) - putString(SSConstants.SS_READ_INDEX_EXTRA, readIndex) - } - } - fragment.show(this, "NowPlaying") -} diff --git a/features/media/src/main/kotlin/app/ss/media/playback/ui/nowPlaying/NowPlayingViewModel.kt b/features/media/src/main/kotlin/app/ss/media/playback/ui/nowPlaying/NowPlayingViewModel.kt deleted file mode 100644 index 12941e19b..000000000 --- a/features/media/src/main/kotlin/app/ss/media/playback/ui/nowPlaying/NowPlayingViewModel.kt +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright (c) 2023. Adventech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -package app.ss.media.playback.ui.nowPlaying - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import app.ss.models.media.AudioFile -import com.cryart.sabbathschool.core.extensions.intent.lessonIndex -import com.cryart.sabbathschool.core.extensions.intent.readIndex -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import ss.foundation.coroutines.flow.stateIn -import ss.libraries.media.api.MediaRepository -import ss.libraries.media.model.extensions.id -import ss.libraries.media.model.extensions.targetIndex -import ss.libraries.media.model.toAudio -import ss.services.media.ui.PlaybackConnection -import javax.inject.Inject - -@HiltViewModel -internal class NowPlayingViewModel @Inject constructor( - private val repository: MediaRepository, - val playbackConnection: PlaybackConnection, - private val savedStateHandle: SavedStateHandle -) : ViewModel() { - - val nowPlayingAudio: StateFlow = playbackConnection.nowPlaying - .map { metaData -> - val def = metaData.toAudio() - if (metaData.id.isEmpty()) { - def - } else { - repository.findAudioFile(metaData.id) ?: def - } - } - .stateIn(viewModelScope, AudioFile("")) - - init { - viewModelScope.launch { - playbackConnection.isConnected.collect { connected -> - if (connected) { - generateQueue() - } - } - } - } - - private fun generateQueue() = viewModelScope.launch { - val nowPlaying = playbackConnection.nowPlaying.first() - val lessonIndex = savedStateHandle.lessonIndex ?: return@launch - // Correct queue or playlist already set - if (nowPlaying.targetIndex?.contains(lessonIndex) == true) return@launch - - val playlist = repository.getPlayList(lessonIndex) - - if (nowPlaying.id.isEmpty()) { - setAudioQueue(playlist) - } else { - val audio = repository.findAudioFile(nowPlaying.id) - if (audio?.targetIndex?.startsWith(lessonIndex) == false) { - val state = playbackConnection.playbackState.first() - setAudioQueue(playlist, state.isPlaying) - } - } - } - - private fun setAudioQueue(playlist: List, play: Boolean = false) { - if (playlist.isEmpty()) return - - val index = playlist.indexOfFirst { it.targetIndex == savedStateHandle.readIndex } - val position = index.coerceAtLeast(0) - playbackConnection.setQueue(playlist, index) - - if (play) { - playbackConnection.playAudios(playlist, position) - } - } -} diff --git a/features/media/src/main/kotlin/app/ss/media/playback/ui/video/VideoListFragment.kt b/features/media/src/main/kotlin/app/ss/media/playback/ui/video/VideoListFragment.kt deleted file mode 100644 index 7e01300cd..000000000 --- a/features/media/src/main/kotlin/app/ss/media/playback/ui/video/VideoListFragment.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (c) 2021. Adventech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -package app.ss.media.playback.ui.video - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed -import androidx.fragment.app.FragmentManager -import app.ss.design.compose.extensions.surface.BottomSheetSurface -import app.ss.media.playback.ui.video.player.VideoPlayerActivity -import com.cryart.design.base.TransparentBottomSheetFragment -import com.google.android.material.bottomsheet.BottomSheetDialog -import dagger.hilt.android.AndroidEntryPoint -import ss.misc.SSConstants - -@AndroidEntryPoint -class VideoListFragment : TransparentBottomSheetFragment() { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed) - setContent { - BottomSheetSurface { - VideoListScreen( - isAtTop = { isAtTop -> - (dialog as? BottomSheetDialog)?.behavior?.isDraggable = isAtTop - }, - onVideoClick = { video -> - val intent = VideoPlayerActivity.launchIntent( - requireActivity(), - video - ) - requireActivity().startActivity(intent) - } - ) - } - } - } - } -} - -fun FragmentManager.showVideoList( - lessonIndex: String -) { - val fragment = VideoListFragment().apply { - arguments = Bundle().apply { - putString(SSConstants.SS_LESSON_INDEX_EXTRA, lessonIndex) - } - } - fragment.show(this, "VideoList") -} diff --git a/features/media/src/main/kotlin/app/ss/media/playback/ui/video/VideoListScreen.kt b/features/media/src/main/kotlin/app/ss/media/playback/ui/video/VideoListScreen.kt index 877281424..0a9635e3a 100644 --- a/features/media/src/main/kotlin/app/ss/media/playback/ui/video/VideoListScreen.kt +++ b/features/media/src/main/kotlin/app/ss/media/playback/ui/video/VideoListScreen.kt @@ -47,7 +47,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -60,8 +59,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import app.ss.design.compose.extensions.isLargeScreen import app.ss.design.compose.extensions.modifier.asPlaceholder import app.ss.design.compose.extensions.modifier.thenIf @@ -81,21 +78,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import app.ss.translations.R as L10n -@Composable -internal fun VideoListScreen( - viewModel: VideoListViewModel = viewModel(), - isAtTop: (Boolean) -> Unit = {}, - onVideoClick: (SSVideo) -> Unit -) { - val videoList by viewModel.videoListFlow.collectAsStateWithLifecycle() - - VideoListScreen( - videoList = videoList, - isAtTop = isAtTop, - onVideoClick = onVideoClick - ) -} - @Composable internal fun VideoListScreen( videoList: VideoListData, diff --git a/features/media/src/main/kotlin/app/ss/media/playback/ui/video/VideoListViewModel.kt b/features/media/src/main/kotlin/app/ss/media/playback/ui/video/VideoListViewModel.kt deleted file mode 100644 index a7d56f199..000000000 --- a/features/media/src/main/kotlin/app/ss/media/playback/ui/video/VideoListViewModel.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright (c) 2021. Adventech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -package app.ss.media.playback.ui.video - -import androidx.annotation.VisibleForTesting -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import app.ss.models.media.SSVideosInfo -import com.cryart.sabbathschool.core.extensions.intent.lessonIndex -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull -import ss.foundation.coroutines.flow.stateIn -import ss.libraries.media.api.MediaRepository -import javax.inject.Inject - -@HiltViewModel -class VideoListViewModel @Inject constructor( - private val repository: MediaRepository, - private val savedStateHandle: SavedStateHandle -) : ViewModel() { - - @OptIn(ExperimentalCoroutinesApi::class) - val videoListFlow: StateFlow = flowOf(savedStateHandle.lessonIndex) - .mapNotNull { it } - .flatMapLatest { index -> - repository.getVideo(index) - }.map { videos -> - videos.toData(savedStateHandle.lessonIndex) - } - .stateIn(viewModelScope, VideoListData.Empty) -} - -@VisibleForTesting -internal fun List.toData( - lessonIndex: String? -): VideoListData = if (size == 1 && first().clips.isNotEmpty()) { - val allVideos = first().clips - val featuredVideo = allVideos.firstOrNull { it.targetIndex == lessonIndex } ?: allVideos.first() - VideoListData.Vertical( - featured = featuredVideo, - clips = allVideos.subtract(setOf(featuredVideo)).toList(), - showDragHandle = true, - ) -} else { - VideoListData.Horizontal( - data = this, - target = lessonIndex, - showDragHandle = true, - ) -} diff --git a/features/media/src/test/kotlin/app/ss/media/playback/ui/video/VideoListViewModelTest.kt b/features/media/src/test/kotlin/app/ss/media/playback/ui/video/VideoListViewModelTest.kt deleted file mode 100644 index 67441d6df..000000000 --- a/features/media/src/test/kotlin/app/ss/media/playback/ui/video/VideoListViewModelTest.kt +++ /dev/null @@ -1,138 +0,0 @@ -package app.ss.media.playback.ui.video - -import androidx.lifecycle.SavedStateHandle -import androidx.test.ext.junit.runners.AndroidJUnit4 -import app.cash.turbine.test -import app.ss.models.media.AudioFile -import app.ss.models.media.SSAudio -import app.ss.models.media.SSVideo -import app.ss.models.media.SSVideosInfo -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.test.runTest -import org.amshove.kluent.shouldBeEqualTo -import org.junit.Test -import org.junit.runner.RunWith -import ss.libraries.media.api.MediaRepository -import ss.misc.SSConstants - -private const val LESSON_INDEX = "2022-01-01" -private val fakeVideo = SSVideo("Artist", "id", "src", "target", "targetIndex", "thumbnail", "title") - -@RunWith(AndroidJUnit4::class) -class VideoListViewModelTest { - - private val videoFlow = MutableStateFlow>(emptyList()) - private val repository = FakeMediaRepository(videoFlow) - - private val underTest = VideoListViewModel( - repository = repository, - savedStateHandle = SavedStateHandle( - mapOf(SSConstants.SS_LESSON_INDEX_EXTRA to LESSON_INDEX), - ), - ) - - @Test - fun `videoListFlow - Vertical state`() = runTest { - val featuredVideo = fakeVideo.copy(targetIndex = LESSON_INDEX) - underTest.videoListFlow.test { - awaitItem() shouldBeEqualTo VideoListData.Horizontal( - data = emptyList(), - target = LESSON_INDEX, - showDragHandle = true, - ) - - videoFlow.emit( - listOf( - SSVideosInfo( - id = "id", - artist = "artist", - clips = listOf(fakeVideo, featuredVideo), - lessonIndex = LESSON_INDEX, - ) - ) - ) - - awaitItem() shouldBeEqualTo VideoListData.Vertical( - featured = featuredVideo, - clips = listOf(fakeVideo), - showDragHandle = true, - ) - - ensureAllEventsConsumed() - } - } - - @Test - fun `videoListFlow - Horizontal state`() = runTest { - val videos = listOf(fakeVideo, fakeVideo.copy(id = "id2")) - val data = listOf( - SSVideosInfo( - id = "id", - artist = "artist", - clips = videos, - lessonIndex = LESSON_INDEX, - ), - SSVideosInfo( - id = "id2", - artist = "artist2", - clips = videos, - lessonIndex = LESSON_INDEX, - ) - ) - underTest.videoListFlow.test { - awaitItem() shouldBeEqualTo VideoListData.Horizontal( - data = emptyList(), - target = LESSON_INDEX, - showDragHandle = true, - ) - - videoFlow.emit( - listOf( - SSVideosInfo( - id = "id", - artist = "artist", - clips = videos, - lessonIndex = LESSON_INDEX, - ), - SSVideosInfo( - id = "id2", - artist = "artist2", - clips = videos, - lessonIndex = LESSON_INDEX, - ) - ) - ) - - awaitItem() shouldBeEqualTo VideoListData.Horizontal( - data = data, - target = LESSON_INDEX, - showDragHandle = true, - ) - - ensureAllEventsConsumed() - } - } -} - -private class FakeMediaRepository( - private val videoFlow: Flow> -) : MediaRepository { - override fun getAudio(lessonIndex: String): Flow> { - return emptyFlow() - } - - override suspend fun findAudioFile(id: String): AudioFile? { - return null - } - - override suspend fun getPlayList(lessonIndex: String): List { - return emptyList() - } - - override fun getVideo(lessonIndex: String): Flow> { - return videoFlow - } - -} diff --git a/features/pdf/lint-baseline.xml b/features/pdf/lint-baseline.xml deleted file mode 100644 index beaf28b56..000000000 --- a/features/pdf/lint-baseline.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - diff --git a/features/pdf/src/main/AndroidManifest.xml b/features/pdf/src/main/AndroidManifest.xml deleted file mode 100644 index 63768c818..000000000 --- a/features/pdf/src/main/AndroidManifest.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/features/pdf/src/main/kotlin/app/ss/pdf/PdfReaderPrefs.kt b/features/pdf/src/main/kotlin/app/ss/pdf/PdfReaderPrefs.kt deleted file mode 100644 index f4ef68876..000000000 --- a/features/pdf/src/main/kotlin/app/ss/pdf/PdfReaderPrefs.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright (c) 2025. Adventech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -package app.ss.pdf - -import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit -import androidx.preference.PreferenceManager -import com.pspdfkit.configuration.PdfConfiguration -import com.pspdfkit.configuration.page.PageLayoutMode -import com.pspdfkit.configuration.page.PageScrollDirection -import com.pspdfkit.configuration.page.PageScrollMode -import com.pspdfkit.configuration.theming.ThemeMode -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import javax.inject.Singleton - -interface PdfReaderPrefs { - fun scrollMode(): PageScrollMode - fun setScrollMode(mode: PageScrollMode) - fun pageLayoutMode(): PageLayoutMode - fun setPageLayoutMode(mode: PageLayoutMode) - fun scrollDirection(): PageScrollDirection - fun setScrollDirection(direction: PageScrollDirection) - fun themeMode(): ThemeMode - fun setThemeMode(mode: ThemeMode) - fun saveConfiguration(configuration: PdfConfiguration) -} - -@Singleton -internal class PdfReaderPrefsImpl @Inject constructor( - @param:ApplicationContext private val context: Context, -) : PdfReaderPrefs { - private val sharedPreferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) - - override fun scrollMode(): PageScrollMode = - sharedPreferences.getString(KEY_SCROLL_MODE, null)?.let { - PageScrollMode.valueOf(it) - } ?: PageScrollMode.CONTINUOUS - - override fun setScrollMode(mode: PageScrollMode) { - sharedPreferences.edit { - putString(KEY_SCROLL_MODE, mode.name) - } - } - - override fun pageLayoutMode(): PageLayoutMode = - sharedPreferences.getString(KEY_LAYOUT_MODE, null)?.let { - PageLayoutMode.valueOf(it) - } ?: PageLayoutMode.SINGLE - - override fun setPageLayoutMode(mode: PageLayoutMode) { - sharedPreferences.edit { - putString(KEY_LAYOUT_MODE, mode.name) - } - } - - override fun scrollDirection(): PageScrollDirection = - sharedPreferences.getString(KEY_SCROLL_DIRECTION, null)?.let { - PageScrollDirection.valueOf(it) - } ?: PageScrollDirection.VERTICAL - - override fun setScrollDirection(direction: PageScrollDirection) { - sharedPreferences.edit { - putString(KEY_SCROLL_DIRECTION, direction.name) - } - } - - override fun themeMode(): ThemeMode = - sharedPreferences.getString(KEY_THEME_MODE, null)?.let { - ThemeMode.valueOf(it) - } ?: ThemeMode.DEFAULT - - override fun setThemeMode(mode: ThemeMode) { - sharedPreferences.edit { - putString(KEY_THEME_MODE, mode.name) - } - } - - override fun saveConfiguration(configuration: PdfConfiguration) { - setScrollMode(configuration.scrollMode) - setPageLayoutMode(configuration.layoutMode) - setScrollDirection(configuration.scrollDirection) - setThemeMode(configuration.themeMode) - } - - companion object { - private const val KEY_LAYOUT_MODE = "key:layout_mode" - private const val KEY_SCROLL_MODE = "key:scroll_mode" - private const val KEY_SCROLL_DIRECTION = "key:scroll_direction" - private const val KEY_THEME_MODE = "key:theme_mode" - } -} diff --git a/features/pdf/src/main/kotlin/app/ss/pdf/ReadPdfPresenter.kt b/features/pdf/src/main/kotlin/app/ss/pdf/ReadPdfPresenter.kt deleted file mode 100644 index 7d7f3e998..000000000 --- a/features/pdf/src/main/kotlin/app/ss/pdf/ReadPdfPresenter.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (c) 2025. Adventech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -package app.ss.pdf - -import androidx.compose.runtime.Composable -import com.slack.circuit.codegen.annotations.CircuitInject -import com.slack.circuit.runtime.Navigator -import com.slack.circuit.runtime.presenter.Presenter -import com.slack.circuitx.android.IntentScreen -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.components.SingletonComponent -import ss.libraries.circuit.navigation.PdfScreen -import ss.libraries.pdf.api.PdfReader - -class ReadPdfPresenter @AssistedInject constructor( - @Assisted private val navigator: Navigator, - @Assisted private val screen: PdfScreen, - private val pdfReader: PdfReader -) : Presenter { - - @Composable - override fun present(): ReadPdfState { - return ReadPdfState( - eventSink = { event -> - when (event) { - ReadPdfEvent.OpenPdf -> { - navigator.goTo(IntentScreen(pdfReader.launchIntent(screen))) - } - } - }) - } - - @CircuitInject(PdfScreen::class, SingletonComponent::class) - @AssistedFactory - interface Factory { - fun create(navigator: Navigator, screen: PdfScreen): ReadPdfPresenter - } -} diff --git a/features/pdf/src/main/kotlin/app/ss/pdf/ReadPdfState.kt b/features/pdf/src/main/kotlin/app/ss/pdf/ReadPdfState.kt deleted file mode 100644 index 8d57d897b..000000000 --- a/features/pdf/src/main/kotlin/app/ss/pdf/ReadPdfState.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2025. Adventech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -package app.ss.pdf - -import com.slack.circuit.runtime.CircuitUiState - -data class ReadPdfState( - val eventSink: (ReadPdfEvent) -> Unit, -) : CircuitUiState - -sealed interface ReadPdfEvent { - data object OpenPdf : ReadPdfEvent -} - diff --git a/features/pdf/src/main/kotlin/app/ss/pdf/ReadPdfUi.kt b/features/pdf/src/main/kotlin/app/ss/pdf/ReadPdfUi.kt deleted file mode 100644 index 8b7d3e616..000000000 --- a/features/pdf/src/main/kotlin/app/ss/pdf/ReadPdfUi.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) 2025. Adventech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -package app.ss.pdf - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import app.ss.design.compose.widget.button.ButtonSpec -import app.ss.design.compose.widget.button.SsButton -import com.slack.circuit.codegen.annotations.CircuitInject -import dagger.hilt.components.SingletonComponent -import ss.libraries.circuit.navigation.PdfScreen - -@CircuitInject(PdfScreen::class, SingletonComponent::class) -@Composable -fun ReadPdfUi(state: ReadPdfState, modifier: Modifier = Modifier) { - Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - SsButton( - spec = ButtonSpec( - text = "Open PDF", - onClick = { state.eventSink(ReadPdfEvent.OpenPdf) } - ) - ) - } -} diff --git a/features/pdf/src/main/kotlin/app/ss/pdf/model/PdfDocumentSpec.kt b/features/pdf/src/main/kotlin/app/ss/pdf/model/PdfDocumentSpec.kt deleted file mode 100644 index 262e45fb3..000000000 --- a/features/pdf/src/main/kotlin/app/ss/pdf/model/PdfDocumentSpec.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (c) 2025. Adventech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -package app.ss.pdf.model - -import androidx.compose.runtime.Immutable -import com.pspdfkit.configuration.activity.PdfActivityConfiguration -import ss.libraries.pdf.api.LocalFile - -@Immutable -data class PdfDocumentSpec( - val pdfActivityConfiguration: PdfActivityConfiguration, - val file: LocalFile, -) diff --git a/features/pdf/src/main/kotlin/app/ss/pdf/ui/ReadPdfViewModel.kt b/features/pdf/src/main/kotlin/app/ss/pdf/ui/ReadPdfViewModel.kt deleted file mode 100644 index 09d9d08ad..000000000 --- a/features/pdf/src/main/kotlin/app/ss/pdf/ui/ReadPdfViewModel.kt +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright (c) 2023. Adventech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -package app.ss.pdf.ui - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import app.ss.models.media.MediaAvailability -import com.pspdfkit.annotations.Annotation -import com.pspdfkit.document.PdfDocument -import dagger.hilt.android.lifecycle.HiltViewModel -import io.adventech.blockkit.model.input.PDFAuxAnnotations -import io.adventech.blockkit.model.input.UserInput -import io.adventech.blockkit.model.input.UserInputRequest -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import ss.foundation.coroutines.flow.stateIn -import ss.libraries.circuit.navigation.PdfScreen -import ss.libraries.pdf.api.LocalFile -import ss.libraries.pdf.api.PdfReader -import ss.resources.api.ResourcesRepository -import timber.log.Timber -import javax.inject.Inject - -@HiltViewModel -class ReadPdfViewModel @Inject constructor( - private val pdfReader: PdfReader, - private val resourcesRepository: ResourcesRepository, - private val savedStateHandle: SavedStateHandle -) : ViewModel() { - - private val _pdfFiles = MutableStateFlow>(emptyList()) - val pdfsFilesFlow: StateFlow> = _pdfFiles.asStateFlow() - - private val SavedStateHandle.screen: PdfScreen? - get() = get(ARG_PDF_SCREEN) - - val resourceId: String? get() = savedStateHandle.screen?.resourceId - val documentIndex: String? get() = savedStateHandle.screen?.documentIndex - val segmentId: String? get() = savedStateHandle.screen?.segmentId - - private val mediaAvailability = MutableStateFlow(MediaAvailability()) - val mediaAvailabilityFlow = mediaAvailability.asStateFlow() - - @OptIn(ExperimentalCoroutinesApi::class) - val annotationsStateFlow: StateFlow>> = - flowOf(savedStateHandle.screen?.documentId) - .filterNotNull() - .flatMapLatest(resourcesRepository::documentInput) - .map { - it.asSequence() - .mapNotNull { it as? UserInput.Annotation } - .toList() - } - .map { input -> - val pdfs = savedStateHandle.screen?.pdfs.orEmpty() - pdfs.mapIndexed { index, pdf -> - index to input.filter { it.pdfId == pdf.id }.flatMap { it.data } - }.toMap() - } - .catch { Timber.e(it) } - .stateIn(viewModelScope, emptyMap>()) - - init { - checkMediaAvailability() - downloadFiles() - } - - private fun checkMediaAvailability() { - val screen = savedStateHandle.screen ?: return - val (_, _, documentIndex, resourceIndex, _) = screen - viewModelScope.launch { - val audioAvailable = resourcesRepository.audio(resourceIndex, documentIndex).getOrNull().orEmpty().isNotEmpty() - val videoAvailable = resourcesRepository.video(resourceIndex, documentIndex).getOrNull().orEmpty().isNotEmpty() - - mediaAvailability.update { MediaAvailability(audioAvailable, videoAvailable) } - } - } - - private fun downloadFiles() = viewModelScope.launch { - val pdfs = savedStateHandle.screen?.pdfs ?: return@launch - val result = pdfReader.downloadFiles(pdfs) - val files = result.getOrDefault(emptyList()) - _pdfFiles.update { files } - } - - fun saveAnnotations(document: PdfDocument, docIndex: Int) { - val pdfs = savedStateHandle.screen?.pdfs ?: return - val documentId = savedStateHandle.screen?.documentId ?: return - val pdfId = pdfs.getOrNull(docIndex)?.id ?: return - - val syncAnnotations = document.annotations().toSync() - - val userInput = UserInputRequest.Annotation( - blockId = pdfId, - pdfId = pdfId, - data = syncAnnotations - ) - - resourcesRepository.saveDocumentInput(documentId, userInput) - } - - private fun List.toSync(): List { - val groupedAnnotations = groupBy { it.pageIndex } - return groupedAnnotations.keys.mapNotNull { pageIndex -> - val list = groupedAnnotations[pageIndex] ?: return@mapNotNull null - val annotations = list.map { it.toInstantJson() }.filter(::invalidInstantJson) - PDFAuxAnnotations(pageIndex, annotations) - } - } - - private fun invalidInstantJson(json: String) = json != "null" -} - -fun PdfDocument.annotations(): List { - val allAnnotations = mutableListOf() - for (i in 0 until pageCount) { - val annotations = annotationProvider.getAnnotations(i) - allAnnotations.addAll(annotations) - } - return allAnnotations -} diff --git a/features/pdf/src/main/kotlin/app/ss/pdf/ui/SSReadPdfActivity.kt b/features/pdf/src/main/kotlin/app/ss/pdf/ui/SSReadPdfActivity.kt deleted file mode 100644 index 8f231adfb..000000000 --- a/features/pdf/src/main/kotlin/app/ss/pdf/ui/SSReadPdfActivity.kt +++ /dev/null @@ -1,187 +0,0 @@ -/* - * Copyright (c) 2023. Adventech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -package app.ss.pdf.ui - -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.ViewGroup -import android.widget.ProgressBar -import androidx.activity.viewModels -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.core.content.ContextCompat -import app.ss.media.playback.ui.nowPlaying.showNowPlaying -import app.ss.media.playback.ui.video.showVideoList -import app.ss.pdf.PdfReaderPrefs -import app.ss.pdf.R -import com.cryart.sabbathschool.core.extensions.view.tint -import com.pspdfkit.document.DocumentSource -import com.pspdfkit.document.PdfDocument -import com.pspdfkit.ui.DocumentDescriptor -import com.pspdfkit.ui.PdfActivity -import com.pspdfkit.ui.tabs.PdfTabBarCloseMode -import dagger.hilt.android.AndroidEntryPoint -import io.adventech.blockkit.model.input.PDFAuxAnnotations -import ss.foundation.coroutines.flow.collectIn -import javax.inject.Inject -import app.ss.translations.R as L10n -import ss.libraries.media.resources.R as MediaR - -@AndroidEntryPoint -class SSReadPdfActivity : PdfActivity() { - - @Inject - lateinit var readerPrefs: PdfReaderPrefs - - private val viewModel by viewModels() - - private var loadedDocuments: List = emptyList() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - initUi() - - collectData() - } - - override fun onGenerateMenuItemIds(menuItems: MutableList): MutableList { - return menuItems.also { - val media = viewModel.mediaAvailabilityFlow.value - if (media.video) { - it.add(0, ID_VIDEO) - } - if (media.audio) { - it.add(0, ID_AUDIO) - } - } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - super.onCreateOptionsMenu(menu) - - menu.findItem(ID_AUDIO)?.custom(L10n.string.ss_media_audio, MediaR.drawable.ic_audio_icon) - menu.findItem(ID_VIDEO)?.custom(L10n.string.ss_media_video, MediaR.drawable.ic_video_icon) - - return true - } - - private fun MenuItem.custom( - @StringRes titleRes: Int, - @DrawableRes iconRes: Int - ) { - title = getString(titleRes) - setIcon(iconRes) - setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS) - icon?.tint(ContextCompat.getColor(this@SSReadPdfActivity, R.color.ss_icon_tint)) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> true.also { finish() } - ID_AUDIO -> true.also { - supportFragmentManager.showNowPlaying(viewModel.resourceId, viewModel.segmentId) - } - ID_VIDEO -> true.also { - viewModel.documentIndex?.let { - supportFragmentManager.showVideoList(it) - } - } - else -> super.onOptionsItemSelected(item) - } - } - - private fun initUi() { - supportActionBar?.setDisplayHomeAsUpEnabled(true) - pspdfKitViews.tabBar?.setCloseMode(PdfTabBarCloseMode.CLOSE_DISABLED) - (pspdfKitViews.emptyView as? ViewGroup)?.apply { - removeAllViews() - addView(ProgressBar(this@SSReadPdfActivity)) - } - } - - private fun collectData() { - viewModel.pdfsFilesFlow.collectIn(this) { files -> - if (documentCoordinator.documents.isNotEmpty()) return@collectIn - - loadedDocuments = files.map { file -> - DocumentDescriptor.fromDocumentSource(DocumentSource(file.uri)).apply { - setTitle(file.title) - } - } - - if (loadedDocuments.isEmpty()) return@collectIn - - loadedDocuments.forEach { documentCoordinator.addDocument(it) } - documentCoordinator.setVisibleDocument(loadedDocuments.first()) - } - - viewModel.annotationsStateFlow.collectIn(this) { annotations -> - annotations.forEach { (index, annotations) -> - val document = documentCoordinator.documents.getOrNull(index)?.document ?: return@forEach - loadAnnotations(document, annotations) - } - } - - viewModel.mediaAvailabilityFlow.collectIn(this) { invalidateOptionsMenu() } - } - - override fun onDocumentLoaded(document: PdfDocument) { - val index = loadedDocuments.indexOfFirst { it.uid == documentCoordinator.visibleDocument?.uid } - if (index >= 0) { - viewModel.annotationsStateFlow.value[index]?.let { annotations -> - loadAnnotations(document, annotations) - } - } - } - - private fun loadAnnotations(document: PdfDocument, annotations: List) { - if (annotations.isEmpty()) return - - with(document.annotationProvider) { - document.annotations() - .forEach { removeAnnotationFromPage(it) } - - annotations - .flatMap { it.annotations } - .forEach { createAnnotationFromInstantJson(it) } - } - } - - override fun onStop() { - val documents = loadedDocuments.mapNotNull { it.document } - documents.forEachIndexed { index, pdfDocument -> - viewModel.saveAnnotations(pdfDocument, index) - } - readerPrefs.saveConfiguration(configuration.configuration) - super.onStop() - } - - companion object { - private const val ID_AUDIO = 23 - private const val ID_VIDEO = 24 - } -} - -internal const val ARG_PDF_SCREEN = "as_arg_pdf_screen" diff --git a/features/pdf/src/main/res/values/themes.xml b/features/pdf/src/main/res/values/themes.xml deleted file mode 100644 index cb1cacde7..000000000 --- a/features/pdf/src/main/res/values/themes.xml +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/features/resource/src/main/kotlin/ss/resource/components/content/ResourceSectionsStateProducer.kt b/features/resource/src/main/kotlin/ss/resource/components/content/ResourceSectionsStateProducer.kt index 34944a238..3d6646b4d 100644 --- a/features/resource/src/main/kotlin/ss/resource/components/content/ResourceSectionsStateProducer.kt +++ b/features/resource/src/main/kotlin/ss/resource/components/content/ResourceSectionsStateProducer.kt @@ -31,7 +31,6 @@ import androidx.compose.ui.unit.dp import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.Navigator -import com.slack.circuitx.android.IntentScreen import io.adventech.blockkit.model.feed.FeedResourceKind import io.adventech.blockkit.model.resource.Resource import io.adventech.blockkit.model.resource.ResourceDocument @@ -43,10 +42,8 @@ import org.joda.time.DateTime import org.joda.time.Interval import ss.libraries.circuit.navigation.CustomTabsIntentScreen import ss.libraries.circuit.navigation.DocumentScreen -import ss.libraries.pdf.api.PdfReader import ss.misc.DateHelper import javax.inject.Inject -import kotlin.collections.lastIndex data class ResourceSectionsState( val specs: ImmutableList, @@ -60,9 +57,7 @@ interface ResourceSectionsStateProducer { operator fun invoke(navigator: Navigator, resource: Resource): ResourceSectionsState } -internal class ResourceSectionsStateProducerImpl @Inject constructor( - private val pdfReader: PdfReader -) : ResourceSectionsStateProducer { +internal class ResourceSectionsStateProducerImpl @Inject constructor() : ResourceSectionsStateProducer { @Composable override fun invoke(navigator: Navigator, resource: Resource): ResourceSectionsState { @@ -72,15 +67,13 @@ internal class ResourceSectionsStateProducerImpl @Inject constructor( navigator.goTo(CustomTabsIntentScreen(it)) } else -> { - val screen = document.pdfScreen()?.let { - IntentScreen(pdfReader.launchIntent(it)) - } ?: DocumentScreen(document.index) + val screen = document.pdfScreen() ?: DocumentScreen(document.index) navigator.goTo(screen) } } } - val specs = buildList { + val specs = buildList { val content = when (resource.sectionView) { ResourceSectionViewType.NORMAL -> rememberNormalViewTypeSpecs(resource, onDocumentClick) ResourceSectionViewType.DROPDOWN -> rememberDropdownViewTypeSpecs(resource, onDocumentClick) @@ -99,7 +92,7 @@ internal class ResourceSectionsStateProducerImpl @Inject constructor( onDocumentClick: (ResourceDocument) -> Unit, ): List = rememberRetained(resource) { buildList { - resource.sections.orEmpty().forEachIndexed { index, section -> + resource.sections.orEmpty().forEach { section -> if (!section.isRoot) { add( HeaderResourceSection( @@ -171,6 +164,6 @@ internal class ResourceSectionsStateProducerImpl @Inject constructor( } } - return sections.orEmpty().first { it.isRoot == false } + return sections.orEmpty().first { !it.isRoot } } } diff --git a/features/resource/src/main/kotlin/ss/resource/producer/ResourceCtaScreenProducer.kt b/features/resource/src/main/kotlin/ss/resource/producer/ResourceCtaScreenProducer.kt index 3264a4f71..30c1ad65c 100644 --- a/features/resource/src/main/kotlin/ss/resource/producer/ResourceCtaScreenProducer.kt +++ b/features/resource/src/main/kotlin/ss/resource/producer/ResourceCtaScreenProducer.kt @@ -27,14 +27,12 @@ import androidx.compose.runtime.Stable import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.screen.Screen -import com.slack.circuitx.android.IntentScreen import io.adventech.blockkit.model.feed.FeedType import io.adventech.blockkit.model.resource.Resource import org.joda.time.DateTime import org.joda.time.DateTimeConstants import org.joda.time.Interval import ss.libraries.circuit.navigation.DocumentScreen -import ss.libraries.pdf.api.PdfReader import ss.misc.DateHelper import ss.resource.components.content.pdfScreen import javax.inject.Inject @@ -55,9 +53,7 @@ interface ResourceCtaScreenProducer { operator fun invoke(resource: Resource?): CtaScreenState } -internal class ResourceCtaScreenProducerImpl @Inject constructor( - private val pdfReader: PdfReader -) : ResourceCtaScreenProducer { +internal class ResourceCtaScreenProducerImpl @Inject constructor() : ResourceCtaScreenProducer { @Composable override fun invoke(resource: Resource?): CtaScreenState { @@ -77,15 +73,15 @@ internal class ResourceCtaScreenProducerImpl @Inject constructor( } sections.forEach { section -> - section.documents.forEachIndexed { index, document -> - val startDate = document.startDate?.let { DateHelper.parseDate(it) } ?: return@forEachIndexed - val endDate = document.endDate?.let { DateHelper.parseDate(it) } ?: return@forEachIndexed + section.documents.forEach { document -> + val startDate = document.startDate?.let { DateHelper.parseDate(it) } ?: return@forEach + val endDate = document.endDate?.let { DateHelper.parseDate(it) } ?: return@forEach val fallsBetween = Interval(startDate, endDate.plusDays(1)).contains(dateTime) if (fallsBetween) { document.pdfScreen()?.let { - return CtaScreenState.Default(IntentScreen(pdfReader.launchIntent(it)), null) + return CtaScreenState.Default(it, null) } return CtaScreenState.Default(DocumentScreen(document.index), document.title) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 78ecb6713..4aafcbd63 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,7 +46,7 @@ ksp = "2.3.6" markwon = "4.6.2" material3-adaptive-navigation-suite = "1.4.0" moshix = "0.35.0" -pdfkit = "2024.9.1" +nutrient = "11.0.0" robolectric = "4.16.1" saket-extendedspans = "1.4.0" saket-telephoto = "0.18.0" @@ -146,6 +146,7 @@ google-material = { module = "com.google.android.material:material", version.ref haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } material3-adaptive-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite", version.ref = "material3-adaptive-navigation-suite" } +nutrient = { module = "io.nutrient:nutrient", version.ref = "nutrient" } saket-extendedspans = { module = "me.saket.extendedspans:extendedspans", version.ref = "saket-extendedspans" } saket-telephoto = { module = "me.saket.telephoto:zoomable", version.ref = "saket-telephoto" } saket-telephoto-flick = { module = "me.saket.telephoto:flick", version.ref = "saket-telephoto" } @@ -157,7 +158,6 @@ kotlin-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines kotlin-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "kotlin-coroutines" } kotlinx-collectionsImmutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-immutable" } markwon-core = { module = "io.noties.markwon:core", version.ref = "markwon" } -pdfkit = { module = "com.pspdfkit:pspdfkit", version.ref = "pdfkit" } snapper = { module = "dev.chrisbanes.snapper:snapper", version.ref = "snapper" } square-moshi-adapters = { module = "com.squareup.moshi:moshi-adapters", version.ref = "square-moshi" } square-moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "square-moshi" } diff --git a/libraries/pdf/api/build.gradle.kts b/libraries/pdf/api/build.gradle.kts index ef6abb2a8..76eafd79c 100644 --- a/libraries/pdf/api/build.gradle.kts +++ b/libraries/pdf/api/build.gradle.kts @@ -4,6 +4,7 @@ plugins { } dependencies { + implementation(libs.nutrient) implementation(projects.common.models) implementation(projects.libraries.circuit.api) } diff --git a/libraries/pdf/api/src/main/kotlin/ss/libraries/pdf/api/PdfReader.kt b/libraries/pdf/api/src/main/kotlin/ss/libraries/pdf/api/PdfReader.kt index a76f99128..3a2013bd1 100644 --- a/libraries/pdf/api/src/main/kotlin/ss/libraries/pdf/api/PdfReader.kt +++ b/libraries/pdf/api/src/main/kotlin/ss/libraries/pdf/api/PdfReader.kt @@ -22,17 +22,12 @@ package ss.libraries.pdf.api -import android.content.Intent import app.ss.models.LessonPdf import app.ss.models.PDFAux -import ss.libraries.circuit.navigation.PdfScreen /** API for handling pdf lessons. */ interface PdfReader { - /** Returns an intent to read the PDF [screen]. */ - fun launchIntent(screen: PdfScreen): Intent - /** Download these [pdfs] to device storage. */ suspend fun downloadFiles(pdfs: List): Result> diff --git a/features/pdf/src/main/kotlin/app/ss/pdf/ui/PdfDocumentView.kt b/libraries/pdf/api/src/main/kotlin/ss/libraries/pdf/api/PdfReaderPrefs.kt similarity index 56% rename from features/pdf/src/main/kotlin/app/ss/pdf/ui/PdfDocumentView.kt rename to libraries/pdf/api/src/main/kotlin/ss/libraries/pdf/api/PdfReaderPrefs.kt index e6ca358a5..1c9bfeee4 100644 --- a/features/pdf/src/main/kotlin/app/ss/pdf/ui/PdfDocumentView.kt +++ b/libraries/pdf/api/src/main/kotlin/ss/libraries/pdf/api/PdfReaderPrefs.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025. Adventech + * Copyright (c) 2026. Adventech * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -20,25 +20,23 @@ * THE SOFTWARE. */ -package app.ss.pdf.ui +package ss.libraries.pdf.api -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import app.ss.pdf.model.PdfDocumentSpec -import com.pspdfkit.jetpack.compose.interactors.rememberDocumentState -import com.pspdfkit.jetpack.compose.utilities.ExperimentalPSPDFKitApi -import com.pspdfkit.jetpack.compose.views.DocumentView +import com.pspdfkit.configuration.PdfConfiguration +import com.pspdfkit.configuration.page.PageLayoutMode +import com.pspdfkit.configuration.page.PageScrollDirection +import com.pspdfkit.configuration.page.PageScrollMode +import com.pspdfkit.configuration.theming.ThemeMode +import kotlinx.coroutines.flow.Flow -@OptIn(ExperimentalPSPDFKitApi::class) -@Composable -fun PdfDocumentView( - spec: PdfDocumentSpec, - modifier: Modifier = Modifier -) { - val documentState = rememberDocumentState(spec.file.uri, spec.pdfActivityConfiguration) - - DocumentView( - documentState = documentState, - modifier = modifier, - ) +interface PdfReaderPrefs { + fun scrollMode(): Flow + fun setScrollMode(mode: PageScrollMode) + fun pageLayoutMode(): Flow + fun setPageLayoutMode(mode: PageLayoutMode) + fun scrollDirection(): Flow + fun setScrollDirection(direction: PageScrollDirection) + fun themeMode(): Flow + fun setThemeMode(mode: ThemeMode) + fun saveConfiguration(configuration: PdfConfiguration) } diff --git a/release/ss_public_keys.properties b/release/ss_public_keys.properties index 9c8e46ec2..01c249019 100644 --- a/release/ss_public_keys.properties +++ b/release/ss_public_keys.properties @@ -19,5 +19,5 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. # -PSPDFKIT_LICENSE=NsLZ8g4nrh749EjVs99CQH_HBIeHSM60cyApThDSXT14C5wtN9AlwGSxyoP-mxqo1Gmj1xW2S3N3F-6-J0YqfI4MGA08GFyP-UF5gioDJ6CFtXN4Rixo05eqejVouSN8dFnrTZ0dlFvIeaG2ODIdGmGuAB0qn2pE0aFY20hHoGpOMy86_kt1KnPGw1tduF9I8Pt3Emb0BZgZjRXPKmGWUbMxlST0go4tnqvgeznu__Mc641I9S5V-F8TEm5y-O1lJar1j-Eg5cptCSNl7Sq3M87-_uv_W1eMPuvbPbTikeP0M9m5CDKOA3cn51MTeEUA7BGRAXYIKttX4MCIz2u0V43eM0WS-uZKTM8nbwSM101bhJhICttwd0ILTkwjazWJwdd4yntrJdJCGNDYVszEzMcuoFd2YBlQKp9b1tYxvjOti6ecHO2MhqSkF49DmQwlB6G2u6SQykulI9wLPfTALN6ItjABWUK3T4jOaAXWVXI19Oz3Bj-bQf7NTcDz-6gl_-dfnufWfOOeMxLC8Ki4Ame1zlpluoU37daWijaVsGgqy63CcTTdPNBiZyVicaUyMg9EiI5IPCnj-hhom1YLUi-v-UnHPIYyH3rXwEYlV4A= +PSPDFKIT_LICENSE=b0qUi6gbdNdpC3Z0-pteQOq6p7opt_2oFC6OXcLEMpze6zSJjUM-yaakE_ZENcy2IjT-IyvoSdPfFU6pAWGjErBvEvlwp0vL6kqEo6JlSDdSKVH7GiXQtPay-HPKsXwTE0ethyHHT65IAF5NzaK8qVyQ9LWuA2sjhh15ixzwjVCWkdJ3gU_FfYhWEEMZuOluvr5jhouaA_2etvk69dxX7yLOKpLxlHPDCX5Eb_vy8EBmubGK9bOS4HBmMcz72zub6-IZSuJsig0V1STOJVFDc6-b6Qm8TojhZS4_fOlPFwGZOsGs-jcgqfSY9-8_wmnSkxh7IktNsImQRr9eG589IeehxcwXGf6syMRFjtpY8roI7Jy8Wz_A_9kqrb6ZiUVE9QVBwKtujJMe90r1IYYuFm4mTOpugyX88pKZFTGNxbTmdUsg_5lqysvXANwzpnTCwc_R_MQGViOqLX-C7rULrB9154uWJWSMlXI4x9K6O6VyAGamg696S420ys-vh0dOOmZW9tbP_Y7WfHU9k-AVfCk7C7OxwuPdZhqeKwSsclXdiLszjc6k7jeNPiBtayP3NVnrrGo8u3GxowIfX5_JPfAcsTBJDJDXTL0jU_MkkfQ= WEB_CLIENT_ID=443920152945-rgptd4sa4vqv7qlpq070pq9pifv48k1j.apps.googleusercontent.com \ No newline at end of file diff --git a/features/pdf/build.gradle.kts b/services/pdf/impl/build.gradle.kts similarity index 64% rename from features/pdf/build.gradle.kts rename to services/pdf/impl/build.gradle.kts index 7520f7840..16ab326ef 100644 --- a/features/pdf/build.gradle.kts +++ b/services/pdf/impl/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023. Adventech + * Copyright (c) 2026. Adventech * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -21,7 +21,6 @@ */ import com.android.build.api.dsl.LibraryExtension -import org.gradle.kotlin.dsl.configure import java.io.FileInputStream import java.util.Properties @@ -39,50 +38,23 @@ val psPdfKitKey = readPropertyValue( ) extensions.configure { - namespace = "app.ss.pdf" + namespace = "ss.services.pdf.impl" defaultConfig { manifestPlaceholders["psPdfKitKey"] = psPdfKitKey } } -foundry { - features { compose() } -} - -ksp { - arg("circuit.codegen.mode", "hilt") -} - dependencies { - implementation(libs.androidx.activity) - implementation(libs.androidx.appcompat) - implementation(libs.androidx.core) - implementation(libs.androidx.lifecycle.viewmodel) + implementation(libs.androidx.datastore.prefs) implementation(libs.androidx.preference) implementation(libs.google.hilt.android) - implementation(libs.google.material) - implementation(libs.pdfkit) { - // We don't need these transitive dependencies - exclude(group = "com.google.android.material", module = "material") - exclude(group = "androidx.compose.runtime", module = "runtime") // imported as aar and breaks the build - } + implementation(libs.nutrient) implementation(libs.timber) - implementation(projects.common.core) - implementation(projects.common.design) - implementation(projects.common.designCompose) - implementation(projects.common.misc) - implementation(projects.common.translations) - implementation(projects.features.media) - implementation(projects.libraries.blockKit.ui) - implementation(projects.libraries.circuit.api) + implementation(projects.common.models) implementation(projects.libraries.foundation.coroutines) - implementation(projects.libraries.lessons.api) - implementation(projects.libraries.media.resources) implementation(projects.libraries.pdf.api) - implementation(projects.services.resources.api) - ksp(libs.circuit.codegen) ksp(libs.google.hilt.compiler) } @@ -99,7 +71,7 @@ fun Project.readPropertyValue( val keyProps = Properties().apply { load(FileInputStream(file)) } - return keyProps.getProperty(key, defaultValue) + keyProps.getProperty(key, defaultValue) } else { defaultValue } diff --git a/services/pdf/impl/lint-baseline.xml b/services/pdf/impl/lint-baseline.xml new file mode 100644 index 000000000..8cf328fb8 --- /dev/null +++ b/services/pdf/impl/lint-baseline.xml @@ -0,0 +1,4 @@ + + + + diff --git a/services/pdf/impl/src/main/AndroidManifest.xml b/services/pdf/impl/src/main/AndroidManifest.xml new file mode 100644 index 000000000..2a5b692b3 --- /dev/null +++ b/services/pdf/impl/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/features/pdf/src/main/kotlin/app/ss/pdf/PdfReaderImpl.kt b/services/pdf/impl/src/main/kotlin/ss/services/pdf/impl/PdfReaderImpl.kt similarity index 56% rename from features/pdf/src/main/kotlin/app/ss/pdf/PdfReaderImpl.kt rename to services/pdf/impl/src/main/kotlin/ss/services/pdf/impl/PdfReaderImpl.kt index 3a743d15f..6f0c1aabf 100644 --- a/features/pdf/src/main/kotlin/app/ss/pdf/PdfReaderImpl.kt +++ b/services/pdf/impl/src/main/kotlin/ss/services/pdf/impl/PdfReaderImpl.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025. Adventech + * Copyright (c) 2026. Adventech * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -20,89 +20,32 @@ * THE SOFTWARE. */ -package app.ss.pdf +package ss.services.pdf.impl import android.content.Context -import android.content.Intent import android.net.Uri import app.ss.models.LessonPdf import app.ss.models.PDFAux -import app.ss.pdf.ui.ARG_PDF_SCREEN -import app.ss.pdf.ui.SSReadPdfActivity -import com.pspdfkit.annotations.AnnotationType -import com.pspdfkit.configuration.activity.PdfActivityConfiguration -import com.pspdfkit.configuration.activity.TabBarHidingMode -import com.pspdfkit.configuration.activity.ThumbnailBarMode -import com.pspdfkit.configuration.page.PageFitMode -import com.pspdfkit.configuration.settings.SettingsMenuItemType -import com.pspdfkit.configuration.sharing.ShareFeatures import com.pspdfkit.document.download.DownloadJob import com.pspdfkit.document.download.DownloadRequest -import com.pspdfkit.ui.PdfActivityIntentBuilder import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import ss.foundation.coroutines.DispatcherProvider -import ss.libraries.circuit.navigation.PdfScreen import ss.libraries.pdf.api.LocalFile import ss.libraries.pdf.api.PdfReader import timber.log.Timber import java.io.File -import java.util.EnumSet import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine @Singleton internal class PdfReaderImpl @Inject constructor( @param:ApplicationContext private val context: Context, - private val readerPrefs: PdfReaderPrefs, private val dispatcherProvider: DispatcherProvider ) : PdfReader, DownloadJob.ProgressListenerAdapter() { - private val allowedAnnotations = listOf( - AnnotationType.HIGHLIGHT, - AnnotationType.INK, - AnnotationType.NOTE, - AnnotationType.WATERMARK, - AnnotationType.STRIKEOUT, - AnnotationType.FREETEXT - ) - - override fun launchIntent(screen: PdfScreen): Intent { - val excludedAnnotationTypes = ArrayList(EnumSet.allOf(AnnotationType::class.java)) - allowedAnnotations.forEach { excludedAnnotationTypes.remove(it) } - - @Suppress("DEPRECATION") - val config = PdfActivityConfiguration.Builder(context) - .hidePageLabels() - .hideDocumentTitleOverlay() - .disableDocumentInfoView() - .hidePageNumberOverlay() - .hideThumbnailGrid() - .disableSearch() - .disablePrinting() - .disableOutline() - .fitMode(PageFitMode.FIT_TO_WIDTH) - .animateScrollOnEdgeTaps(true) - .excludedAnnotationTypes(excludedAnnotationTypes) - .setEnabledShareFeatures(EnumSet.noneOf(ShareFeatures::class.java)) - .setThumbnailBarMode(ThumbnailBarMode.THUMBNAIL_BAR_MODE_NONE) - .setSettingsMenuItems(EnumSet.allOf(SettingsMenuItemType::class.java)) - .setTabBarHidingMode(TabBarHidingMode.AUTOMATIC) - .scrollMode(readerPrefs.scrollMode()) - .scrollDirection(readerPrefs.scrollDirection()) - .layoutMode(readerPrefs.pageLayoutMode()) - .themeMode(readerPrefs.themeMode()) - .build() - - return PdfActivityIntentBuilder.emptyActivity(context) - .configuration(config) - .activityClass(SSReadPdfActivity::class.java) - .build() - .apply { putExtra(ARG_PDF_SCREEN, screen) } - } - override suspend fun downloadFiles(pdfs: List): Result> { return try { val files = pdfs.mapNotNull { @@ -122,11 +65,11 @@ internal class PdfReaderImpl @Inject constructor( private suspend fun downloadFile( context: Context, pdf: PDFAux - ): LocalFile? = suspendCoroutine { continuation -> + ): LocalFile? = suspendCancellableCoroutine { continuation -> val outputFile = File(context.getDir(FILE_DIRECTORY, Context.MODE_PRIVATE), "${pdf.id}.pdf") if (outputFile.exists()) { continuation.resume(LocalFile(pdf.title, Uri.fromFile(outputFile))) - return@suspendCoroutine + return@suspendCancellableCoroutine } val request: DownloadRequest = DownloadRequest.Builder(context) diff --git a/services/pdf/impl/src/main/kotlin/ss/services/pdf/impl/PdfReaderPrefsImpl.kt b/services/pdf/impl/src/main/kotlin/ss/services/pdf/impl/PdfReaderPrefsImpl.kt new file mode 100644 index 000000000..c6c42417b --- /dev/null +++ b/services/pdf/impl/src/main/kotlin/ss/services/pdf/impl/PdfReaderPrefsImpl.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026. Adventech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package ss.services.pdf.impl + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.SharedPreferencesMigration +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import androidx.preference.PreferenceManager +import com.pspdfkit.configuration.PdfConfiguration +import com.pspdfkit.configuration.page.PageLayoutMode +import com.pspdfkit.configuration.page.PageScrollDirection +import com.pspdfkit.configuration.page.PageScrollMode +import com.pspdfkit.configuration.theming.ThemeMode +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import ss.foundation.coroutines.DispatcherProvider +import ss.foundation.coroutines.Scopable +import ss.foundation.coroutines.ioScopable +import ss.libraries.pdf.api.PdfReaderPrefs +import javax.inject.Inject +import javax.inject.Singleton + +internal const val KEY_LAYOUT_MODE = "key:layout_mode" +internal const val KEY_SCROLL_MODE = "key:scroll_mode" +internal const val KEY_SCROLL_DIRECTION = "key:scroll_direction" +internal const val KEY_THEME_MODE = "key:theme_mode" + +private val Context.pdfDataStore: DataStore by preferencesDataStore( + name = "pdf_prefs", + produceMigrations = { + listOf( + SharedPreferencesMigration( + it, + "${it.packageName}_preferences", + setOf( + KEY_LAYOUT_MODE, + KEY_SCROLL_MODE, + KEY_SCROLL_DIRECTION, + KEY_THEME_MODE, + ) + ) + ) + } +) + +@Singleton +internal class PdfReaderPrefsImpl( + private val dataStore: DataStore, + private val sharedPreferences: SharedPreferences, + private val dispatcherProvider: DispatcherProvider, +) : PdfReaderPrefs, Scopable by ioScopable(dispatcherProvider) { + + @Inject + constructor( + @ApplicationContext context: Context, + dispatcherProvider: DispatcherProvider, + ) : this( + dataStore = context.pdfDataStore, + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context), + dispatcherProvider = dispatcherProvider + ) + + private val scrollModeKey = stringPreferencesKey(KEY_SCROLL_MODE) + private val layoutModeKey = stringPreferencesKey(KEY_LAYOUT_MODE) + private val scrollDirectionKey = stringPreferencesKey(KEY_SCROLL_DIRECTION) + private val themeModeKey = stringPreferencesKey(KEY_THEME_MODE) + + override fun scrollMode(): Flow = dataStore.data.map { preferences -> + preferences[scrollModeKey]?.let { + PageScrollMode.valueOf(it) + } ?: PageScrollMode.CONTINUOUS + }.flowOn(dispatcherProvider.io) + + override fun setScrollMode(mode: PageScrollMode) { + sharedPreferences.edit { putString(KEY_SCROLL_MODE, mode.name) } + scope.launch { dataStore.edit { it[scrollModeKey] = mode.name } } + } + + override fun pageLayoutMode(): Flow = dataStore.data.map { preferences -> + preferences[layoutModeKey]?.let { + PageLayoutMode.valueOf(it) + } ?: PageLayoutMode.SINGLE + }.flowOn(dispatcherProvider.io) + + override fun setPageLayoutMode(mode: PageLayoutMode) { + sharedPreferences.edit { putString(KEY_LAYOUT_MODE, mode.name) } + scope.launch { dataStore.edit { it[layoutModeKey] = mode.name } } + } + + override fun scrollDirection(): Flow = dataStore.data.map { preferences -> + preferences[scrollDirectionKey]?.let { + PageScrollDirection.valueOf(it) + } ?: PageScrollDirection.VERTICAL + }.flowOn(dispatcherProvider.io) + + override fun setScrollDirection(direction: PageScrollDirection) { + sharedPreferences.edit { putString(KEY_SCROLL_DIRECTION, direction.name) } + scope.launch { dataStore.edit { it[scrollDirectionKey] = direction.name } } + } + + override fun themeMode(): Flow = dataStore.data.map { preferences -> + preferences[themeModeKey]?.let { ThemeMode.valueOf(it) } ?: ThemeMode.DEFAULT + }.flowOn(dispatcherProvider.io) + + override fun setThemeMode(mode: ThemeMode) { + sharedPreferences.edit { putString(KEY_THEME_MODE, mode.name) } + scope.launch { dataStore.edit { it[themeModeKey] = mode.name } } + } + + override fun saveConfiguration(configuration: PdfConfiguration) { + setScrollMode(configuration.scrollMode) + setPageLayoutMode(configuration.layoutMode) + setScrollDirection(configuration.scrollDirection) + setThemeMode(configuration.themeMode) + } +} diff --git a/features/pdf/src/main/kotlin/app/ss/pdf/di/BindingsModule.kt b/services/pdf/impl/src/main/kotlin/ss/services/pdf/impl/di/BindingsModule.kt similarity index 87% rename from features/pdf/src/main/kotlin/app/ss/pdf/di/BindingsModule.kt rename to services/pdf/impl/src/main/kotlin/ss/services/pdf/impl/di/BindingsModule.kt index 52ad7d470..8bb67d0d4 100644 --- a/features/pdf/src/main/kotlin/app/ss/pdf/di/BindingsModule.kt +++ b/services/pdf/impl/src/main/kotlin/ss/services/pdf/impl/di/BindingsModule.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023. Adventech + * Copyright (c) 2026. Adventech * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -20,16 +20,16 @@ * THE SOFTWARE. */ -package app.ss.pdf.di +package ss.services.pdf.impl.di -import app.ss.pdf.PdfReaderImpl -import app.ss.pdf.PdfReaderPrefs -import app.ss.pdf.PdfReaderPrefsImpl +import ss.services.pdf.impl.PdfReaderImpl +import ss.services.pdf.impl.PdfReaderPrefsImpl import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import ss.libraries.pdf.api.PdfReader +import ss.libraries.pdf.api.PdfReaderPrefs @Module @InstallIn(SingletonComponent::class) diff --git a/settings.gradle.kts b/settings.gradle.kts index 95b66949a..e809cfe49 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,7 +33,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - maven("https://customers.pspdfkit.com/maven") + maven("https://my.nutrient.io/maven") } } @@ -57,7 +57,6 @@ include( ":features:feed", ":features:media", ":features:navigation-suite", - ":features:pdf", ":features:resource", ":features:settings", ":features:share", @@ -91,6 +90,7 @@ include( ":services:lessons:impl", ":services:media:impl", ":services:media:ui", + ":services:pdf:impl", ":services:prefs:impl", ":services:resources:api", ":services:resources:impl",