228056e22836
feat: Add copy shell feedback (v2.3.48)
- time
- 2026-06-26 14:06 UTC · 4 months ago
- machine
- koh2
- parent
- 454b3951f979
↓ peach-android-228056e2.tar.gzatlas at this save
files changed6 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 −0app/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 {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+}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(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 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+}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 }