diff --git a/DESIGN.md b/DESIGN.md index a98dd18..d9d40c5 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -95,7 +95,9 @@ Use the shared springs in `GlassMotion` (or springs close to them): **The user tunes it (Settings -> Liquid Glass -> Motion).** `MotionSettings` holds multipliers on the numbers above (bounce, speed, press depth, release jiggle, drag stretch, long-press bloom) with -the profiles Normal (the design, exactly), Performance, Bouncy and Super springy. So every bouncing +the profiles Normal (the design, exactly), Performance, Bouncy and Super springy. Bouncy +(`MotionStyle.Default`) runs until the user picks one, here or in the onboarding Appearance step, +and Reset returns to it. So every bouncing spring goes through `MotionSettings.damping(designed)` / `stiffness(designed)`, and every press size goes through the style. The `GlassMotion` springs already do. A new bouncing spring written by hand must do the same. `settle`, `fade` and layout springs never bounce and ignore it. @@ -108,6 +110,10 @@ by hand must do the same. `settle`, `fade` and layout springs never bounce and i read `jelly.scale` in `graphicsLayer`. Never use `animateFloatAsState(if (pressed) ...)`: in a scrolling list a quick tap's Press and Release arrive together, so the dip never shows. - A tap shorter than `GlassMotion.MinPressMillis` (90 ms) still plays the whole press before releasing. +- The press light (the white glow under the finger, and the brighter glow of a long-press bloom) has + its own critically damped spring. Never drive it from the bouncy press or bloom value: those ring + through rest on release, and under Bouncy / Super springy every swing back up re-lit the glass, a + shine on each jiggle. The shape jiggles; the light only fades. - Menus unfold from their anchor with anisotropic scale (the width first, then the height) and fold back fast without a bounce. Rows cascade in behind the unfold. @@ -120,6 +126,9 @@ by hand must do the same. `settle`, `fade` and layout springs never bounce and i Back. - Long-press on a glass button blooms (grows ≤ ~14 dp, glows brighter, one LongPress haptic). A long-press **action** runs on release, never mid-press. +- A tapped back button (`GlassBackButton`) leaves after `GlassMotion.BackTapDelayMillis` (280 ms), + so the press is seen first, as on iOS. Not longer: it must never read as lag. The system back + gesture stays instant, and Reduce glass motion skips the wait. --- @@ -148,12 +157,19 @@ by hand must do the same. `settle`, `fade` and layout springs never bounce and i --- ## 6. Layout and UX +- Holding a back button goes to the root of where you are: the library from a tool or document, + the Settings index from a Settings page (never past it to Home). +- The tab bar is rebuilt whenever it reappears, so it must follow the current tab from its first + frame; never skip the first selection change it sees. - Settings is an iOS-style index of rows. Each topic opens its own page with a back button. Keep the root light: three small panels. - Use 16 dp screen gutters and 12–16 dp between panels. Rows are 44–48 dp tall. Text is 13–16 sp: titles SemiBold, values in the secondary colour. - Menus are compact: rows about 40 dp, menu width about 228 dp. - Every popover, menu and sheet dismisses on Back and on an outside tap. +- Asks the app makes of people (the GitHub star prompt) wait for a calm moment after something + went right: never at launch, mid-task, in a document opened from another app, or after a failure, + and they back off when dismissed. `GitHubStarPromptManager` holds the rules; don't add fixed counts. - Strings live in `values/strings.xml`. Never hard-code user-facing text. --- @@ -184,6 +200,10 @@ by hand must do the same. `settle`, `fade` and layout springs never bounce and i 20 dp icons, the Word editor's size, in the PDF and PowerPoint viewers too. The morph grows to twice its size while armed. Settings -> Personalization -> Swap share and open makes the tap share and the hold-and-swipe open; the ink still follows what is behind it. +- The Word editor's PDF and Print buttons make the PDF from the editor's own pages + (`DocxPdfWriter`: same styles, labels, pictures, tables and Word's page breaks, real text), or + from the Office engine when it is installed. Never the old web-preview converter, whose output + looks like the previous viewer. - Word documents open as pages, desktop-like: true proportions and margins, fit to width, pinch to zoom (the page relays out sharp, it is never bitmap-scaled), Word's own page breaks. diff --git a/app/src/main/java/com/chethan616/clearpdf/data/repository/Preferences.kt b/app/src/main/java/com/chethan616/clearpdf/data/repository/Preferences.kt index e760f21..099edba 100644 --- a/app/src/main/java/com/chethan616/clearpdf/data/repository/Preferences.kt +++ b/app/src/main/java/com/chethan616/clearpdf/data/repository/Preferences.kt @@ -252,47 +252,104 @@ object AppSettingsManager { } /** - * Tracks usage for the GitHub star prompt. - * Best practice prompt rules: - * - Requires at least 10 successful PDF interactions - * - Permanently disables after user stars or clicks "Don't show again" - * - Snoozes for 30 days if user clicks "Not now" + * Decides when to ask for a GitHub star from how the person really uses the app, not a fixed count. + * Everything stays on the device. + * + * - **Earned.** A task that produced a file (merge, compress, scan, export...) scores + * [SUCCESS_POINTS]; opening a document scores [OPEN_POINTS]. Nothing is asked below [SCORE_NEEDED]. + * - **A returning user.** Used on [ACTIVE_DAYS_NEEDED] different days and installed at least + * [MIN_INSTALL_DAYS] days ago, so the first sessions are never interrupted. + * - **A good moment.** Only after something went right in this launch (a file made, a document + * read), and only once the person is back on Home or Tools (the app checks that). Never at launch, + * never mid-task. + * - **Not after a failure.** A task that failed in the last [FAILURE_QUIET_MS] holds it back. + * - **Backs off.** "Not now" snoozes for [SNOOZE_DAYS] in turn and the score starts over; once + * those run out, the next "Not now" is taken as a no. A star or "Don't show again" ends it. At most + * one ask per launch. */ object GitHubStarPromptManager { private const val PREFS_NAME = "clearpdf_settings" - private const val KEY_INTERACTION_COUNT = "github_star_interaction_count" + /** The old fixed counter (3 interactions); carried into the score once. */ + private const val KEY_LEGACY_COUNT = "github_star_interaction_count" + private const val KEY_SCORE = "github_star_score" + private const val KEY_ACTIVE_DAYS = "github_star_active_days" + private const val KEY_LAST_ACTIVE_DAY = "github_star_last_active_day" + private const val KEY_LAST_FAILURE_MS = "github_star_last_failure_ms" + private const val KEY_DISMISS_COUNT = "github_star_dismiss_count" private const val KEY_PROMPT_SNOOZE_UNTIL_MS = "github_star_prompt_snooze_until_ms" private const val KEY_NEVER_SHOW = "github_star_never_show" - private const val INTERACTION_THRESHOLD = 3 - private const val THIRTY_DAYS_MS = 30L * 24L * 60L * 60L * 1000L + private const val SUCCESS_POINTS = 3 + private const val OPEN_POINTS = 1 + private const val SCORE_NEEDED = 9 + private const val ACTIVE_DAYS_NEEDED = 3 + private const val MIN_INSTALL_DAYS = 3 + private const val DAY_MS = 24L * 60L * 60L * 1000L + private const val FAILURE_QUIET_MS = 10L * 60L * 1000L + private val SNOOZE_DAYS = intArrayOf(21, 60) const val REPO_URL: String = "https://github.com/Chethan616/ClearPDF" + /** Something went right in this launch; the app may ask at its next calm moment. */ + @Volatile private var positiveMoment = false + @Volatile private var askedThisLaunch = false + private fun prefs(context: Context): SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - fun recordPdfInteraction(context: Context): Boolean { - val preferences = prefs(context) - if (preferences.getBoolean(KEY_NEVER_SHOW, false)) return false + /** Once per launch: counts the distinct days the app is used. */ + fun recordActiveDay(context: Context) { + val p = prefs(context) + val today = System.currentTimeMillis() / DAY_MS + if (p.getLong(KEY_LAST_ACTIVE_DAY, -1L) != today) { + p.edit() + .putLong(KEY_LAST_ACTIVE_DAY, today) + .putInt(KEY_ACTIVE_DAYS, p.getInt(KEY_ACTIVE_DAYS, 0) + 1) + .apply() + } + } - val now = System.currentTimeMillis() - val snoozeUntil = preferences.getLong(KEY_PROMPT_SNOOZE_UNTIL_MS, 0L) - if (now < snoozeUntil) return false + /** A task produced a file. True when this would be a good time to ask. */ + fun recordSuccess(context: Context): Boolean = record(context, SUCCESS_POINTS) - val nextCount = preferences.getInt(KEY_INTERACTION_COUNT, 0) + 1 - preferences.edit().putInt(KEY_INTERACTION_COUNT, nextCount).apply() - return nextCount >= INTERACTION_THRESHOLD + /** A document opened and rendered. True when this would be a good time to ask. */ + fun recordDocumentOpened(context: Context): Boolean = record(context, OPEN_POINTS) + + /** A task failed: nobody is asked for a star right after the app let them down. */ + fun recordFailure(context: Context) { + prefs(context).edit().putLong(KEY_LAST_FAILURE_MS, System.currentTimeMillis()).apply() } - fun shouldShowPrompt(context: Context): Boolean { - val preferences = prefs(context) - if (preferences.getBoolean(KEY_NEVER_SHOW, false)) return false + private fun record(context: Context, points: Int): Boolean { + val p = prefs(context) + if (p.getBoolean(KEY_NEVER_SHOW, false)) return false + p.edit().putInt(KEY_SCORE, score(p) + points).apply() + positiveMoment = true + return shouldShowPrompt(context) + } + private fun score(p: SharedPreferences): Int = + p.getInt(KEY_SCORE, -1).takeIf { it >= 0 } ?: (p.getInt(KEY_LEGACY_COUNT, 0) * SUCCESS_POINTS) + + private fun installedAt(context: Context): Long = runCatching { + context.packageManager.getPackageInfo(context.packageName, 0).firstInstallTime + }.getOrDefault(0L) + + fun shouldShowPrompt(context: Context): Boolean { + if (askedThisLaunch || !positiveMoment) return false + val p = prefs(context) + if (p.getBoolean(KEY_NEVER_SHOW, false)) return false val now = System.currentTimeMillis() - val snoozeUntil = preferences.getLong(KEY_PROMPT_SNOOZE_UNTIL_MS, 0L) - if (now < snoozeUntil) return false - return preferences.getInt(KEY_INTERACTION_COUNT, 0) >= INTERACTION_THRESHOLD + if (now < p.getLong(KEY_PROMPT_SNOOZE_UNTIL_MS, 0L)) return false + if (now - p.getLong(KEY_LAST_FAILURE_MS, 0L) < FAILURE_QUIET_MS) return false + if (now - installedAt(context) < MIN_INSTALL_DAYS * DAY_MS) return false + if (p.getInt(KEY_ACTIVE_DAYS, 0) < ACTIVE_DAYS_NEEDED) return false + return score(p) >= SCORE_NEEDED + } + + /** The prompt is on screen: no second ask in this launch, whatever the person picks. */ + fun onPromptShown() { + askedThisLaunch = true } fun onPromptAccepted(context: Context) { @@ -300,17 +357,23 @@ object GitHubStarPromptManager { } fun onPromptDismissed(context: Context) { - val snoozeUntil = System.currentTimeMillis() + THIRTY_DAYS_MS - prefs(context).edit() - .putInt(KEY_INTERACTION_COUNT, 0) - .putLong(KEY_PROMPT_SNOOZE_UNTIL_MS, snoozeUntil) + val p = prefs(context) + val dismissals = p.getInt(KEY_DISMISS_COUNT, 0) + 1 + if (dismissals > SNOOZE_DAYS.size) { + setNeverShowAgain(context) + return + } + p.edit() + .putInt(KEY_DISMISS_COUNT, dismissals) + .putInt(KEY_SCORE, 0) + .putLong(KEY_PROMPT_SNOOZE_UNTIL_MS, System.currentTimeMillis() + SNOOZE_DAYS[dismissals - 1] * DAY_MS) .apply() } fun setNeverShowAgain(context: Context) { prefs(context).edit() .putBoolean(KEY_NEVER_SHOW, true) - .putInt(KEY_INTERACTION_COUNT, 0) + .putInt(KEY_SCORE, 0) .apply() } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/DocsApp.kt b/app/src/main/java/com/chethan616/clearpdf/ui/DocsApp.kt index dc5e279..cefb489 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/DocsApp.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/DocsApp.kt @@ -75,7 +75,6 @@ import com.chethan616.clearpdf.ui.components.LocalGlassOverlayHost import com.chethan616.clearpdf.ui.components.LocalBackToLibraryAction import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import kotlinx.coroutines.flow.collectLatest /** Walk the ContextWrapper chain to the hosting Activity (for locale-change recreate). */ private tailrec fun android.content.Context.findActivity(): android.app.Activity? = when (this) { @@ -148,14 +147,11 @@ fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = nu OnboardingManager.shouldShowWhatsNew(context) } + // A finished task only reports that a good moment happened; the ask itself waits for a calm + // screen (below). It no longer pops up at launch. + var starMoment by remember { mutableIntStateOf(0) } LaunchedEffect(context) { - if (showWhatsNew) return@LaunchedEffect - showStarPrompt = GitHubStarPromptManager.shouldShowPrompt(context) - StarPromptEventBus.promptRequests.collectLatest { - if (GitHubStarPromptManager.shouldShowPrompt(context)) { - showStarPrompt = true - } - } + StarPromptEventBus.promptRequests.collect { starMoment++ } } val openRepo = remember(context) { @@ -267,6 +263,20 @@ fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = nu } } + // The star ask, at a calm moment only: back on Home or Tools after something went right in + // this launch (GitHubStarPromptManager holds the rest: earned use, a returning user, no + // recent failure, backing off). Never mid-task, never in a document opened from another + // app, never on a launch that showed the tour or What's New. Leaving the tab cancels it. + LaunchedEffect(currentRoute, starMoment) { + if (showWhatsNew || needsOnboarding || documentWindow) return@LaunchedEffect + if (currentRoute != "home" && currentRoute != "tools") return@LaunchedEffect + kotlinx.coroutines.delay(900) + if (GitHubStarPromptManager.shouldShowPrompt(context)) { + GitHubStarPromptManager.onPromptShown() + showStarPrompt = true + } + } + // Keep tab highlight synced even when navigation occurs from cards/deep links. LaunchedEffect(currentRoute) { selectedTab = when (currentRoute) { @@ -290,6 +300,7 @@ fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = nu remember(context) { com.chethan616.clearpdf.ui.components.GlassSettings.load(context) com.chethan616.clearpdf.ui.components.MotionSettings.load(context) + GitHubStarPromptManager.recordActiveDay(context) true } val platformHaptics = androidx.compose.ui.platform.LocalHapticFeedback.current diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassMotion.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassMotion.kt index fe43458..716c9ac 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassMotion.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassMotion.kt @@ -63,4 +63,10 @@ object GlassMotion { /** A tap shorter than this still shows the whole press before releasing. */ const val MinPressMillis = 90L + + /** + * How long a tapped back button waits before leaving: long enough for the swell to land and start + * springing back (the press is visible, as on iOS), short enough that it never reads as lag. + */ + const val BackTapDelayMillis = 280L } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassPrimitives.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassPrimitives.kt index d07154c..6ea59b6 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassPrimitives.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassPrimitives.kt @@ -1,5 +1,6 @@ package com.chethan616.clearpdf.ui.components +import kotlinx.coroutines.launch import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -68,8 +69,29 @@ fun GlassBackButton( onLongPressBack: (() -> Unit)? = LocalBackToLibraryAction.current, longPressLabel: String? = null ) { + // A tap waits [GlassMotion.BackTapDelayMillis] so the press is seen before the screen goes; it + // used to leave on the same frame, before the glass had moved. The system back gesture stays + // instant (it is the screen's BackHandler, not this). Further taps while waiting are ignored, and + // leaving the screen meanwhile cancels the wait, so Back never fires twice. + val scope = androidx.compose.runtime.rememberCoroutineScope() + val currentOnBack by androidx.compose.runtime.rememberUpdatedState(onBack) + val reduced = com.chethan616.clearpdf.ui.theme.LocalReducedGlassMotion.current + val pending = remember { booleanArrayOf(false) } LiquidIconButton( - onClick = onBack, + onClick = { + if (reduced) currentOnBack() + else if (!pending[0]) { + pending[0] = true + scope.launch { + try { + kotlinx.coroutines.delay(GlassMotion.BackTapDelayMillis) + currentOnBack() + } finally { + pending[0] = false + } + } + } + }, onLongClick = onLongPressBack, onLongClickLabel = onLongPressBack?.let { longPressLabel ?: stringResource(R.string.back_to_library) }, backdrop = backdrop, diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassSearchHeader.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassSearchHeader.kt index b1abc9c..2d1bc99 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassSearchHeader.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassSearchHeader.kt @@ -1,5 +1,6 @@ package com.chethan616.clearpdf.ui.components +import kotlinx.coroutines.flow.first import androidx.activity.compose.BackHandler import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloat @@ -99,7 +100,8 @@ val GlassSearchPillHeight = 36.dp * what each one is allowed to do: `progress` owns the layout width and stays critically damped (an * overshooting width re-measures the glass and re-runs its blur+lens at a new size, and a negative * one throws), `fade` owns alpha and stays critically damped (overshoot clips at 1.0 and reads as a - * flicker), and `bounce` owns the scales and the icon spin, where overshoot is the whole point. + * flicker), and `grow` owns the scales and the icon spin. `grow` is critically damped too; the + * spring you feel is a separate wobble with the glass buttons' physics (see below). */ @Composable fun GlassSearchHeader( @@ -129,20 +131,32 @@ fun GlassSearchHeader( transitionSpec = { GlassMotion.fade() }, label = "searchFade" ) { if (it) 1f else 0f } - // The bounce rides its own clock so it can overshoot without dragging `progress` — and therefore - // the layout width — past 1. Everything it drives is a draw-time property, so the overshoot costs - // a layer-matrix update, not a re-measure: `drawBackdrop` never re-runs its blur or lens. - // Symmetric on purpose: this used to spring open with `morph()` but close with the fully - // rigid `settle()` (no overshoot at all), on the theory that overshooting past the field's - // floor scale on the way in would look broken. It doesn't — every use of `bounce` below either - // clamps to [0,1] (the pill's shrink) or tolerates a few percent past its floor for a couple of - // frames (the field's grow-in, the icon's spin) — so there was nothing actually protecting - // against, just an animation that sprang open and then went dead on the way back, closing every - // single time compared with opening. - val bounce by transition.animateFloat( - transitionSpec = { GlassMotion.morph() }, - label = "searchBounce" + // The unfurl itself never overshoots. It used to ride `morph()`, and a 0.32 -> 1 scale on a + // full-width capsule turns that spring's overshoot into tens of dp (far more under Bouncy / + // Super springy), with the icon swinging well past its quarter turn: the pill over-jiggled. + // Now it lands cleanly at the morph's speed, and the spring comes from `wobble` below. + val growState = transition.animateFloat( + transitionSpec = { spring(dampingRatio = 1f, stiffness = MotionSettings.stiffness(420f)) }, + label = "searchGrow" ) { if (it) 1f else 0f } + val grow by growState + // The glass buttons' release jiggle (InteractiveHighlight): once the capsule lands it is kicked + // into one out-of-phase squash, about 2.5 dp a side, on the same spring and Motion settings as + // every LiquidButton, instead of the whole pill rebounding. + val wobble = remember { androidx.compose.animation.core.Animatable(0f, 0.001f) } + val firstRun = remember { booleanArrayOf(true) } + LaunchedEffect(active) { + if (firstRun[0]) { firstRun[0] = false; return@LaunchedEffect } + val target = if (active) 1f else 0f + androidx.compose.runtime.snapshotFlow { growState.value } + .first { kotlin.math.abs(it - target) < 0.15f } + wobble.snapTo(0f) + wobble.animateTo( + 0f, + GlassMotion.wobble(), + initialVelocity = 34f * MotionSettings.style.wobble.coerceIn(0f, 3f) + ) + } val dismiss = { onQueryChange("") @@ -184,11 +198,12 @@ fun GlassSearchHeader( Box( Modifier.graphicsLayer { alpha = 1f - fade - // Clamped: the pill only needs the eased shape, not the overshoot — it is on - // its way out and a rebound would fight the fade. - val s = lerp(1f, 0.86f, bounce.coerceIn(0f, 1f)) - scaleX = s - scaleY = s + // Shrinks toward the circle as search opens; coming back it gets the + // buttons' landing jiggle. + val s = lerp(1f, 0.86f, grow) + val w = if (active) 0f else wobble.value + scaleX = s + w * (2.5f.dp.toPx() / size.width).coerceAtMost(0.045f) + scaleY = s - w * (2.5f.dp.toPx() / size.height).coerceAtMost(0.045f) } ) { GlassTitlePill( @@ -206,11 +221,12 @@ fun GlassSearchHeader( .graphicsLayer { alpha = fade // Grow out of the circle: the capsule's right edge is pinned, so it - // unfurls leftward, overshoots past full width and springs back. The - // overshoot runs into the leading slot, which has collapsed by then. + // unfurls leftward and lands, then jiggles like a released glass button + // (X and Y out of phase, ~2.5 dp). transformOrigin = TransformOrigin(1f, 0.5f) - scaleX = lerp(0.32f, 1f, bounce) - scaleY = lerp(0.72f, 1f, bounce) + val w = if (active) wobble.value else 0f + scaleX = lerp(0.32f, 1f, grow) + w * (2.5f.dp.toPx() / size.width).coerceAtMost(0.045f) + scaleY = lerp(0.72f, 1f, grow) - w * (2.5f.dp.toPx() / size.height).coerceAtMost(0.045f) } ) { GlassSearchPill( @@ -239,10 +255,10 @@ fun GlassSearchHeader( ) { val tint = LiquidGlassColors.text(LocalIsDarkMode.current) // The icon spins a quarter turn as it swaps, so the circle feels like it re-purposes - // rather than just blinking to a new glyph. On `bounce`, so it overshoots the turn in - // step with the capsule instead of arriving ahead of it. + // rather than just blinking to a new glyph. It lands with the capsule and shares its + // small wobble rather than swinging past the turn on its own. Box( - Modifier.graphicsLayer { rotationZ = lerp(0f, 90f, bounce) }, + Modifier.graphicsLayer { rotationZ = lerp(0f, 90f, grow) + wobble.value * 6f }, contentAlignment = Alignment.Center ) { if (showTitle) { diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidBottomTabs.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidBottomTabs.kt index 9c453c7..52b44d7 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidBottomTabs.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidBottomTabs.kt @@ -54,7 +54,6 @@ import com.kyant.backdrop.shadow.InnerShadow import com.kyant.backdrop.shadow.Shadow import com.kyant.shapes.Capsule import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.sign @@ -142,11 +141,17 @@ fun LiquidBottomTabs( } } LaunchedEffect(dampedDragAnimation) { + // No drop(1): the bar is rebuilt every time it reappears (it steps away on Settings pages + // and tool screens), and the selection can change between its first frame and this + // effect starting. Holding Back on a Settings page did exactly that, and drop(1) threw + // the change away, leaving the pill on Settings over Home. Move the pill whenever it is + // not where the index says, and report only the bar's own changes (a drag) upward. snapshotFlow { currentIndex } - .drop(1) .collectLatest { index -> - dampedDragAnimation.animateToValue(index.toFloat()) - onTabSelected(index) + if (dampedDragAnimation.targetValue.fastRoundToInt() != index) { + dampedDragAnimation.animateToValue(index.toFloat()) + } + if (index != selectedTabIndexState.value()) onTabSelected(index) } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/MotionSettings.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/MotionSettings.kt index 9b16e80..559e71d 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/components/MotionSettings.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/MotionSettings.kt @@ -8,7 +8,8 @@ import androidx.compose.runtime.setValue /** * How springy the Liquid Glass motion is (Settings -> Liquid Glass -> Motion). Every value is a - * multiplier on the physics the app was designed with, so [Normal] reproduces it exactly: + * multiplier on the physics the app was designed with, so [Normal] reproduces it exactly. Until the + * user picks one (in Settings or the onboarding tour) the app runs [Default], which is [Bouncy]: * * - [bounce]: 0.5 is the design; higher lowers the springs' damping (more overshoot), lower raises * it. Springs that must never bounce (fades, layout, dismissals) ignore it. @@ -33,13 +34,15 @@ data class MotionStyle( val Bouncy = MotionStyle(bounce = 0.75f, speed = 0.92f, press = 1.3f, wobble = 1.6f, stretch = 1.2f, bloom = 1.2f) val SuperSpringy = MotionStyle(bounce = 0.95f, speed = 0.82f, press = 1.6f, wobble = 2.2f, stretch = 1.5f, bloom = 1.5f) val Presets = listOf(Normal, Performance, Bouncy, SuperSpringy) + /** What the app runs until the user picks a profile, and what Reset returns to. */ + val Default = Bouncy } } object MotionSettings { private const val PREFS = "ui_prefs" - var style: MotionStyle by mutableStateOf(MotionStyle.Normal) + var style: MotionStyle by mutableStateOf(MotionStyle.Default) private set private var loaded = false @@ -48,7 +51,7 @@ object MotionSettings { if (loaded) return loaded = true val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - val d = MotionStyle.Normal + val d = MotionStyle.Default style = MotionStyle( bounce = p.getFloat("motion_bounce", d.bounce), speed = p.getFloat("motion_speed", d.speed), diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/DocxEditorScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/DocxEditorScreen.kt index 859fefa..a6b0684 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/DocxEditorScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/DocxEditorScreen.kt @@ -234,7 +234,7 @@ private enum class DocPanel { NONE, TEXT_COLOR, HIGHLIGHT } /** Formatting chosen with nothing selected: it applies to what is typed next at [at]. */ private class Pending(val paraId: Long, val at: Int, val fmt: CharFmt) -private sealed class DocItem(val key: Any, val page: Int) { +internal sealed class DocItem(val key: Any, val page: Int) { /** A paragraph, or (reading) the part of one that falls on [page], when Word broke it across pages. */ class Para( val para: DocPara, @@ -254,7 +254,7 @@ private sealed class DocItem(val key: Any, val page: Int) { class Bottom(page: Int) : DocItem("bottom", page) } -private class Flat(val items: List, val indexOf: Map, val pageStarts: List) { +internal class Flat(val items: List, val indexOf: Map, val pageStarts: List) { val pages: Int get() = pageStarts.size } @@ -264,7 +264,7 @@ private class Flat(val items: List, val indexOf: Map, val pa * breaks, and before paragraphs set to start a page. While reading, a paragraph Word broke across * two pages is shown in two parts; while editing it stays whole (one field) and the page ends after it. */ -private fun flatten(blocks: List, styles: DocxStyles, splitParas: Boolean): Flat { +internal fun flatten(blocks: List, styles: DocxStyles, splitParas: Boolean): Flat { val items = ArrayList() val index = HashMap() val pageStarts = arrayListOf(0) @@ -757,8 +757,10 @@ private fun DocxEditorContent( // The exact page layout comes from the PDF path, which uses the Office engine (LibreOffice) // whenever it is installed. fun openAsPdf() { - if (!viewModel.dirty) meta.sourceUri?.let(onOpenPdf) - else viewModel.exportToPdf(context) { pdf -> + // Always this document's own pages as a PDF (see exportToPdf). Handing the untouched .docx + // to the PDF route made that route convert it with the old web preview instead, which + // opened the previous viewer's look rather than the page being read. + viewModel.exportToPdf(context) { pdf -> if (pdf != null) onOpenPdf(pdf) else Toast.makeText(context, R.string.docx_read_failed, Toast.LENGTH_SHORT).show() } } @@ -1496,9 +1498,9 @@ private fun StaticBlock(block: DocBlock, look: DocxLook, env: DocEnv) { // ── pictures ────────────────────────────────────────────────────────────────── -private class DecodedPicture(val image: ImageBitmap, val paperLike: Boolean) +internal class DecodedPicture(val image: ImageBitmap, val paperLike: Boolean) -private object DocImages { +internal object DocImages { private val cache = object : LruCache>(48 * 1024 * 1024) { override fun sizeOf(key: String, value: Pair) = value.first.byteCount } @@ -1579,7 +1581,7 @@ private fun DocPicture(info: ObjInfo, pkg: DocxPackage, look: DocxLook, maxWidth // ── tables ──────────────────────────────────────────────────────────────────── -private class TableLook( +internal class TableLook( val fractions: FloatArray, val width: Dp, val indent: Dp, @@ -1593,12 +1595,12 @@ private class TableLook( val scale: Float ) -private class CellProps(val span: Int, val vContinue: Boolean, val fill: Int?, val vAlign: String?) +internal class CellProps(val span: Int, val vContinue: Boolean, val fill: Int?, val vAlign: String?) private val cellPropsCache = HashMap() private val tableLookCache = java.util.WeakHashMap>() -private fun cellProps(tcPr: String): CellProps = synchronized(cellPropsCache) { +internal fun cellProps(tcPr: String): CellProps = synchronized(cellPropsCache) { cellPropsCache.getOrPut(tcPr) { val root = if (tcPr.isEmpty()) null else XmlScan.scan(tcPr).children.firstOrNull() fun child(n: String): XNode? = root?.child(n) @@ -1616,7 +1618,7 @@ private fun cellProps(tcPr: String): CellProps = synchronized(cellPropsCache) { * A table at its real size on the page: Word's column grid, alignment and indent. Only a table * wider than the page's text area (plus a little of the margin, which Word allows) is shrunk. */ -private fun tableLookOf(t: DocTable, pkg: DocxPackage, look: DocxLook, availWidth: Dp): TableLook { +internal fun tableLookOf(t: DocTable, pkg: DocxPackage, look: DocxLook, availWidth: Dp): TableLook { val cacheKey = look.scale * 100_000f + availWidth.value synchronized(tableLookCache) { tableLookCache[t]?.let { if (it.first == cacheKey) return it.second } } val tblPr = if (t.tblPr.isEmpty()) null else XmlScan.scan(t.tblPr).children.firstOrNull() diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/DocxPdfWriter.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/DocxPdfWriter.kt new file mode 100644 index 0000000..3458fe5 --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/DocxPdfWriter.kt @@ -0,0 +1,358 @@ +package com.chethan616.clearpdf.ui.screen + +import android.content.Context +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.pdf.PdfDocument +import android.util.Log +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.drawscope.CanvasDrawScope +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.font.createFontFamilyResolver +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.chethan616.clearpdf.docx.DocBlock +import com.chethan616.clearpdf.docx.DocPara +import com.chethan616.clearpdf.docx.DocRaw +import com.chethan616.clearpdf.docx.DocRow +import com.chethan616.clearpdf.docx.DocTable +import com.chethan616.clearpdf.docx.DocxObjects +import com.chethan616.clearpdf.docx.DocxPackage +import com.chethan616.clearpdf.docx.ObjInfo +import com.chethan616.clearpdf.docx.RProps +import com.chethan616.clearpdf.docx.XmlScan +import java.io.File +import java.io.FileOutputStream +import kotlin.math.roundToInt + +/** + * A Word document as a PDF, drawn from the Word editor's own page layout: the same [DocxLook] + * styles, list labels, pictures, tables and Word's page breaks ([flatten]) the editor shows, laid + * out with Compose's text engine at one unit per point and drawn straight onto [PdfDocument] pages, + * so the text stays real, selectable text. + * + * This replaced the old web-preview conversion for the editor's PDF and Print buttons, which + * produced the previous viewer's look rather than the page the person was reading. Where our fonts + * run longer than Word's and a page overflows, the content carries on to a new page, breaking + * between lines, pictures and table rows. + */ +internal object DocxPdfWriter { + fun write(context: Context, pkg: DocxPackage, blocks: List, out: File): Boolean = + runCatching { Exporter(context, pkg, blocks).run(out); true } + .onFailure { Log.w("DocxPdf", "Couldn't draw the document as a PDF", it) } + .getOrDefault(false) +} + +/** Something laid out: its height in points, where a page may end inside it, and how to draw it at (0, 0). */ +private class Laid(val height: Float, val breaks: FloatArray, val draw: DrawScope.() -> Unit) + +private class Exporter(context: Context, private val pkg: DocxPackage, private val blocks: List) { + private val density = Density(1f, 1f) + private val look = DocxLook(pkg, false, 1f, 1f, DocxLook.carlito()) + private val measurer = TextMeasurer(createFontFamilyResolver(context), density, LayoutDirection.Ltr, 0) + private val labels = numberingLabels(pkg, blocks) + private val setup = pkg.page + private val pageW = (setup.width / 20f).coerceAtLeast(72f) + private val pageH = (setup.height / 20f).coerceAtLeast(72f) + private val left = setup.left.coerceAtLeast(0) / 20f + private val right = setup.right.coerceAtLeast(0) / 20f + private val top = setup.top.coerceAtLeast(0) / 20f + private val bottom = setup.bottom.coerceAtLeast(0) / 20f + private val textW = (pageW - left - right).coerceAtLeast(72f) + private val scope = CanvasDrawScope() + private val bitmapPaint = Paint(Paint.FILTER_BITMAP_FLAG or Paint.ANTI_ALIAS_FLAG) + + fun run(out: File) { + val flat = flatten(blocks, pkg.styles, splitParas = true) + val doc = PdfDocument() + var page: PdfDocument.Page? = null + var number = 0 + var y = 0f + var fresh = true + val limit = pageH - bottom + + fun newPage() { + page?.let(doc::finishPage) + number++ + page = doc.startPage(PdfDocument.PageInfo.Builder(pageW.roundToInt(), pageH.roundToInt(), number).create()) + y = top + fresh = true + } + + fun slice(l: Laid, from: Float, to: Float) { + val p = page ?: return + val at = y + scope.draw(density, LayoutDirection.Ltr, Canvas(p.canvas), Size(pageW, pageH)) { + clipRect(0f, at, pageW, at + (to - from)) { + translate(left, at - from) { l.draw(this) } + } + } + y += to - from + fresh = false + } + + // Whole when it fits; otherwise up to the last line (or picture, or row) that does, and the + // rest on the next page. Something taller than a whole page is cut where the page ends. + fun place(l: Laid) { + var from = 0f + while (l.height - from > 0.01f) { + val avail = limit - y + if (l.height - from <= avail + 0.5f) { slice(l, from, l.height); return } + var cut = -1f + for (b in l.breaks) if (b > from + 0.5f && b - from <= avail && b > cut) cut = b + when { + cut > 0f -> { slice(l, from, cut); from = cut; newPage() } + !fresh -> newPage() + else -> { + val to = from + avail.coerceAtLeast(1f) + slice(l, from, to); from = to; newPage() + } + } + } + } + + for (item in flat.items) when (item) { + is DocItem.Top -> newPage() + // Word's own page break; skipped when an overflow has just started a page anyway. + is DocItem.Gap -> if (!fresh) newPage() + is DocItem.Bottom -> Unit + is DocItem.Para -> place( + para(item.para, look, textW, null, item.range, item.spaceTop && item.first, item.spaceBottom && item.last) + ) + is DocItem.TRow -> place(row(item.table, item.row, item.rowIndex, look, textW)) + } + if (page == null) newPage() + page?.let(doc::finishPage) + out.parentFile?.mkdirs() + try { + FileOutputStream(out).use { doc.writeTo(it) } + } finally { + doc.close() + } + } + + /** One paragraph, as [ParagraphBlock] lays it out: pictures, text boxes, label + text, rule. */ + private fun para( + p: DocPara, + lk: DocxLook, + width: Float, + cell: RProps?, + range: IntRange?, + spaceTop: Boolean, + spaceBottom: Boolean + ): Laid { + val pl = lk.para(p, cell) + val full = lk.annotate(p, pl, cell) + val annotated = if (range == null) full else full.subSequence(range.first, (range.last + 1).coerceAtMost(full.length)) + val objects = p.fmts.withIndex() + .filter { (i, f) -> f.obj != null && (range == null || i in range) } + .map { DocxObjects.info(it.value.obj!!) } + val pictures = objects.filter { it.imageRid != null } + val boxes = objects.filter { it.textBox.isNotEmpty() } + val blank = annotated.text.all { it == '​' || it.isWhitespace() } + val first = range == null || range.first == 0 + val label = if (first) labels[p.id] else null + val rtl = pl.rtl + val dir = if (rtl) LayoutDirection.Rtl else LayoutDirection.Ltr + val padStart = pl.padStart.value + val padEnd = pl.padEnd.value + val contentW = (width - padStart - padEnd).coerceAtLeast(8f) + val contentX = if (rtl) padEnd else padStart + val before = if (spaceTop) pl.before.value else 0f + val after = if (spaceBottom) pl.after.value else 0f + + class Part(val x: Float, val y: Float, val l: Laid) + val parts = ArrayList() + val breaks = ArrayList() + var y = before + + for (pic in pictures) { + val l = picture(pic, lk, contentW, pl.style.textAlign, rtl) ?: continue + parts += Part(contentX, y, l); y += l.height; breaks += y + } + for (box in boxes) { + val inner = stack(box.textBox, lk, (contentW - 16f).coerceAtLeast(8f)) + val h = inner.height + 16f + val ink = lk.ink.copy(0.22f) + val l = Laid(h + 8f, floatArrayOf()) { + drawRoundRect(ink, Offset(0f, 4f), Size(contentW, h), CornerRadius(4f), style = Stroke(0.5f)) + translate(8f, 12f) { inner.draw(this) } + } + parts += Part(contentX, y, l); y += l.height; breaks += y + } + if (!(blank && (pictures.isNotEmpty() || boxes.isNotEmpty()))) { + val labelW = if (label != null) pl.hanging.value else 0f + val bodyW = (contentW - labelW).coerceAtLeast(8f) + val text = measurer.measure( + annotated, + if (first) pl.style else pl.style.copy(textIndent = null), + constraints = Constraints.fixedWidth(bodyW.roundToInt().coerceAtLeast(1)), + layoutDirection = dir + ) + val lab = if (label != null && labelW >= 1f) measurer.measure( + AnnotatedString(label), + pl.style.copy(textAlign = TextAlign.Start, textIndent = null), + overflow = TextOverflow.Clip, + maxLines = 1, + constraints = Constraints.fixedWidth(labelW.roundToInt().coerceAtLeast(1)), + layoutDirection = dir + ) else null + // Label and text share their first baseline, like the editor's alignByBaseline row. + val base = maxOf(text.firstBaseline, lab?.firstBaseline ?: 0f) + val ty = base - text.firstBaseline + val ly = base - (lab?.firstBaseline ?: 0f) + val rowH = maxOf(ty + text.size.height, if (lab != null) ly + lab.size.height else 0f) + val textX = if (rtl) contentX else contentX + labelW + val labelX = if (rtl) contentX + bodyW else contentX + val rowY = y + for (i in 0 until text.lineCount) breaks += rowY + ty + text.getLineBottom(i) + parts += Part(0f, y, Laid(rowH, floatArrayOf()) { + drawText(text, topLeft = Offset(textX, ty)) + lab?.let { drawText(it, topLeft = Offset(labelX, ly)) } + }) + y += rowH + } + if (pl.borderBottom && (range == null || range.last >= p.text.length - 1)) { + val ink = lk.ink.copy(0.55f) + parts += Part(contentX, y, Laid(4f, floatArrayOf()) { + drawLine(ink, Offset(0f, 3.5f), Offset(contentW, 3.5f), 1f) + }) + y += 4f + } + val contentBottom = y + y += after + val shading = pl.shading + return Laid(y, breaks.toFloatArray()) { + if (shading != null && contentBottom > before) { + drawRect(shading, Offset(0f, before), Size(width, contentBottom - before)) + } + for (part in parts) translate(part.x, part.y) { part.l.draw(this) } + } + } + + /** A picture at its size in the document, aligned like [DocPicture], with 2 pt above and below. */ + private fun picture(info: ObjInfo, lk: DocxLook, maxWidth: Float, paraAlign: TextAlign?, rtl: Boolean): Laid? { + val rid = info.imageRid ?: return null + val bytes = pkg.image(rid) ?: return null + val want = if (info.widthPt > 0f) info.widthPt * lk.scale else maxWidth + val w = want.coerceAtMost(maxWidth.coerceAtLeast(24f)).coerceAtLeast(12f) + // About 150 dpi: sharp in print without a camera photo's full weight. + val target = (w * 2.1f).roundToInt().coerceIn(64, 1800) + val pic = DocImages.decode("$rid@${System.identityHashCode(pkg)}@pdf$target", bytes, target) + val ratio = if (info.widthPt > 0f && info.heightPt > 0f) info.heightPt / info.widthPt + else pic?.image?.let { it.height.toFloat() / it.width.coerceAtLeast(1) } ?: 0.6f + val h = w * ratio.coerceIn(0.05f, 20f) + val start = when (info.hAlign) { + "center" -> 0.5f + "right", "outside" -> 1f + "left", "inside" -> 0f + else -> when (paraAlign) { + TextAlign.Center -> 0.5f + TextAlign.End, TextAlign.Right -> 1f + else -> 0f + } + } + val x = (maxWidth - w) * (if (rtl) 1f - start else start) + val bmp = pic?.image?.asAndroidBitmap() + return Laid(h + 4f, floatArrayOf()) { + if (bmp != null) drawIntoCanvas { it.nativeCanvas.drawBitmap(bmp, null, RectF(x, 2f, x + w, 2f + h), bitmapPaint) } + } + } + + /** Blocks one under another (table cells, text boxes, content controls). */ + private fun stack(list: List, lk: DocxLook, width: Float, cell: RProps? = null): Laid { + val parts = ArrayList>() + val breaks = ArrayList() + var y = 0f + fun add(l: Laid) { + parts += y to l + for (b in l.breaks) breaks += y + b + y += l.height + breaks += y + } + for (b in list) when (b) { + is DocPara -> add(para(b, lk, width, cell, null, spaceTop = true, spaceBottom = true)) + is DocTable -> for ((i, r) in b.rows.withIndex()) add(row(b, r, i, lk, width)) + is DocRaw -> if (b.preview.isNotEmpty()) add(stack(b.preview, lk, width, cell)) + } + return Laid(y, breaks.toFloatArray()) { for ((py, l) in parts) translate(0f, py) { l.draw(this) } } + } + + /** One table row, as [TableRowBlock] lays it out: Word's grid, fills, borders, cell alignment. */ + private fun row(table: DocTable, row: DocRow, rowIndex: Int, lk: DocxLook, availWidth: Float): Laid { + val tl = tableLookOf(table, pkg, lk, availWidth.dp) + val cellLook = lk.scaled(tl.scale) + val lastRow = rowIndex == table.rows.lastIndex + val gridBefore = if (row.trPr.isEmpty()) 0 + else XmlScan.scan(row.trPr).children.firstOrNull()?.child("gridBefore")?.attr(row.trPr, "val")?.toIntOrNull() ?: 0 + val header = tl.firstRow && rowIndex == 0 + val banded = tl.bands && tl.bandFill != null && !header && (rowIndex - if (tl.firstRow) 1 else 0) % 2 == 0 + val line = lk.ink.copy(0.55f) + val tw = tl.width.value + fun frac(from: Int, span: Int): Float { + var f = 0f + for (i in from until (from + span).coerceAtMost(tl.fractions.size)) f += tl.fractions[i] + return f.coerceAtLeast(0.0001f) + } + val x0 = when (tl.jc) { + "center" -> (availWidth - tw) / 2f + "right", "end" -> availWidth - tw + else -> tl.indent.value + } + val padH = 5.4f * cellLook.scale + + class Cell(val x: Float, val w: Float, val fill: Color?, val content: Laid?, val vAlign: String?, val vContinue: Boolean, val last: Boolean) + val cells = ArrayList() + var col = gridBefore + var x = x0 + if (gridBefore > 0) tw * frac(0, gridBefore) else 0f + for ((ci, c) in row.cells.withIndex()) { + val cp = cellProps(c.tcPr) + val fill = cp.fill ?: if (header) tl.headerFill else if (banded) tl.bandFill else null + val cw = tw * frac(col, cp.span) + val content = if (cp.vContinue) null + else stack(c.blocks, cellLook, (cw - 2f * padH).coerceAtLeast(4f), if (header) tl.headerR else null) + cells += Cell(x, cw, lk.fillOf(fill), content, cp.vAlign, cp.vContinue, ci == row.cells.lastIndex) + x += cw + col += cp.span + } + val rowH = (cells.maxOfOrNull { (it.content?.height ?: 0f) + 2f } ?: 2f).coerceAtLeast(2f) + return Laid(rowH, floatArrayOf()) { + for (c in cells) { + c.fill?.let { drawRect(it, Offset(c.x, 0f), Size(c.w, rowH)) } + c.content?.let { content -> + val free = rowH - 2f - content.height + val dy = 1f + when (c.vAlign) { + "center" -> free / 2f + "bottom" -> free + else -> 0f + } + translate(c.x + padH, dy) { content.draw(this) } + } + if (tl.borders) { + val sw = 0.5f + if (!c.vContinue) drawLine(line, Offset(c.x, 0f), Offset(c.x + c.w, 0f), sw) + drawLine(line, Offset(c.x, 0f), Offset(c.x, rowH), sw) + if (c.last) drawLine(line, Offset(c.x + c.w, 0f), Offset(c.x + c.w, rowH), sw) + if (lastRow) drawLine(line, Offset(c.x, rowH), Offset(c.x + c.w, rowH), sw) + } + } + } + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/OnboardingScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/OnboardingScreen.kt index 8e36121..b1b384d 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/OnboardingScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/OnboardingScreen.kt @@ -616,6 +616,41 @@ private fun AppearanceChooser( } } + // Motion profile: the same presets as Settings -> Liquid Glass -> Motion (Bouncy until the + // user picks). Each chip is itself a glass button, so tapping one plays the new physics. + BasicText( + stringResource(R.string.glass_motion), + style = TextStyle(inkSoft, 13.sp, fontWeight = FontWeight.SemiBold) + ) + val motionStyle = com.chethan616.clearpdf.ui.components.MotionSettings.style + val motionPresets = listOf( + com.chethan616.clearpdf.ui.components.MotionStyle.Normal to stringResource(R.string.motion_preset_normal), + com.chethan616.clearpdf.ui.components.MotionStyle.Performance to stringResource(R.string.motion_preset_performance), + com.chethan616.clearpdf.ui.components.MotionStyle.Bouncy to stringResource(R.string.motion_preset_bouncy), + com.chethan616.clearpdf.ui.components.MotionStyle.SuperSpringy to stringResource(R.string.motion_preset_super) + ) + Row( + Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + motionPresets.forEach { (preset, label) -> + val selected = motionStyle == preset + LiquidButton( + onClick = { com.chethan616.clearpdf.ui.components.MotionSettings.update(context, preset) }, + backdrop = backdrop, + tint = if (selected) LiquidGlassColors.Purple else Color.Unspecified, + surfaceColor = if (selected) Color.Unspecified else (if (isDark) Color.White.copy(0.10f) else Color.Black.copy(0.06f)), + horizontalContentPadding = 14.dp + ) { + BasicText( + label, + style = TextStyle(if (selected) Color.White else ink, 12.sp, fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium), + maxLines = 1 + ) + } + } + } + Box(Modifier.fillMaxWidth().height(1.dp).background(ink.copy(0.10f))) Row( diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/ScanDocumentScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/ScanDocumentScreen.kt index 8937e04..9b8147b 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/ScanDocumentScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/ScanDocumentScreen.kt @@ -499,7 +499,7 @@ fun ScanDocumentScreen( viewModel.cancelScanning() if (pdfUri != null) { viewModel.markAsSaved(pdfUri) - if (GitHubStarPromptManager.recordPdfInteraction(context)) { + if (GitHubStarPromptManager.recordSuccess(context)) { StarPromptEventBus.requestPrompt() } Toast.makeText(context, context.getString(R.string.pdf_saved_successfully), Toast.LENGTH_SHORT).show() diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/SettingsScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/SettingsScreen.kt index 6f22e9a..1b552ab 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/SettingsScreen.kt @@ -241,6 +241,10 @@ fun SettingsScreen( title = pageTitle, backdrop = headerBackdrop, onBack = if (page != PageIndex) ({ page = PageIndex }) else null, + // Holding Back on a Settings page returns to Settings itself. The app-wide + // default (back to the library) popped all the way Home from in here. + onBackLongPress = if (page != PageIndex) ({ page = PageIndex }) else null, + backLongPressLabel = stringResource(R.string.settings_title), modifier = entrance.sectionFade(0) ) } @@ -818,7 +822,7 @@ private fun MotionTuningSection( Spacer(Modifier.height(8.dp)) ToolPrimaryButton( text = stringResource(R.string.motion_reset), - onClick = { set(com.chethan616.clearpdf.ui.components.MotionStyle.Normal) }, + onClick = { set(com.chethan616.clearpdf.ui.components.MotionStyle.Default) }, backdrop = backdrop, accent = LiquidGlassColors.Purple, icon = Icons.Rounded.Refresh, diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/utils/InteractiveHighlight.kt b/app/src/main/java/com/chethan616/clearpdf/ui/utils/InteractiveHighlight.kt index 9d71ecb..f40a213 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/utils/InteractiveHighlight.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/utils/InteractiveHighlight.kt @@ -60,11 +60,19 @@ class InteractiveHighlight( // a touch before settling — the "boing" that makes the release feel physical. private val expandInSpec get() = spring(dampingRatio = MotionSettings.damping(0.52f), stiffness = MotionSettings.stiffness(340f), visibilityThreshold = 0.001f) private val expandOutSpec get() = spring(dampingRatio = MotionSettings.damping(0.46f), stiffness = MotionSettings.stiffness(420f), visibilityThreshold = 0.001f) + // The light never bounces (DESIGN.md: alpha never bounces). It used to read the press and bloom + // springs above, so on release, while those rang through rest (a lot under Bouncy / Super + // springy), every swing back above rest lit the glass up again: a shine on each jiggle. + // Critically damped, so it only ever rises with the finger and fades once it lifts. + private val glowInSpec get() = spring(dampingRatio = 1f, stiffness = MotionSettings.stiffness(900f), visibilityThreshold = 0.001f) + private val glowOutSpec get() = spring(dampingRatio = 1f, stiffness = MotionSettings.stiffness(380f), visibilityThreshold = 0.001f) private val pressProgressAnimation = Animatable(0f, 0.001f) private val positionAnimation = Animatable(Offset.Zero, Offset.VectorConverter, Offset.VisibilityThreshold) private val expandAnimation = Animatable(0f, 0.001f) private val wobbleAnimation = Animatable(0f, 0.001f) + private val glowAnimation = Animatable(0f, 0.001f) + private val bloomGlowAnimation = Animatable(0f, 0.001f) private var startPosition = Offset.Zero private var positionUpdateJob: Job? = null @@ -104,10 +112,10 @@ half4 main(float2 coord) { val modifier: Modifier = Modifier.drawWithContent { - val progress = pressProgressAnimation.value + val progress = glowAnimation.value.fastCoerceIn(0f, 1f) // A bloomed long-press lifts the glow a little further, so the control reads as "picked up". - val bloom = expandAnimation.value.fastCoerceIn(0f, 1f) - if (progress > 0f) { + val bloom = bloomGlowAnimation.value.fastCoerceIn(0f, 1f) + if (progress > 0f || bloom > 0f) { val shader = this@InteractiveHighlight.shader if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && shader != null) { drawRect(Color.White.copy(0.08f * progress + 0.05f * bloom), blendMode = BlendMode.Plus) @@ -141,9 +149,11 @@ half4 main(float2 coord) { val kick = WobbleKick * MotionSettings.style.wobble.fastCoerceIn(0f, 3f) * pressProgressAnimation.value.fastCoerceIn(0f, 1.1f) launch { wobbleAnimation.animateTo(0f, wobbleSpec, initialVelocity = kick) } launch { pressProgressAnimation.animateTo(0f, pressOutSpec) } + launch { glowAnimation.animateTo(0f, glowOutSpec) } launch { positionAnimation.animateTo(startPosition, positionAnimationSpec) } if (expandAnimation.value != 0f || expandAnimation.targetValue != 0f) { launch { expandAnimation.animateTo(0f, expandOutSpec) } + launch { bloomGlowAnimation.animateTo(0f, glowOutSpec) } } } } @@ -161,6 +171,7 @@ half4 main(float2 coord) { releaseJob?.cancel() animationScope.launch { launch { pressProgressAnimation.animateTo(1f, pressInSpec) } + launch { glowAnimation.animateTo(1f, glowInSpec) } launch { positionAnimation.snapTo(startPosition) } // A small taller-first nudge as the glass swells under the finger. launch { wobbleAnimation.animateTo(0f, wobbleSpec, initialVelocity = -PressKick) } @@ -171,6 +182,7 @@ half4 main(float2 coord) { longPressJob = animationScope.launch { delay(longPressMs) onLongPressExpand?.invoke() + launch { bloomGlowAnimation.animateTo(1f, glowInSpec) } expandAnimation.animateTo(1f, expandInSpec) } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/CompressPdfViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/CompressPdfViewModel.kt index 72d862d..9f4ed2e 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/CompressPdfViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/CompressPdfViewModel.kt @@ -177,10 +177,11 @@ class CompressPdfViewModel(private val compressPdfUseCase: CompressPdfUseCase) : resultMessage = context.getString(R.string.compression_success, origKb, compKb, reduction, saveLabel) ) - if (GitHubStarPromptManager.recordPdfInteraction(context)) { + if (GitHubStarPromptManager.recordSuccess(context)) { StarPromptEventBus.requestPrompt() } } catch (e: Exception) { + GitHubStarPromptManager.recordFailure(context) _uiState.value = _uiState.value.copy( isCompressing = false, errorMessage = context.getString(R.string.compression_failed) diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/CreatePdfViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/CreatePdfViewModel.kt index b565960..9da2fbd 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/CreatePdfViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/CreatePdfViewModel.kt @@ -172,10 +172,11 @@ class CreatePdfViewModel(private val createPdfUseCase: CreatePdfUseCase) : ViewM ) } - if (GitHubStarPromptManager.recordPdfInteraction(context)) { + if (GitHubStarPromptManager.recordSuccess(context)) { StarPromptEventBus.requestPrompt() } } catch (e: Exception) { + GitHubStarPromptManager.recordFailure(context) _uiState.update { it.copy(isCreating = false, errorMessage = context.getString(R.string.create_failed)) } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/DocxEditorViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/DocxEditorViewModel.kt index dcf32dc..927129f 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/DocxEditorViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/DocxEditorViewModel.kt @@ -302,12 +302,30 @@ class DocxEditorViewModel : ViewModel() { } } + /** + * The document as a PDF, for the PDF button and Print, best source first: the Office engine + * (LibreOffice, Word's own layout rules) when it is installed; otherwise the editor's own pages + * drawn straight into a PDF ([DocxPdfWriter]), exactly as they read here, unsaved edits + * included; and only if that fails, the old web-preview converter. + */ fun exportToPdf(context: Context, onDone: (Uri?) -> Unit) { + val p = pkg + val d = doc + val name = meta.fileName.ifBlank { "Document.docx" } currentFile(context) { src -> - if (src == null) return@currentFile onDone(null) viewModelScope.launch { - val out = withContext(Dispatchers.IO) { - runCatching { com.chethan616.clearpdf.utils.UniversalDocumentConverter.convertToPdf(context, src) }.getOrNull() + val out = withContext(Dispatchers.Default) { + if (src != null) { + runCatching { com.chethan616.clearpdf.office.OfficeEngine.tryConvert(context, src, name.lowercase()) } + .getOrNull()?.let { return@withContext Uri.fromFile(it) } + } + if (p != null && d != null) { + val dir = File(context.cacheDir, "converted_pdfs/${System.currentTimeMillis()}") + val pdf = File(dir, name.substringBeforeLast('.').ifBlank { "Document" }.replace('/', '_') + ".pdf") + if (com.chethan616.clearpdf.ui.screen.DocxPdfWriter.write(context, p, d.blocks, pdf)) return@withContext Uri.fromFile(pdf) + } + if (src == null) null + else runCatching { com.chethan616.clearpdf.utils.UniversalDocumentConverter.convertToPdf(context, src) }.getOrNull() } onDone(out) } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ImagesToPdfViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ImagesToPdfViewModel.kt index 4a2c460..37960fe 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ImagesToPdfViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ImagesToPdfViewModel.kt @@ -87,8 +87,9 @@ class ImagesToPdfViewModel(private val converter: PdfConverter) : ViewModel() { saveLocationLabel = saveLabel, resultMessage = context.getString(R.string.images_success, images.size, saveLabel) ) - if (GitHubStarPromptManager.recordPdfInteraction(context)) StarPromptEventBus.requestPrompt() + if (GitHubStarPromptManager.recordSuccess(context)) StarPromptEventBus.requestPrompt() } catch (e: Exception) { + GitHubStarPromptManager.recordFailure(context) _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = context.getString(R.string.conversion_failed)) } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/MergePdfViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/MergePdfViewModel.kt index a3ba911..9e63c2c 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/MergePdfViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/MergePdfViewModel.kt @@ -122,10 +122,11 @@ class MergePdfViewModel(private val mergePdfUseCase: MergePdfUseCase) : ViewMode resultMessage = context.getString(R.string.merge_success, uris.size, outName, saveLabel) ) - if (GitHubStarPromptManager.recordPdfInteraction(context)) { + if (GitHubStarPromptManager.recordSuccess(context)) { StarPromptEventBus.requestPrompt() } } catch (e: Exception) { + GitHubStarPromptManager.recordFailure(context) _uiState.value = _uiState.value.copy( isMerging = false, errorMessage = context.getString(R.string.merge_failed) diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PageOrganizerViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PageOrganizerViewModel.kt index fdc30bb..057165a 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PageOrganizerViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PageOrganizerViewModel.kt @@ -190,9 +190,10 @@ class PageOrganizerViewModel(private val editor: PdfEditor) : ViewModel() { savedLayout = layoutOf(pages) ) onSaved?.invoke() - val shouldPrompt = withContext(Dispatchers.IO) { GitHubStarPromptManager.recordPdfInteraction(context) } + val shouldPrompt = withContext(Dispatchers.IO) { GitHubStarPromptManager.recordSuccess(context) } if (shouldPrompt) StarPromptEventBus.requestPrompt() } catch (e: Exception) { + GitHubStarPromptManager.recordFailure(context) _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = context.getString(R.string.organize_save_failed)) } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfViewerViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfViewerViewModel.kt index 433be64..d67d03a 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfViewerViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfViewerViewModel.kt @@ -422,7 +422,7 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel pageCount = doc.pageCount, sizeBytes = doc.sizeBytes )) - if (GitHubStarPromptManager.recordPdfInteraction(context)) { + if (GitHubStarPromptManager.recordDocumentOpened(context)) { StarPromptEventBus.requestPrompt() } renderPage(context, 0, DEFAULT_RENDER_WIDTH) @@ -823,10 +823,11 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel exportMessage = context.getString(R.string.viewer_save_success, outputName), lastExportedUri = outputUri ) - if (GitHubStarPromptManager.recordPdfInteraction(context)) { + if (GitHubStarPromptManager.recordSuccess(context)) { StarPromptEventBus.requestPrompt() } } catch (_: Exception) { + GitHubStarPromptManager.recordFailure(context) _uiState.value = _uiState.value.copy( isExporting = false, exportError = context.getString(R.string.viewer_save_failed) diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/SplitPdfViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/SplitPdfViewModel.kt index e07f695..5abd93c 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/SplitPdfViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/SplitPdfViewModel.kt @@ -176,10 +176,11 @@ class SplitPdfViewModel(private val splitter: PdfSplitter) : ViewModel() { resultMessage = context.getString(R.string.split_success, results.size, saveLabel) ) - if (GitHubStarPromptManager.recordPdfInteraction(context)) { + if (GitHubStarPromptManager.recordSuccess(context)) { StarPromptEventBus.requestPrompt() } } catch (e: Exception) { + GitHubStarPromptManager.recordFailure(context) _uiState.value = _uiState.value.copy( isSplitting = false, errorMessage = context.getString(R.string.split_failed) @@ -210,10 +211,11 @@ class SplitPdfViewModel(private val splitter: PdfSplitter) : ViewModel() { resultMessage = context.getString(R.string.extract_success, pages.size, saveLabel) ) - if (GitHubStarPromptManager.recordPdfInteraction(context)) { + if (GitHubStarPromptManager.recordSuccess(context)) { StarPromptEventBus.requestPrompt() } } catch (e: Exception) { + GitHubStarPromptManager.recordFailure(context) _uiState.value = _uiState.value.copy( isSplitting = false, errorMessage = context.getString(R.string.extract_failed)