diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 7b663b9..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 = 2 - versionName = "0.2.0" + 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/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/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 9c743f3..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 @@ -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,24 +94,25 @@ 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, + onCompleteSignIn = viewModel::completeSignInFromLink, onSignOut = viewModel::signOut, onDeleteData = viewModel::deleteListeningData, 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( @@ -382,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(), @@ -415,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, ) { @@ -426,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()