diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fd84724a..d64f1918 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/foss/java/eu/darken/capod/common/review/FossReviewTool.kt b/app/src/foss/java/eu/darken/capod/common/review/FossReviewTool.kt new file mode 100644 index 00000000..ed51ecd3 --- /dev/null +++ b/app/src/foss/java/eu/darken/capod/common/review/FossReviewTool.kt @@ -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 = 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") + } +} diff --git a/app/src/foss/java/eu/darken/capod/common/review/ReviewModule.kt b/app/src/foss/java/eu/darken/capod/common/review/ReviewModule.kt new file mode 100644 index 00000000..0050e31d --- /dev/null +++ b/app/src/foss/java/eu/darken/capod/common/review/ReviewModule.kt @@ -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 +} diff --git a/app/src/gplay/java/eu/darken/capod/common/review/GplayReviewTool.kt b/app/src/gplay/java/eu/darken/capod/common/review/GplayReviewTool.kt new file mode 100644 index 00000000..dac10bd1 --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/review/GplayReviewTool.kt @@ -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 = 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 = 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 = 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) + } +} diff --git a/app/src/gplay/java/eu/darken/capod/common/review/ReviewModule.kt b/app/src/gplay/java/eu/darken/capod/common/review/ReviewModule.kt new file mode 100644 index 00000000..98b33952 --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/review/ReviewModule.kt @@ -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) + } +} diff --git a/app/src/gplay/java/eu/darken/capod/common/review/ReviewSettings.kt b/app/src/gplay/java/eu/darken/capod/common/review/ReviewSettings.kt new file mode 100644 index 00000000..b342cb35 --- /dev/null +++ b/app/src/gplay/java/eu/darken/capod/common/review/ReviewSettings.kt @@ -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 + get() = context.dataStore + + // Explicit serializer: `java.time.Instant` has no `@Serializable` companion, so the reified + // `serializer()` 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") + } +} diff --git a/app/src/main/java/eu/darken/capod/common/review/ReviewTool.kt b/app/src/main/java/eu/darken/capod/common/review/ReviewTool.kt new file mode 100644 index 00000000..80d25da3 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/review/ReviewTool.kt @@ -0,0 +1,18 @@ +package eu.darken.capod.common.review + +import android.app.Activity +import kotlinx.coroutines.flow.Flow + +interface ReviewTool { + + val state: Flow + + data class State( + val shouldAskForReview: Boolean = false, + val hasReviewed: Boolean = false, + ) + + suspend fun dismiss() + + suspend fun reviewNow(activity: Activity) +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt index c51b1acb..eb9c32fd 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt @@ -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) { diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt index 20b32fce..f01fd9aa 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt @@ -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() @@ -92,6 +95,7 @@ class OverviewViewModel @Inject constructor( val showTroubleshootSuggestion: Boolean, val batteryEstimates: Map, 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 = 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) diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/ReviewCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/ReviewCard.kt new file mode 100644 index 00000000..77ebac0f --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/ReviewCard.kt @@ -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 = {}, + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7b4e8f61..d6846199 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -534,6 +534,9 @@ Without one, device settings and auto-connect aren\'t available. Reactions are per device Auto-play, auto-pause and pop-ups are now configured per device. Tap the settings icon on a card while your headphones are connected. + Review + Maybe later + Would you like to leave CAPod a review? ❤️ General