Add residence sharing via .casera files

- Add SharedResidence model and package type detection for .casera files
- Add generateSharePackage API endpoint integration
- Create ResidenceSharingManager for iOS and Android
- Add share button to residence detail screens (owner only)
- Add residence import handling with confirmation dialogs
- Update Quick Look extensions to show house icon for residence packages
- Route .casera imports by type (contractor vs residence)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-06 18:54:46 -06:00
parent 04c3389e4d
commit 83e2cd14a6
27 changed files with 1445 additions and 43 deletions

View File

@@ -32,7 +32,10 @@ struct ResidenceDetailView: View {
@State private var showDeleteConfirmation = false
@State private var isDeleting = false
@State private var showingUpgradePrompt = false
@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
@@ -146,6 +149,22 @@ struct ResidenceDetailView: View {
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: "add_11th_task", 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
@@ -336,15 +355,21 @@ private extension ResidenceDetailView {
}
.disabled(viewModel.isGeneratingReport)
}
// Share Residence button (owner only)
if let residence = viewModel.selectedResidence, isCurrentUserOwner(of: residence) {
Button {
showManageUsers = true
shareResidence(residence)
} label: {
Image(systemName: "person.2")
if sharingManager.isGeneratingPackage {
ProgressView()
} else {
Image(systemName: "square.and.arrow.up")
}
}
.disabled(sharingManager.isGeneratingPackage)
}
Button {
// Check LIVE task count before adding
let totalTasks = tasksResponse?.columns.reduce(0) { $0 + $1.tasks.count } ?? 0
@@ -357,7 +382,7 @@ private extension ResidenceDetailView {
Image(systemName: "plus")
}
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
if let residence = viewModel.selectedResidence, isCurrentUserOwner(of: residence) {
Button {
showDeleteConfirmation = true
@@ -369,6 +394,15 @@ private extension ResidenceDetailView {
}
}
}
func shareResidence(_ residence: ResidenceResponse) {
Task {
if let url = await sharingManager.createShareableFile(residence: residence) {
shareFileURL = url
showShareSheet = true
}
}
}
}
// MARK: - Data Loading

View File

@@ -0,0 +1,208 @@
import Foundation
import ComposeApp
/// Manages residence share package creation and import via .casera files.
/// For residences, the share code is generated server-side (unlike contractors which are exported client-side).
@MainActor
class ResidenceSharingManager: ObservableObject {
// MARK: - Singleton
static let shared = ResidenceSharingManager()
// MARK: - Published Properties
/// True while generating a share package from the server
@Published var isGeneratingPackage: Bool = false
/// True while importing a residence from a share package
@Published var isImporting: Bool = false
/// Error message if generation or import fails
@Published var errorMessage: String?
/// True after successful import
@Published var importSuccess: Bool = false
/// Name of the imported residence
@Published var importedResidenceName: String?
// MARK: - Private Properties
private let jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
return encoder
}()
private let jsonDecoder = JSONDecoder()
private init() {}
// MARK: - Export (Share)
/// Creates a shareable .casera file for a residence.
/// This calls the backend to generate a one-time share code, then packages it.
/// - Parameter residence: The residence to share
/// - Returns: URL to the temporary file, or nil if creation failed
func createShareableFile(residence: ResidenceResponse) async -> URL? {
isGeneratingPackage = true
errorMessage = nil
defer { isGeneratingPackage = false }
// Call API to generate share package
let result: Any
do {
result = try await APILayer.shared.generateSharePackage(residenceId: residence.id)
} catch {
errorMessage = "Failed to generate share code: \(error.localizedDescription)"
return nil
}
guard let success = result as? ApiResultSuccess<SharedResidence>,
let sharedResidence = success.data else {
if let error = result as? ApiResultError {
errorMessage = ErrorMessageParser.parse(error.message)
} else {
errorMessage = "Failed to generate share code"
}
return nil
}
// Create Swift-compatible structure for JSON encoding
let exportData = SharedResidenceExport(from: sharedResidence)
guard let jsonData = try? jsonEncoder.encode(exportData) else {
print("ResidenceSharingManager: Failed to encode residence to JSON")
errorMessage = "Failed to create share file"
return nil
}
// Create a safe filename
let safeName = residence.name
.replacingOccurrences(of: " ", with: "_")
.replacingOccurrences(of: "/", with: "-")
.prefix(50)
let fileName = "\(safeName).casera"
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
do {
try jsonData.write(to: tempURL)
return tempURL
} catch {
print("ResidenceSharingManager: Failed to write .casera file: \(error)")
errorMessage = "Failed to save share file"
return nil
}
}
// MARK: - Import
/// Imports a residence share from a .casera file URL.
/// This validates the share code with the server and adds the user to the residence.
/// - Parameters:
/// - url: The URL to the .casera file
/// - completion: Called with true on success, false on failure
func importResidence(from url: URL, completion: @escaping (Bool) -> Void) {
isImporting = true
errorMessage = nil
// Verify user is authenticated
guard TokenStorage.shared.getToken() != nil else {
errorMessage = "You must be logged in to join a residence"
isImporting = false
completion(false)
return
}
// Start accessing security-scoped resource if needed
let accessing = url.startAccessingSecurityScopedResource()
defer {
if accessing {
url.stopAccessingSecurityScopedResource()
}
}
do {
let data = try Data(contentsOf: url)
let exportData = try jsonDecoder.decode(SharedResidenceExport.self, from: data)
// Use the share code to join the residence
Task {
do {
let result = try await APILayer.shared.joinWithCode(code: exportData.shareCode)
if let success = result as? ApiResultSuccess<JoinResidenceResponse>,
let joinResponse = success.data {
self.importedResidenceName = joinResponse.residence.name
self.importSuccess = true
self.isImporting = false
completion(true)
} else if let error = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isImporting = false
completion(false)
} else {
self.errorMessage = "Unknown error occurred"
self.isImporting = false
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
self.isImporting = false
completion(false)
}
}
} catch {
errorMessage = "Failed to read residence share file: \(error.localizedDescription)"
isImporting = false
completion(false)
}
}
/// Resets the import state after showing success/error feedback
func resetState() {
errorMessage = nil
importSuccess = false
importedResidenceName = nil
}
}
// MARK: - Swift Codable Structure
/// Swift-native Codable structure for .casera residence share format.
/// This mirrors the Kotlin SharedResidence model for JSON serialization.
struct SharedResidenceExport: Codable {
let version: Int
let type: String
let shareCode: String
let residenceName: String
let sharedBy: String?
let expiresAt: String?
let exportedAt: String?
let exportedBy: String?
enum CodingKeys: String, CodingKey {
case version
case type
case shareCode = "share_code"
case residenceName = "residence_name"
case sharedBy = "shared_by"
case expiresAt = "expires_at"
case exportedAt = "exported_at"
case exportedBy = "exported_by"
}
/// Initialize from Kotlin SharedResidence
init(from sharedResidence: SharedResidence) {
self.version = Int(sharedResidence.version)
self.type = sharedResidence.type
self.shareCode = sharedResidence.shareCode
self.residenceName = sharedResidence.residenceName
self.sharedBy = sharedResidence.sharedBy
self.expiresAt = sharedResidence.expiresAt
self.exportedAt = sharedResidence.exportedAt
self.exportedBy = sharedResidence.exportedBy
}
}