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_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>

View File

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

View File

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

View File

@@ -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,

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.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,

View File

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

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

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,
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"
}

View File

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

View File

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

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