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:
@@ -17348,10 +17348,6 @@
|
||||
"comment" : "The text on a button that triggers the import action.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Import Contractor" : {
|
||||
"comment" : "The title of an alert dialog that appears when a user attempts to import a contractor.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Import Failed" : {
|
||||
"comment" : "A dialog title when importing a contractor fails.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -17368,9 +17364,16 @@
|
||||
"comment" : "A title for the registration screen.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Join Failed" : {
|
||||
"comment" : "An alert title displayed when joining a residence fails.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Join Residence" : {
|
||||
"comment" : "A button label that allows a user to join an existing residence.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Joined Residence" : {
|
||||
|
||||
},
|
||||
"Joining residence..." : {
|
||||
"comment" : "A message displayed while waiting for the app to join a residence.",
|
||||
@@ -24606,6 +24609,10 @@
|
||||
"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.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -29683,8 +29690,8 @@
|
||||
"comment" : "The title of the welcome screen in the preview.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Would you like to import this contractor to your contacts?" : {
|
||||
"comment" : "A message displayed in an alert when a user imports a contractor.",
|
||||
"You now have access to %@." : {
|
||||
"comment" : "A message displayed when a user successfully imports a residence, indicating that they now have access to it. The argument is the name of the residence that was imported.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"You now have full access to all Pro features!" : {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,20 @@ import WidgetKit
|
||||
struct iOSApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
@StateObject private var themeManager = ThemeManager.shared
|
||||
@StateObject private var sharingManager = ContractorSharingManager.shared
|
||||
@StateObject private var contractorSharingManager = ContractorSharingManager.shared
|
||||
@StateObject private var residenceSharingManager = ResidenceSharingManager.shared
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var deepLinkResetToken: String?
|
||||
@State private var pendingImportURL: URL?
|
||||
@State private var pendingImportType: CaseraPackageType = .contractor
|
||||
@State private var showImportConfirmation: Bool = false
|
||||
|
||||
/// Type of casera package being imported
|
||||
enum CaseraPackageType {
|
||||
case contractor
|
||||
case residence
|
||||
}
|
||||
|
||||
init() {
|
||||
// Initialize DataManager with platform-specific managers
|
||||
// This must be done before any other operations that access DataManager
|
||||
@@ -37,7 +45,8 @@ struct iOSApp: App {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.environmentObject(themeManager)
|
||||
.environmentObject(sharingManager)
|
||||
.environmentObject(contractorSharingManager)
|
||||
.environmentObject(residenceSharingManager)
|
||||
.onOpenURL { url in
|
||||
handleIncomingURL(url: url)
|
||||
}
|
||||
@@ -55,12 +64,19 @@ struct iOSApp: App {
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
// Import confirmation dialog
|
||||
.alert("Import Contractor", isPresented: $showImportConfirmation) {
|
||||
// Import confirmation dialog - routes to appropriate handler
|
||||
.alert(importConfirmationTitle, isPresented: $showImportConfirmation) {
|
||||
Button("Import") {
|
||||
if let url = pendingImportURL {
|
||||
sharingManager.importContractor(from: url) { _ in
|
||||
pendingImportURL = nil
|
||||
switch pendingImportType {
|
||||
case .contractor:
|
||||
contractorSharingManager.importContractor(from: url) { _ in
|
||||
pendingImportURL = nil
|
||||
}
|
||||
case .residence:
|
||||
residenceSharingManager.importResidence(from: url) { _ in
|
||||
pendingImportURL = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,27 +84,66 @@ struct iOSApp: App {
|
||||
pendingImportURL = nil
|
||||
}
|
||||
} message: {
|
||||
Text("Would you like to import this contractor to your contacts?")
|
||||
Text(importConfirmationMessage)
|
||||
}
|
||||
// Import success dialog
|
||||
.alert("Contractor Imported", isPresented: $sharingManager.importSuccess) {
|
||||
// Contractor import success dialog
|
||||
.alert("Contractor Imported", isPresented: $contractorSharingManager.importSuccess) {
|
||||
Button("OK") {
|
||||
sharingManager.resetImportState()
|
||||
contractorSharingManager.resetImportState()
|
||||
}
|
||||
} message: {
|
||||
Text("\(sharingManager.importedContractorName ?? "Contractor") has been added to your contacts.")
|
||||
Text("\(contractorSharingManager.importedContractorName ?? "Contractor") has been added to your contacts.")
|
||||
}
|
||||
// Import error dialog
|
||||
// Contractor import error dialog
|
||||
.alert("Import Failed", isPresented: .init(
|
||||
get: { sharingManager.importError != nil },
|
||||
set: { if !$0 { sharingManager.resetImportState() } }
|
||||
get: { contractorSharingManager.importError != nil },
|
||||
set: { if !$0 { contractorSharingManager.resetImportState() } }
|
||||
)) {
|
||||
Button("OK") {
|
||||
sharingManager.resetImportState()
|
||||
contractorSharingManager.resetImportState()
|
||||
}
|
||||
} message: {
|
||||
Text(sharingManager.importError ?? "An error occurred while importing the contractor.")
|
||||
Text(contractorSharingManager.importError ?? "An error occurred while importing the contractor.")
|
||||
}
|
||||
// Residence import success dialog
|
||||
.alert("Joined Residence", isPresented: $residenceSharingManager.importSuccess) {
|
||||
Button("OK") {
|
||||
residenceSharingManager.resetState()
|
||||
}
|
||||
} message: {
|
||||
Text("You now have access to \(residenceSharingManager.importedResidenceName ?? "the residence").")
|
||||
}
|
||||
// Residence import error dialog
|
||||
.alert("Join Failed", isPresented: .init(
|
||||
get: { residenceSharingManager.errorMessage != nil && !residenceSharingManager.isImporting },
|
||||
set: { if !$0 { residenceSharingManager.resetState() } }
|
||||
)) {
|
||||
Button("OK") {
|
||||
residenceSharingManager.resetState()
|
||||
}
|
||||
} message: {
|
||||
Text(residenceSharingManager.errorMessage ?? "An error occurred while joining the residence.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Import Dialog Helpers
|
||||
|
||||
private var importConfirmationTitle: String {
|
||||
switch pendingImportType {
|
||||
case .contractor:
|
||||
return "Import Contractor"
|
||||
case .residence:
|
||||
return "Join Residence"
|
||||
}
|
||||
}
|
||||
|
||||
private var importConfirmationMessage: String {
|
||||
switch pendingImportType {
|
||||
case .contractor:
|
||||
return "Would you like to import this contractor to your contacts?"
|
||||
case .residence:
|
||||
return "Would you like to join this shared residence?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +155,7 @@ struct iOSApp: App {
|
||||
|
||||
// Handle .casera file imports
|
||||
if url.pathExtension.lowercased() == "casera" {
|
||||
handleContractorImport(url: url)
|
||||
handleCaseraFileImport(url: url)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -113,16 +168,43 @@ struct iOSApp: App {
|
||||
print("Unrecognized URL: \(url)")
|
||||
}
|
||||
|
||||
/// Handles .casera file imports
|
||||
private func handleContractorImport(url: URL) {
|
||||
print("Contractor file received: \(url)")
|
||||
/// Handles .casera file imports - detects type and routes accordingly
|
||||
private func handleCaseraFileImport(url: URL) {
|
||||
print("Casera file received: \(url)")
|
||||
|
||||
// Check if user is authenticated
|
||||
guard TokenStorage.shared.getToken() != nil else {
|
||||
sharingManager.importError = "You must be logged in to import a contractor"
|
||||
contractorSharingManager.importError = "You must be logged in to import"
|
||||
return
|
||||
}
|
||||
|
||||
// Read file and detect type
|
||||
let accessing = url.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if accessing {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let typeString = json["type"] as? String {
|
||||
// Route based on type
|
||||
if typeString == "residence" {
|
||||
pendingImportType = .residence
|
||||
} else {
|
||||
pendingImportType = .contractor
|
||||
}
|
||||
} else {
|
||||
// Default to contractor for backward compatibility (files without type field)
|
||||
pendingImportType = .contractor
|
||||
}
|
||||
} catch {
|
||||
print("Failed to read casera file: \(error)")
|
||||
pendingImportType = .contractor
|
||||
}
|
||||
|
||||
// Store URL and show confirmation dialog
|
||||
pendingImportURL = url
|
||||
showImportConfirmation = true
|
||||
|
||||
Reference in New Issue
Block a user