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:
@@ -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
|
||||
|
||||
208
iosApp/iosApp/Residence/ResidenceSharingManager.swift
Normal file
208
iosApp/iosApp/Residence/ResidenceSharingManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user