diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ManageUsersDialog.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ManageUsersDialog.kt index 698ecd2..267aea9 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ManageUsersDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ManageUsersDialog.kt @@ -5,13 +5,21 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Share 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.FontStyle import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.example.casera.models.ResidenceUser import com.example.casera.models.ResidenceShareCode import com.example.casera.network.ApiResult @@ -26,7 +34,8 @@ fun ManageUsersDialog( isPrimaryOwner: Boolean, residenceOwnerId: Int, onDismiss: () -> Unit, - onUserRemoved: () -> Unit = {} + onUserRemoved: () -> Unit = {}, + onSharePackage: () -> Unit = {} ) { var users by remember { mutableStateOf>(emptyList()) } val ownerId = residenceOwnerId @@ -37,6 +46,7 @@ fun ManageUsersDialog( val residenceApi = remember { ResidenceApi() } val scope = rememberCoroutineScope() + val clipboardManager = LocalClipboardManager.current // Load users LaunchedEffect(residenceId) { @@ -69,7 +79,16 @@ fun ManageUsersDialog( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text("Manage Users") + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.PersonAdd, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Invite Others") + } IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, "Close") } @@ -91,70 +110,147 @@ fun ManageUsersDialog( modifier = Modifier.padding(16.dp) ) } else { - // Share code section (primary owner only) + // Share sections (primary owner only) if (isPrimaryOwner) { + // Easy Share section (on top - recommended) Card( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer + containerColor = MaterialTheme.colorScheme.surfaceVariant ) ) { Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Easy Share", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = { onSharePackage() }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.Share, "Share", modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Send Invite Link") + } + + Text( + text = "Send a .casera file via Messages, Email, or share. They just tap to join.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp) + ) + } + } + + // Divider with "or" + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + HorizontalDivider(modifier = Modifier.weight(1f)) + Text( + text = "or", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp) + ) + HorizontalDivider(modifier = Modifier.weight(1f)) + } + + // Share Code section + Card( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Share Code", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Column(modifier = Modifier.weight(1f)) { + if (shareCode != null) { Text( - text = "Share Code", - style = MaterialTheme.typography.titleSmall + text = shareCode!!.code, + style = MaterialTheme.typography.headlineMedium.copy( + fontFamily = FontFamily.Monospace, + letterSpacing = 4.sp + ), + color = MaterialTheme.colorScheme.primary ) - if (shareCode != null) { - Text( - text = shareCode!!.code, - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.primary - ) - } else { - Text( - text = "No active code", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + IconButton( + onClick = { + clipboardManager.setText(AnnotatedString(shareCode!!.code)) + } + ) { + Icon( + Icons.Default.ContentCopy, + contentDescription = "Copy code", + tint = MaterialTheme.colorScheme.primary ) } + } else { + Text( + text = "No active code", + style = MaterialTheme.typography.bodyMedium.copy( + fontStyle = FontStyle.Italic + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } + } - FilledTonalButton( - 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 -> {} + Spacer(modifier = Modifier.height(8.dp)) + + 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 - ) { - Icon(Icons.Default.Share, "Generate", modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.width(8.dp)) - Text(if (shareCode != null) "New Code" else "Generate") + isGeneratingCode = false + } + }, + enabled = !isGeneratingCode, + modifier = Modifier.fillMaxWidth() + ) { + if (isGeneratingCode) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Icon(Icons.Default.Refresh, "Generate", modifier = Modifier.size(18.dp)) } + Spacer(modifier = Modifier.width(8.dp)) + Text(if (shareCode != null) "Generate New Code" else "Generate Code") } if (shareCode != null) { Text( - text = "Share this code with others to give them access to $residenceName", + text = "Share this 6-character code. They can enter it in the app to join.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp) @@ -172,7 +268,7 @@ fun ManageUsersDialog( ) LazyColumn( - modifier = Modifier.fillMaxWidth().height(300.dp) + modifier = Modifier.fillMaxWidth().height(200.dp) ) { items(users) { user -> UserListItem( diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt index ef5cdb0..008bdfb 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt @@ -260,6 +260,10 @@ fun ResidenceDetailScreen( residenceViewModel.getResidence(residenceId) { result -> residenceState = result } + }, + onSharePackage = { + // Use the existing share function with the residence + shareResidence(residence) } ) } diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index 7ba80eb..55e7483 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -16942,6 +16942,10 @@ }, "Downloading..." : { + }, + "Easy Share" : { + "comment" : "A section header for the \"Easy Share\" feature on the Share Code Card.", + "isCommentAutoGenerated" : true }, "Edit" : { "comment" : "A label for an edit action.", @@ -16985,6 +16989,10 @@ "comment" : "A description below the email input field, instructing the user to enter their email address to receive a password reset code.", "isCommentAutoGenerated" : true }, + "Error" : { + "comment" : "The title of an alert that appears when there's an error.", + "isCommentAutoGenerated" : true + }, "error_generic" : { "extractionState" : "manual", "localizations" : { @@ -17330,8 +17338,12 @@ "comment" : "A label indicating a free feature.", "isCommentAutoGenerated" : true }, - "Generate" : { - "comment" : "A button label that says \"Generate\".", + "Generate Code" : { + "comment" : "A button label that generates a new invitation code.", + "isCommentAutoGenerated" : true + }, + "Generate New Code" : { + "comment" : "A button label that appears when a user wants to generate a new invitation code.", "isCommentAutoGenerated" : true }, "Hour" : { @@ -17370,6 +17382,10 @@ "comment" : "A label displayed next to an image of a play button, indicating that a task is currently in progress.", "isCommentAutoGenerated" : true }, + "Invite Others" : { + "comment" : "A header that suggests inviting others to join the app.", + "isCommentAutoGenerated" : true + }, "Join a Residence" : { "comment" : "A button label that instructs the user to join an existing residence.", "isCommentAutoGenerated" : true @@ -17419,10 +17435,6 @@ }, "Need inspiration?" : { - }, - "New Code" : { - "comment" : "A button label that appears when the user has already generated a share code.", - "isCommentAutoGenerated" : true }, "New Password" : { @@ -24722,6 +24734,13 @@ "comment" : "A label displayed above the picker for selecting the notification time.", "isCommentAutoGenerated" : true }, + "Send a .casera file via Messages, Email, or AirDrop. They just tap to join." : { + + }, + "Send Invite Link" : { + "comment" : "A button label for sending an invitation link to others.", + "isCommentAutoGenerated" : true + }, "Send New Code" : { "comment" : "A button label that allows a user to request a new verification code.", "isCommentAutoGenerated" : true @@ -24871,12 +24890,8 @@ "comment" : "A label displayed above the share code section of the view.", "isCommentAutoGenerated" : true }, - "Share Failed" : { - "comment" : "An alert title that appears when sharing a file fails.", - "isCommentAutoGenerated" : true - }, - "Share this code with others to give them access to %@" : { - "comment" : "A caption below the share code, explaining that it can be shared with others to give them access to a residence. The argument is the name of the residence.", + "Share this 6-character code. They can enter it in the app to join." : { + "comment" : "A description of how to share the invitation code with others.", "isCommentAutoGenerated" : true }, "Shared Users (%lld)" : { diff --git a/iosApp/iosApp/Residence/ManageUsersView.swift b/iosApp/iosApp/Residence/ManageUsersView.swift index 90b789e..4cb5267 100644 --- a/iosApp/iosApp/Residence/ManageUsersView.swift +++ b/iosApp/iosApp/Residence/ManageUsersView.swift @@ -6,6 +6,7 @@ struct ManageUsersView: View { let residenceName: String let isPrimaryOwner: Bool let residenceOwnerId: Int32 + let residence: ResidenceResponse? @Environment(\.dismiss) private var dismiss @State private var users: [ResidenceUserResponse] = [] @@ -14,6 +15,8 @@ struct ManageUsersView: View { @State private var isLoading = true @State private var errorMessage: String? @State private var isGeneratingCode = false + @State private var shareFileURL: URL? + @StateObject private var sharingManager = ResidenceSharingManager.shared var body: some View { NavigationView { @@ -36,7 +39,9 @@ struct ManageUsersView: View { shareCode: shareCode, residenceName: residenceName, isGeneratingCode: isGeneratingCode, - onGenerateCode: generateShareCode + isGeneratingPackage: sharingManager.isGeneratingPackage, + onGenerateCode: generateShareCode, + onEasyShare: easyShare ) .padding(.horizontal) .padding(.top) @@ -82,6 +87,32 @@ struct ManageUsersView: View { shareCode = nil loadUsers() } + .sheet(isPresented: Binding( + get: { shareFileURL != nil }, + set: { if !$0 { shareFileURL = nil } } + )) { + if let url = shareFileURL { + ShareSheet(activityItems: [url]) + } + } + .alert("Error", isPresented: Binding( + get: { sharingManager.errorMessage != nil }, + set: { if !$0 { sharingManager.errorMessage = nil } } + )) { + Button("OK", role: .cancel) {} + } message: { + Text(sharingManager.errorMessage ?? "Failed to create share link.") + } + } + + private func easyShare() { + guard let residence = residence else { return } + + Task { + if let url = await sharingManager.createShareableFile(residence: residence) { + shareFileURL = url + } + } } private func loadUsers() { @@ -196,5 +227,5 @@ struct ManageUsersView: View { } #Preview { - ManageUsersView(residenceId: 1, residenceName: "My Home", isPrimaryOwner: true, residenceOwnerId: 1) + ManageUsersView(residenceId: 1, residenceName: "My Home", isPrimaryOwner: true, residenceOwnerId: 1, residence: nil) } diff --git a/iosApp/iosApp/Residence/ResidenceDetailView.swift b/iosApp/iosApp/Residence/ResidenceDetailView.swift index 57b2459..5336c1d 100644 --- a/iosApp/iosApp/Residence/ResidenceDetailView.swift +++ b/iosApp/iosApp/Residence/ResidenceDetailView.swift @@ -33,10 +33,7 @@ struct ResidenceDetailView: View { @State private var isDeleting = false @State private var showingUpgradePrompt = false @State private var upgradeTriggerKey = "" - @State private var showShareSheet = false - @State private var shareFileURL: URL? @StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared - @StateObject private var sharingManager = ResidenceSharingManager.shared @Environment(\.dismiss) private var dismiss @@ -125,7 +122,8 @@ struct ResidenceDetailView: View { residenceId: residence.id, residenceName: residence.name, isPrimaryOwner: isCurrentUserOwner(of: residence), - residenceOwnerId: residence.ownerId + residenceOwnerId: residence.ownerId, + residence: residence ) } } @@ -150,22 +148,6 @@ struct ResidenceDetailView: View { .sheet(isPresented: $showingUpgradePrompt) { UpgradePromptView(triggerKey: upgradeTriggerKey.isEmpty ? "add_11th_task" : upgradeTriggerKey, isPresented: $showingUpgradePrompt) } - .sheet(isPresented: $showShareSheet) { - if let url = shareFileURL { - ShareSheet(activityItems: [url]) - } - } - // Share error alert - .alert("Share Failed", isPresented: .init( - get: { sharingManager.errorMessage != nil }, - set: { if !$0 { sharingManager.resetState() } } - )) { - Button("OK") { - sharingManager.resetState() - } - } message: { - Text(sharingManager.errorMessage ?? "Failed to create share link.") - } // MARK: onChange & lifecycle .onChange(of: viewModel.reportMessage) { message in @@ -357,26 +339,7 @@ private extension ResidenceDetailView { .disabled(viewModel.isGeneratingReport) } - // Share Residence button (owner only) - if let residence = viewModel.selectedResidence, isCurrentUserOwner(of: residence) { - Button { - if subscriptionCache.canShareResidence() { - shareResidence(residence) - } else { - upgradeTriggerKey = "share_residence" - showingUpgradePrompt = true - } - } label: { - if sharingManager.isGeneratingPackage { - ProgressView() - } else { - Image(systemName: "square.and.arrow.up") - } - } - .disabled(sharingManager.isGeneratingPackage) - } - - // Manage Users button (owner only) + // Manage Users button (owner only) - includes share code generation and easy share if let residence = viewModel.selectedResidence, isCurrentUserOwner(of: residence) { Button { if subscriptionCache.canShareResidence() { @@ -415,15 +378,6 @@ private extension ResidenceDetailView { } } } - - func shareResidence(_ residence: ResidenceResponse) { - Task { - if let url = await sharingManager.createShareableFile(residence: residence) { - shareFileURL = url - showShareSheet = true - } - } - } } // MARK: - Data Loading diff --git a/iosApp/iosApp/Subviews/Residence/ShareCodeCard.swift b/iosApp/iosApp/Subviews/Residence/ShareCodeCard.swift index 6d394d1..a58ae0c 100644 --- a/iosApp/iosApp/Subviews/Residence/ShareCodeCard.swift +++ b/iosApp/iosApp/Subviews/Residence/ShareCodeCard.swift @@ -6,48 +6,136 @@ struct ShareCodeCard: View { let shareCode: ShareCodeResponse? let residenceName: String let isGeneratingCode: Bool + let isGeneratingPackage: Bool let onGenerateCode: () -> Void + let onEasyShare: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 16) { + // Header HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Share Code") - .font(.subheadline) - .foregroundColor(Color.appTextSecondary) + Image(systemName: "person.badge.plus") + .font(.title2) + .foregroundColor(Color.appPrimary) + Text("Invite Others") + .font(.headline) + .foregroundColor(Color.appTextPrimary) + } + // Easy Share Section (on top - recommended method) + VStack(alignment: .leading, spacing: 8) { + Text("Easy Share") + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + + Button(action: onEasyShare) { + HStack { + if isGeneratingPackage { + ProgressView() + .tint(.white) + } else { + Image(systemName: "square.and.arrow.up") + } + Text("Send Invite Link") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(isGeneratingPackage) + + Text("Send a .casera file via Messages, Email, or AirDrop. They just tap to join.") + .font(.caption) + .foregroundColor(Color.appTextSecondary) + } + .padding() + .background(Color.appBackgroundSecondary) + .cornerRadius(12) + + // Divider with "or" + HStack { + Rectangle() + .fill(Color.appTextSecondary.opacity(0.3)) + .frame(height: 1) + Text("or") + .font(.caption) + .foregroundColor(Color.appTextSecondary) + Rectangle() + .fill(Color.appTextSecondary.opacity(0.3)) + .frame(height: 1) + } + + // Share Code Section + VStack(alignment: .leading, spacing: 8) { + Text("Share Code") + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + + HStack { if let shareCode = shareCode { Text(shareCode.code) - .font(.title) - .fontWeight(.bold) + .font(.system(size: 32, weight: .bold, design: .monospaced)) .foregroundColor(Color.appPrimary) + .kerning(4) + + Spacer() + + Button { + UIPasteboard.general.string = shareCode.code + } label: { + Image(systemName: "doc.on.doc") + .foregroundColor(Color.appPrimary) + } + .buttonStyle(.bordered) } else { Text("No active code") .font(.body) .foregroundColor(Color.appTextSecondary) + .italic() + + Spacer() } } - Spacer() - + // Generate Code Button Button(action: onGenerateCode) { HStack { - Image(systemName: "square.and.arrow.up") - Text(shareCode != nil ? "New Code" : "Generate") + if isGeneratingCode { + ProgressView() + .tint(.white) + } else { + Image(systemName: "arrow.clockwise") + } + Text(shareCode != nil ? "Generate New Code" : "Generate Code") } + .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .disabled(isGeneratingCode) - } - if shareCode != nil { - Text("Share this code with others to give them access to \(residenceName)") - .font(.caption) - .foregroundColor(Color.appTextSecondary) + if shareCode != nil { + Text("Share this 6-character code. They can enter it in the app to join.") + .font(.caption) + .foregroundColor(Color.appTextSecondary) + } } + .padding() + .background(Color.appBackgroundSecondary) + .cornerRadius(12) } .padding() - .background(Color.appPrimary.opacity(0.1)) - .cornerRadius(12) + .background(Color.appPrimary.opacity(0.05)) + .cornerRadius(16) } } + +#Preview { + ShareCodeCard( + shareCode: nil, + residenceName: "My Home", + isGeneratingCode: false, + isGeneratingPackage: false, + onGenerateCode: {}, + onEasyShare: {} + ) + .padding() +}