Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,51 @@ object OnboardingManager {
private const val PREFS_NAME = "clearpdf_onboarding"
private const val KEY_COMPLETED = "onboarding_completed"
private const val KEY_LOCALE = "selected_locale"
// The app versionCode the user last finished onboarding on. When a newer build is
// installed this is behind [currentVersionCode], so the tour is shown again to surface
// what changed in the update.
private const val KEY_ONBOARDED_VERSION = "onboarded_version_code"

private fun prefs(context: Context): SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)

fun hasCompletedOnboarding(context: Context): Boolean =
prefs(context).getBoolean(KEY_COMPLETED, false)

/** The installed app's versionCode (0 if it can't be resolved). */
fun currentVersionCode(context: Context): Int = try {
val info = context.packageManager.getPackageInfo(context.packageName, 0)
if (android.os.Build.VERSION.SDK_INT >= 28) info.longVersionCode.toInt()
else @Suppress("DEPRECATION") info.versionCode
} catch (e: Exception) {
0
}

private fun onboardedVersionCode(context: Context): Int =
prefs(context).getInt(KEY_ONBOARDED_VERSION, -1)

/**
* Onboarding is shown on the very first launch AND again after every app update — i.e.
* whenever the installed [currentVersionCode] is newer than the one the user last completed
* the tour on. Completing (or replaying) it records the current version so it won't repeat
* until the next update.
*/
fun shouldShowOnboarding(context: Context): Boolean {
if (!hasCompletedOnboarding(context)) return true
return onboardedVersionCode(context) < currentVersionCode(context)
}

fun setOnboardingComplete(context: Context) =
prefs(context).edit().putBoolean(KEY_COMPLETED, true).apply()
prefs(context).edit()
.putBoolean(KEY_COMPLETED, true)
.putInt(KEY_ONBOARDED_VERSION, currentVersionCode(context))
.apply()

fun resetOnboarding(context: Context) =
prefs(context).edit().putBoolean(KEY_COMPLETED, false).apply()
prefs(context).edit()
.putBoolean(KEY_COMPLETED, false)
.remove(KEY_ONBOARDED_VERSION)
.apply()

fun getSelectedLocale(context: Context): String =
prefs(context).getString(KEY_LOCALE, "en") ?: "en"
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/com/chethan616/clearpdf/ui/DocsApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = nu
// share or a shortcut skips the tour and goes straight to the document — the flag is left unset,
// so onboarding still appears on the next ordinary cold start rather than being lost.
val needsOnboarding = remember {
!OnboardingManager.hasCompletedOnboarding(context) &&
// Shows on first launch and again after each app update (version-aware).
OnboardingManager.shouldShowOnboarding(context) &&
shortcutRoute == null && incomingPdfUri == null
}
// The locale the Activity actually booted with. Onboarding changes `selectedLocale` in place for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.magnifier
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectDragGestures
Expand Down Expand Up @@ -53,6 +54,7 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
Expand Down Expand Up @@ -106,13 +108,18 @@ internal fun PdfContinuousPage(
onSelectMarkup: (Int) -> Unit = {},
onDeleteMarkup: (Int) -> Unit = {},
onCopySelection: () -> Unit = {},
onHighlightSelection: () -> Unit = {}
onHighlightSelection: () -> Unit = {},
onSelectAll: () -> Unit = {}
) {
var draftPoints by remember(page, activeTool) { mutableStateOf<List<Offset>>(emptyList()) }
var draftRectStart by remember(page, activeTool) { mutableStateOf<Offset?>(null) }
var draftRectEnd by remember(page, activeTool) { mutableStateOf<Offset?>(null) }
var selDragStart by remember(page, activeTool) { mutableStateOf<Offset?>(null) }
var selDragEnd by remember(page, activeTool) { mutableStateOf<Offset?>(null) }
// Live focus point for the native magnifier loupe. Offset.Unspecified hides it; while a
// selection drag is in flight it tracks the finger so text stays legible under the fingertip,
// exactly like the platform text selector.
var magnifierFocus by remember(page, activeTool) { mutableStateOf(Offset.Unspecified) }
val selectionHandleDiameterPx = with(LocalDensity.current) { 32.dp.toPx() }
val selectionHandleHitRadiusPx = with(LocalDensity.current) { 30.dp.toPx() }

Expand All @@ -127,6 +134,15 @@ internal fun PdfContinuousPage(
.padding(vertical = 6.dp)
.then(if (bitmap == null) Modifier.aspectRatio(1f / 1.414f) else Modifier)
.background(Color(0xFF15181E))
// Native platform loupe (Android 9+). Inactive — and a no-op on older devices —
// whenever the focus point is Unspecified, so it costs nothing outside a drag.
.magnifier(
sourceCenter = { magnifierFocus },
zoom = 1.5f,
size = DpSize(112.dp, 64.dp),
cornerRadius = 32.dp,
elevation = 4.dp
)
.onSizeChanged { sz ->
pageCanvasSizes[page] = Size(sz.width.toFloat(), sz.height.toFloat())
if (bitmap != null) pageBitmapSizes[page] = Size(bitmap.width.toFloat(), bitmap.height.toFloat())
Expand Down Expand Up @@ -569,7 +585,10 @@ internal fun PdfContinuousPage(
val s = selDragStart; val e = selDragEnd
if (s == null || e == null || ocrBlocks.isEmpty()) return
val frame = Rect(0f, 0f, size.width.toFloat(), size.height.toFloat())
onSelectOcrRange(ocrWordRangesBetween(ocrBlocks, frame, s, e))
// Character-precise sweep (native granularity) — extends smoothly across
// words, lines and paragraphs instead of snapping a whole word at a time.
onSelectOcrRange(ocrCharRangesBetween(ocrBlocks, frame, s, e))
magnifierFocus = e
}

// Do not use detectDragGestures here: it commits to a one-finger drag before
Expand Down Expand Up @@ -605,6 +624,7 @@ internal fun PdfContinuousPage(
}
selDragStart = null
selDragEnd = null
magnifierFocus = Offset.Unspecified
} else if (!multiTouch) {
val change = event.changes.firstOrNull { it.id == pointerId }
if (change != null) {
Expand All @@ -626,6 +646,7 @@ internal fun PdfContinuousPage(
} while (event.changes.any { it.pressed })
selDragStart = null
selDragEnd = null
magnifierFocus = Offset.Unspecified
}
}
)
Expand Down Expand Up @@ -655,7 +676,7 @@ internal fun PdfContinuousPage(
}
val gapPx = with(density) { 8.dp.toPx() }
val bubbleHpx = with(density) { 44.dp.toPx() }
val bubbleWpx = with(density) { (if (selectionHasHighlight) 226.dp else 150.dp).toPx() }
val bubbleWpx = with(density) { (if (selectionHasHighlight) 372.dp else 296.dp).toPx() }
// Flip below the selection when it's too close to the page top.
val placeBelow = selTop < bubbleHpx + gapPx
val by = (if (placeBelow) selBottom + gapPx else selTop - bubbleHpx - gapPx).coerceIn(0f, (csz.height - bubbleHpx).coerceAtLeast(0f))
Expand All @@ -679,6 +700,15 @@ internal fun PdfContinuousPage(
.padding(horizontal = 16.dp, vertical = 9.dp)
)
Box(Modifier.width(1.dp).height(20.dp).background(Color.White.copy(0.14f)))
BasicText(
"Select all",
style = TextStyle(Color.White, 13.sp, FontWeight.SemiBold),
modifier = Modifier
.clip(RoundedCornerShape(18.dp))
.clickable { onSelectAll() }
.padding(horizontal = 16.dp, vertical = 9.dp)
)
Box(Modifier.width(1.dp).height(20.dp).background(Color.White.copy(0.14f)))
BasicText(
if (selectionHasHighlight) "Recolor" else "Highlight",
style = TextStyle(Color(0xFFFFCC33), 13.sp, FontWeight.SemiBold),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,62 @@ internal fun ocrWordRangesBetween(
}
}

/**
* Character-precise contiguous selection between two content points — the granularity the
* native Android text selector uses. Every extracted glyph box (per-character metrics) becomes a
* hit target; the run from the character nearest [p1] to the one nearest [p2], in reading order,
* is returned as merged per-line ranges. Falls back to word granularity for lines that expose no
* per-character metrics so selection never dead-ends.
*/
internal fun ocrCharRangesBetween(
blocks: List<OcrTextBlock>,
frame: Rect,
p1: Offset,
p2: Offset
): List<OcrTextRange> {
if (blocks.isEmpty()) return emptyList()
val chars = ArrayList<OcrCharHit>()
blocks.readingOrder().forEach { block ->
val n = minOf(block.charLefts.size, block.charRights.size, block.text.length)
val top = frame.top + block.top * frame.height
val bottom = frame.top + block.bottom * frame.height
for (i in 0 until n) {
val l = frame.left + block.charLefts[i] * frame.width
val r = frame.left + block.charRights[i] * frame.width
chars.add(OcrCharHit(block.id, i, Rect(min(l, r), top, max(l, r), bottom)))
}
}
// No glyph metrics on this page (image-only OCR) → keep the proven word sweep.
if (chars.isEmpty()) return ocrWordRangesBetween(blocks, frame, p1, p2)
val i1 = chars.nearestCharIndex(p1)
val i2 = chars.nearestCharIndex(p2)
val lo = min(i1, i2)
val hi = max(i1, i2)
// Each block's glyphs are emitted contiguously, so a contiguous slice yields one clean
// [start, end) range per block — exactly what the highlight / copy models expect.
return chars.subList(lo, hi + 1)
.groupBy { it.blockId }
.map { (id, hits) -> OcrTextRange(id, hits.minOf { it.charIndex }, hits.maxOf { it.charIndex } + 1) }
}

private data class OcrCharHit(val blockId: String, val charIndex: Int, val rect: Rect)

private fun List<OcrCharHit>.nearestCharIndex(point: Offset): Int =
indices.minByOrNull { index ->
val rect = this[index].rect
val dx = when {
point.x < rect.left -> rect.left - point.x
point.x > rect.right -> point.x - rect.right
else -> 0f
}
val dy = when {
point.y < rect.top -> rect.top - point.y
point.y > rect.bottom -> point.y - rect.bottom
else -> 0f
}
dx * dx + dy * dy
} ?: 0

private data class OcrWordHit(val range: OcrTextRange, val rect: Rect)

private fun List<OcrWordHit>.nearestWordIndex(point: Offset): Int =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -913,10 +913,19 @@ fun PdfViewerScreen(
onHighlightSelection = {
val m = getPageMarks(page)
selectedTextRanges(page).forEach { range ->
if (!m.any { it is PdfMarkup.TextBlockHighlightMarkup && it.blockId == range.blockId && it.start == range.start && it.end == range.end })
m.add(PdfMarkup.TextBlockHighlightMarkup(range.blockId, Color(currentColorLong), 0.38f, range.start, range.end))
// Recolor semantics: drop any highlight already covering this exact
// range so a re-tap replaces the colour instead of stacking layers.
m.removeAll { it is PdfMarkup.TextBlockHighlightMarkup && it.blockId == range.blockId && it.start == range.start && it.end == range.end }
m.add(PdfMarkup.TextBlockHighlightMarkup(range.blockId, Color(currentColorLong), 0.38f, range.start, range.end))
}
viewModel.clearOcrSelection(page)
// Keep the selection live so the pill immediately offers Recolor / Delete —
// this is the fix for "after highlighting there's no delete option".
lastInteractionAtMs = System.currentTimeMillis()
},
onSelectAll = {
val ids = state.ocrBlocksByPage[page].orEmpty().map { it.id }.toSet()
if (ids.isNotEmpty()) viewModel.selectOcrBlocks(page, ids, append = false)
lastInteractionAtMs = System.currentTimeMillis()
}
)
}
Expand Down
Loading