diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 3079b01..99862c0 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -269,6 +269,29 @@
Camera
Library
Add photos of completed work (optional)
+ Link this task completion to a contractor
+ Completion Details
+ All fields are optional
+ Add any notes about the completed work
+ Describe the work done, issues found, etc.
+ Rate the quality of work performed
+ Enter name manually below
+
+
+ Manage Users
+ Invite Others
+ Easy Share
+ Send Invite Link
+ Send a .casera file via Messages, Email, or share. They just tap to join.
+ Share Code
+ No active code
+ Generate Code
+ Generate New Code
+ Share this 6-character code. They can enter it in the app to join.
+ Users (%1$d)
+ Owner
+ Remove
+ or
Contractors
@@ -527,6 +550,11 @@
Contractor Sharing
Actionable Notifications
Home Screen Widgets
+ Privacy Policy
+ View our privacy policy
+ Version %1$s
+ Casera
+ Edit Profile
Settings
@@ -646,6 +674,10 @@
Pending
Manage your residences
View and manage tasks
+ Overdue
+ Due This Week
+ Next 30 Days
+ Your Properties
Subscription
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt
index afc324b..3d4aef9 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt
@@ -41,8 +41,10 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.example.casera.ui.screens.MainScreen
+import com.example.casera.ui.screens.ManageUsersScreen
import com.example.casera.ui.screens.NotificationPreferencesScreen
import com.example.casera.ui.screens.ProfileScreen
+import com.example.casera.ui.subscription.UpgradeScreen
import com.example.casera.ui.theme.MyCribTheme
import com.example.casera.ui.theme.ThemeManager
import com.example.casera.navigation.*
@@ -583,6 +585,44 @@ fun App(
},
onNavigateToContractorDetail = { contractorId ->
navController.navigate(ContractorDetailRoute(contractorId))
+ },
+ onNavigateToManageUsers = { residenceId, residenceName, isPrimaryOwner, ownerId ->
+ navController.navigate(
+ ManageUsersRoute(
+ residenceId = residenceId,
+ residenceName = residenceName,
+ isPrimaryOwner = isPrimaryOwner,
+ residenceOwnerId = ownerId
+ )
+ )
+ }
+ )
+ }
+
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+ ManageUsersScreen(
+ residenceId = route.residenceId,
+ residenceName = route.residenceName,
+ isPrimaryOwner = route.isPrimaryOwner,
+ residenceOwnerId = route.residenceOwnerId,
+ onNavigateBack = {
+ navController.popBackStack()
+ }
+ )
+ }
+
+ composable {
+ UpgradeScreen(
+ onNavigateBack = {
+ navController.popBackStack()
+ },
+ onPurchase = { planId ->
+ // Handle purchase - integrate with billing system
+ navController.popBackStack()
+ },
+ onRestorePurchases = {
+ // Handle restore - integrate with billing system
}
)
}
@@ -631,6 +671,9 @@ fun App(
},
onNavigateToNotificationPreferences = {
navController.navigate(NotificationPreferencesRoute)
+ },
+ onNavigateToUpgrade = {
+ navController.navigate(UpgradeRoute)
}
)
}
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/navigation/Routes.kt b/composeApp/src/commonMain/kotlin/com/example/casera/navigation/Routes.kt
index f4ea992..1d29cc0 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/navigation/Routes.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/navigation/Routes.kt
@@ -121,3 +121,31 @@ object NotificationPreferencesRoute
// Onboarding Routes
@Serializable
object OnboardingRoute
+
+// Task Completion Route
+@Serializable
+data class CompleteTaskRoute(
+ val taskId: Int,
+ val taskTitle: String,
+ val residenceName: String
+)
+
+// Manage Users Route
+@Serializable
+data class ManageUsersRoute(
+ val residenceId: Int,
+ val residenceName: String,
+ val isPrimaryOwner: Boolean,
+ val residenceOwnerId: Int
+)
+
+// Photo Viewer Route
+@Serializable
+data class PhotoViewerRoute(
+ val imageUrls: String, // JSON encoded list
+ val initialIndex: Int = 0
+)
+
+// Upgrade/Subscription Route
+@Serializable
+object UpgradeRoute
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt
index cd5e298..0829e7c 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt
@@ -9,7 +9,7 @@ package com.example.casera.network
*/
object ApiConfig {
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
- val CURRENT_ENV = Environment.DEV
+ val CURRENT_ENV = Environment.LOCAL
enum class Environment {
LOCAL,
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/PhotoViewerScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/PhotoViewerScreen.kt
new file mode 100644
index 0000000..cda7877
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/PhotoViewerScreen.kt
@@ -0,0 +1,229 @@
+package com.example.casera.ui.components
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.gestures.detectTransformGestures
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.ZoomIn
+import androidx.compose.material.icons.filled.ZoomOut
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import com.example.casera.ui.theme.AppSpacing
+
+/**
+ * Full-screen photo viewer with swipeable gallery.
+ * Matches iOS PhotoViewerSheet functionality.
+ *
+ * Features:
+ * - Horizontal paging between images
+ * - Pinch to zoom
+ * - Double-tap to zoom
+ * - Page indicator dots
+ * - Close button
+ * - Image counter
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun PhotoViewerScreen(
+ imageUrls: List,
+ initialIndex: Int = 0,
+ onDismiss: () -> Unit
+) {
+ val pagerState = rememberPagerState(
+ initialPage = initialIndex.coerceIn(0, (imageUrls.size - 1).coerceAtLeast(0)),
+ pageCount = { imageUrls.size }
+ )
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black)
+ ) {
+ // Photo Pager
+ HorizontalPager(
+ state = pagerState,
+ modifier = Modifier.fillMaxSize()
+ ) { page ->
+ ZoomableImage(
+ imageUrl = imageUrls[page],
+ onDismiss = onDismiss
+ )
+ }
+
+ // Top Bar with close button and counter
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .statusBarsPadding()
+ .padding(AppSpacing.lg),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Close Button
+ IconButton(
+ onClick = onDismiss,
+ modifier = Modifier
+ .size(44.dp)
+ .clip(CircleShape)
+ .background(Color.Black.copy(alpha = 0.5f))
+ ) {
+ Icon(
+ Icons.Default.Close,
+ contentDescription = "Close",
+ tint = Color.White
+ )
+ }
+
+ // Image Counter
+ if (imageUrls.size > 1) {
+ Surface(
+ shape = RoundedCornerShape(16.dp),
+ color = Color.Black.copy(alpha = 0.5f)
+ ) {
+ Text(
+ text = "${pagerState.currentPage + 1} / ${imageUrls.size}",
+ color = Color.White,
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
+ )
+ }
+ }
+ }
+
+ // Page Indicator Dots (bottom)
+ if (imageUrls.size > 1) {
+ Row(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .navigationBarsPadding()
+ .padding(bottom = AppSpacing.xl),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ repeat(imageUrls.size) { index ->
+ val isSelected = pagerState.currentPage == index
+ val size by animateFloatAsState(
+ targetValue = if (isSelected) 10f else 6f,
+ label = "dotSize"
+ )
+ val alpha by animateFloatAsState(
+ targetValue = if (isSelected) 1f else 0.5f,
+ label = "dotAlpha"
+ )
+
+ Box(
+ modifier = Modifier
+ .size(size.dp)
+ .clip(CircleShape)
+ .background(Color.White.copy(alpha = alpha))
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ZoomableImage(
+ imageUrl: String,
+ onDismiss: () -> Unit
+) {
+ var scale by remember { mutableStateOf(1f) }
+ var offsetX by remember { mutableStateOf(0f) }
+ var offsetY by remember { mutableStateOf(0f) }
+
+ val animatedScale by animateFloatAsState(
+ targetValue = scale,
+ label = "scale"
+ )
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .pointerInput(Unit) {
+ detectTransformGestures { _, pan, zoom, _ ->
+ scale = (scale * zoom).coerceIn(0.5f, 4f)
+ if (scale > 1f) {
+ offsetX += pan.x
+ offsetY += pan.y
+ } else {
+ offsetX = 0f
+ offsetY = 0f
+ }
+ }
+ }
+ .pointerInput(Unit) {
+ detectTapGestures(
+ onDoubleTap = {
+ if (scale > 1f) {
+ scale = 1f
+ offsetX = 0f
+ offsetY = 0f
+ } else {
+ scale = 2.5f
+ }
+ }
+ )
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ AsyncImage(
+ model = imageUrl,
+ contentDescription = "Photo",
+ modifier = Modifier
+ .fillMaxSize()
+ .graphicsLayer(
+ scaleX = animatedScale,
+ scaleY = animatedScale,
+ translationX = offsetX,
+ translationY = offsetY
+ ),
+ contentScale = ContentScale.Fit
+ )
+ }
+}
+
+/**
+ * Bottom sheet variant of the photo viewer for modal presentation.
+ */
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
+@Composable
+fun PhotoViewerSheet(
+ imageUrls: List,
+ initialIndex: Int = 0,
+ onDismiss: () -> Unit
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ containerColor = Color.Black,
+ dragHandle = null,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ PhotoViewerScreen(
+ imageUrls = imageUrls,
+ initialIndex = initialIndex,
+ onDismiss = onDismiss
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/common/StatItem.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/common/StatItem.kt
index b12e89e..7869166 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/common/StatItem.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/common/StatItem.kt
@@ -5,6 +5,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -16,8 +17,10 @@ import org.jetbrains.compose.ui.tooling.preview.Preview
fun StatItem(
icon: ImageVector,
value: String,
- label: String
+ label: String,
+ valueColor: Color? = null
) {
+ val effectiveValueColor = valueColor ?: MaterialTheme.colorScheme.onPrimaryContainer
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
@@ -26,13 +29,13 @@ fun StatItem(
icon,
contentDescription = null,
modifier = Modifier.size(28.dp),
- tint = MaterialTheme.colorScheme.onPrimaryContainer
+ tint = valueColor ?: MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = value,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onPrimaryContainer
+ color = effectiveValueColor
)
Text(
text = label,
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/AllTasksScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/AllTasksScreen.kt
index e19ce1d..ab92131 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/AllTasksScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/AllTasksScreen.kt
@@ -34,7 +34,8 @@ fun AllTasksScreen(
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
bottomNavBarPadding: androidx.compose.ui.unit.Dp = 0.dp,
navigateToTaskId: Int? = null,
- onClearNavigateToTask: () -> Unit = {}
+ onClearNavigateToTask: () -> Unit = {},
+ onNavigateToCompleteTask: ((TaskDetail, String) -> Unit)? = null
) {
val tasksState by viewModel.tasksState.collectAsState()
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
@@ -209,8 +210,16 @@ fun AllTasksScreen(
DynamicTaskKanbanView(
columns = taskData.columns,
onCompleteTask = { task ->
- selectedTask = task
- showCompleteDialog = true
+ if (onNavigateToCompleteTask != null) {
+ // Use full-screen navigation
+ val residenceName = (myResidencesState as? ApiResult.Success)
+ ?.data?.residences?.find { it.id == task.residenceId }?.name ?: ""
+ onNavigateToCompleteTask(task, residenceName)
+ } else {
+ // Fall back to dialog
+ selectedTask = task
+ showCompleteDialog = true
+ }
},
onEditTask = { task ->
onNavigateToEditTask(task)
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/CompleteTaskScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/CompleteTaskScreen.kt
new file mode 100644
index 0000000..6748f39
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/CompleteTaskScreen.kt
@@ -0,0 +1,630 @@
+package com.example.casera.ui.screens
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+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.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import casera.composeapp.generated.resources.*
+import com.example.casera.models.TaskCompletionCreateRequest
+import com.example.casera.models.ContractorSummary
+import com.example.casera.network.ApiResult
+import com.example.casera.platform.*
+import com.example.casera.ui.theme.AppRadius
+import com.example.casera.ui.theme.AppSpacing
+import com.example.casera.viewmodel.ContractorViewModel
+import org.jetbrains.compose.resources.stringResource
+
+private const val MAX_IMAGES = 5
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CompleteTaskScreen(
+ taskId: Int,
+ taskTitle: String,
+ residenceName: String = "",
+ onNavigateBack: () -> Unit,
+ onComplete: (TaskCompletionCreateRequest, List) -> Unit,
+ contractorViewModel: ContractorViewModel = viewModel { ContractorViewModel() }
+) {
+ var completedByName by remember { mutableStateOf("") }
+ var actualCost by remember { mutableStateOf("") }
+ var notes by remember { mutableStateOf("") }
+ var rating by remember { mutableStateOf(3) }
+ var selectedImages by remember { mutableStateOf>(emptyList()) }
+ var selectedContractor by remember { mutableStateOf(null) }
+ var showContractorPicker by remember { mutableStateOf(false) }
+ var isSubmitting by remember { mutableStateOf(false) }
+
+ val contractorsState by contractorViewModel.contractorsState.collectAsState()
+ val hapticFeedback = rememberHapticFeedback()
+
+ LaunchedEffect(Unit) {
+ contractorViewModel.loadContractors()
+ }
+
+ val imagePicker = rememberImagePicker { images ->
+ val newTotal = (selectedImages + images).take(MAX_IMAGES)
+ selectedImages = newTotal
+ }
+
+ val cameraPicker = rememberCameraPicker { image ->
+ if (selectedImages.size < MAX_IMAGES) {
+ selectedImages = selectedImages + image
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ stringResource(Res.string.completions_complete_task_title, taskTitle),
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(Icons.Default.Close, contentDescription = stringResource(Res.string.common_cancel))
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ )
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .verticalScroll(rememberScrollState())
+ ) {
+ // Task Info Section
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = AppSpacing.lg, vertical = AppSpacing.md),
+ shape = RoundedCornerShape(AppRadius.md),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(AppSpacing.lg)
+ ) {
+ Text(
+ text = taskTitle,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+
+ Spacer(modifier = Modifier.height(AppSpacing.sm))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (residenceName.isNotEmpty()) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(AppSpacing.xs)
+ ) {
+ Icon(
+ Icons.Default.Home,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = residenceName,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // Contractor Section
+ SectionHeader(
+ title = stringResource(Res.string.completions_select_contractor),
+ subtitle = stringResource(Res.string.completions_contractor_helper)
+ )
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = AppSpacing.lg)
+ .clickable { showContractorPicker = true },
+ shape = RoundedCornerShape(AppRadius.md),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(AppSpacing.lg),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(AppSpacing.md)
+ ) {
+ Icon(
+ Icons.Default.Build,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Column {
+ Text(
+ text = selectedContractor?.name
+ ?: stringResource(Res.string.completions_none_manual),
+ style = MaterialTheme.typography.bodyLarge
+ )
+ selectedContractor?.company?.let { company ->
+ Text(
+ text = company,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ Icon(
+ Icons.Default.ChevronRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(AppSpacing.lg))
+
+ // Completion Details Section
+ SectionHeader(
+ title = stringResource(Res.string.completions_details_section),
+ subtitle = stringResource(Res.string.completions_optional_info)
+ )
+
+ Column(
+ modifier = Modifier.padding(horizontal = AppSpacing.lg),
+ verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
+ ) {
+ // Completed By Name
+ OutlinedTextField(
+ value = completedByName,
+ onValueChange = { completedByName = it },
+ label = { Text(stringResource(Res.string.completions_completed_by_name)) },
+ placeholder = { Text(stringResource(Res.string.completions_completed_by_placeholder)) },
+ leadingIcon = { Icon(Icons.Default.Person, null) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = selectedContractor == null,
+ shape = RoundedCornerShape(AppRadius.md)
+ )
+
+ // Actual Cost
+ OutlinedTextField(
+ value = actualCost,
+ onValueChange = { actualCost = it },
+ label = { Text(stringResource(Res.string.completions_actual_cost_optional)) },
+ leadingIcon = { Icon(Icons.Default.AttachMoney, null) },
+ prefix = { Text("$") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
+ shape = RoundedCornerShape(AppRadius.md)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(AppSpacing.lg))
+
+ // Notes Section
+ SectionHeader(
+ title = stringResource(Res.string.completions_notes_optional),
+ subtitle = stringResource(Res.string.completions_notes_helper)
+ )
+
+ OutlinedTextField(
+ value = notes,
+ onValueChange = { notes = it },
+ placeholder = { Text(stringResource(Res.string.completions_notes_placeholder)) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = AppSpacing.lg)
+ .height(120.dp),
+ shape = RoundedCornerShape(AppRadius.md)
+ )
+
+ Spacer(modifier = Modifier.height(AppSpacing.lg))
+
+ // Rating Section
+ SectionHeader(
+ title = stringResource(Res.string.completions_quality_rating),
+ subtitle = stringResource(Res.string.completions_rate_quality)
+ )
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = AppSpacing.lg),
+ shape = RoundedCornerShape(AppRadius.md),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(AppSpacing.lg),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "$rating / 5",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(AppSpacing.md))
+
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ (1..5).forEach { star ->
+ val isSelected = star <= rating
+ val starColor by animateColorAsState(
+ targetValue = if (isSelected) Color(0xFFFFD700)
+ else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f),
+ animationSpec = tween(durationMillis = 150),
+ label = "starColor"
+ )
+
+ IconButton(
+ onClick = {
+ hapticFeedback.perform(HapticFeedbackType.Selection)
+ rating = star
+ },
+ modifier = Modifier.size(56.dp)
+ ) {
+ Icon(
+ imageVector = if (isSelected) Icons.Default.Star else Icons.Default.StarOutline,
+ contentDescription = "$star stars",
+ tint = starColor,
+ modifier = Modifier.size(40.dp)
+ )
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(AppSpacing.lg))
+
+ // Photos Section
+ SectionHeader(
+ title = stringResource(Res.string.completions_photos_count, selectedImages.size, MAX_IMAGES),
+ subtitle = stringResource(Res.string.completions_add_photos_helper)
+ )
+
+ Column(
+ modifier = Modifier.padding(horizontal = AppSpacing.lg),
+ verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(AppSpacing.md)
+ ) {
+ OutlinedButton(
+ onClick = {
+ hapticFeedback.perform(HapticFeedbackType.Light)
+ cameraPicker()
+ },
+ modifier = Modifier.weight(1f),
+ enabled = selectedImages.size < MAX_IMAGES
+ ) {
+ Icon(Icons.Default.CameraAlt, null, modifier = Modifier.size(20.dp))
+ Spacer(modifier = Modifier.width(AppSpacing.sm))
+ Text(stringResource(Res.string.completions_camera))
+ }
+
+ OutlinedButton(
+ onClick = {
+ hapticFeedback.perform(HapticFeedbackType.Light)
+ imagePicker()
+ },
+ modifier = Modifier.weight(1f),
+ enabled = selectedImages.size < MAX_IMAGES
+ ) {
+ Icon(Icons.Default.PhotoLibrary, null, modifier = Modifier.size(20.dp))
+ Spacer(modifier = Modifier.width(AppSpacing.sm))
+ Text(stringResource(Res.string.completions_library))
+ }
+ }
+
+ if (selectedImages.isNotEmpty()) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .horizontalScroll(rememberScrollState()),
+ horizontalArrangement = Arrangement.spacedBy(AppSpacing.md)
+ ) {
+ selectedImages.forEachIndexed { index, imageData ->
+ ImageThumbnailCard(
+ imageData = imageData,
+ onRemove = {
+ hapticFeedback.perform(HapticFeedbackType.Light)
+ selectedImages = selectedImages.toMutableList().also {
+ it.removeAt(index)
+ }
+ }
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(AppSpacing.xl))
+
+ // Complete Button
+ Button(
+ onClick = {
+ isSubmitting = true
+ val notesWithContractor = buildString {
+ selectedContractor?.let {
+ append("Contractor: ${it.name}")
+ it.company?.let { company -> append(" ($company)") }
+ append("\n")
+ }
+ if (completedByName.isNotBlank()) {
+ append("Completed by: $completedByName\n")
+ }
+ if (notes.isNotBlank()) {
+ append(notes)
+ }
+ }.ifBlank { null }
+
+ onComplete(
+ TaskCompletionCreateRequest(
+ taskId = taskId,
+ completedAt = null,
+ actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
+ notes = notesWithContractor,
+ rating = rating,
+ imageUrls = null
+ ),
+ selectedImages
+ )
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = AppSpacing.lg)
+ .height(56.dp),
+ enabled = !isSubmitting,
+ shape = RoundedCornerShape(AppRadius.md)
+ ) {
+ if (isSubmitting) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ color = MaterialTheme.colorScheme.onPrimary,
+ strokeWidth = 2.dp
+ )
+ } else {
+ Icon(Icons.Default.CheckCircle, null)
+ Spacer(modifier = Modifier.width(AppSpacing.sm))
+ Text(
+ stringResource(Res.string.completions_complete_button),
+ fontWeight = FontWeight.SemiBold
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(AppSpacing.xl))
+ }
+ }
+
+ // Contractor Picker Bottom Sheet
+ if (showContractorPicker) {
+ ContractorPickerSheet(
+ contractors = when (val state = contractorsState) {
+ is ApiResult.Success -> state.data
+ else -> emptyList()
+ },
+ isLoading = contractorsState is ApiResult.Loading,
+ selectedContractor = selectedContractor,
+ onSelect = { contractor ->
+ selectedContractor = contractor
+ showContractorPicker = false
+ },
+ onDismiss = { showContractorPicker = false }
+ )
+ }
+}
+
+@Composable
+private fun SectionHeader(
+ title: String,
+ subtitle: String? = null
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = AppSpacing.lg, vertical = AppSpacing.sm)
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ subtitle?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
+
+@Composable
+private fun ImageThumbnailCard(
+ imageData: ImageData,
+ onRemove: () -> Unit
+) {
+ val imageBitmap = rememberImageBitmap(imageData)
+
+ Box(
+ modifier = Modifier
+ .size(100.dp)
+ .clip(RoundedCornerShape(AppRadius.md))
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ ) {
+ if (imageBitmap != null) {
+ Image(
+ bitmap = imageBitmap,
+ contentDescription = imageData.fileName,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop
+ )
+ } else {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ Icons.Default.PhotoLibrary,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
+ modifier = Modifier.size(40.dp)
+ )
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(AppSpacing.xs)
+ .size(24.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.error)
+ .clickable(onClick = onRemove),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ Icons.Default.Close,
+ contentDescription = "Remove",
+ tint = MaterialTheme.colorScheme.onError,
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ContractorPickerSheet(
+ contractors: List,
+ isLoading: Boolean,
+ selectedContractor: ContractorSummary?,
+ onSelect: (ContractorSummary?) -> Unit,
+ onDismiss: () -> Unit
+) {
+ val sheetState = rememberModalBottomSheetState()
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = AppSpacing.xl)
+ ) {
+ Text(
+ text = stringResource(Res.string.completions_select_contractor),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(horizontal = AppSpacing.lg, vertical = AppSpacing.md)
+ )
+
+ HorizontalDivider()
+
+ // None option
+ ListItem(
+ headlineContent = { Text(stringResource(Res.string.completions_none_manual)) },
+ supportingContent = { Text(stringResource(Res.string.completions_enter_manually)) },
+ trailingContent = {
+ if (selectedContractor == null) {
+ Icon(
+ Icons.Default.Check,
+ contentDescription = "Selected",
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ },
+ modifier = Modifier.clickable { onSelect(null) }
+ )
+
+ HorizontalDivider()
+
+ if (isLoading) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(AppSpacing.xl),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ } else {
+ contractors.forEach { contractor ->
+ ListItem(
+ headlineContent = { Text(contractor.name) },
+ supportingContent = {
+ Column {
+ contractor.company?.let { Text(it) }
+ }
+ },
+ trailingContent = {
+ if (selectedContractor?.id == contractor.id) {
+ Icon(
+ Icons.Default.Check,
+ contentDescription = "Selected",
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ },
+ modifier = Modifier.clickable { onSelect(contractor) }
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/MainScreen.kt
index 015afd4..c2a488a 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/MainScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/MainScreen.kt
@@ -14,8 +14,11 @@ import androidx.navigation.toRoute
import com.example.casera.navigation.*
import com.example.casera.repository.LookupsRepository
import com.example.casera.models.Residence
+import com.example.casera.models.TaskDetail
import com.example.casera.storage.TokenStorage
+import com.example.casera.ui.subscription.UpgradeScreen
import casera.composeapp.generated.resources.*
+import kotlinx.serialization.json.Json
import org.jetbrains.compose.resources.stringResource
@Composable
@@ -167,7 +170,16 @@ fun MainScreen(
onAddTask = onAddTask,
bottomNavBarPadding = paddingValues.calculateBottomPadding(),
navigateToTaskId = navigateToTaskId,
- onClearNavigateToTask = onClearNavigateToTask
+ onClearNavigateToTask = onClearNavigateToTask,
+ onNavigateToCompleteTask = { task, residenceName ->
+ navController.navigate(
+ CompleteTaskRoute(
+ taskId = task.id,
+ taskTitle = task.title,
+ residenceName = residenceName
+ )
+ )
+ }
)
}
}
@@ -262,6 +274,9 @@ fun MainScreen(
onLogout = onLogout,
onNavigateToNotificationPreferences = {
navController.navigate(NotificationPreferencesRoute)
+ },
+ onNavigateToUpgrade = {
+ navController.navigate(UpgradeRoute)
}
)
}
@@ -276,6 +291,59 @@ fun MainScreen(
)
}
}
+
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+ Box(modifier = Modifier.fillMaxSize()) {
+ CompleteTaskScreen(
+ taskId = route.taskId,
+ taskTitle = route.taskTitle,
+ residenceName = route.residenceName,
+ onNavigateBack = {
+ navController.popBackStack()
+ },
+ onComplete = { request, images ->
+ // Navigation back happens in the screen after successful completion
+ navController.popBackStack()
+ }
+ )
+ }
+ }
+
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+ Box(modifier = Modifier.fillMaxSize()) {
+ ManageUsersScreen(
+ residenceId = route.residenceId,
+ residenceName = route.residenceName,
+ isPrimaryOwner = route.isPrimaryOwner,
+ residenceOwnerId = route.residenceOwnerId,
+ onNavigateBack = {
+ navController.popBackStack()
+ },
+ onUserRemoved = {
+ // Could trigger a refresh if needed
+ }
+ )
+ }
+ }
+
+ composable {
+ Box(modifier = Modifier.fillMaxSize()) {
+ UpgradeScreen(
+ onNavigateBack = {
+ navController.popBackStack()
+ },
+ onPurchase = { planId ->
+ // Handle purchase - integrate with billing system
+ navController.popBackStack()
+ },
+ onRestorePurchases = {
+ // Handle restore - integrate with billing system
+ }
+ )
+ }
+ }
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ManageUsersScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ManageUsersScreen.kt
new file mode 100644
index 0000000..b0111cb
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ManageUsersScreen.kt
@@ -0,0 +1,518 @@
+package com.example.casera.ui.screens
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.platform.LocalClipboardManager
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import casera.composeapp.generated.resources.*
+import com.example.casera.models.ResidenceUser
+import com.example.casera.models.ResidenceShareCode
+import com.example.casera.network.ApiResult
+import com.example.casera.network.ResidenceApi
+import com.example.casera.storage.TokenStorage
+import com.example.casera.ui.theme.AppRadius
+import com.example.casera.ui.theme.AppSpacing
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.stringResource
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ManageUsersScreen(
+ residenceId: Int,
+ residenceName: String,
+ isPrimaryOwner: Boolean,
+ residenceOwnerId: Int,
+ onNavigateBack: () -> Unit,
+ onUserRemoved: () -> Unit = {},
+ onSharePackage: () -> Unit = {}
+) {
+ var users by remember { mutableStateOf>(emptyList()) }
+ var shareCode by remember { mutableStateOf(null) }
+ var isLoading by remember { mutableStateOf(true) }
+ var error by remember { mutableStateOf(null) }
+ var isGeneratingCode by remember { mutableStateOf(false) }
+ var showRemoveConfirmation by remember { mutableStateOf(null) }
+
+ val residenceApi = remember { ResidenceApi() }
+ val scope = rememberCoroutineScope()
+ val clipboardManager = LocalClipboardManager.current
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ LaunchedEffect(residenceId) {
+ shareCode = null
+ val token = TokenStorage.getToken()
+ if (token != null) {
+ when (val result = residenceApi.getResidenceUsers(token, residenceId)) {
+ is ApiResult.Success -> {
+ users = result.data
+ isLoading = false
+ }
+ is ApiResult.Error -> {
+ error = result.message
+ isLoading = false
+ }
+ else -> {}
+ }
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Column {
+ Text(
+ stringResource(Res.string.manage_users_invite_title),
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ residenceName,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.common_back))
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ )
+ },
+ snackbarHost = { SnackbarHost(snackbarHostState) }
+ ) { paddingValues ->
+ if (isLoading) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ } else if (error != null) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
+ ) {
+ Icon(
+ Icons.Default.Error,
+ contentDescription = null,
+ modifier = Modifier.size(48.dp),
+ tint = MaterialTheme.colorScheme.error
+ )
+ Text(
+ text = error ?: "Unknown error",
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ } else {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ contentPadding = PaddingValues(AppSpacing.lg),
+ verticalArrangement = Arrangement.spacedBy(AppSpacing.lg)
+ ) {
+ // Share sections (primary owner only)
+ if (isPrimaryOwner) {
+ // Easy Share Section
+ item {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(AppRadius.lg),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(AppSpacing.lg)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm)
+ ) {
+ Icon(
+ Icons.Default.Share,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ Text(
+ text = stringResource(Res.string.manage_users_easy_share),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ }
+
+ Spacer(modifier = Modifier.height(AppSpacing.md))
+
+ Button(
+ onClick = onSharePackage,
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Icon(Icons.Default.Send, null, modifier = Modifier.size(20.dp))
+ Spacer(modifier = Modifier.width(AppSpacing.sm))
+ Text(
+ stringResource(Res.string.manage_users_send_invite),
+ fontWeight = FontWeight.SemiBold
+ )
+ }
+
+ Spacer(modifier = Modifier.height(AppSpacing.sm))
+
+ Text(
+ text = stringResource(Res.string.manage_users_easy_share_desc),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
+ )
+ }
+ }
+ }
+
+ // Divider with "or"
+ item {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ HorizontalDivider(modifier = Modifier.weight(1f))
+ Text(
+ text = stringResource(Res.string.manage_users_or),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = AppSpacing.lg)
+ )
+ HorizontalDivider(modifier = Modifier.weight(1f))
+ }
+ }
+
+ // Share Code Section
+ item {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(AppRadius.lg),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(AppSpacing.lg)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm)
+ ) {
+ Icon(
+ Icons.Default.Key,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = stringResource(Res.string.manage_users_share_code),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ }
+
+ Spacer(modifier = Modifier.height(AppSpacing.lg))
+
+ // Share code display
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(AppRadius.md),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(AppSpacing.lg),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (shareCode != null) {
+ Text(
+ text = shareCode!!.code,
+ style = MaterialTheme.typography.headlineMedium.copy(
+ fontFamily = FontFamily.Monospace,
+ letterSpacing = 4.sp
+ ),
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold
+ )
+ IconButton(
+ onClick = {
+ clipboardManager.setText(AnnotatedString(shareCode!!.code))
+ scope.launch {
+ snackbarHostState.showSnackbar("Code copied to clipboard")
+ }
+ }
+ ) {
+ Icon(
+ Icons.Default.ContentCopy,
+ contentDescription = "Copy code",
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ } else {
+ Text(
+ text = stringResource(Res.string.manage_users_no_code),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(AppSpacing.md))
+
+ Button(
+ onClick = {
+ scope.launch {
+ isGeneratingCode = true
+ val token = TokenStorage.getToken()
+ if (token != null) {
+ when (val result = residenceApi.generateShareCode(token, residenceId)) {
+ is ApiResult.Success -> {
+ shareCode = result.data.shareCode
+ }
+ is ApiResult.Error -> {
+ error = result.message
+ }
+ else -> {}
+ }
+ }
+ isGeneratingCode = false
+ }
+ },
+ enabled = !isGeneratingCode,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ if (isGeneratingCode) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ color = MaterialTheme.colorScheme.onPrimary,
+ strokeWidth = 2.dp
+ )
+ } else {
+ Icon(Icons.Default.Refresh, null, modifier = Modifier.size(20.dp))
+ }
+ Spacer(modifier = Modifier.width(AppSpacing.sm))
+ Text(
+ if (shareCode != null) stringResource(Res.string.manage_users_generate_new)
+ else stringResource(Res.string.manage_users_generate)
+ )
+ }
+
+ if (shareCode != null) {
+ Spacer(modifier = Modifier.height(AppSpacing.sm))
+ Text(
+ text = stringResource(Res.string.manage_users_code_desc),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+
+ item {
+ HorizontalDivider()
+ }
+ }
+
+ // Users Header
+ item {
+ Text(
+ text = stringResource(Res.string.manage_users_users_count, users.size),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ }
+
+ // Users List
+ items(users) { user ->
+ UserCard(
+ user = user,
+ isOwner = user.id == residenceOwnerId,
+ canRemove = isPrimaryOwner && user.id != residenceOwnerId,
+ onRemove = { showRemoveConfirmation = user }
+ )
+ }
+
+ // Bottom spacing
+ item {
+ Spacer(modifier = Modifier.height(AppSpacing.xl))
+ }
+ }
+ }
+ }
+
+ // Remove User Confirmation Dialog
+ showRemoveConfirmation?.let { user ->
+ AlertDialog(
+ onDismissRequest = { showRemoveConfirmation = null },
+ title = { Text(stringResource(Res.string.manage_users_remove)) },
+ text = { Text("Remove ${user.username} from this property?") },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ scope.launch {
+ val token = TokenStorage.getToken()
+ if (token != null) {
+ when (residenceApi.removeUser(token, residenceId, user.id)) {
+ is ApiResult.Success -> {
+ users = users.filter { it.id != user.id }
+ onUserRemoved()
+ }
+ is ApiResult.Error -> {
+ // Show error
+ }
+ else -> {}
+ }
+ }
+ showRemoveConfirmation = null
+ }
+ },
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.error
+ )
+ ) {
+ Text(stringResource(Res.string.manage_users_remove))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showRemoveConfirmation = null }) {
+ Text(stringResource(Res.string.common_cancel))
+ }
+ }
+ )
+ }
+}
+
+@Composable
+private fun UserCard(
+ user: ResidenceUser,
+ isOwner: Boolean,
+ canRemove: Boolean,
+ onRemove: () -> Unit
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(AppRadius.md),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(AppSpacing.lg),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(AppSpacing.md)
+ ) {
+ // Avatar
+ Surface(
+ shape = RoundedCornerShape(AppRadius.md),
+ color = MaterialTheme.colorScheme.primaryContainer,
+ modifier = Modifier.size(48.dp)
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Text(
+ text = user.username.take(2).uppercase(),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ }
+ }
+
+ Column {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm)
+ ) {
+ Text(
+ text = user.username,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold
+ )
+ if (isOwner) {
+ Surface(
+ color = MaterialTheme.colorScheme.primaryContainer,
+ shape = RoundedCornerShape(AppRadius.xs)
+ ) {
+ Text(
+ text = stringResource(Res.string.manage_users_owner_badge),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.padding(horizontal = AppSpacing.sm, vertical = 2.dp)
+ )
+ }
+ }
+ }
+
+ if (!user.email.isNullOrEmpty()) {
+ Text(
+ text = user.email,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ val fullName = listOfNotNull(user.firstName, user.lastName)
+ .filter { it.isNotEmpty() }
+ .joinToString(" ")
+ if (fullName.isNotEmpty()) {
+ Text(
+ text = fullName,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+
+ if (canRemove) {
+ IconButton(onClick = onRemove) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = stringResource(Res.string.manage_users_remove),
+ tint = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt
index b15b3e7..ec41a8d 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt
@@ -40,6 +40,7 @@ fun ProfileScreen(
onNavigateBack: () -> Unit,
onLogout: () -> Unit,
onNavigateToNotificationPreferences: () -> Unit = {},
+ onNavigateToUpgrade: (() -> Unit)? = null,
viewModel: AuthViewModel = viewModel { AuthViewModel() }
) {
var firstName by remember { mutableStateOf("") }
@@ -176,6 +177,47 @@ fun ProfileScreen(
Spacer(modifier = Modifier.height(8.dp))
+ // Edit Profile Section (scrolls down to profile fields)
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { /* Profile fields are below - could add scroll behavior */ },
+ shape = RoundedCornerShape(AppRadius.md),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(AppSpacing.lg),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(AppSpacing.xs)
+ ) {
+ Text(
+ text = stringResource(Res.string.profile_edit_profile),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ text = if (firstName.isNotBlank() || lastName.isNotBlank())
+ "$firstName $lastName".trim()
+ else email,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Icon(
+ imageVector = Icons.Default.Person,
+ contentDescription = stringResource(Res.string.profile_edit_profile),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+
// Theme Selector Section
Card(
modifier = Modifier
@@ -296,6 +338,47 @@ fun ProfileScreen(
}
}
+ // Privacy Policy Section
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ uriHandler.openUri("https://mycrib.treytartt.com/privacy")
+ },
+ shape = RoundedCornerShape(AppRadius.md),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(AppSpacing.lg),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(AppSpacing.xs)
+ ) {
+ Text(
+ text = stringResource(Res.string.profile_privacy),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ text = stringResource(Res.string.profile_privacy_subtitle),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Icon(
+ imageVector = Icons.Default.Lock,
+ contentDescription = stringResource(Res.string.profile_privacy),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+
// Subscription Section - Only show if limitations are enabled
if (currentSubscription?.limitationsEnabled == true) {
Divider(modifier = Modifier.padding(vertical = AppSpacing.sm))
@@ -384,7 +467,13 @@ fun ProfileScreen(
}
Button(
- onClick = { showUpgradePrompt = true },
+ onClick = {
+ if (onNavigateToUpgrade != null) {
+ onNavigateToUpgrade()
+ } else {
+ showUpgradePrompt = true
+ }
+ },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
@@ -560,6 +649,28 @@ fun ProfileScreen(
}
Spacer(modifier = Modifier.height(16.dp))
+
+ // App Version Section
+ HorizontalDivider(modifier = Modifier.padding(vertical = AppSpacing.md))
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(AppSpacing.xs)
+ ) {
+ Text(
+ text = stringResource(Res.string.profile_app_name),
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = stringResource(Res.string.profile_app_version, getAppVersion()),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
}
}
@@ -617,3 +728,13 @@ private fun UpgradeBenefitRow(
)
}
}
+
+/**
+ * Get app version - in a real implementation this would come from
+ * platform-specific build configuration
+ */
+private fun getAppVersion(): String {
+ // This would be replaced with actual version from build config
+ // For now, return a placeholder that matches iOS
+ return "1.0.0"
+}
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt
index 008bdfb..d961146 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt
@@ -50,6 +50,7 @@ fun ResidenceDetailScreen(
onNavigateToEditResidence: (Residence) -> Unit,
onNavigateToEditTask: (TaskDetail) -> Unit,
onNavigateToContractorDetail: (Int) -> Unit = {},
+ onNavigateToManageUsers: ((Int, String, Boolean, Int) -> Unit)? = null,
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() },
taskViewModel: TaskViewModel = viewModel { TaskViewModel() }
@@ -473,7 +474,16 @@ fun ResidenceDetailScreen(
IconButton(onClick = {
val shareCheck = SubscriptionHelper.canShareResidence()
if (shareCheck.allowed) {
- showManageUsersDialog = true
+ if (onNavigateToManageUsers != null) {
+ onNavigateToManageUsers(
+ residence.id,
+ residence.name,
+ residence.ownerId == currentUser?.id,
+ residence.ownerId
+ )
+ } else {
+ showManageUsersDialog = true
+ }
} else {
upgradeTriggerKey = shareCheck.triggerKey
showUpgradePrompt = true
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidencesScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidencesScreen.kt
index d5b982a..43e9117 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidencesScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidencesScreen.kt
@@ -1,5 +1,6 @@
package com.example.casera.ui.screens
+import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -15,10 +16,13 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.ApiResultHandler
import com.example.casera.ui.components.JoinResidenceDialog
@@ -397,15 +401,21 @@ fun ResidencesScreen(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
+ StatItem(
+ icon = Icons.Default.Warning,
+ value = "${response.summary.totalOverdue}",
+ label = stringResource(Res.string.home_overdue),
+ valueColor = if (response.summary.totalOverdue > 0) MaterialTheme.colorScheme.error else null
+ )
StatItem(
icon = Icons.Default.CalendarToday,
value = "${response.summary.tasksDueNextWeek}",
- label = stringResource(Res.string.tasks_column_due_soon)
+ label = stringResource(Res.string.home_due_this_week)
)
StatItem(
icon = Icons.Default.Event,
value = "${response.summary.tasksDueNextMonth}",
- label = stringResource(Res.string.tasks_column_upcoming)
+ label = stringResource(Res.string.home_next_30_days)
)
}
}
@@ -415,7 +425,7 @@ fun ResidencesScreen(
// Properties Header
item {
Text(
- text = "Your Properties",
+ text = stringResource(Res.string.home_your_properties),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 8.dp)
@@ -424,6 +434,20 @@ fun ResidencesScreen(
// Residences
items(response.residences) { residence ->
+ val hasOverdue = residence.overdueCount > 0
+
+ // Pulsing animation for overdue indicator
+ val infiniteTransition = rememberInfiniteTransition(label = "pulse")
+ val pulseScale by infiniteTransition.animateFloat(
+ initialValue = 1f,
+ targetValue = 1.15f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(800, easing = EaseInOut),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "pulseScale"
+ )
+
Card(
modifier = Modifier
.fillMaxWidth()
@@ -444,29 +468,87 @@ fun ResidencesScreen(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
- // Gradient circular house icon
+ // Pulsing circular house icon when overdue
Box(
modifier = Modifier
.size(56.dp)
+ .then(
+ if (hasOverdue) Modifier.scale(pulseScale) else Modifier
+ )
.clip(CircleShape)
- .background(MaterialTheme.colorScheme.primaryContainer),
+ .background(
+ if (hasOverdue) MaterialTheme.colorScheme.errorContainer
+ else MaterialTheme.colorScheme.primaryContainer
+ ),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Home,
contentDescription = null,
- tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ tint = if (hasOverdue) MaterialTheme.colorScheme.onErrorContainer
+ else MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(28.dp)
)
}
Column(modifier = Modifier.weight(1f)) {
- Text(
- text = residence.name,
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onSurface
- )
+ // Name with primary star indicator
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ Text(
+ text = residence.name,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ if (residence.isPrimary) {
+ Icon(
+ Icons.Default.Star,
+ contentDescription = "Primary residence",
+ modifier = Modifier.size(16.dp),
+ tint = Color(0xFFFFD700) // Gold color
+ )
+ }
+ }
+
+ // Property type
+ residence.propertyTypeName?.let { typeName ->
+ Text(
+ text = typeName.uppercase(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary,
+ letterSpacing = 1.sp
+ )
+ }
+
Spacer(modifier = Modifier.height(4.dp))
+
+ // Street address (if available)
+ if (residence.streetAddress.isNotBlank()) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Icon(
+ Icons.Default.Place,
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = residence.streetAddress,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+
+ // City, State
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/UpgradeScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/UpgradeScreen.kt
new file mode 100644
index 0000000..a3ea9a7
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/UpgradeScreen.kt
@@ -0,0 +1,460 @@
+package com.example.casera.ui.subscription
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import casera.composeapp.generated.resources.*
+import com.example.casera.cache.SubscriptionCache
+import com.example.casera.ui.theme.AppRadius
+import com.example.casera.ui.theme.AppSpacing
+import org.jetbrains.compose.resources.stringResource
+
+/**
+ * Full-screen upgrade experience matching iOS UpgradeFeatureView.
+ *
+ * Features:
+ * - Hero section with gradient
+ * - Plan selection (Monthly/Yearly)
+ * - Feature comparison
+ * - Purchase button with loading state
+ * - Restore purchases option
+ * - Terms and conditions
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun UpgradeScreen(
+ onNavigateBack: () -> Unit,
+ onPurchase: (planId: String) -> Unit,
+ onRestorePurchases: () -> Unit
+) {
+ var selectedPlan by remember { mutableStateOf(PlanType.YEARLY) }
+ var isPurchasing by remember { mutableStateOf(false) }
+ var isRestoring by remember { mutableStateOf(false) }
+
+ val featureBenefits = SubscriptionCache.featureBenefits.value
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {},
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(Icons.Default.Close, contentDescription = "Close")
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = Color.Transparent
+ )
+ )
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .verticalScroll(rememberScrollState())
+ ) {
+ // Hero Section
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.primaryContainer,
+ MaterialTheme.colorScheme.background
+ )
+ )
+ )
+ .padding(AppSpacing.xl),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
+ ) {
+ // Crown Icon
+ Box(
+ modifier = Modifier
+ .size(80.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ Icons.Default.Stars,
+ contentDescription = null,
+ modifier = Modifier.size(48.dp),
+ tint = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+
+ Text(
+ text = "Upgrade to Pro",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Text(
+ text = "Unlock the full potential of Casera",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(AppSpacing.lg))
+
+ // Plan Selection
+ Column(
+ modifier = Modifier.padding(horizontal = AppSpacing.lg),
+ verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
+ ) {
+ Text(
+ text = "Choose Your Plan",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+
+ // Yearly Plan (Recommended)
+ PlanCard(
+ title = "Yearly",
+ price = "$29.99/year",
+ savings = "Save 50%",
+ isSelected = selectedPlan == PlanType.YEARLY,
+ isRecommended = true,
+ onClick = { selectedPlan = PlanType.YEARLY }
+ )
+
+ // Monthly Plan
+ PlanCard(
+ title = "Monthly",
+ price = "$4.99/month",
+ savings = null,
+ isSelected = selectedPlan == PlanType.MONTHLY,
+ isRecommended = false,
+ onClick = { selectedPlan = PlanType.MONTHLY }
+ )
+ }
+
+ Spacer(modifier = Modifier.height(AppSpacing.xl))
+
+ // Features Section
+ Column(
+ modifier = Modifier.padding(horizontal = AppSpacing.lg),
+ verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
+ ) {
+ Text(
+ text = "What's Included",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+
+ FeatureItem(
+ icon = Icons.Default.Home,
+ title = "Unlimited Properties",
+ description = "Track maintenance for all your homes"
+ )
+
+ FeatureItem(
+ icon = Icons.Default.CheckCircle,
+ title = "Unlimited Tasks",
+ description = "Never forget a maintenance task again"
+ )
+
+ FeatureItem(
+ icon = Icons.Default.People,
+ title = "Contractor Management",
+ description = "Save and rate your trusted contractors"
+ )
+
+ FeatureItem(
+ icon = Icons.Default.Description,
+ title = "Document Vault",
+ description = "Store warranties, receipts, and manuals"
+ )
+
+ FeatureItem(
+ icon = Icons.Default.Share,
+ title = "Family Sharing",
+ description = "Invite family members to collaborate"
+ )
+
+ FeatureItem(
+ icon = Icons.Default.Notifications,
+ title = "Smart Reminders",
+ description = "Get notified when tasks are due"
+ )
+ }
+
+ Spacer(modifier = Modifier.height(AppSpacing.xl))
+
+ // Purchase Button
+ Button(
+ onClick = {
+ isPurchasing = true
+ val planId = if (selectedPlan == PlanType.YEARLY) "casera_pro_yearly" else "casera_pro_monthly"
+ onPurchase(planId)
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = AppSpacing.lg)
+ .height(56.dp),
+ enabled = !isPurchasing && !isRestoring,
+ shape = RoundedCornerShape(AppRadius.md)
+ ) {
+ if (isPurchasing) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ color = MaterialTheme.colorScheme.onPrimary,
+ strokeWidth = 2.dp
+ )
+ } else {
+ Text(
+ text = "Subscribe Now",
+ fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+ }
+
+ // Restore Purchases
+ TextButton(
+ onClick = {
+ isRestoring = true
+ onRestorePurchases()
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = AppSpacing.lg),
+ enabled = !isPurchasing && !isRestoring
+ ) {
+ if (isRestoring) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp
+ )
+ Spacer(modifier = Modifier.width(AppSpacing.sm))
+ }
+ Text("Restore Purchases")
+ }
+
+ // Terms
+ Text(
+ text = "Subscription automatically renews unless cancelled at least 24 hours before the end of the current period. Manage subscriptions in your device settings.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = AppSpacing.xl, vertical = AppSpacing.md)
+ )
+
+ // Terms Links
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = AppSpacing.xl),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ TextButton(onClick = { /* Open terms */ }) {
+ Text(
+ "Terms of Use",
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ Text(
+ " • ",
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ TextButton(onClick = { /* Open privacy */ }) {
+ Text(
+ "Privacy Policy",
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+ }
+ }
+}
+
+private enum class PlanType {
+ MONTHLY, YEARLY
+}
+
+@Composable
+private fun PlanCard(
+ title: String,
+ price: String,
+ savings: String?,
+ isSelected: Boolean,
+ isRecommended: Boolean,
+ onClick: () -> Unit
+) {
+ val borderColor by animateColorAsState(
+ targetValue = if (isSelected) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
+ label = "borderColor"
+ )
+
+ val backgroundColor by animateColorAsState(
+ targetValue = if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
+ else MaterialTheme.colorScheme.surface,
+ label = "backgroundColor"
+ )
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .border(
+ width = if (isSelected) 2.dp else 1.dp,
+ color = borderColor,
+ shape = RoundedCornerShape(AppRadius.lg)
+ )
+ .clickable(onClick = onClick),
+ shape = RoundedCornerShape(AppRadius.lg),
+ colors = CardDefaults.cardColors(
+ containerColor = backgroundColor
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(AppSpacing.lg),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(AppSpacing.md)
+ ) {
+ // Radio button
+ RadioButton(
+ selected = isSelected,
+ onClick = onClick,
+ colors = RadioButtonDefaults.colors(
+ selectedColor = MaterialTheme.colorScheme.primary
+ )
+ )
+
+ Column {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm)
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ if (isRecommended) {
+ Surface(
+ shape = RoundedCornerShape(AppRadius.xs),
+ color = MaterialTheme.colorScheme.primary
+ ) {
+ Text(
+ text = "BEST VALUE",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.padding(horizontal = AppSpacing.sm, vertical = 2.dp)
+ )
+ }
+ }
+ }
+
+ Text(
+ text = price,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ savings?.let {
+ Surface(
+ shape = RoundedCornerShape(AppRadius.sm),
+ color = MaterialTheme.colorScheme.tertiaryContainer
+ ) {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onTertiaryContainer,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.padding(horizontal = AppSpacing.md, vertical = AppSpacing.xs)
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun FeatureItem(
+ icon: ImageVector,
+ title: String,
+ description: String
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(AppSpacing.md),
+ verticalAlignment = Alignment.Top
+ ) {
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primaryContainer),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ icon,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ text = description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Icon(
+ Icons.Default.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+}