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