228056e22836

feat: Add copy shell feedback (v2.3.48)

- Emit copy pulses from centralized clipboard writes - Render a reduce-motion-aware shell ring and status pip overlay - Add pulse bus coverage and mark T016 copy feedback done

time
2026-06-26 14:06 UTC · 4 months ago
machine
koh2
parent
454b3951f979
6 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/shared/CopyFeedbackBus.kt+15 −0app/src/main/java/com/peachpasswords/shared/PeachClipboard.kt+1 −0app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+61 −54app/src/main/java/com/peachpasswords/ui/components/CopyFeedbackOverlay.kt+84 −0app/src/test/java/com/peachpasswords/shared/PeachClipboardTest.kt+8 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23470020-        versionName "2.3.47"19+        versionCode 23480020+        versionName "2.3.48"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/shared/CopyFeedbackBus.kt+15 −0
@@ -0,0 +1,15 @@1+package com.peachpasswords.shared2+3+import kotlinx.coroutines.flow.MutableStateFlow4+import kotlinx.coroutines.flow.StateFlow5+import kotlinx.coroutines.flow.asStateFlow6+import kotlinx.coroutines.flow.update7+8+object CopyFeedbackBus {9+    private val _pulses = MutableStateFlow(0)10+    val pulses: StateFlow<Int> = _pulses.asStateFlow()11+12+    fun pulse() {13+        _pulses.update { it + 1 }14+    }15+}

view file

app/src/main/java/com/peachpasswords/shared/PeachClipboard.kt+1 −0
@@ -35,6 +35,7 @@3535         if (value.isEmpty()) return3636         val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager3737         cm.setPrimaryClip(sensitiveClip(label, value))38+        CopyFeedbackBus.pulse()3839         if (hint) Toast.makeText(context, "Copied — clears in 30s", Toast.LENGTH_SHORT).show()3940         pending?.cancel()4041         pending = scheduleClear(

view file

app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+61 −54
@@ -7,9 +7,12 @@77 import androidx.compose.animation.fadeOut88 import androidx.compose.animation.slideInVertically99 import androidx.compose.animation.slideOutVertically10+import androidx.compose.foundation.layout.Box11+import androidx.compose.foundation.layout.fillMaxSize1012 import androidx.compose.runtime.Composable1113 import androidx.compose.runtime.LaunchedEffect1214 import androidx.compose.runtime.getValue15+import androidx.compose.ui.Modifier1316 import androidx.lifecycle.compose.collectAsStateWithLifecycle1417 import androidx.navigation.NavHostController1518 import androidx.navigation.NavType@@ -19,6 +22,7 @@1922 import androidx.navigation.navArgument2023 import com.peachpasswords.ui.codex.CodexGenerateScreen2124 import com.peachpasswords.ui.codex.CodexRestoreScreen25+import com.peachpasswords.ui.components.CopyFeedbackOverlay2226 import com.peachpasswords.ui.lock.LockScreen2327 import com.peachpasswords.ui.onboarding.OnboardingScreen2428 import com.peachpasswords.ui.pairing.ChirpPairScreen@@ -67,60 +71,61 @@6771         }6872     }6973 70-    NavHost(71-        navController = navController,72-        startDestination = startDestination.route,73-        enterTransition = {74-            if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {75-                slideInVertically(76-                    initialOffsetY = { it },77-                    animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),78-                ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))79-            } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {80-                EnterTransition.None81-            } else {82-                slideInVertically(83-                    initialOffsetY = { (it * 0.05f).toInt() },84-                    animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),85-                ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))86-            }87-        },88-        exitTransition = {89-            if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {90-                ExitTransition.None91-            } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {92-                slideOutVertically(93-                    targetOffsetY = { it },94-                    animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),95-                ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))96-            } else {97-                slideOutVertically(98-                    targetOffsetY = { -(it * 0.03f).toInt() },99-                    animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),100-                ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))101-            }102-        },103-        popEnterTransition = {104-            if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {105-                EnterTransition.None106-            } else {107-                fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))108-            }109-        },110-        popExitTransition = {111-            if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {112-                slideOutVertically(113-                    targetOffsetY = { it },114-                    animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),115-                ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))116-            } else {117-                slideOutVertically(118-                    targetOffsetY = { (it * 0.05f).toInt() },119-                    animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),120-                ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))121-            }122-        },123-    ) {74+    Box(Modifier.fillMaxSize()) {75+        NavHost(76+            navController = navController,77+            startDestination = startDestination.route,78+            enterTransition = {79+                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {80+                    slideInVertically(81+                        initialOffsetY = { it },82+                        animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),83+                    ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))84+                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {85+                    EnterTransition.None86+                } else {87+                    slideInVertically(88+                        initialOffsetY = { (it * 0.05f).toInt() },89+                        animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),90+                    ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))91+                }92+            },93+            exitTransition = {94+                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {95+                    ExitTransition.None96+                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {97+                    slideOutVertically(98+                        targetOffsetY = { it },99+                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),100+                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))101+                } else {102+                    slideOutVertically(103+                        targetOffsetY = { -(it * 0.03f).toInt() },104+                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),105+                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))106+                }107+            },108+            popEnterTransition = {109+                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {110+                    EnterTransition.None111+                } else {112+                    fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))113+                }114+            },115+            popExitTransition = {116+                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {117+                    slideOutVertically(118+                        targetOffsetY = { it },119+                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),120+                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))121+                } else {122+                    slideOutVertically(123+                        targetOffsetY = { (it * 0.05f).toInt() },124+                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),125+                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))126+                }127+            },128+        ) {124129         composable(PeachNav.Onboarding.route) {125130             OnboardingScreen(126131                 onFinished = {@@ -232,6 +237,8 @@232237                 onRestore = { nav(PeachNav.Import) },233238             )234239         }240+        }241+        CopyFeedbackOverlay()235242     }236243 }237244 

view file

app/src/main/java/com/peachpasswords/ui/components/CopyFeedbackOverlay.kt+84 −0
@@ -0,0 +1,84 @@1+package com.peachpasswords.ui.components2+3+import androidx.compose.animation.core.Animatable4+import androidx.compose.animation.core.FastOutSlowInEasing5+import androidx.compose.animation.core.tween6+import androidx.compose.foundation.Canvas7+import androidx.compose.foundation.background8+import androidx.compose.foundation.layout.Box9+import androidx.compose.foundation.layout.fillMaxSize10+import androidx.compose.foundation.layout.padding11+import androidx.compose.foundation.layout.size12+import androidx.compose.foundation.layout.statusBarsPadding13+import androidx.compose.foundation.shape.CircleShape14+import androidx.compose.runtime.Composable15+import androidx.compose.runtime.LaunchedEffect16+import androidx.compose.runtime.collectAsState17+import androidx.compose.runtime.getValue18+import androidx.compose.runtime.remember19+import androidx.compose.ui.Alignment20+import androidx.compose.ui.Modifier21+import androidx.compose.ui.draw.clip22+import androidx.compose.ui.geometry.CornerRadius23+import androidx.compose.ui.graphics.drawscope.Stroke24+import androidx.compose.ui.graphics.graphicsLayer25+import androidx.compose.ui.unit.dp26+import com.peachpasswords.shared.CopyFeedbackBus27+import com.peachpasswords.ui.theme.LocalPeachAccent28+import com.peachpasswords.ui.theme.LocalReduceMotion29+import kotlinx.coroutines.launch30+31+@Composable32+fun CopyFeedbackOverlay(modifier: Modifier = Modifier) {33+    val pulse by CopyFeedbackBus.pulses.collectAsState()34+    val accent = LocalPeachAccent.current35+    val reduceMotion = LocalReduceMotion.current36+    val ringAlpha = remember { Animatable(0f) }37+    val pipPulse = remember { Animatable(0f) }38+39+    LaunchedEffect(pulse, reduceMotion) {40+        if (pulse == 0) return@LaunchedEffect41+        if (reduceMotion) {42+            ringAlpha.snapTo(0.42f)43+            pipPulse.snapTo(1f)44+            ringAlpha.animateTo(0f, tween(220))45+            pipPulse.animateTo(0f, tween(220))46+        } else {47+            launch {48+                ringAlpha.snapTo(0f)49+                ringAlpha.animateTo(0.62f, tween(190, easing = FastOutSlowInEasing))50+                ringAlpha.animateTo(0f, tween(530, easing = FastOutSlowInEasing))51+            }52+            launch {53+                pipPulse.snapTo(0f)54+                pipPulse.animateTo(1f, tween(160, easing = FastOutSlowInEasing))55+                pipPulse.animateTo(0f, tween(240, easing = FastOutSlowInEasing))56+            }57+        }58+    }59+60+    Box(modifier.fillMaxSize()) {61+        Canvas(Modifier.fillMaxSize()) {62+            val stroke = 1.5.dp.toPx()63+            drawRoundRect(64+                color = accent.copy(alpha = ringAlpha.value * 0.55f),65+                style = Stroke(width = stroke),66+                cornerRadius = CornerRadius(20.dp.toPx(), 20.dp.toPx()),67+            )68+        }69+        Box(70+            Modifier71+                .align(Alignment.TopStart)72+                .statusBarsPadding()73+                .padding(start = 74.dp, top = 23.dp)74+                .size(6.dp)75+                .graphicsLayer {76+                    val scale = 1f + (pipPulse.value * 1.2f)77+                    scaleX = scale78+                    scaleY = scale79+                }80+                .clip(CircleShape)81+                .background(accent.copy(alpha = 0.28f + (pipPulse.value * 0.62f))),82+        )83+    }84+}

view file

app/src/test/java/com/peachpasswords/shared/PeachClipboardTest.kt+8 −0
@@ -49,4 +49,12 @@4949     @Test fun thirty_second_default_matches_browser() {5050         assertTrue(PeachClipboard.CLEAR_DELAY_MS == 30_000L)5151     }52+53+    @Test fun copy_feedback_bus_pulses_monotonically() {54+        val before = CopyFeedbackBus.pulses.value55+        CopyFeedbackBus.pulse()56+        CopyFeedbackBus.pulse()57+58+        assertEquals(before + 2, CopyFeedbackBus.pulses.value)59+    }5260 }

view file