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) + ) + } +}