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:
+190
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+190
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+422
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user