From b150c20e4b5ef6bdd985f588608b2e6fa4937ba7 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sun, 14 Dec 2025 13:17:19 -0600 Subject: [PATCH] Reorganize share UI with Easy Share on top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move Easy Share (.casera file) section above Share Code section in the invite users dialog. Both platforms now have consistent UI with both buttons using the same filled button style. iOS changes: - Move share functionality into ManageUsersView - Remove share button from ResidenceDetailView toolbar - Redesign ShareCodeCard with Easy Share on top Android changes: - Update ManageUsersDialog with matching layout - Connect share package callback to existing share function 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../casera/ui/components/ManageUsersDialog.kt | 180 ++++++++++++++---- .../ui/screens/ResidenceDetailScreen.kt | 4 + iosApp/iosApp/Localizable.xcstrings | 39 ++-- iosApp/iosApp/Residence/ManageUsersView.swift | 35 +++- .../Residence/ResidenceDetailView.swift | 52 +---- .../Subviews/Residence/ShareCodeCard.swift | 124 ++++++++++-- 6 files changed, 311 insertions(+), 123 deletions(-) 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() +}