diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionReport.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionReport.kt index 9afca7d..5d0fae2 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionReport.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionReport.kt @@ -228,6 +228,7 @@ object SessionReport { } if (outcome == "connected") runCatching { app.seb3thehacker.gearslip.stats.WorkedPrompt.onDriveWorked(context, headUnitInfo, protocolVersion, carScreen, carDpi) + app.seb3thehacker.gearslip.ui.DonatePrompt.onDriveWorked(context) } runCatching { app.seb3thehacker.gearslip.update.UpdateChecker.onDriveEnded(context) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/DonatePrompt.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/DonatePrompt.kt new file mode 100644 index 0000000..313ba00 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/DonatePrompt.kt @@ -0,0 +1,117 @@ +package app.seb3thehacker.gearslip.ui + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.seb3thehacker.gearslip.GearslipLog +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * The donation ask on Home, after a drive that worked. A driver sees it first after their third + * such drive, so Gearslip has earned it, and then at most once every three months. Tapping either + * donate button ends it for good. + */ +object DonatePrompt { + + private const val PREFS = "donate_prompt" + private const val KEY_DRIVES = "drives" + private const val KEY_LAST = "last_shown" + private const val KEY_DONATED = "donated" + private const val KEY_WAITING = "waiting" + private const val FIRST_AFTER = 3 + private const val GAP_MS = 90L * 24 * 60 * 60 * 1000 + + private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + private var loaded = false + private val flow = MutableStateFlow(false) + + /** True while the ask is waiting for the driver to open Home. */ + fun pending(context: Context): StateFlow { + if (!loaded) { + loaded = true + flow.value = prefs(context).getBoolean(KEY_WAITING, false) + } + return flow + } + + /** A drive that reached the car's screen ended cleanly. Asks if it's time. */ + fun onDriveWorked(context: Context) { + val p = prefs(context) + if (p.getBoolean(KEY_DONATED, false)) return + val drives = p.getInt(KEY_DRIVES, 0) + 1 + val due = drives >= FIRST_AFTER && System.currentTimeMillis() - p.getLong(KEY_LAST, 0L) > GAP_MS + p.edit().putInt(KEY_DRIVES, drives).apply { + if (due) putBoolean(KEY_WAITING, true) + }.apply() + if (due) { + loaded = true + flow.value = true + } + } + + /** The driver closed the ask; [donated] if they picked a way to give. */ + fun answer(context: Context, donated: Boolean) { + prefs(context).edit() + .putBoolean(KEY_WAITING, false) + .putLong(KEY_LAST, System.currentTimeMillis()) + .apply { if (donated) putBoolean(KEY_DONATED, true) } + .apply() + loaded = true + flow.value = false + GearslipLog.i(if (donated) "donate prompt: opened a donation link" else "donate prompt: not now") + } +} + +/** Shows the donation ask over Home when one is waiting and the car isn't connected. */ +@Composable +internal fun DonateDialog(live: Boolean) { + val context = LocalContext.current + val waiting by remember { DonatePrompt.pending(context) }.collectAsStateWithLifecycle() + if (!waiting || live) return + fun give(url: String) { + DonatePrompt.answer(context, donated = true) + GearslipLinks.open(context, url) + } + AlertDialog( + onDismissRequest = { DonatePrompt.answer(context, donated = false) }, + icon = { Icon(Icons.Filled.Favorite, contentDescription = null) }, + title = { Text("Glad that drive worked") }, + text = { + Text( + "Gearslip is free, with no ads and no tracking. If it gets you on the road, a " + + "donation keeps it going.", + ) + }, + confirmButton = { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { give(GearslipLinks.SPONSORS) }, Modifier.fillMaxWidth()) { + Text("Donate on GitHub") + } + OutlinedButton(onClick = { give(GearslipLinks.LIBERAPAY) }, Modifier.fillMaxWidth()) { + Text("Donate on Liberapay") + } + TextButton(onClick = { DonatePrompt.answer(context, donated = false) }, Modifier.fillMaxWidth()) { + Text("Not now") + } + } + }, + ) +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt index 8e228b8..f235914 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt @@ -144,6 +144,8 @@ fun HomeScreen( } // Sharing just went on from the "it worked" ask: a little thanks. if (celebrate) ConfettiFall(onDone = WorkedPrompt::celebrated) + // The usage notes ask comes first; the donation ask waits until that's answered. + if (workedCar == null) DonateDialog(live) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt index 30fe681..249b4e1 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt @@ -119,6 +119,10 @@ fun SettingsScreen( verticalArrangement = Arrangement.spacedBy(24.dp), ) { Spacer(Modifier.height(0.dp)) + SettingsSection( + "Support Gearslip", + footer = "Gearslip is free, with no ads and no tracking. Donations keep it going.", + ) { DonateRows() } ConnectionSection() HelpSection(onOpenHelp, onOpenCarPreview, onOpenLogs) CarAudioSection() @@ -131,10 +135,6 @@ fun SettingsScreen( ) { UsageStatsRows() } SettingsSection("Updates") { UpdateRow() } CertificateSection() - SettingsSection( - "Support Gearslip", - footer = "Gearslip is free, with no ads and no tracking. Donations keep it going.", - ) { DonateRows() } AboutSection(onOpenWhatsNew, onReplayTutorial, onReplayIntro) Spacer(Modifier.height(16.dp)) }