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:
Trey t
2025-12-14 17:02:08 -06:00
parent b150c20e4b
commit e44bcdd988
14 changed files with 2254 additions and 21 deletions

View File

@@ -269,6 +269,29 @@
<string name="completions_camera">Camera</string> <string name="completions_camera">Camera</string>
<string name="completions_library">Library</string> <string name="completions_library">Library</string>
<string name="completions_add_photos_helper">Add photos of completed work (optional)</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 --> <!-- Contractors -->
<string name="contractors_title">Contractors</string> <string name="contractors_title">Contractors</string>
@@ -527,6 +550,11 @@
<string name="profile_benefit_contractor_sharing">Contractor Sharing</string> <string name="profile_benefit_contractor_sharing">Contractor Sharing</string>
<string name="profile_benefit_actionable_notifications">Actionable Notifications</string> <string name="profile_benefit_actionable_notifications">Actionable Notifications</string>
<string name="profile_benefit_widgets">Home Screen Widgets</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 --> <!-- Settings -->
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
@@ -646,6 +674,10 @@
<string name="home_pending">Pending</string> <string name="home_pending">Pending</string>
<string name="home_manage_residences">Manage your residences</string> <string name="home_manage_residences">Manage your residences</string>
<string name="home_view_manage_tasks">View and manage tasks</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 --> <!-- Subscription -->
<string name="subscription_title">Subscription</string> <string name="subscription_title">Subscription</string>

View File

@@ -41,8 +41,10 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.toRoute import androidx.navigation.toRoute
import com.example.casera.ui.screens.MainScreen 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.NotificationPreferencesScreen
import com.example.casera.ui.screens.ProfileScreen 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.MyCribTheme
import com.example.casera.ui.theme.ThemeManager import com.example.casera.ui.theme.ThemeManager
import com.example.casera.navigation.* import com.example.casera.navigation.*
@@ -583,6 +585,44 @@ fun App(
}, },
onNavigateToContractorDetail = { contractorId -> onNavigateToContractorDetail = { contractorId ->
navController.navigate(ContractorDetailRoute(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 = { onNavigateToNotificationPreferences = {
navController.navigate(NotificationPreferencesRoute) navController.navigate(NotificationPreferencesRoute)
},
onNavigateToUpgrade = {
navController.navigate(UpgradeRoute)
} }
) )
} }

View File

@@ -121,3 +121,31 @@ object NotificationPreferencesRoute
// Onboarding Routes // Onboarding Routes
@Serializable @Serializable
object OnboardingRoute 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

View File

@@ -9,7 +9,7 @@ package com.example.casera.network
*/ */
object ApiConfig { object ApiConfig {
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️ // ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
val CURRENT_ENV = Environment.DEV val CURRENT_ENV = Environment.LOCAL
enum class Environment { enum class Environment {
LOCAL, LOCAL,

View File

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

View File

@@ -5,6 +5,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -16,8 +17,10 @@ import org.jetbrains.compose.ui.tooling.preview.Preview
fun StatItem( fun StatItem(
icon: ImageVector, icon: ImageVector,
value: String, value: String,
label: String label: String,
valueColor: Color? = null
) { ) {
val effectiveValueColor = valueColor ?: MaterialTheme.colorScheme.onPrimaryContainer
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
@@ -26,13 +29,13 @@ fun StatItem(
icon, icon,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(28.dp), modifier = Modifier.size(28.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer tint = valueColor ?: MaterialTheme.colorScheme.onPrimaryContainer
) )
Text( Text(
text = value, text = value,
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer color = effectiveValueColor
) )
Text( Text(
text = label, text = label,

View File

@@ -34,7 +34,8 @@ fun AllTasksScreen(
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }, residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
bottomNavBarPadding: androidx.compose.ui.unit.Dp = 0.dp, bottomNavBarPadding: androidx.compose.ui.unit.Dp = 0.dp,
navigateToTaskId: Int? = null, navigateToTaskId: Int? = null,
onClearNavigateToTask: () -> Unit = {} onClearNavigateToTask: () -> Unit = {},
onNavigateToCompleteTask: ((TaskDetail, String) -> Unit)? = null
) { ) {
val tasksState by viewModel.tasksState.collectAsState() val tasksState by viewModel.tasksState.collectAsState()
val completionState by taskCompletionViewModel.createCompletionState.collectAsState() val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
@@ -209,8 +210,16 @@ fun AllTasksScreen(
DynamicTaskKanbanView( DynamicTaskKanbanView(
columns = taskData.columns, columns = taskData.columns,
onCompleteTask = { task -> onCompleteTask = { task ->
selectedTask = task if (onNavigateToCompleteTask != null) {
showCompleteDialog = true // 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 -> onEditTask = { task ->
onNavigateToEditTask(task) onNavigateToEditTask(task)

View File

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

View File

@@ -14,8 +14,11 @@ import androidx.navigation.toRoute
import com.example.casera.navigation.* import com.example.casera.navigation.*
import com.example.casera.repository.LookupsRepository import com.example.casera.repository.LookupsRepository
import com.example.casera.models.Residence import com.example.casera.models.Residence
import com.example.casera.models.TaskDetail
import com.example.casera.storage.TokenStorage import com.example.casera.storage.TokenStorage
import com.example.casera.ui.subscription.UpgradeScreen
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import kotlinx.serialization.json.Json
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@Composable @Composable
@@ -167,7 +170,16 @@ fun MainScreen(
onAddTask = onAddTask, onAddTask = onAddTask,
bottomNavBarPadding = paddingValues.calculateBottomPadding(), bottomNavBarPadding = paddingValues.calculateBottomPadding(),
navigateToTaskId = navigateToTaskId, 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, onLogout = onLogout,
onNavigateToNotificationPreferences = { onNavigateToNotificationPreferences = {
navController.navigate(NotificationPreferencesRoute) 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
}
)
}
}
} }
} }
} }

View File

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

View File

@@ -40,6 +40,7 @@ fun ProfileScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onNavigateToNotificationPreferences: () -> Unit = {}, onNavigateToNotificationPreferences: () -> Unit = {},
onNavigateToUpgrade: (() -> Unit)? = null,
viewModel: AuthViewModel = viewModel { AuthViewModel() } viewModel: AuthViewModel = viewModel { AuthViewModel() }
) { ) {
var firstName by remember { mutableStateOf("") } var firstName by remember { mutableStateOf("") }
@@ -176,6 +177,47 @@ fun ProfileScreen(
Spacer(modifier = Modifier.height(8.dp)) 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 // Theme Selector Section
Card( Card(
modifier = Modifier 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 // Subscription Section - Only show if limitations are enabled
if (currentSubscription?.limitationsEnabled == true) { if (currentSubscription?.limitationsEnabled == true) {
Divider(modifier = Modifier.padding(vertical = AppSpacing.sm)) Divider(modifier = Modifier.padding(vertical = AppSpacing.sm))
@@ -384,7 +467,13 @@ fun ProfileScreen(
} }
Button( Button(
onClick = { showUpgradePrompt = true }, onClick = {
if (onNavigateToUpgrade != null) {
onNavigateToUpgrade()
} else {
showUpgradePrompt = true
}
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary containerColor = MaterialTheme.colorScheme.primary
@@ -560,6 +649,28 @@ fun ProfileScreen(
} }
Spacer(modifier = Modifier.height(16.dp)) 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"
}

View File

@@ -50,6 +50,7 @@ fun ResidenceDetailScreen(
onNavigateToEditResidence: (Residence) -> Unit, onNavigateToEditResidence: (Residence) -> Unit,
onNavigateToEditTask: (TaskDetail) -> Unit, onNavigateToEditTask: (TaskDetail) -> Unit,
onNavigateToContractorDetail: (Int) -> Unit = {}, onNavigateToContractorDetail: (Int) -> Unit = {},
onNavigateToManageUsers: ((Int, String, Boolean, Int) -> Unit)? = null,
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }, residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() }, taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() },
taskViewModel: TaskViewModel = viewModel { TaskViewModel() } taskViewModel: TaskViewModel = viewModel { TaskViewModel() }
@@ -473,7 +474,16 @@ fun ResidenceDetailScreen(
IconButton(onClick = { IconButton(onClick = {
val shareCheck = SubscriptionHelper.canShareResidence() val shareCheck = SubscriptionHelper.canShareResidence()
if (shareCheck.allowed) { if (shareCheck.allowed) {
showManageUsersDialog = true if (onNavigateToManageUsers != null) {
onNavigateToManageUsers(
residence.id,
residence.name,
residence.ownerId == currentUser?.id,
residence.ownerId
)
} else {
showManageUsersDialog = true
}
} else { } else {
upgradeTriggerKey = shareCheck.triggerKey upgradeTriggerKey = shareCheck.triggerKey
showUpgradePrompt = true showUpgradePrompt = true

View File

@@ -1,5 +1,6 @@
package com.example.casera.ui.screens package com.example.casera.ui.screens
import androidx.compose.animation.core.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -15,10 +16,13 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.ApiResultHandler import com.example.casera.ui.components.ApiResultHandler
import com.example.casera.ui.components.JoinResidenceDialog import com.example.casera.ui.components.JoinResidenceDialog
@@ -397,15 +401,21 @@ fun ResidencesScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly 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( StatItem(
icon = Icons.Default.CalendarToday, icon = Icons.Default.CalendarToday,
value = "${response.summary.tasksDueNextWeek}", value = "${response.summary.tasksDueNextWeek}",
label = stringResource(Res.string.tasks_column_due_soon) label = stringResource(Res.string.home_due_this_week)
) )
StatItem( StatItem(
icon = Icons.Default.Event, icon = Icons.Default.Event,
value = "${response.summary.tasksDueNextMonth}", 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 // Properties Header
item { item {
Text( Text(
text = "Your Properties", text = stringResource(Res.string.home_your_properties),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 8.dp) modifier = Modifier.padding(top = 8.dp)
@@ -424,6 +434,20 @@ fun ResidencesScreen(
// Residences // Residences
items(response.residences) { residence -> 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( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -444,29 +468,87 @@ fun ResidencesScreen(
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Gradient circular house icon // Pulsing circular house icon when overdue
Box( Box(
modifier = Modifier modifier = Modifier
.size(56.dp) .size(56.dp)
.then(
if (hasOverdue) Modifier.scale(pulseScale) else Modifier
)
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer), .background(
if (hasOverdue) MaterialTheme.colorScheme.errorContainer
else MaterialTheme.colorScheme.primaryContainer
),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
Icons.Default.Home, Icons.Default.Home,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer, tint = if (hasOverdue) MaterialTheme.colorScheme.onErrorContainer
else MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(28.dp) modifier = Modifier.size(28.dp)
) )
} }
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( // Name with primary star indicator
text = residence.name, Row(
style = MaterialTheme.typography.titleMedium, verticalAlignment = Alignment.CenterVertically,
color = MaterialTheme.colorScheme.onSurface 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)) 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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(4.dp)

View File

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