From 1e5275dd2cafebdf61522c4cf5986e4a80589349 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Sat, 6 Jun 2026 03:16:26 +0000 Subject: [PATCH 1/2] android: fix sign-in (INTERNET permission) + scrollable layout; v0.2.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from on-device testing of 0.2.0: - Add the INTERNET permission. Cascade was an offline app (bundled audio), so it was never declared — and the account/sync feature is the first code that makes an HTTP call, so every request threw ("Couldn't send the sign-in link"). - Make the main screen vertically scrollable and drop the weight-based spacer (incompatible with scrolling). Content was overflowing the viewport with no scroll, clipping the custom-timer input field into a stray box overlapping the sleep-timer chips. Also moved the sign-in/account box below the timer controls. Bumps to 0.2.1 / versionCode 3. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/android/app/build.gradle.kts | 4 +-- apps/android/app/src/main/AndroidManifest.xml | 4 +++ .../page/stephens/cascade/ui/CascadeScreen.kt | 25 +++++++++++-------- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 7b663b9..89e3d7b 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -15,8 +15,8 @@ android { applicationId = "page.stephens.cascade" minSdk = 26 targetSdk = 35 - versionCode = 2 - versionName = "0.2.0" + versionCode = 3 + versionName = "0.2.1" ndk { // Keep parity with cargo-ndk targets in the build script below. diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index c1d4163..0929920 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,10 @@ + + + diff --git a/apps/android/app/src/main/java/page/stephens/cascade/ui/CascadeScreen.kt b/apps/android/app/src/main/java/page/stephens/cascade/ui/CascadeScreen.kt index 9c743f3..5313654 100644 --- a/apps/android/app/src/main/java/page/stephens/cascade/ui/CascadeScreen.kt +++ b/apps/android/app/src/main/java/page/stephens/cascade/ui/CascadeScreen.kt @@ -15,6 +15,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions @@ -62,17 +64,18 @@ fun CascadeScreen(viewModel: CascadeViewModel) { modifier = Modifier .fillMaxSize() .systemBarsPadding() + .verticalScroll(rememberScrollState()) .padding(horizontal = 24.dp, vertical = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Header(subtitle = snapshot.subtitle) - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(32.dp)) TimerReadout( kind = snapshot.timer.kind, label = snapshot.timer.remainingLabel, progress = snapshot.timer.progress, ) - Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(32.dp)) PlayButton( isPlaying = snapshot.isPlaying, label = snapshot.primaryButtonLabel, @@ -91,8 +94,16 @@ fun CascadeScreen(viewModel: CascadeViewModel) { trackingEnabled = snapshot.listening.trackingEnabled, onToggleTracking = viewModel::setListeningTracking, ) + Spacer(Modifier.height(24.dp)) + TimerControls( + activeKind = snapshot.timer.kind, + onStartPomodoro = viewModel::startPomodoro, + onStartSleep = viewModel::startSleepTimer, + onStartStopwatch = viewModel::startStopwatch, + onCancel = viewModel::cancelTimer, + ) if (syncState.available) { - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(28.dp)) AccountControls( state = syncState, onSignIn = viewModel::signIn, @@ -101,14 +112,6 @@ fun CascadeScreen(viewModel: CascadeViewModel) { onDeleteAccount = viewModel::deleteAccount, ) } - Spacer(Modifier.height(24.dp)) - TimerControls( - activeKind = snapshot.timer.kind, - onStartPomodoro = viewModel::startPomodoro, - onStartSleep = viewModel::startSleepTimer, - onStartStopwatch = viewModel::startStopwatch, - onCancel = viewModel::cancelTimer, - ) Spacer(Modifier.height(12.dp)) snapshot.errorMessage?.let { Text( From 93b41c13ccdf63e5b210d2c10b7fce4fff545cd6 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Sat, 6 Jun 2026 03:34:04 +0000 Subject: [PATCH 2/2] android: paste-the-link sign-in fallback; v0.2.2 Magic links won't reliably reopen a sideloaded, debug-signed APK (no stable signing key to pin Android App Links to), so add the desktop-style fallback: "Email me a link" requests it, then paste the link back into the app to finish. SyncManager.completeSignInFromLink extracts the token from the pasted URL (or a raw token) and runs the existing verify flow. Two-step UI with weight-based fields so the buttons don't overflow. Bumps to 0.2.2 / versionCode 4. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/android/app/build.gradle.kts | 4 +-- .../page/stephens/cascade/sync/SyncManager.kt | 23 ++++++++++++ .../page/stephens/cascade/ui/CascadeScreen.kt | 35 +++++++++++++++++-- .../stephens/cascade/ui/CascadeViewModel.kt | 1 + 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 89e3d7b..7b27433 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -15,8 +15,8 @@ android { applicationId = "page.stephens.cascade" minSdk = 26 targetSdk = 35 - versionCode = 3 - versionName = "0.2.1" + versionCode = 4 + versionName = "0.2.2" ndk { // Keep parity with cargo-ndk targets in the build script below. diff --git a/apps/android/app/src/main/java/page/stephens/cascade/sync/SyncManager.kt b/apps/android/app/src/main/java/page/stephens/cascade/sync/SyncManager.kt index 9b88a58..db6b98c 100644 --- a/apps/android/app/src/main/java/page/stephens/cascade/sync/SyncManager.kt +++ b/apps/android/app/src/main/java/page/stephens/cascade/sync/SyncManager.kt @@ -84,6 +84,16 @@ class SyncManager( } } + /** Complete sign-in from a pasted link (…/auth?token=XYZ) or a raw token. */ + fun completeSignInFromLink(input: String) { + val token = extractToken(input) + if (token == null) { + _state.value = _state.value.copy(status = "Paste the full sign-in link.") + return + } + completeSignIn(token) + } + fun signOut() { val account = _state.value.account _state.value = _state.value.copy(account = null, status = null) @@ -155,6 +165,19 @@ class SyncManager( scope.launch { sync(account) } } + /** Pull the token out of a pasted sign-in URL, or accept a raw token. */ + private fun extractToken(input: String): String? { + val s = input.trim() + if (s.isEmpty()) return null + val idx = s.indexOf("token=") + if (idx >= 0) { + val rest = s.substring(idx + "token=".length) + val amp = rest.indexOf('&') + return if (amp >= 0) rest.substring(0, amp) else rest + } + return s + } + companion object { private const val SYNC_THRESHOLD_MS = 30_000L } diff --git a/apps/android/app/src/main/java/page/stephens/cascade/ui/CascadeScreen.kt b/apps/android/app/src/main/java/page/stephens/cascade/ui/CascadeScreen.kt index 5313654..39bbfe0 100644 --- a/apps/android/app/src/main/java/page/stephens/cascade/ui/CascadeScreen.kt +++ b/apps/android/app/src/main/java/page/stephens/cascade/ui/CascadeScreen.kt @@ -107,6 +107,7 @@ fun CascadeScreen(viewModel: CascadeViewModel) { AccountControls( state = syncState, onSignIn = viewModel::signIn, + onCompleteSignIn = viewModel::completeSignInFromLink, onSignOut = viewModel::signOut, onDeleteData = viewModel::deleteListeningData, onDeleteAccount = viewModel::deleteAccount, @@ -385,11 +386,13 @@ private fun ListeningStats( private fun AccountControls( state: SyncUiState, onSignIn: (String) -> Unit, + onCompleteSignIn: (String) -> Unit, onSignOut: () -> Unit, onDeleteData: () -> Unit, onDeleteAccount: () -> Unit, ) { var email by remember { mutableStateOf("") } + var link by remember { mutableStateOf("") } var showManage by remember { mutableStateOf(false) } Column( modifier = Modifier.fillMaxWidth(), @@ -418,8 +421,16 @@ private fun AccountControls( } } else { Text("Sync across devices", style = MaterialTheme.typography.labelMedium) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(4.dp)) + Text( + "1. Email yourself a link. 2. Paste it back here to sign in.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + ) + Spacer(Modifier.height(10.dp)) + // Step 1 — request the magic link. Row( + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -429,11 +440,31 @@ private fun AccountControls( label = { Text("you@example.com") }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), - modifier = Modifier.size(width = 200.dp, height = 64.dp), + modifier = Modifier.weight(1f), ) Button( onClick = { if (email.isNotBlank()) onSignIn(email.trim()) }, enabled = !state.busy && email.isNotBlank(), + ) { Text("Email link") } + } + Spacer(Modifier.height(8.dp)) + // Step 2 — paste the link from the email to finish. + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = link, + onValueChange = { link = it }, + label = { Text("paste the sign-in link") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.weight(1f), + ) + Button( + onClick = { if (link.isNotBlank()) onCompleteSignIn(link.trim()) }, + enabled = !state.busy && link.isNotBlank(), ) { Text("Sign in") } } } diff --git a/apps/android/app/src/main/java/page/stephens/cascade/ui/CascadeViewModel.kt b/apps/android/app/src/main/java/page/stephens/cascade/ui/CascadeViewModel.kt index 604edcd..4ef45fa 100644 --- a/apps/android/app/src/main/java/page/stephens/cascade/ui/CascadeViewModel.kt +++ b/apps/android/app/src/main/java/page/stephens/cascade/ui/CascadeViewModel.kt @@ -57,6 +57,7 @@ class CascadeViewModel( fun setListeningTracking(enabled: Boolean) = bridge.dispatch(Command.SetListeningTracking(enabled)) fun signIn(email: String) = syncManager.signIn(email) + fun completeSignInFromLink(input: String) = syncManager.completeSignInFromLink(input) fun signOut() = syncManager.signOut() fun deleteListeningData() = syncManager.deleteData() fun deleteAccount() = syncManager.deleteAccount()