diff --git a/app/src/test/java/com/cashpilot/android/redesign/BeforeScreensTest.kt b/app/src/test/java/com/cashpilot/android/redesign/BeforeScreensTest.kt new file mode 100644 index 0000000..e576fe6 --- /dev/null +++ b/app/src/test/java/com/cashpilot/android/redesign/BeforeScreensTest.kt @@ -0,0 +1,80 @@ +package com.cashpilot.android.redesign + +import android.app.Application +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.test.core.app.ApplicationProvider +import com.cashpilot.android.model.Settings +import com.cashpilot.android.ui.AppPresentation +import com.cashpilot.android.ui.MainViewModel +import com.cashpilot.android.ui.screen.DashboardScreen +import com.cashpilot.android.ui.screen.SettingsScreen +import com.cashpilot.android.ui.screen.SetupScreen +import com.cashpilot.android.ui.theme.CashPilotTheme +import com.github.takahirom.roborazzi.captureRoboImage +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * The CURRENT app's screens, rendered from the shipping composables. + * + * The real MainViewModel is built, then its flows are swapped for fixed ones + * so each screen shows a known situation (Fixtures). Nothing about the screens + * themselves is stubbed. Off unless CASHPILOT_RENDER_REDESIGN is set, so CI's + * verifyRoborazziDebug never sees these images. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = PHONE) +class BeforeScreensTest { + + private fun vmFor(s: Fixtures.Scenario): MainViewModel { + val vm = MainViewModel(ApplicationProvider.getApplicationContext()) + // Each public flow is a separate field from its private MutableStateFlow + // (asStateFlow() is a read-only copy), so both are replaced. + fun put(field: String, value: Any?) { + val flow = MutableStateFlow(value) + listOf(field, field.removePrefix("_")).distinct().forEach { name -> + val f = MainViewModel::class.java.getDeclaredField(name) + f.isAccessible = true + f.set(vm, flow) + } + } + put("settings", Settings(serverUrl = s.serverUrl, apiKey = if (s.serverConfigured) "k" else "", setupCompleted = true)) + put("_apps", s.apps) + put("_summary", AppPresentation.summarise(s.apps)) + put("_isRefreshing", false) + put("_hasNotificationAccess", s.notif) + put("_hasUsageAccess", s.usage) + put("_hasBatteryOptOut", s.battery) + put("isBlind", s.blind) + put("lastHeartbeat", s.lastHeartbeat) + put("lastHeartbeatFailed", s.heartbeatFailed) + put("earnings", s.earnings) + put("earningsAsOf", s.earningsAsOf) + put("_publicIp", if (s.serverConfigured) "203.0.113.24" else null) + return vm + } + + private fun shot(name: String, content: @Composable () -> Unit) { + renderGate() + captureRoboImage("$OUT/before/$name.png") { + CashPilotTheme(darkTheme = true) { Surface { content() } } + } + } + + @Test + fun current() { + Fixtures.all().forEach { (name, s) -> + val vm = vmFor(s) + shot("dashboard_$name") { DashboardScreen(vm, onNavigateToSettings = {}) } + } + val first = vmFor(Fixtures.firstRun) + shot("setup") { SetupScreen(first, onComplete = {}) } + shot("settings") { SettingsScreen(vmFor(Fixtures.healthy), onBack = {}) } + } +} diff --git a/app/src/test/java/com/cashpilot/android/redesign/Common.kt b/app/src/test/java/com/cashpilot/android/redesign/Common.kt new file mode 100644 index 0000000..e34d262 --- /dev/null +++ b/app/src/test/java/com/cashpilot/android/redesign/Common.kt @@ -0,0 +1,307 @@ +package com.cashpilot.android.redesign + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.cashpilot.android.R +import com.cashpilot.android.ui.AppState + +// --------------------------------------------------------------------------- +// Onboarding: three short steps instead of one long page. Required things +// (the two permissions) come first; the optional server comes last, so skipping +// it is the natural end of the flow, not a button hunt. +// --------------------------------------------------------------------------- + +@Composable +private fun StepDots(at: Int) { + val c = LocalCp.current + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + repeat(3) { i -> + Box( + Modifier.size(width = if (i == at) 22.dp else 8.dp, height = 8.dp).clip(RoundedCornerShape(50)) + .background(if (i <= at) c.accent else c.line), + ) + } + } +} + +@Composable +private fun OnboardFrame(step: Int, title: String, lead: String, bottom: @Composable () -> Unit, body: @Composable () -> Unit) { + Column(Modifier.fillMaxSize().padding(horizontal = 24.dp).padding(top = 40.dp, bottom = 24.dp)) { + StepDots(step) + Gap(28) + Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Gap(10) + Text(lead, style = MaterialTheme.typography.bodyLarge, color = LocalCp.current.text2) + Gap(28) + Column(Modifier.weight(1f)) { body() } + bottom() + } +} + +@Composable +private fun Fact(icon: Int, title: String, body: String) { + val c = LocalCp.current + Row(Modifier.padding(bottom = 20.dp)) { + Box(Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(c.accentSoft), contentAlignment = Alignment.Center) { + Ic(icon, tint = c.accent) + } + Spacer(Modifier.width(14.dp)) + Column { + Text(title, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleSmall) + Gap(2) + Text(body, style = MaterialTheme.typography.bodyMedium, color = c.text2) + } + } +} + +@Composable +fun OnboardWelcome() = OnboardFrame( + 0, "Know when an earning app stops", + "CashPilot checks the passive-income apps on this phone and shows you which ones stopped, and how to start them again.", + bottom = { Primary("Get started", Modifier.fillMaxWidth()) }, +) { + Fact(R.drawable.ic_check_circle, "Which apps are running", "EarnApp, Pawns, Grass, MystNodes and 7 more, checked whenever you open CashPilot.") + Fact(R.drawable.ic_warning, "What to do when one stops", "One tap opens the app so you can start it again.") + Fact(R.drawable.ic_cloud, "Figures from your server, if you have one", "A CashPilot server reports what each app's account shows. Optional; reports go every 15 minutes.") + Fact(R.drawable.ic_visibility_off, "Nothing leaves this phone", "Except to your own server, if you add one. No ads, no tracking.") +} + +@Composable +private fun PermissionCard(icon: Int, title: String, why: String, notRead: String, granted: Boolean, required: Boolean) { + val c = LocalCp.current + Panel(Modifier.padding(bottom = 12.dp), border = if (granted) c.line else if (required) c.lineStrong else c.line) { + Row(verticalAlignment = Alignment.CenterVertically) { + Ic(icon, tint = if (granted) c.ok else c.text) + Spacer(Modifier.width(12.dp)) + Text(title, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + when { + granted -> Pill(if (required) "Allowed" else "Done", c.ok, c.okSoft) + required -> SmallAction("Allow", filled = true) + // Optional, and not a permission: it only opens Android's + // battery settings list. No exemption dialog, so the button + // never reads as "Allow". + else -> SmallAction("Open settings", color = c.text) + } + } + Gap(8) + Text(why, style = MaterialTheme.typography.bodyMedium, color = c.text2) + Gap(4) + Text(notRead, style = MaterialTheme.typography.bodySmall, color = c.muted) + } +} + +@Composable +fun OnboardPermissions(notif: Boolean, usage: Boolean, battery: Boolean) = OnboardFrame( + 1, "Let CashPilot see your apps", + "Android hides other apps' state. Two switches in system settings fix that; you come straight back here.", + bottom = { + val c = LocalCp.current + if (!(notif && usage)) { + Text( + "Allow both to continue. Without them CashPilot can't tell running from stopped.", + style = MaterialTheme.typography.bodySmall, color = c.muted, modifier = Modifier.padding(bottom = 10.dp), + ) + } + Primary("Continue", Modifier.fillMaxWidth(), enabled = notif && usage) + }, +) { + PermissionCard( + R.drawable.ic_notifications, "Notification access", + "These apps keep a notification up while they run. CashPilot checks whether it is there, for the 11 supported apps only.", + "Never reads notification text. You'll see exactly what it reads before Android asks.", notif, required = true, + ) + PermissionCard( + R.drawable.ic_query_stats, "Usage access", + "Shows when each supported app last ran and how much data it moved today, which shows it is working.", + "Never looks at other apps or websites. You'll see exactly what it reads first.", usage, required = true, + ) + PermissionCard( + R.drawable.ic_battery_alert, "Battery optimisation", + "CashPilot reports every 15 minutes. With battery optimisation on, Android can hold those reports back while the screen is off.", + "Optional. Opens Android's battery list: choose All apps, then CashPilot, then Don't optimize.", battery, required = false, + ) +} + +enum class PairResult { NONE, OK, KEY_REFUSED } + +@Composable +fun OnboardServer(result: PairResult) = OnboardFrame( + 2, "Connect your server", + "Optional. Without one you still see which apps run and which stopped. A server adds the figures its provider accounts show, and lists this phone in your fleet.", + bottom = { + Primary(if (result == PairResult.OK) "Finish" else "Test and finish", Modifier.fillMaxWidth(), enabled = result != PairResult.KEY_REFUSED) + Gap(8) + Secondary("Use without a server", Modifier.fillMaxWidth()) + }, +) { + val c = LocalCp.current + OutlinedTextField( + value = if (result == PairResult.NONE) "" else "https://cashpilot.home.example", onValueChange = {}, + label = { Text("Server address") }, placeholder = { Text("https://cashpilot.example.com") }, + singleLine = true, modifier = Modifier.fillMaxWidth(), + ) + Gap(12) + OutlinedTextField( + value = if (result == PairResult.NONE) "" else "cp_live_8f2c1d9a7b", onValueChange = {}, + label = { Text("API key") }, singleLine = true, modifier = Modifier.fillMaxWidth(), + visualTransformation = PasswordVisualTransformation(), + supportingText = { Text("On your server: Fleet → Reveal API Key. Stored only on this phone.") }, + ) + Gap(4) + Text( + "Use https://. Plain http:// works only for an address on your own network, such as 192.168.x.x or a .local or Tailscale name.", + style = MaterialTheme.typography.bodySmall, color = c.muted, + ) + Gap(16) + when (result) { + PairResult.NONE -> Unit + PairResult.OK -> Panel(border = c.ok, fill = c.okSoft) { + Row(verticalAlignment = Alignment.CenterVertically) { + Ic(R.drawable.ic_check_circle, tint = c.ok) + Spacer(Modifier.width(10.dp)) + Column { + Text("Connected", fontWeight = FontWeight.SemiBold) + Text("Your server answered and accepted the key. This phone will appear in Fleet as Pixel 8.", style = MaterialTheme.typography.bodySmall, color = c.text2) + } + } + } + PairResult.KEY_REFUSED -> Panel(border = c.bad, fill = c.badSoft) { + Row(verticalAlignment = Alignment.Top) { + Ic(R.drawable.ic_warning, tint = c.bad) + Spacer(Modifier.width(10.dp)) + Column { + Text("The server refused this API key", fontWeight = FontWeight.SemiBold) + Text("The address is right: the server answered. Copy the key again from Fleet → Reveal API Key.", style = MaterialTheme.typography.bodySmall, color = c.text2) + } + } + } + } +} + +// --------------------------------------------------------------------------- +// The one state where the home screen can say nothing true. Every direction +// shows this instead of its home, with the fix as the only thing on screen. +// --------------------------------------------------------------------------- + +@Composable +fun BlindHome(h: Home) { + val c = LocalCp.current + Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.Center) { + Box(Modifier.size(56.dp).clip(CircleShape).background(c.warnSoft), contentAlignment = Alignment.Center) { + Ic(R.drawable.ic_visibility_off, tint = c.warn, size = 28) + } + Gap(20) + Text("CashPilot can't see your apps yet", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Gap(10) + Text( + "${h.watched.size} supported apps are installed, but Android won't show CashPilot whether they run until you allow two things. Each one explains what it reads first.", + style = MaterialTheme.typography.bodyLarge, color = c.text2, + ) + Gap(24) + Primary("Allow notification access", Modifier.fillMaxWidth()) + Gap(10) + Secondary("Allow usage access", Modifier.fillMaxWidth()) + Gap(24) + ServerLine(h) + } +} + +// --------------------------------------------------------------------------- +// Settings: grouped by what the user is checking, every row says its state. +// --------------------------------------------------------------------------- + +@Composable +private fun SettingRow(title: String, value: String, valueColor: Color = LocalCp.current.text2, trailing: @Composable () -> Unit = { Ic(R.drawable.ic_chevron_right, size = 18) }) { + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge) + Text(value, style = MaterialTheme.typography.bodySmall, color = valueColor) + } + trailing() + } +} + +@Composable +fun SettingsCommon(h: Home) { + val c = LocalCp.current + Column(Modifier.fillMaxSize()) { + Row(Modifier.padding(start = 8.dp, top = 24.dp, end = 8.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) { Ic(R.drawable.ic_arrow_back, tint = c.text) } + Text("Settings", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + } + Column(Modifier.padding(horizontal = 16.dp)) { + Gap(8) + SectionLabel("Server") + Panel(pad = androidx.compose.foundation.layout.PaddingValues(0.dp)) { + when { + h.server == ServerState.NONE -> + SettingRow("No server", "Everything stays on this phone · add one for figures", c.muted) + h.server == ServerState.FAILING -> + SettingRow(h.host, if (h.lastReport == 0L) "Not answering · no report yet" else "Not answering · last report ${ago(h.lastReport)}", c.warn) + h.lastReport == 0L -> + SettingRow(h.host, "Paired · waiting for the first report", c.muted) + else -> + SettingRow(h.host, "Reports every 15 min · last report ${ago(h.lastReport)}", c.ok) + } + Divider() + SettingRow("API key", "•••• •••• 7b9a · stored on this phone only") + Divider() + SettingRow("Test connection", "Sends a report now and shows what the server said") + } + Gap(20) + SectionLabel("This phone") + Panel(pad = androidx.compose.foundation.layout.PaddingValues(0.dp)) { + SettingRow("Notification access", if (h.notif) "Allowed" else "Off · can't see running apps", if (h.notif) c.ok else c.bad) + Divider() + SettingRow("Usage access", if (h.usage) "Allowed" else "Off · no last-seen times", if (h.usage) c.ok else c.bad) + Divider() + SettingRow("Battery optimisation", if (h.battery) "Off for CashPilot" else "On · reports may arrive late", if (h.battery) c.ok else c.warn) + } + Gap(20) + SectionLabel("Apps to watch") + Panel(pad = androidx.compose.foundation.layout.PaddingValues(0.dp)) { + h.watched.take(3).forEachIndexed { i, a -> + if (i > 0) Divider() + Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { + AppMark(a.name, 32) + Spacer(Modifier.width(12.dp)) + Text(a.name, Modifier.weight(1f)) + Switch(checked = a.state != AppState.DISABLED, onCheckedChange = {}) + } + } + Divider() + SettingRow("All ${h.apps.size} supported apps", "${h.watched.size} installed and watched · ${h.notInstalled.size} not installed") + } + Gap(20) + SectionLabel("About") + Panel(pad = androidx.compose.foundation.layout.PaddingValues(0.dp)) { + SettingRow("CashPilot for Android 0.4.0", "Source code, licence and the server project") + Divider() + SettingRow("Demo mode", "Shows the screens with sample data") + } + } + } +} diff --git a/app/src/test/java/com/cashpilot/android/redesign/DirectionA.kt b/app/src/test/java/com/cashpilot/android/redesign/DirectionA.kt new file mode 100644 index 0000000..9e8ba27 --- /dev/null +++ b/app/src/test/java/com/cashpilot/android/redesign/DirectionA.kt @@ -0,0 +1,194 @@ +package com.cashpilot.android.redesign + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.cashpilot.android.R +import com.cashpilot.android.ui.AppState +import com.cashpilot.android.util.FormatUtils + +/** + * Direction A: the status board. + * + * One sentence answers the question the app is opened for ("is everything + * earning?"), then one quiet row per app. Colour appears only where something + * is wrong, so a healthy phone is a calm screen and a broken one is not. + */ + +@Composable +private fun Verdict(h: Home) { + val c = LocalCp.current + val bad = h.stopped.size + val (ring, title, sub) = when { + bad > 0 -> Triple(c.bad, "$bad of ${h.watched.size} apps stopped", "Opening a stopped app usually starts it again.") + h.server == ServerState.FAILING -> Triple(c.warn, "All ${h.watched.size} apps running", "But your server hasn't heard from this phone.") + else -> Triple(c.ok, "All ${h.watched.size} apps running", "Nothing needs you right now.") + } + Column(Modifier.padding(horizontal = 20.dp)) { + Box(Modifier.size(64.dp).clip(CircleShape).border(5.dp, ring, CircleShape), contentAlignment = Alignment.Center) { + if (bad > 0) { + Text("$bad", style = MaterialTheme.typography.headlineSmall.merge(Tnum), fontWeight = FontWeight.Bold, color = ring) + } else { + Ic(R.drawable.ic_check_circle, tint = ring, size = 32) + } + } + Gap(16) + Text(title, fontSize = 30.sp, lineHeight = 36.sp, fontWeight = FontWeight.Bold) + Gap(6) + Text(sub, style = MaterialTheme.typography.bodyLarge, color = c.text2) + Gap(14) + ServerLine(h) + } +} + +@Composable +private fun BoardRow(a: AppRow) { + val c = LocalCp.current + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { + AppMark(a.name) + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text(a.name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) + Row(verticalAlignment = Alignment.CenterVertically) { + Dot(stateColor(a.state), 7) + Spacer(Modifier.width(6.dp)) + Text( + if (a.state == AppState.RUNNING) "Running · ${FormatUtils.formatBytes(a.bytes24h)} today" else stateWord(a), + style = MaterialTheme.typography.bodySmall, + color = if (a.state == AppState.STOPPED) c.bad else c.text2, + ) + } + } + if (a.state == AppState.STOPPED) SmallAction("Open", filled = true) else Ic(R.drawable.ic_chevron_right, tint = c.muted, size = 18) + } +} + +@Composable +private fun EarningsStrip(h: Home) { + val c = LocalCp.current + Panel(pad = PaddingValues(horizontal = 16.dp, vertical = 14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Your server reports, ${h.windowDays} days", style = MaterialTheme.typography.bodySmall, color = c.text2) + when { + h.server == ServerState.NONE -> Text("Add a server to see figures", fontWeight = FontWeight.SemiBold, color = c.accent) + h.total == null -> Text("Nothing read yet", fontWeight = FontWeight.SemiBold) + else -> Text(money(h.total), style = MaterialTheme.typography.titleLarge.merge(Tnum), fontWeight = FontWeight.Bold) + } + } + if (h.stale) Pill("From ${ago(h.earningsAsOf)}", c.warn, c.warnSoft) else Ic(R.drawable.ic_chevron_right, tint = c.muted, size = 18) + } + } +} + +@Composable +fun AHome(h: Home) { + if (h.blind) return BlindHome(h) + if (h.watched.isEmpty()) return NoAppsHome(h) + val c = LocalCp.current + Column(Modifier.fillMaxSize()) { + TopBar("CashPilot") { Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) { Ic(R.drawable.ic_settings) } } + Gap(12) + Verdict(h) + Gap(24) + Column(Modifier.padding(horizontal = 16.dp)) { + if (h.server == ServerState.FAILING) { + Panel(border = c.warn, fill = c.warnSoft, pad = PaddingValues(horizontal = 16.dp, vertical = 12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("The next report goes out once the server answers.", style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) + SmallAction("Check", c.warn) + } + } + Gap(12) + } + Panel(pad = PaddingValues(vertical = 4.dp)) { + h.watched.forEachIndexed { i, a -> + if (i > 0) Box(Modifier.padding(start = 70.dp)) { Divider() } + BoardRow(a) + } + } + Gap(12) + EarningsStrip(h) + Gap(12) + Text( + "${h.notInstalled.size} other supported apps ›", + style = MaterialTheme.typography.bodyMedium, color = c.accent, fontWeight = FontWeight.Medium, + modifier = Modifier.padding(start = 4.dp), + ) + } + } +} + +/** Tapping a row: everything known about one app, and the one thing to do. */ +@Composable +fun AAppDetail(a: AppRow, h: Home) { + val c = LocalCp.current + Column(Modifier.fillMaxSize()) { + Row(Modifier.padding(start = 8.dp, top = 24.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) { Ic(R.drawable.ic_arrow_back, tint = c.text) } + } + Column(Modifier.padding(horizontal = 20.dp)) { + Gap(8) + AppMark(a.name, 64) + Gap(16) + Text(a.name, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Gap(6) + Row(verticalAlignment = Alignment.CenterVertically) { + Dot(stateColor(a.state)) + Spacer(Modifier.width(8.dp)) + Text( + if (a.state == AppState.STOPPED) "Stopped. Last seen working ${ago(a.lastActive)}." else stateWord(a), + color = if (a.state == AppState.STOPPED) c.bad else c.text2, + ) + } + Gap(20) + Primary("Open ${a.name}", Modifier.fillMaxWidth()) + Gap(8) + Text( + "These apps often stop when Android clears them from memory. Opening the app usually starts it again.", + style = MaterialTheme.typography.bodySmall, color = c.muted, + ) + Gap(24) + Panel(pad = PaddingValues(0.dp)) { + Fact2("Server reports, ${h.windowDays} days", money(a.usd) + if (a.shared) " · whole account" else "") + Divider() + Fact2("Data moved, last 24 h", FormatUtils.formatBytes(a.bytes24h)) + Divider() + Fact2("Last seen working", ago(a.lastActive)) + Divider() + Fact2("Watched by CashPilot", "Yes · stop watching") + } + } + } +} + +@Composable +private fun Fact2(k: String, v: String) { + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp)) { + Text(k, color = LocalCp.current.text2, modifier = Modifier.weight(1f)) + Text(v, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyLarge.merge(Tnum)) + } +} diff --git a/app/src/test/java/com/cashpilot/android/redesign/DirectionB.kt b/app/src/test/java/com/cashpilot/android/redesign/DirectionB.kt new file mode 100644 index 0000000..a3ae557 --- /dev/null +++ b/app/src/test/java/com/cashpilot/android/redesign/DirectionB.kt @@ -0,0 +1,149 @@ +package com.cashpilot.android.redesign + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.cashpilot.android.R +import com.cashpilot.android.ui.AppState + +/** + * Direction B: the ledger. + * + * Money first, because money is why these apps are installed. The headline is + * the 30-day figure the server already sends; every row is an app with what it + * earned and, beside it, whether it is still earning. A stopped app reads as a + * line in the ledger that has gone red. + */ + +private val series = listOf(0xFFF472B6, 0xFF22D3EE, 0xFF8B5CF6, 0xFF46B37F, 0xFFE0A53C, 0xFF3987E5).map { Color(it) } + +@Composable +private fun ShareBar(rows: List, total: Double) { + val c = LocalCp.current + Row(Modifier.fillMaxWidth().height(10.dp).clip(RoundedCornerShape(50)).background(c.raised)) { + rows.forEachIndexed { i, r -> + val w = ((r.usd ?: 0.0) / total).toFloat() + if (w > 0f) Box(Modifier.weight(w).fillMaxHeight().background(series[i % series.size])) + } + } +} + +@Composable +private fun Hero(h: Home) { + val c = LocalCp.current + Column(Modifier.padding(horizontal = 20.dp)) { + Text("Your providers report, last ${h.windowDays} days", color = c.text2) + Gap(4) + if (h.server == ServerState.NONE) { + Text("—", fontSize = 52.sp, fontWeight = FontWeight.Bold, color = c.muted) + Gap(6) + Text("Figures come from your CashPilot server, which reads each provider's account. Without one, this list still shows what runs.", color = c.text2, style = MaterialTheme.typography.bodyMedium) + Gap(14) + Primary("Connect a server") + } else { + Row(verticalAlignment = Alignment.Bottom) { + Text(money(h.total), fontSize = 52.sp, lineHeight = 56.sp, fontWeight = FontWeight.Bold, style = Tnum) + Spacer(Modifier.width(10.dp)) + if (h.stale) Box(Modifier.padding(bottom = 12.dp)) { Pill("as of ${ago(h.earningsAsOf)}", c.warn, c.warnSoft) } + } + Gap(6) + Text( + "By the apps on this phone, read from your providers by ${h.host}.", + style = MaterialTheme.typography.bodySmall, color = c.muted, + ) + Gap(16) + val ranked = h.watched.filter { it.usd != null }.sortedByDescending { it.usd } + ShareBar(ranked, h.total ?: 1.0) + } + } +} + +@Composable +private fun LedgerRow(a: AppRow, i: Int, paired: Boolean) { + val c = LocalCp.current + val stopped = a.state == AppState.STOPPED + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(4.dp, 36.dp).clip(RoundedCornerShape(2.dp)).background(if (a.usd != null) series[i % series.size] else c.line)) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(a.name + if (a.shared) " *" else "", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) + Row(verticalAlignment = Alignment.CenterVertically) { + Dot(stateColor(a.state), 7) + Spacer(Modifier.width(6.dp)) + Text( + stateWord(a), + style = MaterialTheme.typography.bodySmall, color = if (stopped) c.bad else c.text2, + ) + } + } + Column(horizontalAlignment = Alignment.End) { + if (paired) Text( + if (a.usd == null) "no reading" else money(a.usd), + style = (if (a.usd == null) MaterialTheme.typography.bodySmall else MaterialTheme.typography.titleMedium).merge(Tnum), + fontWeight = if (a.usd == null) FontWeight.Normal else FontWeight.SemiBold, + color = if (a.usd == null) c.muted else c.text, + ) + if (stopped) { Gap(4); SmallAction("Open", filled = true) } + } + } +} + +@Composable +fun BHome(h: Home) { + if (h.blind) return BlindHome(h) + if (h.watched.isEmpty()) return NoAppsHome(h) + val c = LocalCp.current + Column(Modifier.fillMaxSize()) { + TopBar("CashPilot") { Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) { Ic(R.drawable.ic_settings) } } + Gap(8) + Hero(h) + Gap(20) + Column(Modifier.padding(horizontal = 16.dp)) { + ServerLine(h, Modifier.padding(start = 4.dp, bottom = 12.dp)) + // Highest earner first; apps with no reading sink to the bottom, + // because "no reading" is not a small number. + val rows = h.watched.sortedWith(compareBy({ it.usd == null }, { -(it.usd ?: 0.0) })) + Panel(pad = PaddingValues(vertical = 4.dp)) { + rows.forEachIndexed { i, a -> + if (i > 0) Box(Modifier.padding(start = 32.dp)) { Divider() } + LedgerRow(a, i, paired = h.server != ServerState.NONE) + } + } + Gap(10) + if (h.watched.any { it.shared }) { + Text( + "* Runs on more than one of your devices. The provider reports one balance for the account, so this is not this phone alone.", + style = MaterialTheme.typography.bodySmall, color = c.muted, modifier = Modifier.padding(horizontal = 4.dp), + ) + } + if (h.withoutReading.isNotEmpty() && h.server != ServerState.NONE) { + Gap(6) + Text( + "No reading means your server has no login for that provider yet. It is not \$0.", + style = MaterialTheme.typography.bodySmall, color = c.muted, modifier = Modifier.padding(horizontal = 4.dp), + ) + } + } + } +} diff --git a/app/src/test/java/com/cashpilot/android/redesign/DirectionC.kt b/app/src/test/java/com/cashpilot/android/redesign/DirectionC.kt new file mode 100644 index 0000000..276e3af --- /dev/null +++ b/app/src/test/java/com/cashpilot/android/redesign/DirectionC.kt @@ -0,0 +1,226 @@ +package com.cashpilot.android.redesign + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.cashpilot.android.R +import com.cashpilot.android.ui.AppState +import com.cashpilot.android.util.FormatUtils + +/** + * Direction C: fix what's broken. + * + * The first tab is a to-do list built from every problem the app can detect: + * a stopped app, a server that stopped answering, a permission or battery + * setting that will cause trouble. Each item has exactly one button that fixes + * it. When the list is empty the screen says so and gets out of the way. + */ + +private data class Task(val icon: Int, val tint: Color, val title: String, val body: String, val action: String) + +@Composable +private fun tasksOf(h: Home): List { + val c = LocalCp.current + val out = mutableListOf() + h.stopped.sortedBy { it.lastActive }.forEach { + out += Task(R.drawable.ic_warning, c.bad, "${it.name} stopped ${ago(it.lastActive)}", "Opening it usually starts it again.", "Open ${it.name}") + } + if (h.server == ServerState.FAILING) { + val since = if (h.lastReport == 0L) "No report has reached ${h.host} yet." else "Nothing has reached ${h.host} ${forHowLong(h.lastReport)}." + out += Task(R.drawable.ic_cloud_off, c.warn, "Your server isn't answering", since, "Check connection") + } + // One permission missing (both missing is the blind screen instead). + if (!h.notif) { + out += Task(R.drawable.ic_notifications, c.bad, "Notification access is off", "CashPilot can't tell which apps are running until it's back on.", "Turn on") + } + if (!h.usage) { + out += Task(R.drawable.ic_query_stats, c.warn, "Usage access is off", "No last-seen times or data use until it's back on.", "Turn on") + } + if (!h.battery) { + out += Task(R.drawable.ic_battery_alert, c.warn, "Reports may arrive late", "Battery optimisation is on for CashPilot, so Android can hold reports back while the screen is off.", "Open battery settings") + } + return out +} + +@Composable +private fun TaskCard(t: Task, first: Boolean) { + val c = LocalCp.current + Panel(Modifier.padding(bottom = 10.dp), border = if (first) t.tint else c.line) { + Row(verticalAlignment = Alignment.Top) { + Box(Modifier.size(36.dp).clip(CircleShape).background(t.tint.copy(alpha = 0.14f)), contentAlignment = Alignment.Center) { + Ic(t.icon, tint = t.tint, size = 18) + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(t.title, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleMedium) + Gap(2) + Text(t.body, style = MaterialTheme.typography.bodyMedium, color = c.text2) + Gap(12) + SmallAction(t.action, color = if (first) c.accent else c.text, filled = first) + } + } + } +} + +@Composable +private fun CNav(selected: Int) { + val c = LocalCp.current + NavigationBar(containerColor = c.card, tonalElevation = 0.dp) { + listOf("Today" to R.drawable.ic_check_circle, "Apps" to R.drawable.ic_query_stats, "Settings" to R.drawable.ic_settings) + .forEachIndexed { i, (label, icon) -> + NavigationBarItem( + selected = i == selected, onClick = {}, + icon = { Icon(painterResource(icon), contentDescription = null, modifier = Modifier.size(22.dp)) }, + label = { Text(label) }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = c.accent, selectedTextColor = c.text, indicatorColor = c.accentSoft, + unselectedIconColor = c.muted, unselectedTextColor = c.muted, + ), + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun RunningChips(rows: List) { + val c = LocalCp.current + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + rows.forEach { a -> + Row( + Modifier.clip(RoundedCornerShape(50)).background(c.card).padding(start = 6.dp, end = 12.dp, top = 6.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppMark(a.name, 22) + Spacer(Modifier.width(8.dp)) + Text(a.name, style = MaterialTheme.typography.labelLarge) + } + } + } +} + +@Composable +fun CHome(h: Home) { + if (h.blind) return BlindHome(h) + if (h.watched.isEmpty()) return NoAppsHome(h) + val c = LocalCp.current + val tasks = tasksOf(h) + Column(Modifier.fillMaxSize()) { + Column(Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(horizontal = 16.dp)) { + Gap(36) + if (tasks.isEmpty()) { + Box(Modifier.size(56.dp).clip(CircleShape).background(c.okSoft), contentAlignment = Alignment.Center) { + Ic(R.drawable.ic_check_circle, tint = c.ok, size = 30) + } + Gap(16) + Text("Nothing to fix", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Gap(6) + Text("${h.running.size} supported apps are running on this phone.", style = MaterialTheme.typography.bodyLarge, color = c.text2) + } else { + Text( + if (tasks.size == 1) "1 thing needs you" else "${tasks.size} things need you", + style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, + ) + Gap(6) + Text("Most important first.", style = MaterialTheme.typography.bodyLarge, color = c.text2) + } + Gap(20) + tasks.forEachIndexed { i, t -> TaskCard(t, i == 0) } + if (tasks.isNotEmpty()) Gap(8) + SectionLabel(if (tasks.isEmpty()) "Running" else "Fine (${h.running.size})") + RunningChips(h.running) + Gap(20) + Panel(pad = PaddingValues(horizontal = 16.dp, vertical = 12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + val label = buildString { + append("Your server reports, ${h.windowDays} days") + if (h.watched.any { it.shared }) append(" · includes whole-account balances") + } + Text(label, style = MaterialTheme.typography.bodySmall, color = c.text2) + Text( + if (h.server == ServerState.NONE) "Needs a server" else money(h.total), + style = MaterialTheme.typography.titleMedium.merge(Tnum), fontWeight = FontWeight.SemiBold, + ) + } + if (h.stale) Pill("From ${ago(h.earningsAsOf)}", c.warn, c.warnSoft) else Ic(R.drawable.ic_chevron_right, tint = c.muted, size = 18) + } + } + Gap(12) + ServerLine(h, Modifier.padding(start = 4.dp)) + } + CNav(0) + } +} + +/** Second tab: every supported app, filterable, nothing hidden. */ +@Composable +fun CApps(h: Home) { + val c = LocalCp.current + Column(Modifier.fillMaxSize()) { + Column(Modifier.weight(1f)) { + TopBar("Apps") + Row(Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("Watched ${h.watched.size}" to true, "Stopped ${h.stopped.size}" to false, "Not installed ${h.notInstalled.size}" to false).forEach { (t, sel) -> + FilterChip( + selected = sel, onClick = {}, label = { Text(t) }, + colors = FilterChipDefaults.filterChipColors(selectedContainerColor = c.accentSoft, selectedLabelColor = c.text, labelColor = c.text2), + ) + } + } + Gap(8) + Panel(Modifier.padding(horizontal = 16.dp), pad = PaddingValues(vertical = 4.dp)) { + h.watched.forEachIndexed { i, a -> + if (i > 0) Box(Modifier.padding(start = 70.dp)) { Divider() } + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { + AppMark(a.name) + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text(a.name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) + Text( + if (a.state == AppState.RUNNING) "Running · ${FormatUtils.formatBytes(a.bytes24h)} today" else stateWord(a), + style = MaterialTheme.typography.bodySmall, color = if (a.state == AppState.STOPPED) c.bad else c.text2, + ) + } + Column(horizontalAlignment = Alignment.End) { + Text(money(a.usd), style = MaterialTheme.typography.bodyLarge.merge(Tnum), color = if (a.usd == null) c.muted else c.text) + if (a.shared) Text("whole account", style = MaterialTheme.typography.labelSmall, color = c.muted) + } + } + } + } + } + CNav(1) + } +} diff --git a/app/src/test/java/com/cashpilot/android/redesign/Fixtures.kt b/app/src/test/java/com/cashpilot/android/redesign/Fixtures.kt new file mode 100644 index 0000000..e7d6ced --- /dev/null +++ b/app/src/test/java/com/cashpilot/android/redesign/Fixtures.kt @@ -0,0 +1,145 @@ +package com.cashpilot.android.redesign + +import com.cashpilot.android.model.AppStatus +import com.cashpilot.android.model.Earnings +import com.cashpilot.android.model.KnownApps +import com.cashpilot.android.model.PlatformEarnings +import com.cashpilot.android.ui.AppDisplayInfo +import com.cashpilot.android.ui.AppPresentation +import java.time.Instant + +/** + * One set of phone situations, shared by the screenshots of the current app and + * of the three proposals, so every image shows the same facts. + */ +object Fixtures { + private val now = System.currentTimeMillis() + private const val MIN = 60_000L + private const val HOUR = 60 * MIN + private const val MB = 1024L * 1024L + + private fun iso(millisAgo: Long) = Instant.ofEpochMilli(now - millisAgo).toString() + + data class Scenario( + val apps: List, + val earnings: Earnings?, + val earningsAsOf: Long, + val serverUrl: String, + val lastHeartbeat: Long, + val heartbeatFailed: Boolean, + val notif: Boolean, + val usage: Boolean, + val battery: Boolean, + ) { + val serverConfigured get() = serverUrl.isNotBlank() + val blind get() = !notif && !usage + } + + private fun running(slug: String, tx: Long, rx: Long) = + AppStatus(slug, running = true, notificationActive = true, netTx24h = tx * MB, netRx24h = rx * MB, lastActive = iso(4 * MIN)) + + private fun stopped(slug: String, agoMillis: Long) = + AppStatus(slug, running = false, netTx24h = 3 * MB, netRx24h = 1 * MB, lastActive = iso(agoMillis)) + + /** Builds the display list exactly as MainViewModel.doRefresh does, then sorts it the same way. */ + private fun build(statuses: Map, installed: Set, disabled: Set = emptySet()): List = + AppPresentation.sortForDashboard( + KnownApps.all.map { app -> + val status = statuses[app.slug] + val state = AppPresentation.resolveState( + installed = app.slug in installed, + enabled = app.slug !in disabled, + running = status?.running, + ) + AppDisplayInfo(app, state, status) + }, + ) + + private val installedSet = setOf("earnapp", "iproyal", "mysterium", "traffmonetizer", "grass", "bytelixir") + + private val earnings = Earnings( + windowDays = 30, + platforms = listOf( + PlatformEarnings("bytelixir", 0.94, false), + PlatformEarnings("earnapp", 4.12, false), + PlatformEarnings("grass", null, false), + PlatformEarnings("iproyal", 2.87, false), + PlatformEarnings("mysterium", 6.40, true), + PlatformEarnings("traffmonetizer", 1.05, false), + ), + totalUsd = 15.38, + platformsWithoutReadings = listOf("grass"), + ) + + /** Paired, reporting, everything installed is earning. */ + val healthy = Scenario( + apps = build( + mapOf( + "earnapp" to running("earnapp", 412, 38), + "iproyal" to running("iproyal", 655, 41), + "mysterium" to running("mysterium", 1290, 210), + "traffmonetizer" to running("traffmonetizer", 233, 19), + "grass" to running("grass", 96, 12), + "bytelixir" to running("bytelixir", 120, 9), + ), + installedSet, + ), + earnings = earnings, + earningsAsOf = now - 2 * MIN, + serverUrl = "https://cashpilot.home.example", + lastHeartbeat = now - 6 * MIN, + heartbeatFailed = false, + notif = true, usage = true, battery = true, + ) + + /** Two earners died, the server stopped answering 25 minutes ago, battery saver is on. */ + val problems = healthy.copy( + apps = build( + mapOf( + "earnapp" to running("earnapp", 412, 38), + "iproyal" to running("iproyal", 655, 41), + "mysterium" to running("mysterium", 1290, 210), + "traffmonetizer" to stopped("traffmonetizer", 26 * HOUR), + "grass" to stopped("grass", 3 * HOUR), + "bytelixir" to running("bytelixir", 120, 9), + ), + installedSet, + ), + earningsAsOf = now - 7 * HOUR, + lastHeartbeat = now - 25 * MIN, + heartbeatFailed = true, + battery = false, + ) + + /** No server. A supported way to use the app. */ + val standalone = healthy.copy( + apps = build( + mapOf( + "earnapp" to running("earnapp", 412, 38), + "iproyal" to running("iproyal", 655, 41), + "grass" to stopped("grass", 3 * HOUR), + ), + setOf("earnapp", "iproyal", "grass"), + ), + earnings = null, + earningsAsOf = 0, + serverUrl = "", + lastHeartbeat = 0, + heartbeatFailed = false, + ) + + /** First launch after setup was skipped: no permissions, so nothing can be seen. */ + val blind = healthy.copy( + apps = build(installedSet.associateWith { null }, installedSet), + notif = false, usage = false, battery = false, + earnings = null, earningsAsOf = 0, lastHeartbeat = 0, + ) + + /** No server, and none of the supported apps installed: what a store reviewer sees. */ + val empty = standalone.copy(apps = build(emptyMap(), emptySet())) + + /** A fresh install: nothing granted, no server typed yet. */ + val firstRun = blind.copy(serverUrl = "") + + fun all() = mapOf("healthy" to healthy, "problems" to problems, "standalone" to standalone, "blind" to blind) +} diff --git a/app/src/test/java/com/cashpilot/android/redesign/Kit.kt b/app/src/test/java/com/cashpilot/android/redesign/Kit.kt new file mode 100644 index 0000000..4246796 --- /dev/null +++ b/app/src/test/java/com/cashpilot/android/redesign/Kit.kt @@ -0,0 +1,361 @@ +package com.cashpilot.android.redesign + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.cashpilot.android.model.KnownApps +import com.cashpilot.android.ui.AppState +import com.cashpilot.android.util.FormatUtils +import java.net.URI + +// --------------------------------------------------------------------------- +// Palette: the web dashboard's "Classic night" theme (app/static/css/style.css, +// data-theme="classic") and its "Light" theme, so the phone and the server look +// like one product. Dynamic colour is off on purpose: Material You repaints the +// app in the wallpaper's colours and the brand disappears. +// --------------------------------------------------------------------------- + +data class Cp( + val bg: Color, val card: Color, val raised: Color, val line: Color, val lineStrong: Color, + val text: Color, val text2: Color, val muted: Color, + val accent: Color, val onAccent: Color, val accent2: Color, + val ok: Color, val warn: Color, val bad: Color, + val okSoft: Color, val warnSoft: Color, val badSoft: Color, val accentSoft: Color, + val dark: Boolean, +) + +val Night = Cp( + bg = Color(0xFF0B0B14), card = Color(0xFF11111D), raised = Color(0xFF171726), + line = Color(0xFF25233A), lineStrong = Color(0xFF4B4670), + text = Color(0xFFE8E6F0), text2 = Color(0xFFBDB6CF), muted = Color(0xFFA59CB5), + accent = Color(0xFFF472B6), onAccent = Color(0xFF0B0B14), accent2 = Color(0xFF22D3EE), + ok = Color(0xFF46B37F), warn = Color(0xFFE0A53C), bad = Color(0xFFF06A5F), + okSoft = Color(0x1F46B37F), warnSoft = Color(0x1FE0A53C), badSoft = Color(0x1FF06A5F), + accentSoft = Color(0x1AF472B6), dark = true, +) + +val Day = Cp( + bg = Color(0xFFF6F7F8), card = Color(0xFFFFFFFF), raised = Color(0xFFEEF0F2), + line = Color(0xFFDDE1E5), lineStrong = Color(0xFF9AA2AB), + text = Color(0xFF16191C), text2 = Color(0xFF464D55), muted = Color(0xFF5F6770), + // The web's light theme uses near-black actions; the phone keeps the brand + // pink, darkened to 6:1 on white so it passes AA for text. + accent = Color(0xFFBE185D), onAccent = Color.White, accent2 = Color(0xFF0E7490), + ok = Color(0xFF1D7A4C), warn = Color(0xFF8F5A00), bad = Color(0xFFB42318), + okSoft = Color(0x1A1D7A4C), warnSoft = Color(0x1F8F5A00), badSoft = Color(0x17B42318), + accentSoft = Color(0x14BE185D), dark = false, +) + +val LocalCp = staticCompositionLocalOf { Night } + +@Composable +fun CpTheme(dark: Boolean, content: @Composable () -> Unit) { + val c = if (dark) Night else Day + val scheme = if (dark) { + darkColorScheme( + primary = c.accent, onPrimary = c.onAccent, secondary = c.accent2, + background = c.bg, onBackground = c.text, surface = c.bg, onSurface = c.text, + surfaceVariant = c.raised, onSurfaceVariant = c.text2, outline = c.lineStrong, + outlineVariant = c.line, error = c.bad, surfaceContainer = c.card, + surfaceContainerHigh = c.raised, surfaceContainerLow = c.card, + primaryContainer = c.accentSoft, onPrimaryContainer = c.text, + ) + } else { + lightColorScheme( + primary = c.accent, onPrimary = c.onAccent, secondary = c.accent2, + background = c.bg, onBackground = c.text, surface = c.bg, onSurface = c.text, + surfaceVariant = c.raised, onSurfaceVariant = c.text2, outline = c.lineStrong, + outlineVariant = c.line, error = c.bad, surfaceContainer = c.card, + surfaceContainerHigh = c.raised, surfaceContainerLow = c.card, + primaryContainer = c.accentSoft, onPrimaryContainer = c.text, + ) + } + CompositionLocalProvider(LocalCp provides c) { + MaterialTheme(colorScheme = scheme) { + Surface(Modifier.fillMaxSize(), color = c.bg, contentColor = c.text) { content() } + } + } +} + +/** Figures line up in columns: tabular digits. */ +val Tnum = TextStyle(fontFeatureSettings = "tnum") + +// --------------------------------------------------------------------------- +// What every direction draws from. Derived from the same Fixtures the "before" +// screenshots use, so the facts on screen are identical across all images. +// --------------------------------------------------------------------------- + +enum class ServerState { NONE, OK, FAILING } + +data class AppRow( + val slug: String, + val name: String, + val state: AppState, + val lastActive: Long?, + val bytes24h: Long, + val usd: Double?, + val shared: Boolean, +) + +data class Home( + val apps: List, + val server: ServerState, + val host: String, + val lastReport: Long, + val total: Double?, + val windowDays: Int, + val earningsAsOf: Long, + val stale: Boolean, + val notif: Boolean, + val usage: Boolean, + val battery: Boolean, +) { + val watched get() = apps.filter { it.state != AppState.NOT_INSTALLED && it.state != AppState.DISABLED } + val running get() = watched.filter { it.state == AppState.RUNNING } + val stopped get() = watched.filter { it.state == AppState.STOPPED } + val notInstalled get() = apps.filter { it.state == AppState.NOT_INSTALLED } + val blind get() = !notif && !usage + val withoutReading get() = watched.filter { it.usd == null } +} + +fun Fixtures.Scenario.toHome(): Home = Home( + apps = apps.map { info -> + val p = earnings?.platforms?.firstOrNull { it.slug == info.app.slug } + AppRow( + slug = info.app.slug, + name = info.app.displayName, + state = info.state, + lastActive = info.status?.lastActive?.let(FormatUtils::parseIso), + bytes24h = (info.status?.netTx24h ?: 0) + (info.status?.netRx24h ?: 0), + usd = p?.usd, + shared = p?.sharedWithOtherWorkers == true, + ) + }, + server = when { + !serverConfigured -> ServerState.NONE + heartbeatFailed -> ServerState.FAILING + else -> ServerState.OK + }, + host = if (serverConfigured) URI(serverUrl).host else "", + lastReport = lastHeartbeat, + total = earnings?.totalUsd, + windowDays = earnings?.windowDays ?: 30, + earningsAsOf = earningsAsOf, + stale = earnings?.totalUsd != null && FormatUtils.earningsAreStale(earningsAsOf, System.currentTimeMillis()), + notif = notif, usage = usage, battery = battery, +) + +fun money(usd: Double?): String = if (usd == null) "—" else "$" + String.format("%.2f", usd) + +fun ago(millis: Long?): String { + if (millis == null || millis == 0L) return "never" + val d = (System.currentTimeMillis() - millis) / 1000 + return when { + d < 60 -> "just now" + d < 3600 -> "${d / 60} min ago" + d < 86_400 -> "${d / 3600} h ago" + d < 2 * 86_400 -> "yesterday" + else -> "${d / 86_400} days ago" + } +} + +/** "for 25 min", "for 3 h": how long something has been wrong. */ +fun forHowLong(millis: Long?): String = ago(millis).removeSuffix(" ago").let { + when (it) { "yesterday" -> "since yesterday"; "just now" -> "for a moment"; else -> "for $it" } +} + +fun referralOf(slug: String) = KnownApps.bySlug[slug]?.referralUrl + +// --------------------------------------------------------------------------- +// Small parts shared by every direction. +// --------------------------------------------------------------------------- + +/** + * A two-letter tile per app. The shipped app would draw the real launcher icon + * from PackageManager for installed apps; the JVM renderer has no such icons, so + * the proposals show where it goes. + */ +@Composable +fun AppMark(name: String, size: Int = 40, dim: Boolean = false) { + val c = LocalCp.current + val hues = listOf(0xFF8B5CF6, 0xFF22D3EE, 0xFFF472B6, 0xFF46B37F, 0xFFE0A53C, 0xFF3987E5, 0xFFD95926) + val base = Color(hues[(name.hashCode() and 0x7fffffff) % hues.size]) + val letters = name.split(" ").let { if (it.size > 1) "${it[0][0]}${it[1][0]}" else name.take(2) } + Box( + Modifier.size(size.dp).clip(RoundedCornerShape((size / 4).dp)) + .background(if (dim) c.raised else base.copy(alpha = if (c.dark) 0.22f else 0.16f)), + contentAlignment = Alignment.Center, + ) { + Text( + letters, fontWeight = FontWeight.Bold, fontSize = (size * 0.36).sp, + color = if (dim) c.muted else if (c.dark) base else base.copy(red = base.red * 0.7f, green = base.green * 0.7f, blue = base.blue * 0.7f), + ) + } +} + +@Composable +fun Dot(color: Color, size: Int = 8) = Box(Modifier.size(size.dp).clip(CircleShape).background(color)) + +@Composable +fun stateColor(state: AppState): Color { + val c = LocalCp.current + return when (state) { + AppState.RUNNING -> c.ok + AppState.STOPPED -> c.bad + AppState.UNKNOWN -> c.warn + else -> c.muted + } +} + +fun stateWord(row: AppRow): String = when (row.state) { + AppState.RUNNING -> "Running" + AppState.STOPPED -> "Stopped ${ago(row.lastActive)}" + AppState.UNKNOWN -> "Can't see it" + AppState.DISABLED -> "Not watched" + AppState.NOT_INSTALLED -> "Not installed" +} + +@Composable +fun Ic(@DrawableRes id: Int, tint: Color = LocalCp.current.text2, size: Int = 20) = + Icon(painterResource(id), contentDescription = null, tint = tint, modifier = Modifier.size(size.dp)) + +@Composable +fun SectionLabel(text: String, modifier: Modifier = Modifier) = Text( + text.uppercase(), modifier = modifier.padding(start = 4.dp, bottom = 8.dp, top = 4.dp), + style = MaterialTheme.typography.labelMedium, letterSpacing = 1.2.sp, + color = LocalCp.current.muted, fontWeight = FontWeight.SemiBold, +) + +/** A flat card with a hairline, the web dashboard's card. */ +@Composable +fun Panel( + modifier: Modifier = Modifier, + border: Color = LocalCp.current.line, + fill: Color = LocalCp.current.card, + pad: PaddingValues = PaddingValues(16.dp), + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)).background(fill) + .border(BorderStroke(1.dp, border), RoundedCornerShape(16.dp)).padding(pad), + content = content, + ) +} + +@Composable +fun Primary(text: String, modifier: Modifier = Modifier, enabled: Boolean = true) = Button( + onClick = {}, enabled = enabled, modifier = modifier.height(48.dp), + shape = RoundedCornerShape(12.dp), +) { Text(text, fontWeight = FontWeight.SemiBold) } + +@Composable +fun Secondary(text: String, modifier: Modifier = Modifier) = OutlinedButton( + onClick = {}, modifier = modifier.height(48.dp), shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, LocalCp.current.lineStrong), + colors = ButtonDefaults.outlinedButtonColors(contentColor = LocalCp.current.text), +) { Text(text, fontWeight = FontWeight.SemiBold) } + +@Composable +fun SmallAction(text: String, color: Color = LocalCp.current.accent, filled: Boolean = false) { + val c = LocalCp.current + Box( + Modifier.clip(RoundedCornerShape(10.dp)) + .background(if (filled) color else Color.Transparent) + .border(1.dp, if (filled) color else c.lineStrong, RoundedCornerShape(10.dp)) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Text(text, color = if (filled) c.onAccent else color, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.labelLarge) + } +} + +@Composable +fun Pill(text: String, color: Color, soft: Color) = Text( + text, + Modifier.clip(RoundedCornerShape(50)).background(soft).padding(horizontal = 10.dp, vertical = 4.dp), + color = color, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, +) + +/** One line about the server, the same words in every direction. */ +@Composable +fun ServerLine(h: Home, modifier: Modifier = Modifier) { + val c = LocalCp.current + val (color, text) = when (h.server) { + ServerState.NONE -> c.muted to "No server · everything stays on this phone" + ServerState.OK -> if (h.lastReport == 0L) { + c.muted to "Paired with ${h.host} · waiting for the first report" + } else { + c.ok to "Last report ${ago(h.lastReport)} · sent every 15 min" + } + ServerState.FAILING -> c.warn to if (h.lastReport == 0L) { + "Can't reach ${h.host} · no report has gone through yet" + } else { + "Can't reach ${h.host} ${forHowLong(h.lastReport)}" + } + } + Row(modifier, verticalAlignment = Alignment.CenterVertically) { + Dot(color) + Spacer(Modifier.width(8.dp)) + Text(text, style = MaterialTheme.typography.bodySmall, color = c.text2, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +@Composable +fun TopBar(title: String, modifier: Modifier = Modifier, actions: @Composable RowScope.() -> Unit = {}) { + Row( + modifier.fillMaxWidth().padding(start = 20.dp, end = 8.dp, top = 12.dp, bottom = 4.dp).height(48.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + actions() + } +} + +@Composable +fun Divider() = HorizontalDivider(color = LocalCp.current.line) + +@Composable +fun Gap(h: Int) = Spacer(Modifier.height(h.dp)) + +@Composable +fun Screen(content: @Composable ColumnScope.() -> Unit) = + Column(Modifier.fillMaxSize().padding(top = 24.dp), verticalArrangement = Arrangement.Top, content = content) diff --git a/app/src/test/java/com/cashpilot/android/redesign/Policy.kt b/app/src/test/java/com/cashpilot/android/redesign/Policy.kt new file mode 100644 index 0000000..37681d5 --- /dev/null +++ b/app/src/test/java/com/cashpilot/android/redesign/Policy.kt @@ -0,0 +1,177 @@ +package com.cashpilot.android.redesign + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.cashpilot.android.R +import com.cashpilot.android.model.KnownApps + +// --------------------------------------------------------------------------- +// Store-policy screens, shared by all three directions. +// --------------------------------------------------------------------------- + +enum class Access { NOTIFICATIONS, USAGE } + +/** + * Shown when the user taps Allow, BEFORE Android's own settings screen opens. + * Google Play requires a prominent in-app disclosure for both special accesses, + * stating exactly what is read. + */ +@Composable +fun Disclosure(kind: Access) { + val c = LocalCp.current + val (icon, title, reads, never) = when (kind) { + Access.NOTIFICATIONS -> listOf( + R.drawable.ic_notifications, "Notification access", + "Only whether one of the 11 apps below is showing an ongoing notification right now. That notification is how those apps show they are running.", + "The text of any notification, and anything from any other app. Nothing about notifications is stored or sent; only the running or stopped result, and only to your own server if you add one.", + ) + Access.USAGE -> listOf( + R.drawable.ic_query_stats, "Usage access", + "For the 11 apps below only: when each one was last in use, and how much data it sent and received in the last 24 hours.", + "Which other apps you use, which websites you visit, or what any app does. The results stay on this phone, unless you add your own server.", + ) + } + Column(Modifier.fillMaxSize().padding(horizontal = 24.dp).padding(top = 40.dp, bottom = 24.dp)) { + Box(Modifier.size(52.dp).clip(RoundedCornerShape(14.dp)).background(c.accentSoft), contentAlignment = Alignment.Center) { + Ic(icon as Int, tint = c.accent, size = 26) + } + Gap(20) + Text("Before you turn on ${(title as String).lowercase()}", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Gap(8) + Text("Android will open its settings next. This is what CashPilot does with it.", color = c.text2) + Gap(20) + Panel { + Text("CASHPILOT READS", style = MaterialTheme.typography.labelMedium, color = c.ok, fontWeight = FontWeight.SemiBold) + Gap(4) + Text(reads as String, style = MaterialTheme.typography.bodyMedium) + Gap(14) + Text("IT NEVER READS", style = MaterialTheme.typography.labelMedium, color = c.bad, fontWeight = FontWeight.SemiBold) + Gap(4) + Text(never as String, style = MaterialTheme.typography.bodyMedium) + } + Gap(14) + Text( + KnownApps.all.joinToString(", ") { it.displayName } + ".", + style = MaterialTheme.typography.bodySmall, color = c.muted, + ) + Spacer(Modifier.weight(1f)) + Primary("Continue to Android settings", Modifier.fillMaxWidth()) + Gap(8) + Secondary("Not now", Modifier.fillMaxWidth()) + } +} + +/** + * Nothing supported is installed. Leads with what the app does for the user, + * not with install buttons: the supported list is one link down, and says that + * some of its links carry our referral code. + */ +@Composable +fun NoAppsHome(h: Home) { + val c = LocalCp.current + Column(Modifier.fillMaxSize().padding(24.dp)) { + Gap(40) + Box(Modifier.size(56.dp).clip(CircleShape).background(c.raised), contentAlignment = Alignment.Center) { + Ic(R.drawable.ic_query_stats, tint = c.text2, size = 28) + } + Gap(20) + Text("No supported app on this phone yet", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Gap(10) + Text( + "CashPilot watches passive-income apps you already use, such as EarnApp or Grass, and tells you when one stops running. Once one is installed it appears here by itself.", + style = MaterialTheme.typography.bodyLarge, color = c.text2, + ) + Gap(24) + Panel { + Text("Works without a server", fontWeight = FontWeight.SemiBold) + Gap(6) + Text( + "On its own, CashPilot shows which apps run, when one stopped, and opens it for you. A CashPilot server adds earnings figures and lists this phone with your other machines.", + style = MaterialTheme.typography.bodyMedium, color = c.text2, + ) + } + Gap(20) + Secondary("Show a demo with sample data", Modifier.fillMaxWidth()) + Gap(14) + Text("See the ${h.apps.size} supported apps ›", color = c.accent, fontWeight = FontWeight.Medium, modifier = Modifier.padding(start = 4.dp)) + Spacer(Modifier.weight(1f)) + ServerLine(h) + } +} + +/** The supported apps a phone does not have, as a plain list with the referral disclosure at the top. */ +@Composable +fun SupportedApps(h: Home) { + val c = LocalCp.current + Column(Modifier.fillMaxSize()) { + Row(Modifier.padding(start = 8.dp, top = 24.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) { Ic(R.drawable.ic_arrow_back, tint = c.text) } + Text("Supported apps", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + } + Column(Modifier.padding(horizontal = 16.dp)) { + Text( + "CashPilot can watch these apps if you install them. What any of them pays depends on the provider, your connection and your region.", + color = c.text2, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp), + ) + Panel(fill = c.raised, pad = PaddingValues(horizontal = 14.dp, vertical = 10.dp)) { + Text( + "Links marked Referral include CashPilot's referral code, which supports CashPilot's development. The others go to the app store.", + style = MaterialTheme.typography.bodySmall, color = c.text2, + ) + } + Gap(12) + Panel(pad = PaddingValues(vertical = 4.dp)) { + h.notInstalled.forEachIndexed { i, a -> + if (i > 0) Box(Modifier.padding(start = 70.dp)) { Divider() } + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { + AppMark(a.name) + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text(a.name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) + Text( + if (referralOf(a.slug) != null) "Referral · provider's sign-up page" else "App store page", + style = MaterialTheme.typography.bodySmall, color = c.muted, + ) + } + SmallAction("Open", color = c.text) + } + } + } + } + } +} + +/** Demo mode: the real home screen with sample data, clearly labelled, for reviewers and the curious. */ +@Composable +fun DemoFrame(content: @Composable () -> Unit) { + val c = LocalCp.current + Column(Modifier.fillMaxSize()) { + Row( + Modifier.fillMaxWidth().padding(top = 24.dp).background(c.accentSoft).padding(horizontal = 20.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Demo · sample data, nothing here is real", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Text("Exit", color = c.accent, fontWeight = FontWeight.SemiBold) + } + Box(Modifier.weight(1f)) { content() } + } +} diff --git a/app/src/test/java/com/cashpilot/android/redesign/ProposalScreensTest.kt b/app/src/test/java/com/cashpilot/android/redesign/ProposalScreensTest.kt new file mode 100644 index 0000000..775593a --- /dev/null +++ b/app/src/test/java/com/cashpilot/android/redesign/ProposalScreensTest.kt @@ -0,0 +1,62 @@ +package com.cashpilot.android.redesign + +import androidx.compose.runtime.Composable +import com.cashpilot.android.ui.AppState +import com.github.takahirom.roborazzi.captureRoboImage +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** The three directions and the screens common to all of them, in dark and light. */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = PHONE) +class ProposalScreensTest { + + private val healthy = Fixtures.healthy.toHome() + private val problems = Fixtures.problems.toHome() + private val standalone = Fixtures.standalone.toHome() + private val blind = Fixtures.blind.toHome() + private val empty = Fixtures.empty.toHome() + + private fun shot(path: String, dark: Boolean = true, content: @Composable () -> Unit) { + captureRoboImage("$OUT/$path.png") { CpTheme(dark) { content() } } + } + + private fun direction(id: String, home: @Composable (Home) -> Unit, extra: @Composable () -> Unit) { + shot("$id/home_healthy") { home(healthy) } + shot("$id/home_problems") { home(problems) } + shot("$id/home_standalone") { home(standalone) } + shot("$id/home_demo") { DemoFrame { home(healthy) } } + shot("$id/home_healthy_light", dark = false) { home(healthy) } + shot("$id/home_problems_light", dark = false) { home(problems) } + shot("$id/detail") { extra() } + } + + @Test + fun directions() { + renderGate() + val grass = problems.apps.first { it.slug == "grass" && it.state == AppState.STOPPED } + direction("a", { AHome(it) }) { AAppDetail(grass, problems) } + direction("b", { BHome(it) }) { SupportedApps(healthy) } + direction("c", { CHome(it) }) { CApps(problems) } + } + + @Test + fun common() { + renderGate() + shot("common/onboard_1_welcome") { OnboardWelcome() } + shot("common/onboard_2_permissions") { OnboardPermissions(notif = true, usage = false, battery = false) } + shot("common/onboard_2_permissions_light", dark = false) { OnboardPermissions(notif = true, usage = false, battery = false) } + shot("common/onboard_3_server_ok") { OnboardServer(PairResult.OK) } + shot("common/onboard_3_server_refused") { OnboardServer(PairResult.KEY_REFUSED) } + shot("common/blind") { BlindHome(blind) } + shot("common/disclose_notifications") { Disclosure(Access.NOTIFICATIONS) } + shot("common/disclose_usage") { Disclosure(Access.USAGE) } + shot("common/no_apps") { NoAppsHome(empty) } + shot("common/supported_apps") { SupportedApps(healthy) } + shot("common/settings") { SettingsCommon(problems) } + } +} diff --git a/app/src/test/java/com/cashpilot/android/redesign/Render.kt b/app/src/test/java/com/cashpilot/android/redesign/Render.kt new file mode 100644 index 0000000..3f940a2 --- /dev/null +++ b/app/src/test/java/com/cashpilot/android/redesign/Render.kt @@ -0,0 +1,18 @@ +package com.cashpilot.android.redesign + +import org.junit.Assume + +/** A phone-sized canvas: 411 x 891 dp, rendered at xhdpi (822 x 1782 px). */ +const val PHONE = "w411dp-h891dp-xhdpi" + +/** Images land in the repository's docs, next to the PR that shows them. */ +const val OUT = "../docs/redesign" + +/** + * The design screenshots are documentation, not goldens: they are written on + * request and never compared. Skipped unless CASHPILOT_RENDER_REDESIGN is set. + */ +fun renderGate() = Assume.assumeTrue( + "design screenshots render only with CASHPILOT_RENDER_REDESIGN=1", + System.getenv("CASHPILOT_RENDER_REDESIGN") != null, +) diff --git a/docs/redesign/a/detail.png b/docs/redesign/a/detail.png new file mode 100644 index 0000000..d69032a Binary files /dev/null and b/docs/redesign/a/detail.png differ diff --git a/docs/redesign/a/home_demo.png b/docs/redesign/a/home_demo.png new file mode 100644 index 0000000..5c3218a Binary files /dev/null and b/docs/redesign/a/home_demo.png differ diff --git a/docs/redesign/a/home_healthy.png b/docs/redesign/a/home_healthy.png new file mode 100644 index 0000000..4d34d96 Binary files /dev/null and b/docs/redesign/a/home_healthy.png differ diff --git a/docs/redesign/a/home_healthy_light.png b/docs/redesign/a/home_healthy_light.png new file mode 100644 index 0000000..4b684b0 Binary files /dev/null and b/docs/redesign/a/home_healthy_light.png differ diff --git a/docs/redesign/a/home_problems.png b/docs/redesign/a/home_problems.png new file mode 100644 index 0000000..8d5be51 Binary files /dev/null and b/docs/redesign/a/home_problems.png differ diff --git a/docs/redesign/a/home_problems_light.png b/docs/redesign/a/home_problems_light.png new file mode 100644 index 0000000..1dffdbf Binary files /dev/null and b/docs/redesign/a/home_problems_light.png differ diff --git a/docs/redesign/a/home_standalone.png b/docs/redesign/a/home_standalone.png new file mode 100644 index 0000000..a8f9992 Binary files /dev/null and b/docs/redesign/a/home_standalone.png differ diff --git a/docs/redesign/b/detail.png b/docs/redesign/b/detail.png new file mode 100644 index 0000000..43f6db0 Binary files /dev/null and b/docs/redesign/b/detail.png differ diff --git a/docs/redesign/b/home_demo.png b/docs/redesign/b/home_demo.png new file mode 100644 index 0000000..e7c49c8 Binary files /dev/null and b/docs/redesign/b/home_demo.png differ diff --git a/docs/redesign/b/home_healthy.png b/docs/redesign/b/home_healthy.png new file mode 100644 index 0000000..197e310 Binary files /dev/null and b/docs/redesign/b/home_healthy.png differ diff --git a/docs/redesign/b/home_healthy_light.png b/docs/redesign/b/home_healthy_light.png new file mode 100644 index 0000000..9abdc28 Binary files /dev/null and b/docs/redesign/b/home_healthy_light.png differ diff --git a/docs/redesign/b/home_problems.png b/docs/redesign/b/home_problems.png new file mode 100644 index 0000000..906022c Binary files /dev/null and b/docs/redesign/b/home_problems.png differ diff --git a/docs/redesign/b/home_problems_light.png b/docs/redesign/b/home_problems_light.png new file mode 100644 index 0000000..067c20e Binary files /dev/null and b/docs/redesign/b/home_problems_light.png differ diff --git a/docs/redesign/b/home_standalone.png b/docs/redesign/b/home_standalone.png new file mode 100644 index 0000000..2575002 Binary files /dev/null and b/docs/redesign/b/home_standalone.png differ diff --git a/docs/redesign/before/dashboard_blind.png b/docs/redesign/before/dashboard_blind.png new file mode 100644 index 0000000..886ff08 Binary files /dev/null and b/docs/redesign/before/dashboard_blind.png differ diff --git a/docs/redesign/before/dashboard_healthy.png b/docs/redesign/before/dashboard_healthy.png new file mode 100644 index 0000000..3fe7f17 Binary files /dev/null and b/docs/redesign/before/dashboard_healthy.png differ diff --git a/docs/redesign/before/dashboard_problems.png b/docs/redesign/before/dashboard_problems.png new file mode 100644 index 0000000..725decf Binary files /dev/null and b/docs/redesign/before/dashboard_problems.png differ diff --git a/docs/redesign/before/dashboard_standalone.png b/docs/redesign/before/dashboard_standalone.png new file mode 100644 index 0000000..b75be5b Binary files /dev/null and b/docs/redesign/before/dashboard_standalone.png differ diff --git a/docs/redesign/before/settings.png b/docs/redesign/before/settings.png new file mode 100644 index 0000000..937c74a Binary files /dev/null and b/docs/redesign/before/settings.png differ diff --git a/docs/redesign/before/setup.png b/docs/redesign/before/setup.png new file mode 100644 index 0000000..0e66297 Binary files /dev/null and b/docs/redesign/before/setup.png differ diff --git a/docs/redesign/c/detail.png b/docs/redesign/c/detail.png new file mode 100644 index 0000000..99b8f67 Binary files /dev/null and b/docs/redesign/c/detail.png differ diff --git a/docs/redesign/c/home_demo.png b/docs/redesign/c/home_demo.png new file mode 100644 index 0000000..53178e3 Binary files /dev/null and b/docs/redesign/c/home_demo.png differ diff --git a/docs/redesign/c/home_healthy.png b/docs/redesign/c/home_healthy.png new file mode 100644 index 0000000..68ace0e Binary files /dev/null and b/docs/redesign/c/home_healthy.png differ diff --git a/docs/redesign/c/home_healthy_light.png b/docs/redesign/c/home_healthy_light.png new file mode 100644 index 0000000..583d3fd Binary files /dev/null and b/docs/redesign/c/home_healthy_light.png differ diff --git a/docs/redesign/c/home_problems.png b/docs/redesign/c/home_problems.png new file mode 100644 index 0000000..234572e Binary files /dev/null and b/docs/redesign/c/home_problems.png differ diff --git a/docs/redesign/c/home_problems_light.png b/docs/redesign/c/home_problems_light.png new file mode 100644 index 0000000..84bbc8a Binary files /dev/null and b/docs/redesign/c/home_problems_light.png differ diff --git a/docs/redesign/c/home_standalone.png b/docs/redesign/c/home_standalone.png new file mode 100644 index 0000000..a630d8b Binary files /dev/null and b/docs/redesign/c/home_standalone.png differ diff --git a/docs/redesign/common/blind.png b/docs/redesign/common/blind.png new file mode 100644 index 0000000..70bac02 Binary files /dev/null and b/docs/redesign/common/blind.png differ diff --git a/docs/redesign/common/disclose_notifications.png b/docs/redesign/common/disclose_notifications.png new file mode 100644 index 0000000..7bbc8e1 Binary files /dev/null and b/docs/redesign/common/disclose_notifications.png differ diff --git a/docs/redesign/common/disclose_usage.png b/docs/redesign/common/disclose_usage.png new file mode 100644 index 0000000..3b9a84d Binary files /dev/null and b/docs/redesign/common/disclose_usage.png differ diff --git a/docs/redesign/common/no_apps.png b/docs/redesign/common/no_apps.png new file mode 100644 index 0000000..4db421b Binary files /dev/null and b/docs/redesign/common/no_apps.png differ diff --git a/docs/redesign/common/onboard_1_welcome.png b/docs/redesign/common/onboard_1_welcome.png new file mode 100644 index 0000000..e002618 Binary files /dev/null and b/docs/redesign/common/onboard_1_welcome.png differ diff --git a/docs/redesign/common/onboard_2_permissions.png b/docs/redesign/common/onboard_2_permissions.png new file mode 100644 index 0000000..d8646cf Binary files /dev/null and b/docs/redesign/common/onboard_2_permissions.png differ diff --git a/docs/redesign/common/onboard_2_permissions_light.png b/docs/redesign/common/onboard_2_permissions_light.png new file mode 100644 index 0000000..ccc1574 Binary files /dev/null and b/docs/redesign/common/onboard_2_permissions_light.png differ diff --git a/docs/redesign/common/onboard_3_server_ok.png b/docs/redesign/common/onboard_3_server_ok.png new file mode 100644 index 0000000..6645a27 Binary files /dev/null and b/docs/redesign/common/onboard_3_server_ok.png differ diff --git a/docs/redesign/common/onboard_3_server_refused.png b/docs/redesign/common/onboard_3_server_refused.png new file mode 100644 index 0000000..4ec2560 Binary files /dev/null and b/docs/redesign/common/onboard_3_server_refused.png differ diff --git a/docs/redesign/common/settings.png b/docs/redesign/common/settings.png new file mode 100644 index 0000000..c90cde1 Binary files /dev/null and b/docs/redesign/common/settings.png differ diff --git a/docs/redesign/common/supported_apps.png b/docs/redesign/common/supported_apps.png new file mode 100644 index 0000000..43f6db0 Binary files /dev/null and b/docs/redesign/common/supported_apps.png differ