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
4 changes: 2 additions & 2 deletions apps/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions apps/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<!-- The optional account/sync feature makes HTTPS calls to the sync service.
(Core playback is fully offline; this is only used once you sign in.) -->
<uses-permission android:name="android.permission.INTERNET" />

<!-- Required for Media3 MediaSessionService to keep audio playing
while the app is backgrounded. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
) {
Expand All @@ -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") }
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading