Add full-screen iOS parity screens and navigation
- Add CompleteTaskScreen for full-screen task completion (matches iOS CompleteTaskView) - Add ManageUsersScreen for full-screen user management (matches iOS ManageUsersView) - Add PhotoViewerScreen with swipeable gallery and pinch-to-zoom - Add UpgradeScreen for full-screen subscription flow (matches iOS UpgradeFeatureView) - Add navigation routes for new screens (CompleteTaskRoute, ManageUsersRoute, PhotoViewerRoute, UpgradeRoute) - Wire navigation callbacks into AllTasksScreen, ResidenceDetailScreen, ProfileScreen - Add overdue count with red color to Summary Card - Add pulsing animation to residence cards when overdue - Add property type, isPrimary star, and street address to Residence Card - Add Edit Profile, Privacy Policy, and App Version to ProfileScreen - Add string resources for new UI elements and localization 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -269,6 +269,29 @@
|
||||
<string name="completions_camera">Camera</string>
|
||||
<string name="completions_library">Library</string>
|
||||
<string name="completions_add_photos_helper">Add photos of completed work (optional)</string>
|
||||
<string name="completions_contractor_helper">Link this task completion to a contractor</string>
|
||||
<string name="completions_details_section">Completion Details</string>
|
||||
<string name="completions_optional_info">All fields are optional</string>
|
||||
<string name="completions_notes_helper">Add any notes about the completed work</string>
|
||||
<string name="completions_notes_placeholder">Describe the work done, issues found, etc.</string>
|
||||
<string name="completions_rate_quality">Rate the quality of work performed</string>
|
||||
<string name="completions_enter_manually">Enter name manually below</string>
|
||||
|
||||
<!-- Manage Users -->
|
||||
<string name="manage_users_title">Manage Users</string>
|
||||
<string name="manage_users_invite_title">Invite Others</string>
|
||||
<string name="manage_users_easy_share">Easy Share</string>
|
||||
<string name="manage_users_send_invite">Send Invite Link</string>
|
||||
<string name="manage_users_easy_share_desc">Send a .casera file via Messages, Email, or share. They just tap to join.</string>
|
||||
<string name="manage_users_share_code">Share Code</string>
|
||||
<string name="manage_users_no_code">No active code</string>
|
||||
<string name="manage_users_generate">Generate Code</string>
|
||||
<string name="manage_users_generate_new">Generate New Code</string>
|
||||
<string name="manage_users_code_desc">Share this 6-character code. They can enter it in the app to join.</string>
|
||||
<string name="manage_users_users_count">Users (%1$d)</string>
|
||||
<string name="manage_users_owner_badge">Owner</string>
|
||||
<string name="manage_users_remove">Remove</string>
|
||||
<string name="manage_users_or">or</string>
|
||||
|
||||
<!-- Contractors -->
|
||||
<string name="contractors_title">Contractors</string>
|
||||
@@ -527,6 +550,11 @@
|
||||
<string name="profile_benefit_contractor_sharing">Contractor Sharing</string>
|
||||
<string name="profile_benefit_actionable_notifications">Actionable Notifications</string>
|
||||
<string name="profile_benefit_widgets">Home Screen Widgets</string>
|
||||
<string name="profile_privacy">Privacy Policy</string>
|
||||
<string name="profile_privacy_subtitle">View our privacy policy</string>
|
||||
<string name="profile_app_version">Version %1$s</string>
|
||||
<string name="profile_app_name">Casera</string>
|
||||
<string name="profile_edit_profile">Edit Profile</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_title">Settings</string>
|
||||
@@ -646,6 +674,10 @@
|
||||
<string name="home_pending">Pending</string>
|
||||
<string name="home_manage_residences">Manage your residences</string>
|
||||
<string name="home_view_manage_tasks">View and manage tasks</string>
|
||||
<string name="home_overdue">Overdue</string>
|
||||
<string name="home_due_this_week">Due This Week</string>
|
||||
<string name="home_next_30_days">Next 30 Days</string>
|
||||
<string name="home_your_properties">Your Properties</string>
|
||||
|
||||
<!-- Subscription -->
|
||||
<string name="subscription_title">Subscription</string>
|
||||
|
||||
@@ -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<ManageUsersRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<ManageUsersRoute>()
|
||||
ManageUsersScreen(
|
||||
residenceId = route.residenceId,
|
||||
residenceName = route.residenceName,
|
||||
isPrimaryOwner = route.isPrimaryOwner,
|
||||
residenceOwnerId = route.residenceOwnerId,
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<UpgradeRoute> {
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>,
|
||||
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<String>,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ImageData>) -> 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<List<ImageData>>(emptyList()) }
|
||||
var selectedContractor by remember { mutableStateOf<ContractorSummary?>(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<ContractorSummary>,
|
||||
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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<CompleteTaskRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<CompleteTaskRoute>()
|
||||
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<ManageUsersRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<ManageUsersRoute>()
|
||||
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<UpgradeRoute> {
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<List<ResidenceUser>>(emptyList()) }
|
||||
var shareCode by remember { mutableStateOf<ResidenceShareCode?>(null) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var isGeneratingCode by remember { mutableStateOf(false) }
|
||||
var showRemoveConfirmation by remember { mutableStateOf<ResidenceUser?>(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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user