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
22 changes: 21 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand All @@ -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.

---

Expand Down Expand Up @@ -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.

---
Expand Down Expand Up @@ -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.

Original file line number Diff line number Diff line change
Expand Up @@ -252,65 +252,128 @@ 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) {
setNeverShowAgain(context)
}

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()
}
}
27 changes: 19 additions & 8 deletions app/src/main/java/com/chethan616/clearpdf/ui/DocsApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading