Rebrand from Casera/MyCrib to honeyDue

Total rebrand across KMM project:
- Kotlin package: com.example.casera -> com.tt.honeyDue (dirs + declarations)
- Gradle: rootProject.name, namespace, applicationId
- Android: manifest, strings.xml (all languages), widget resources
- iOS: pbxproj bundle IDs, Info.plist, entitlements, xcconfig
- iOS directories: Casera/ -> HoneyDue/, CaseraTests/ -> HoneyDueTests/, etc.
- Swift source: all class/struct/enum renames
- Deep links: casera:// -> honeydue://, .casera -> .honeydue
- App icons replaced with honeyDue honeycomb icon
- Domains: casera.treytartt.com -> honeyDue.treytartt.com
- Bundle IDs: com.tt.casera -> com.tt.honeyDue
- Database table names preserved

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-07 06:33:57 -06:00
parent 9c574c4343
commit 1e2adf7660
450 changed files with 1730 additions and 1788 deletions
@@ -0,0 +1,190 @@
package com.tt.honeyDue.ui.components
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.tt.honeyDue.models.Contractor
import com.tt.honeyDue.models.SharedContractor
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.sharing.ContractorSharingManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
/**
* Represents the current state of the contractor import flow.
*/
sealed class ImportState {
data object Idle : ImportState()
data class Confirmation(val sharedContractor: SharedContractor) : ImportState()
data class Importing(val sharedContractor: SharedContractor) : ImportState()
data class Success(val contractorName: String) : ImportState()
data class Error(val message: String) : ImportState()
}
/**
* Android-specific composable that handles the contractor import flow.
* Shows confirmation dialog, performs import, and displays result.
*
* @param pendingImportUri The URI of the .honeydue file to import (or null if none)
* @param onClearImport Called when import flow is complete and URI should be cleared
* @param onImportSuccess Called when import succeeds, with the imported contractor
*/
@Composable
fun ContractorImportHandler(
pendingImportUri: Uri?,
onClearImport: () -> Unit,
onImportSuccess: (Contractor) -> Unit = {}
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var importState by remember { mutableStateOf<ImportState>(ImportState.Idle) }
var pendingUri by remember { mutableStateOf<Uri?>(null) }
var importedContractor by remember { mutableStateOf<Contractor?>(null) }
val json = remember {
Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
}
// Parse the .honeydue file when a new URI is received
LaunchedEffect(pendingImportUri) {
if (pendingImportUri != null && importState is ImportState.Idle) {
pendingUri = pendingImportUri
withContext(Dispatchers.IO) {
try {
val inputStream = context.contentResolver.openInputStream(pendingImportUri)
if (inputStream != null) {
val jsonString = inputStream.bufferedReader().use { it.readText() }
inputStream.close()
val sharedContractor = json.decodeFromString(
SharedContractor.serializer(),
jsonString
)
withContext(Dispatchers.Main) {
importState = ImportState.Confirmation(sharedContractor)
}
} else {
withContext(Dispatchers.Main) {
importState = ImportState.Error("Could not open file")
}
}
} catch (e: Exception) {
e.printStackTrace()
withContext(Dispatchers.Main) {
importState = ImportState.Error("Invalid contractor file: ${e.message}")
}
}
}
}
}
// Show appropriate dialog based on state
when (val state = importState) {
is ImportState.Idle -> {
// No dialog
}
is ImportState.Confirmation -> {
ContractorImportConfirmDialog(
sharedContractor = state.sharedContractor,
isImporting = false,
onConfirm = {
importState = ImportState.Importing(state.sharedContractor)
scope.launch {
pendingUri?.let { uri ->
when (val result = ContractorSharingManager.importContractor(context, uri)) {
is ApiResult.Success -> {
importedContractor = result.data
importState = ImportState.Success(result.data.name)
}
is ApiResult.Error -> {
importState = ImportState.Error(result.message)
}
else -> {
importState = ImportState.Error("Import failed unexpectedly")
}
}
}
}
},
onDismiss = {
importState = ImportState.Idle
pendingUri = null
onClearImport()
}
)
}
is ImportState.Importing -> {
// Show the confirmation dialog with loading state
ContractorImportConfirmDialog(
sharedContractor = state.sharedContractor,
isImporting = true,
onConfirm = {},
onDismiss = {}
)
}
is ImportState.Success -> {
ContractorImportSuccessDialog(
contractorName = state.contractorName,
onDismiss = {
importedContractor?.let { onImportSuccess(it) }
importState = ImportState.Idle
pendingUri = null
importedContractor = null
onClearImport()
}
)
}
is ImportState.Error -> {
ContractorImportErrorDialog(
errorMessage = state.message,
onRetry = pendingUri?.let { uri ->
{
// Retry by re-parsing the file
scope.launch {
withContext(Dispatchers.IO) {
try {
val inputStream = context.contentResolver.openInputStream(uri)
if (inputStream != null) {
val jsonString = inputStream.bufferedReader().use { it.readText() }
inputStream.close()
val sharedContractor = json.decodeFromString(
SharedContractor.serializer(),
jsonString
)
withContext(Dispatchers.Main) {
importState = ImportState.Confirmation(sharedContractor)
}
}
} catch (e: Exception) {
// Keep showing error
}
}
}
}
},
onDismiss = {
importState = ImportState.Idle
pendingUri = null
onClearImport()
}
)
}
}
}
@@ -0,0 +1,190 @@
package com.tt.honeyDue.ui.components
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.tt.honeyDue.models.JoinResidenceResponse
import com.tt.honeyDue.models.SharedResidence
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.sharing.ResidenceSharingManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
/**
* Represents the current state of the residence import flow.
*/
sealed class ResidenceImportState {
data object Idle : ResidenceImportState()
data class Confirmation(val sharedResidence: SharedResidence) : ResidenceImportState()
data class Importing(val sharedResidence: SharedResidence) : ResidenceImportState()
data class Success(val residenceName: String) : ResidenceImportState()
data class Error(val message: String) : ResidenceImportState()
}
/**
* Android-specific composable that handles the residence import flow.
* Shows confirmation dialog, performs import, and displays result.
*
* @param pendingImportUri The URI of the .honeydue file to import (or null if none)
* @param onClearImport Called when import flow is complete and URI should be cleared
* @param onImportSuccess Called when import succeeds, with the join response
*/
@Composable
fun ResidenceImportHandler(
pendingImportUri: Uri?,
onClearImport: () -> Unit,
onImportSuccess: (JoinResidenceResponse) -> Unit = {}
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var importState by remember { mutableStateOf<ResidenceImportState>(ResidenceImportState.Idle) }
var pendingUri by remember { mutableStateOf<Uri?>(null) }
var importedResponse by remember { mutableStateOf<JoinResidenceResponse?>(null) }
val json = remember {
Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
}
// Parse the .honeydue file when a new URI is received
LaunchedEffect(pendingImportUri) {
if (pendingImportUri != null && importState is ResidenceImportState.Idle) {
pendingUri = pendingImportUri
withContext(Dispatchers.IO) {
try {
val inputStream = context.contentResolver.openInputStream(pendingImportUri)
if (inputStream != null) {
val jsonString = inputStream.bufferedReader().use { it.readText() }
inputStream.close()
val sharedResidence = json.decodeFromString(
SharedResidence.serializer(),
jsonString
)
withContext(Dispatchers.Main) {
importState = ResidenceImportState.Confirmation(sharedResidence)
}
} else {
withContext(Dispatchers.Main) {
importState = ResidenceImportState.Error("Could not open file")
}
}
} catch (e: Exception) {
e.printStackTrace()
withContext(Dispatchers.Main) {
importState = ResidenceImportState.Error("Invalid residence file: ${e.message}")
}
}
}
}
}
// Show appropriate dialog based on state
when (val state = importState) {
is ResidenceImportState.Idle -> {
// No dialog
}
is ResidenceImportState.Confirmation -> {
ResidenceImportConfirmDialog(
sharedResidence = state.sharedResidence,
isImporting = false,
onConfirm = {
importState = ResidenceImportState.Importing(state.sharedResidence)
scope.launch {
pendingUri?.let { uri ->
when (val result = ResidenceSharingManager.importResidence(context, uri)) {
is ApiResult.Success -> {
importedResponse = result.data
importState = ResidenceImportState.Success(result.data.residence.name)
}
is ApiResult.Error -> {
importState = ResidenceImportState.Error(result.message)
}
else -> {
importState = ResidenceImportState.Error("Import failed unexpectedly")
}
}
}
}
},
onDismiss = {
importState = ResidenceImportState.Idle
pendingUri = null
onClearImport()
}
)
}
is ResidenceImportState.Importing -> {
// Show the confirmation dialog with loading state
ResidenceImportConfirmDialog(
sharedResidence = state.sharedResidence,
isImporting = true,
onConfirm = {},
onDismiss = {}
)
}
is ResidenceImportState.Success -> {
ResidenceImportSuccessDialog(
residenceName = state.residenceName,
onDismiss = {
importedResponse?.let { onImportSuccess(it) }
importState = ResidenceImportState.Idle
pendingUri = null
importedResponse = null
onClearImport()
}
)
}
is ResidenceImportState.Error -> {
ResidenceImportErrorDialog(
errorMessage = state.message,
onRetry = pendingUri?.let { uri ->
{
// Retry by re-parsing the file
scope.launch {
withContext(Dispatchers.IO) {
try {
val inputStream = context.contentResolver.openInputStream(uri)
if (inputStream != null) {
val jsonString = inputStream.bufferedReader().use { it.readText() }
inputStream.close()
val sharedResidence = json.decodeFromString(
SharedResidence.serializer(),
jsonString
)
withContext(Dispatchers.Main) {
importState = ResidenceImportState.Confirmation(sharedResidence)
}
}
} catch (e: Exception) {
// Keep showing error
}
}
}
}
},
onDismiss = {
importState = ResidenceImportState.Idle
pendingUri = null
onClearImport()
}
)
}
}
}
@@ -0,0 +1,101 @@
package com.tt.honeyDue.ui.components.auth
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.tt.honeyDue.auth.GoogleSignInManager
import com.tt.honeyDue.auth.GoogleSignInResult
import kotlinx.coroutines.launch
@Composable
actual fun GoogleSignInButton(
onSignInStarted: () -> Unit,
onSignInSuccess: (idToken: String) -> Unit,
onSignInError: (message: String) -> Unit,
enabled: Boolean
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var isLoading by remember { mutableStateOf(false) }
val googleSignInManager = remember { GoogleSignInManager(context) }
OutlinedButton(
onClick = {
if (!isLoading && enabled) {
isLoading = true
onSignInStarted()
scope.launch {
when (val result = googleSignInManager.signIn()) {
is GoogleSignInResult.Success -> {
isLoading = false
onSignInSuccess(result.idToken)
}
is GoogleSignInResult.Error -> {
isLoading = false
onSignInError(result.message)
}
GoogleSignInResult.Cancelled -> {
isLoading = false
// User cancelled, no error needed
}
}
}
}
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = enabled && !isLoading,
shape = RoundedCornerShape(12.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
// Google "G" logo using Material colors
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "G",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = Color(0xFF4285F4) // Google Blue
)
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Continue with Google",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
@@ -0,0 +1,422 @@
package com.tt.honeyDue.ui.subscription
import android.app.Activity
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.android.billingclient.api.ProductDetails
import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.platform.BillingManager
import com.tt.honeyDue.ui.theme.AppSpacing
import kotlinx.coroutines.launch
/**
* Android-specific upgrade screen that connects to Google Play Billing.
* This version shows real product pricing from Google Play Console.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UpgradeFeatureScreenAndroid(
triggerKey: String,
icon: ImageVector,
billingManager: BillingManager,
onNavigateBack: () -> Unit
) {
val context = LocalContext.current
val activity = context as? Activity
val scope = rememberCoroutineScope()
var showFeatureComparison by remember { mutableStateOf(false) }
var selectedProductId by remember { mutableStateOf<String?>(null) }
var showSuccessAlert by remember { mutableStateOf(false) }
// Observe billing manager state
val isLoading by billingManager.isLoading.collectAsState()
val products by billingManager.products.collectAsState()
val purchaseError by billingManager.purchaseError.collectAsState()
val purchasedProductIDs by billingManager.purchasedProductIDs.collectAsState()
// Look up trigger data from cache
val triggerData by remember { derivedStateOf {
DataManager.upgradeTriggers.value[triggerKey]
} }
// Fallback values if trigger not found
val title = triggerData?.title ?: "Upgrade Required"
val message = triggerData?.message ?: "This feature is available with a Pro subscription."
// Load products on launch
LaunchedEffect(Unit) {
billingManager.loadProducts()
}
// Check for successful purchase
LaunchedEffect(purchasedProductIDs) {
if (purchasedProductIDs.isNotEmpty()) {
showSuccessAlert = true
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(title, fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.height(AppSpacing.xl))
// Feature Icon
Icon(
imageVector = Icons.Default.Stars,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.tertiary
)
Spacer(Modifier.height(AppSpacing.lg))
// Title
Text(
title,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = AppSpacing.lg)
)
Spacer(Modifier.height(AppSpacing.md))
// Description
Text(
message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = AppSpacing.lg)
)
Spacer(Modifier.height(AppSpacing.xl))
// Pro Features Preview Card
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppSpacing.lg),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant
) {
Column(
modifier = Modifier.padding(AppSpacing.lg),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
FeatureRowAndroid(Icons.Default.Home, "Unlimited properties")
FeatureRowAndroid(Icons.Default.CheckCircle, "Unlimited tasks")
FeatureRowAndroid(Icons.Default.People, "Contractor management")
FeatureRowAndroid(Icons.Default.Description, "Document & warranty storage")
}
}
Spacer(Modifier.height(AppSpacing.xl))
// Subscription Products Section
if (isLoading && products.isEmpty()) {
CircularProgressIndicator(
modifier = Modifier.padding(AppSpacing.lg),
color = MaterialTheme.colorScheme.primary
)
} else if (products.isNotEmpty()) {
// Calculate savings for annual
val monthlyProduct = billingManager.getMonthlyProduct()
val annualProduct = billingManager.getAnnualProduct()
val annualSavings = billingManager.calculateAnnualSavings(monthlyProduct, annualProduct)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppSpacing.lg),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
products.forEach { product ->
val isAnnual = product.productId.contains("annual")
val savingsBadge = if (isAnnual && annualSavings != null) {
"Save $annualSavings%"
} else null
SubscriptionProductCardAndroid(
productDetails = product,
formattedPrice = billingManager.getFormattedPrice(product) ?: "Loading...",
savingsBadge = savingsBadge,
isSelected = selectedProductId == product.productId,
isProcessing = isLoading && selectedProductId == product.productId,
onSelect = {
selectedProductId = product.productId
activity?.let { act ->
billingManager.launchPurchaseFlow(
activity = act,
productDetails = product,
onSuccess = { showSuccessAlert = true },
onError = { /* Error shown via purchaseError flow */ }
)
}
}
)
}
}
} else {
// Fallback if no products loaded
Button(
onClick = {
scope.launch {
billingManager.loadProducts()
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppSpacing.lg)
) {
Text("Retry Loading Products")
}
}
// Error Message
purchaseError?.let { error ->
Spacer(Modifier.height(AppSpacing.md))
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppSpacing.lg),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
) {
Row(
modifier = Modifier.padding(AppSpacing.md),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Text(
error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.weight(1f)
)
IconButton(
onClick = { billingManager.clearError() },
modifier = Modifier.size(24.dp)
) {
Icon(
Icons.Default.Close,
contentDescription = "Dismiss",
modifier = Modifier.size(16.dp)
)
}
}
}
}
Spacer(Modifier.height(AppSpacing.lg))
// Compare Plans
TextButton(onClick = { showFeatureComparison = true }) {
Text("Compare Free vs Pro")
}
// Restore Purchases
TextButton(onClick = {
scope.launch {
val restored = billingManager.restorePurchases()
if (restored) {
showSuccessAlert = true
}
}
}) {
Text(
"Restore Purchases",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(AppSpacing.xl * 2))
}
if (showFeatureComparison) {
FeatureComparisonDialog(
onDismiss = { showFeatureComparison = false },
onUpgrade = {
showFeatureComparison = false
// Select first product if available
products.firstOrNull()?.let { product ->
selectedProductId = product.productId
activity?.let { act ->
billingManager.launchPurchaseFlow(
activity = act,
productDetails = product,
onSuccess = { showSuccessAlert = true },
onError = { }
)
}
}
}
)
}
if (showSuccessAlert) {
AlertDialog(
onDismissRequest = {
showSuccessAlert = false
onNavigateBack()
},
title = { Text("Subscription Active") },
text = { Text("You now have full access to all Pro features!") },
confirmButton = {
TextButton(onClick = {
showSuccessAlert = false
onNavigateBack()
}) {
Text("Done")
}
}
)
}
}
}
@Composable
private fun FeatureRowAndroid(icon: ImageVector, text: String) {
Row(
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun SubscriptionProductCardAndroid(
productDetails: ProductDetails,
formattedPrice: String,
savingsBadge: String?,
isSelected: Boolean,
isProcessing: Boolean,
onSelect: () -> Unit
) {
val isAnnual = productDetails.productId.contains("annual")
val productName = if (isAnnual) "honeyDue Pro Annual" else "honeyDue Pro Monthly"
val billingPeriod = if (isAnnual) "Billed annually" else "Billed monthly"
Card(
onClick = onSelect,
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = if (isSelected)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surface
),
border = if (isSelected)
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
else
null
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Row(
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Text(
productName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
savingsBadge?.let { badge ->
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.tertiaryContainer
) {
Text(
badge,
modifier = Modifier.padding(
horizontal = AppSpacing.sm,
vertical = 2.dp
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary,
fontWeight = FontWeight.Bold
)
}
}
}
Text(
billingPeriod,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (isProcessing) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else {
Text(
formattedPrice,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}