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

@@ -3,6 +3,14 @@ import QuickLook
class PreviewViewController: UIViewController, QLPreviewingController {
// MARK: - Types
/// Represents the type of .casera package
private enum PackageType {
case contractor
case residence
}
// MARK: - UI Elements
private let containerView: UIView = {
@@ -89,6 +97,8 @@ class PreviewViewController: UIViewController, QLPreviewingController {
// MARK: - Data
private var contractorData: ContractorPreviewData?
private var residenceData: ResidencePreviewData?
private var currentPackageType: PackageType = .contractor
// MARK: - Lifecycle
@@ -187,20 +197,46 @@ class PreviewViewController: UIViewController, QLPreviewingController {
func preparePreviewOfFile(at url: URL) async throws {
print("CaseraQLPreview: preparePreviewOfFile called with URL: \(url)")
// Parse the .casera file
let data = try Data(contentsOf: url)
let decoder = JSONDecoder()
let contractor = try decoder.decode(ContractorPreviewData.self, from: data)
self.contractorData = contractor
print("CaseraQLPreview: Parsed contractor: \(contractor.name)")
await MainActor.run {
self.updateUI(with: contractor)
// Detect package type first
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let typeString = json["type"] as? String,
typeString == "residence" {
currentPackageType = .residence
let decoder = JSONDecoder()
let residence = try decoder.decode(ResidencePreviewData.self, from: data)
self.residenceData = residence
print("CaseraQLPreview: Parsed residence: \(residence.residenceName)")
await MainActor.run {
self.updateUIForResidence(with: residence)
}
} else {
currentPackageType = .contractor
let decoder = JSONDecoder()
let contractor = try decoder.decode(ContractorPreviewData.self, from: data)
self.contractorData = contractor
print("CaseraQLPreview: Parsed contractor: \(contractor.name)")
await MainActor.run {
self.updateUIForContractor(with: contractor)
}
}
}
private func updateUI(with contractor: ContractorPreviewData) {
private func updateUIForContractor(with contractor: ContractorPreviewData) {
// Update icon
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
iconImageView.image = UIImage(systemName: "person.crop.rectangle.stack", withConfiguration: config)
titleLabel.text = contractor.name
subtitleLabel.text = "Casera Contractor File"
instructionLabel.text = "Tap the share button below, then select \"Casera\" to import this contractor."
// Clear existing details
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
@@ -227,6 +263,28 @@ class PreviewViewController: UIViewController, QLPreviewingController {
addDetailRow(icon: "person", text: "Shared by \(exportedBy)")
}
}
private func updateUIForResidence(with residence: ResidencePreviewData) {
// Update icon
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
titleLabel.text = residence.residenceName
subtitleLabel.text = "Casera Residence Invite"
instructionLabel.text = "Tap the share button below, then select \"Casera\" to join this residence."
// Clear existing details
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
// Add details
if let sharedBy = residence.sharedBy, !sharedBy.isEmpty {
addDetailRow(icon: "person", text: "Shared by \(sharedBy)")
}
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
addDetailRow(icon: "clock", text: "Expires: \(expiresAt)")
}
}
}
// MARK: - Data Model
@@ -262,3 +320,24 @@ struct ContractorPreviewData: Codable {
case exportedBy = "exported_by"
}
}
struct ResidencePreviewData: 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, 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"
}
}

View File

@@ -10,19 +10,37 @@ import QuickLookThumbnailing
class ThumbnailProvider: QLThumbnailProvider {
/// Represents the type of .casera package
private enum PackageType {
case contractor
case residence
}
override func provideThumbnail(for request: QLFileThumbnailRequest, _ handler: @escaping (QLThumbnailReply?, Error?) -> Void) {
let thumbnailSize = request.maximumSize
// Detect package type from file
let packageType = detectPackageType(at: request.fileURL)
handler(QLThumbnailReply(contextSize: thumbnailSize, currentContextDrawing: { () -> Bool in
// Draw background
let backgroundColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
backgroundColor.setFill()
UIRectFill(CGRect(origin: .zero, size: thumbnailSize))
// Choose icon based on package type
let iconName: String
switch packageType {
case .contractor:
iconName = "person.crop.rectangle.stack"
case .residence:
iconName = "house.fill"
}
// Draw icon
let config = UIImage.SymbolConfiguration(pointSize: min(thumbnailSize.width, thumbnailSize.height) * 0.5, weight: .regular)
if let icon = UIImage(systemName: "person.crop.rectangle.stack", withConfiguration: config) {
if let icon = UIImage(systemName: iconName, withConfiguration: config) {
let tintedIcon = icon.withTintColor(.white, renderingMode: .alwaysOriginal)
let iconSize = tintedIcon.size
let iconOrigin = CGPoint(
@@ -35,4 +53,19 @@ class ThumbnailProvider: QLThumbnailProvider {
return true
}), nil)
}
/// Detects the package type by reading the "type" field from the JSON
private func detectPackageType(at url: URL) -> PackageType {
do {
let data = try Data(contentsOf: url)
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let typeString = json["type"] as? String,
typeString == "residence" {
return .residence
}
} catch {
// Default to contractor on error
}
return .contractor
}
}

View File

@@ -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!" : {

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
}
}

View File

@@ -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