mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
feat(overview): Ask happy users for a Play review
Adds a review prompt card to the overview. On Google Play it uses the in-app review flow, gated on the user having been Pro for a while, not having dismissed it recently and not having reviewed yet. FOSS gets a no-op implementation. The card is the lowest priority item on the overview and stays hidden while a permission, troubleshooter, background-monitoring-off or no-profiles card is on screen.
This commit is contained in:
@@ -198,6 +198,9 @@ dependencies {
|
||||
"gplayImplementation"("com.android.billingclient:billing:8.3.0")
|
||||
"gplayImplementation"("com.android.billingclient:billing-ktx:8.3.0")
|
||||
|
||||
"gplayImplementation"("com.google.android.play:review:2.0.2")
|
||||
"gplayImplementation"("com.google.android.play:review-ktx:2.0.2")
|
||||
|
||||
// Robolectric-backed Compose UI tests (run as regular unit tests via the vintage engine).
|
||||
testImplementation(platform("androidx.compose:compose-bom:${Versions.Compose.bom}"))
|
||||
testImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package eu.darken.capod.common.review
|
||||
|
||||
import android.app.Activity
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class FossReviewTool @Inject constructor() : ReviewTool {
|
||||
|
||||
|
||||
override val state: Flow<ReviewTool.State> = flowOf(ReviewTool.State())
|
||||
|
||||
override suspend fun dismiss() {
|
||||
log(TAG, INFO) { "dismiss()" }
|
||||
// NOOP
|
||||
}
|
||||
|
||||
override suspend fun reviewNow(activity: Activity) {
|
||||
log(TAG, INFO) { "reviewNow($activity)" }
|
||||
// NOOP
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Review", "Tool", "FOSS")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package eu.darken.capod.common.review
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class ReviewModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun reviewTool(tool: FossReviewTool): ReviewTool
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package eu.darken.capod.common.review
|
||||
|
||||
import android.app.Activity
|
||||
import com.google.android.play.core.ktx.launchReview
|
||||
import com.google.android.play.core.ktx.requestReview
|
||||
import com.google.android.play.core.review.ReviewInfo
|
||||
import com.google.android.play.core.review.ReviewManager
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.datastore.value
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.replayingShare
|
||||
import eu.darken.capod.common.flow.throttleLatest
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@Singleton
|
||||
class GplayReviewTool @Inject constructor(
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val settings: ReviewSettings,
|
||||
private val manager: ReviewManager,
|
||||
upgradeRepo: UpgradeRepo,
|
||||
) : ReviewTool {
|
||||
|
||||
// Test seam: the probe backoff runs on AppScope (a real dispatcher), so a virtual-time test
|
||||
// cannot advance the production bound. Same pattern as UpgradeRepoGplay.launchTimeoutMs.
|
||||
internal var probeRetryDelay: Duration = PROBE_RETRY_DELAY
|
||||
|
||||
// Local bookkeeping only: decided without talking to Play, so an ineligible user never
|
||||
// triggers a Play round-trip.
|
||||
private val isLocallyEligible: Flow<Boolean> = combine(
|
||||
settings.lastDismissed.flow,
|
||||
settings.reviewedAt.flow,
|
||||
upgradeRepo.upgradeInfo,
|
||||
) { lastDismissed, reviewedAt, upgradeInfo ->
|
||||
val now = Instant.now()
|
||||
|
||||
// Free trial is 14 days, only ask for review after the user has paid something
|
||||
val hasPaidForPro = Duration.between(upgradeInfo.upgradedAt ?: now, now) > Duration.ofDays(21)
|
||||
val isSnoozed = Duration.between(lastDismissed ?: Instant.EPOCH, now) < Duration.ofDays(14)
|
||||
val hasReviewed = reviewedAt != null
|
||||
|
||||
log(TAG) { "Eligibility: hasPaidForPro=$hasPaidForPro (${upgradeInfo.upgradedAt})" }
|
||||
log(TAG) { "Eligibility: isSnoozed=$isSnoozed ($lastDismissed), hasReviewed=$hasReviewed ($reviewedAt)" }
|
||||
|
||||
hasPaidForPro && !isSnoozed && !hasReviewed
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
// Only probed once the user is eligible: Play counts requests against the app's quota, and an
|
||||
// `isNoOp` answer is Play's deliberate verdict, i.e. an answer and not a failure to retry.
|
||||
private val isReviewAvailable: Flow<Boolean> = isLocallyEligible
|
||||
.flatMapLatest { eligible ->
|
||||
if (!eligible) return@flatMapLatest flowOf(false)
|
||||
|
||||
flow {
|
||||
for (attempt in 1..PROBE_ATTEMPTS) {
|
||||
val info = try {
|
||||
manager.requestReview()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Probe $attempt/$PROBE_ATTEMPTS failed: ${e.asLog()}" }
|
||||
if (attempt < PROBE_ATTEMPTS) delay(probeRetryDelay.toMillis())
|
||||
continue
|
||||
}
|
||||
log(TAG) { "Probe $attempt/$PROBE_ATTEMPTS returned ${info.desc()}" }
|
||||
emit(info.canShow)
|
||||
return@flow
|
||||
}
|
||||
// Re-probed when eligibility changes or on the next process start
|
||||
log(TAG, WARN) { "Probe gave up after $PROBE_ATTEMPTS attempts" }
|
||||
emit(false)
|
||||
}
|
||||
}
|
||||
.replayingShare(appScope)
|
||||
|
||||
override val state: Flow<ReviewTool.State> = combine(
|
||||
isLocallyEligible,
|
||||
isReviewAvailable,
|
||||
settings.reviewedAt.flow,
|
||||
) { eligible, available, reviewedAt ->
|
||||
log(TAG) { "State: eligible=$eligible, available=$available, reviewedAt=$reviewedAt" }
|
||||
ReviewTool.State(
|
||||
shouldAskForReview = eligible && available,
|
||||
hasReviewed = reviewedAt != null,
|
||||
)
|
||||
}
|
||||
.throttleLatest(500)
|
||||
.onStart { emit(ReviewTool.State()) }
|
||||
.replayingShare(appScope)
|
||||
|
||||
// Single-flight: a second tap must not queue up behind the first, or Play's flow would be
|
||||
// launched again the moment the user returns from it.
|
||||
private val reviewLock = Mutex()
|
||||
|
||||
override suspend fun dismiss() {
|
||||
log(TAG, INFO) { "dismiss()" }
|
||||
settings.lastDismissed.value(Instant.now())
|
||||
}
|
||||
|
||||
override suspend fun reviewNow(activity: Activity) {
|
||||
log(TAG, INFO) { "reviewNow($activity)" }
|
||||
|
||||
if (!reviewLock.tryLock()) {
|
||||
log(TAG, WARN) { "reviewNow(...) is already in progress, skipping" }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// ReviewInfo is short lived, Google wants it requested shortly before the launch,
|
||||
// a token cached at process start is likely stale by the time the user taps.
|
||||
val reviewInfo = try {
|
||||
manager.requestReview()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// A transient failure is not user intent: don't snooze the card, the next tap retries
|
||||
log(TAG, ERROR) { "Failed to get a fresh ReviewInfo: ${e.asLog()}" }
|
||||
return
|
||||
}
|
||||
log(TAG) { "reviewNow(...): Fresh ${reviewInfo.desc()}" }
|
||||
|
||||
if (!reviewInfo.canShow) {
|
||||
// Play's quota verdict, asking again right away would be pointless
|
||||
log(TAG, WARN) { "Play says we can't show the prompt, snoozing" }
|
||||
settings.lastDismissed.value(Instant.now())
|
||||
return
|
||||
}
|
||||
|
||||
if (activity.isFinishing || activity.isDestroyed) {
|
||||
log(TAG, WARN) { "Activity is gone, aborting: $activity" }
|
||||
return
|
||||
}
|
||||
|
||||
val reviewTime = measureTimeMillis {
|
||||
try {
|
||||
manager.launchReview(activity, reviewInfo)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "Failed to launch review flow: ${e.asLog()}" }
|
||||
return
|
||||
}
|
||||
}
|
||||
log(TAG) { "Review completed after ${reviewTime}ms" }
|
||||
|
||||
if (Duration.ofMillis(reviewTime) >= Duration.ofSeconds(2)) {
|
||||
log(TAG, INFO) { "Marking review as completed" }
|
||||
settings.reviewedAt.value(Instant.now())
|
||||
} else {
|
||||
log(TAG, INFO) { "Review was too quick, counting as dismiss" }
|
||||
settings.lastDismissed.value(Instant.now())
|
||||
}
|
||||
} finally {
|
||||
reviewLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private val ReviewInfo.canShow: Boolean
|
||||
get() = when {
|
||||
toString().contains("isNoOp=true") -> false
|
||||
else -> true
|
||||
}
|
||||
|
||||
private fun ReviewInfo.desc(): String {
|
||||
return "ReviewInfo(canShow=$canShow, ${toString()})"
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Review", "Tool", "Gplay")
|
||||
private const val PROBE_ATTEMPTS = 3
|
||||
private val PROBE_RETRY_DELAY = Duration.ofSeconds(30)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package eu.darken.capod.common.review
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.play.core.review.ReviewManager
|
||||
import com.google.android.play.core.review.ReviewManagerFactory
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class ReviewModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun reviewTool(tool: GplayReviewTool): ReviewTool
|
||||
|
||||
companion object {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun reviewManager(@ApplicationContext context: Context): ReviewManager = ReviewManagerFactory.create(context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package eu.darken.capod.common.review
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.datastore.createValue
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
|
||||
import eu.darken.capod.common.serialization.SerializationCapod
|
||||
import kotlinx.serialization.builtins.nullable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ReviewSettings @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@SerializationCapod json: Json,
|
||||
) {
|
||||
|
||||
private val Context.dataStore by preferencesDataStore(name = "settings_review_gplay")
|
||||
|
||||
val dataStore: DataStore<Preferences>
|
||||
get() = context.dataStore
|
||||
|
||||
// Explicit serializer: `java.time.Instant` has no `@Serializable` companion, so the reified
|
||||
// `serializer<T>()` the inline overload uses cannot resolve one for it.
|
||||
val lastDismissed = dataStore.createValue(
|
||||
key = "review.dismissedAt",
|
||||
defaultValue = null as Instant?,
|
||||
json = json,
|
||||
serializer = InstantEpochMillisSerializer.nullable,
|
||||
)
|
||||
val reviewedAt = dataStore.createValue(
|
||||
key = "review.reviewedAt",
|
||||
defaultValue = null as Instant?,
|
||||
json = json,
|
||||
serializer = InstantEpochMillisSerializer.nullable,
|
||||
)
|
||||
|
||||
companion object {
|
||||
internal val TAG = logTag("Review", "Settings", "Gplay")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package eu.darken.capod.common.review
|
||||
|
||||
import android.app.Activity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface ReviewTool {
|
||||
|
||||
val state: Flow<State>
|
||||
|
||||
data class State(
|
||||
val shouldAskForReview: Boolean = false,
|
||||
val hasReviewed: Boolean = false,
|
||||
)
|
||||
|
||||
suspend fun dismiss()
|
||||
|
||||
suspend fun reviewNow(activity: Activity)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package eu.darken.capod.main.ui.overview
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -62,6 +63,7 @@ import eu.darken.capod.main.ui.overview.cards.MonitoringActiveCard
|
||||
import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
|
||||
import eu.darken.capod.main.ui.overview.cards.PermissionCard
|
||||
import eu.darken.capod.main.ui.overview.cards.ReactionsMovedHintCard
|
||||
import eu.darken.capod.main.ui.overview.cards.ReviewCard
|
||||
import eu.darken.capod.main.ui.overview.cards.SinglePodsCard
|
||||
import eu.darken.capod.main.ui.overview.cards.TroubleshootSuggestionCard
|
||||
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
|
||||
@@ -163,6 +165,10 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
||||
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
|
||||
val currentState = state ?: return
|
||||
|
||||
// Play's in-app review flow needs a hosting Activity. Without one the card still renders, but
|
||||
// with its review action disabled instead of silently doing nothing.
|
||||
val activity = context as? Activity
|
||||
|
||||
OverviewScreen(
|
||||
state = currentState,
|
||||
snackbarHostState = snackbarHostState,
|
||||
@@ -191,6 +197,8 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
||||
onSetupPairedDevice = {
|
||||
currentState.soleProfileId?.let { vm.goToEditProfile(it) } ?: vm.goToDeviceManager()
|
||||
},
|
||||
onReviewNow = activity?.let { host -> { vm.reviewNow(host) } },
|
||||
onReviewDismiss = { vm.reviewDismiss() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -210,6 +218,8 @@ fun OverviewScreen(
|
||||
onEditProfile: (PodDevice) -> Unit = {},
|
||||
onToggleDeviceExpansion: (PodDevice) -> Unit = {},
|
||||
onSetupPairedDevice: () -> Unit = {},
|
||||
onReviewNow: (() -> Unit)? = null,
|
||||
onReviewDismiss: () -> Unit = {},
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -312,6 +322,16 @@ fun OverviewScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 3b. Review prompt, only shown while no higher priority card is on screen
|
||||
if (state.showReviewCard) {
|
||||
item(key = "review") {
|
||||
ReviewCard(
|
||||
onReview = onReviewNow,
|
||||
onDismiss = onReviewDismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Profiled device cards (limited to 1 for free users)
|
||||
if (!state.isScanBlocked && state.isBluetoothEnabled) {
|
||||
if (state.monitoringStatus == OverviewViewModel.MonitoringStatus.BACKGROUND_OFF) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package eu.darken.capod.main.ui.overview
|
||||
|
||||
import android.app.Activity
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
@@ -15,6 +16,7 @@ import eu.darken.capod.common.flow.combine
|
||||
import eu.darken.capod.common.flow.throttleLatest
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.permissions.Permission
|
||||
import eu.darken.capod.common.review.ReviewTool
|
||||
import eu.darken.capod.common.uix.ViewModel4
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
@@ -65,6 +67,7 @@ class OverviewViewModel @Inject constructor(
|
||||
private val monitorModeResolver: MonitorModeResolver,
|
||||
private val batteryEstimator: BatteryEstimator,
|
||||
private val timeSource: TimeSource,
|
||||
private val reviewTool: ReviewTool,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
|
||||
val requestPermissionEvent = SingleEventFlow<Permission>()
|
||||
@@ -92,6 +95,7 @@ class OverviewViewModel @Inject constructor(
|
||||
val showTroubleshootSuggestion: Boolean,
|
||||
val batteryEstimates: Map<String, BatteryEstimate>,
|
||||
val effectiveMode: MonitorMode,
|
||||
val reviewState: ReviewTool.State,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -123,19 +127,23 @@ class OverviewViewModel @Inject constructor(
|
||||
.onStart { emit(false) }
|
||||
.distinctUntilChanged()
|
||||
|
||||
private val overviewUiSettings = combineFlows(
|
||||
private val overviewUiSettings = combine(
|
||||
generalSettings.reactionsHintDismissed.flow,
|
||||
generalSettings.hideUnmatchedDevices.flow,
|
||||
troubleshootSuggestion,
|
||||
batteryEstimator.estimates,
|
||||
monitorModeResolver.effectiveMode,
|
||||
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion, batteryEstimates, effectiveMode ->
|
||||
// The review prompt is a nice-to-have: a failing review backend must never take the whole
|
||||
// overview down with it, so it falls back to "don't ask".
|
||||
reviewTool.state.catch { emit(ReviewTool.State()) },
|
||||
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion, batteryEstimates, effectiveMode, reviewState ->
|
||||
OverviewUiSettings(
|
||||
reactionsHintDismissed = reactionsHintDismissed,
|
||||
hideUnmatchedDevices = hideUnmatched,
|
||||
showTroubleshootSuggestion = showTroubleshootSuggestion,
|
||||
batteryEstimates = batteryEstimates,
|
||||
effectiveMode = effectiveMode,
|
||||
reviewState = reviewState,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -212,7 +220,7 @@ class OverviewViewModel @Inject constructor(
|
||||
val currentProfileIds = profiles.map { it.id }.toSet()
|
||||
val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet()
|
||||
|
||||
State(
|
||||
val state = State(
|
||||
now = timeSource.now(),
|
||||
permissions = permissions,
|
||||
devices = devices,
|
||||
@@ -228,6 +236,15 @@ class OverviewViewModel @Inject constructor(
|
||||
showTroubleshootSuggestion = uiSettings.showTroubleshootSuggestion,
|
||||
batteryEstimates = uiSettings.batteryEstimates,
|
||||
)
|
||||
|
||||
// Asking for a review is the lowest priority thing the overview can say: it only appears on
|
||||
// an otherwise quiet screen, never stacked on top of something the user has to act on.
|
||||
val showReviewCard = uiSettings.reviewState.shouldAskForReview && !state.hasHigherPriorityCard
|
||||
if (uiSettings.reviewState.shouldAskForReview && !showReviewCard) {
|
||||
log(TAG) { "Could show review card but higher priority cards are currently being shown" }
|
||||
}
|
||||
|
||||
state.copy(showReviewCard = showReviewCard)
|
||||
}.asLiveState()
|
||||
|
||||
enum class BluetoothIconState { HIDDEN, DISABLED, NEARBY, CONNECTED }
|
||||
@@ -249,6 +266,7 @@ class OverviewViewModel @Inject constructor(
|
||||
val hideUnmatchedDevices: Boolean = false,
|
||||
val showTroubleshootSuggestion: Boolean = false,
|
||||
val batteryEstimates: Map<String, BatteryEstimate> = emptyMap(),
|
||||
val showReviewCard: Boolean = false,
|
||||
) {
|
||||
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
|
||||
|
||||
@@ -312,6 +330,18 @@ class OverviewViewModel @Inject constructor(
|
||||
else -> MonitoringStatus.HIDDEN
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the overview is currently showing a card that outranks the review prompt: a
|
||||
* missing permission, the troubleshooter hint, the background-monitoring-off notice or the
|
||||
* no-profiles setup card. All of those ask the user to do something, so the review prompt
|
||||
* stays hidden while any of them is on screen.
|
||||
*/
|
||||
val hasHigherPriorityCard: Boolean
|
||||
get() = permissions.isNotEmpty() ||
|
||||
showTroubleshootSuggestion ||
|
||||
monitoringStatus == MonitoringStatus.BACKGROUND_OFF ||
|
||||
(profiles.isEmpty() && !isScanBlocked && isBluetoothEnabled)
|
||||
|
||||
val soleProfileId: ProfileId? get() = profiles.singleOrNull()?.id
|
||||
|
||||
fun isPinned(device: PodDevice, index: Int): Boolean =
|
||||
@@ -392,6 +422,16 @@ class OverviewViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun reviewNow(activity: Activity) {
|
||||
log(TAG, INFO) { "reviewNow($activity)" }
|
||||
launch { reviewTool.reviewNow(activity) }
|
||||
}
|
||||
|
||||
fun reviewDismiss() {
|
||||
log(TAG, INFO) { "reviewDismiss()" }
|
||||
launch { reviewTool.dismiss() }
|
||||
}
|
||||
|
||||
fun requestPermission(permission: Permission) {
|
||||
log(TAG, INFO) { "requestPermission($permission)" }
|
||||
requestPermissionEvent.tryEmit(permission)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Stars
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
|
||||
/**
|
||||
* Asks the user to leave a review. [onReview] is null when no hosting Activity is available, Play's
|
||||
* in-app review flow can't be launched without one, so the action is shown disabled instead.
|
||||
*/
|
||||
@Composable
|
||||
fun ReviewCard(
|
||||
onReview: (() -> Unit)?,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Stars,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.review_app_review_action),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.review_app_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(text = stringResource(R.string.review_app_dismiss_action))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { onReview?.invoke() },
|
||||
enabled = onReview != null,
|
||||
) {
|
||||
Text(text = stringResource(R.string.review_app_review_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun ReviewCardPreview() = PreviewWrapper {
|
||||
ReviewCard(
|
||||
onReview = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
@@ -534,6 +534,9 @@
|
||||
<string name="overview_card_missing_paired_device_consequence">Without one, device settings and auto-connect aren\'t available.</string>
|
||||
<string name="overview_reactions_hint_title">Reactions are per device</string>
|
||||
<string name="overview_reactions_hint_body">Auto-play, auto-pause and pop-ups are now configured per device. Tap the settings icon on a card while your headphones are connected.</string>
|
||||
<string name="review_app_review_action">Review</string>
|
||||
<string name="review_app_dismiss_action">Maybe later</string>
|
||||
<string name="review_app_body">Would you like to leave CAPod a review? ❤️</string>
|
||||
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">General</string>
|
||||
|
||||
Reference in New Issue
Block a user