Add contractor sharing feature and move settings to navigation bar

Contractor Sharing:
- Add .casera file format for sharing contractors between users
- Create SharedContractor model with JSON serialization
- Implement ContractorSharingManager for iOS (Swift) and Android (Kotlin)
- Register .casera file type in iOS Info.plist and Android manifest
- Add share button to ContractorDetailView (iOS) and ContractorDetailScreen (Android)
- Add import confirmation, success, and error dialogs
- Create expect/actual platform implementations for sharing and import handling

Navigation Changes:
- Remove Profile tab from bottom tab bar (iOS and Android)
- Add settings gear icon to left side of "My Properties" title
- Settings gear opens Profile/Settings screen as sheet (iOS) or navigates (Android)
- Add property button to top right action bar

Bug Fixes:
- Fix ResidenceUsersResponse to match API's flat array response format
- Fix GenerateShareCodeResponse handling to access nested shareCode property
- Update ManageUsersDialog to accept residenceOwnerId parameter

🤖 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-05 22:30:19 -06:00
parent 2965ec4031
commit 859a6679ed
43 changed files with 1848 additions and 148 deletions

View File

@@ -11,6 +11,8 @@ struct ContractorDetailView: View {
@State private var showingEditSheet = false
@State private var showingDeleteAlert = false
@State private var showingShareSheet = false
@State private var shareFileURL: URL?
var body: some View {
ZStack {
@@ -25,6 +27,10 @@ struct ContractorDetailView: View {
ToolbarItem(placement: .navigationBarTrailing) {
if let contractor = viewModel.selectedContractor {
Menu {
Button(action: { shareContractor(contractor) }) {
Label(L10n.Common.share, systemImage: "square.and.arrow.up")
}
Button(action: { viewModel.toggleFavorite(id: contractorId) { _ in
viewModel.loadContractorDetail(id: contractorId)
}}) {
@@ -50,6 +56,11 @@ struct ContractorDetailView: View {
}
}
}
.sheet(isPresented: $showingShareSheet) {
if let url = shareFileURL {
ShareSheet(activityItems: [url])
}
}
.sheet(isPresented: $showingEditSheet) {
ContractorFormSheet(
contractor: viewModel.selectedContractor,
@@ -88,6 +99,13 @@ struct ContractorDetailView: View {
}
}
private func shareContractor(_ contractor: Contractor) {
if let url = ContractorSharingManager.shared.createShareableFile(contractor: contractor) {
shareFileURL = url
showingShareSheet = true
}
}
// MARK: - Content State View
@ViewBuilder

View File

@@ -0,0 +1,240 @@
import Foundation
import ComposeApp
/// Manages contractor export and import via .casera files.
/// Singleton that handles file creation for sharing and parsing for import.
@MainActor
class ContractorSharingManager: ObservableObject {
// MARK: - Singleton
static let shared = ContractorSharingManager()
// MARK: - Published Properties
@Published var isImporting: Bool = false
@Published var importError: String?
@Published var importSuccess: Bool = false
@Published var importedContractorName: String?
// MARK: - Private Properties
private let jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
return encoder
}()
private let jsonDecoder = JSONDecoder()
private init() {}
// MARK: - Export
/// Creates a shareable .casera file for a contractor.
/// - Parameter contractor: The contractor to export
/// - Returns: URL to the temporary file, or nil if creation failed
func createShareableFile(contractor: Contractor) -> URL? {
// Get current username for export metadata
let currentUsername = DataManagerObservable.shared.currentUser?.username ?? "Unknown"
// Convert Contractor to SharedContractor using Kotlin extension
let sharedContractor = contractor.toSharedContractor(exportedBy: currentUsername)
// Create Swift-compatible structure for JSON encoding
let exportData = SharedContractorExport(from: sharedContractor)
guard let jsonData = try? jsonEncoder.encode(exportData) else {
print("ContractorSharingManager: Failed to encode contractor to JSON")
return nil
}
// Create a safe filename
let safeName = contractor.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("ContractorSharingManager: Failed to write .casera file: \(error)")
return nil
}
}
// MARK: - Import
/// Imports a contractor from a .casera file URL.
/// - Parameters:
/// - url: The URL to the .casera file
/// - completion: Called with true on success, false on failure
func importContractor(from url: URL, completion: @escaping (Bool) -> Void) {
isImporting = true
importError = nil
// Verify user is authenticated
guard TokenStorage.shared.getToken() != nil else {
importError = "You must be logged in to import a contractor"
isImporting = false
completion(false)
return
}
// Start accessing security-scoped resource if needed (for files from Files app)
let accessing = url.startAccessingSecurityScopedResource()
defer {
if accessing {
url.stopAccessingSecurityScopedResource()
}
}
do {
let data = try Data(contentsOf: url)
let exportData = try jsonDecoder.decode(SharedContractorExport.self, from: data)
// Resolve specialty names to IDs
let specialties = DataManagerObservable.shared.contractorSpecialties
let specialtyIds = exportData.resolveSpecialtyIds(availableSpecialties: specialties)
// Create the request
let createRequest = exportData.toCreateRequest(specialtyIds: specialtyIds)
// Call API to create contractor
Task {
do {
let result = try await APILayer.shared.createContractor(request: createRequest)
if let success = result as? ApiResultSuccess<Contractor>,
let contractor = success.data {
self.importedContractorName = contractor.name
self.importSuccess = true
self.isImporting = false
completion(true)
} else if let error = result as? ApiResultError {
self.importError = ErrorMessageParser.parse(error.message)
self.isImporting = false
completion(false)
} else {
self.importError = "Unknown error occurred"
self.isImporting = false
completion(false)
}
} catch {
self.importError = error.localizedDescription
self.isImporting = false
completion(false)
}
}
} catch {
importError = "Failed to read contractor file: \(error.localizedDescription)"
isImporting = false
completion(false)
}
}
/// Resets the import state after showing success/error feedback
func resetImportState() {
importError = nil
importSuccess = false
importedContractorName = nil
}
}
// MARK: - Swift Codable Structure
/// Swift-native Codable structure for .casera file format.
/// This mirrors the Kotlin SharedContractor model for JSON serialization.
struct SharedContractorExport: Codable {
let version: Int
let name: String
let company: String?
let phone: String?
let email: String?
let website: String?
let notes: String?
let streetAddress: String?
let city: String?
let stateProvince: String?
let postalCode: String?
let specialtyNames: [String]
let rating: Double?
let isFavorite: Bool
let exportedAt: String?
let exportedBy: String?
enum CodingKeys: String, CodingKey {
case version
case name
case company
case phone
case email
case website
case notes
case streetAddress = "street_address"
case city
case stateProvince = "state_province"
case postalCode = "postal_code"
case specialtyNames = "specialty_names"
case rating
case isFavorite = "is_favorite"
case exportedAt = "exported_at"
case exportedBy = "exported_by"
}
/// Initialize from Kotlin SharedContractor
init(from sharedContractor: SharedContractor) {
self.version = Int(sharedContractor.version)
self.name = sharedContractor.name
self.company = sharedContractor.company
self.phone = sharedContractor.phone
self.email = sharedContractor.email
self.website = sharedContractor.website
self.notes = sharedContractor.notes
self.streetAddress = sharedContractor.streetAddress
self.city = sharedContractor.city
self.stateProvince = sharedContractor.stateProvince
self.postalCode = sharedContractor.postalCode
self.specialtyNames = sharedContractor.specialtyNames
self.rating = sharedContractor.rating?.doubleValue
self.isFavorite = sharedContractor.isFavorite
self.exportedAt = sharedContractor.exportedAt
self.exportedBy = sharedContractor.exportedBy
}
/// Resolve specialty names to IDs using available specialties
func resolveSpecialtyIds(availableSpecialties: [ContractorSpecialty]) -> [Int32] {
return specialtyNames.compactMap { name in
availableSpecialties.first { specialty in
specialty.name.lowercased() == name.lowercased()
}?.id
}
}
/// Convert to ContractorCreateRequest for API call
func toCreateRequest(specialtyIds: [Int32]) -> ContractorCreateRequest {
let residenceIdValue: KotlinInt? = nil
let ratingValue: KotlinDouble? = rating.map { KotlinDouble(double: $0) }
let specialtyIdsValue: [KotlinInt]? = specialtyIds.isEmpty ? nil : specialtyIds.map { KotlinInt(int: $0) }
return ContractorCreateRequest(
name: name,
residenceId: residenceIdValue,
company: company,
phone: phone,
email: email,
website: website,
streetAddress: streetAddress,
city: city,
stateProvince: stateProvince,
postalCode: postalCode,
rating: ratingValue,
isFavorite: isFavorite,
notes: notes,
specialtyIds: specialtyIdsValue
)
}
}

View File

@@ -36,6 +36,7 @@ struct AccessibilityIdentifiers {
static let documentsTab = "TabBar.Documents"
static let profileTab = "TabBar.Profile"
static let backButton = "Navigation.BackButton"
static let settingsButton = "Navigation.SettingsButton"
}
// MARK: - Residence

View File

@@ -582,6 +582,8 @@ enum L10n {
static var yes: String { String(localized: "common_yes") }
static var no: String { String(localized: "common_no") }
static var ok: String { String(localized: "common_ok") }
static var share: String { String(localized: "common_share") }
static var `import`: String { String(localized: "common_import") }
}
// MARK: - Errors

View File

@@ -39,5 +39,43 @@
<array>
<string>remote-notification</string>
</array>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Casera Contractor</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>com.casera.contractor</string>
</array>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.casera.contractor</string>
<key>UTTypeDescription</key>
<string>Casera Contractor</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.json</string>
<string>public.data</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>casera</string>
</array>
<key>public.mime-type</key>
<string>application/json</string>
</dict>
</dict>
</array>
</dict>
</plist>

View File

@@ -39,6 +39,10 @@
}
}
},
"%@ has been added to your contacts." : {
"comment" : "A message displayed when a contractor is successfully imported to the user's contacts. The placeholder is replaced with the name of the imported contractor.",
"isCommentAutoGenerated" : true
},
"%@, %@" : {
"comment" : "A city and state combination.",
"isCommentAutoGenerated" : true,
@@ -4747,6 +4751,17 @@
}
}
},
"common_import" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Import"
}
}
}
},
"common_loading" : {
"extractionState" : "manual",
"localizations" : {
@@ -5072,6 +5087,17 @@
}
}
},
"common_share" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Share"
}
}
}
},
"common_success" : {
"extractionState" : "manual",
"localizations" : {
@@ -5229,6 +5255,9 @@
},
"Continue with Free" : {
},
"Contractor Imported" : {
},
"Contractors" : {
"comment" : "A tab label for the contractors section.",
@@ -17315,6 +17344,18 @@
}
}
},
"Import" : {
"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
},
"In Progress" : {
"comment" : "A label displayed next to an image of a play button, indicating that a task is currently in progress.",
"isCommentAutoGenerated" : true
@@ -17381,6 +17422,10 @@
"comment" : "A message displayed when no task templates match a search query.",
"isCommentAutoGenerated" : true
},
"OK" : {
"comment" : "A button that dismisses the success dialog.",
"isCommentAutoGenerated" : true
},
"or" : {
},
@@ -17406,10 +17451,6 @@
"comment" : "The title of the \"Pro\" plan in the feature comparison view.",
"isCommentAutoGenerated" : true
},
"Profile" : {
"comment" : "A label for the \"Profile\" tab in the main tab view.",
"isCommentAutoGenerated" : true
},
"profile_account" : {
"extractionState" : "manual",
"localizations" : {
@@ -29636,6 +29677,10 @@
"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.",
"isCommentAutoGenerated" : true
},
"You now have full access to all Pro features!" : {
"comment" : "A message displayed to users after successfully upgrading to the Pro version of the app.",
"isCommentAutoGenerated" : true

View File

@@ -47,16 +47,6 @@ struct MainTabView: View {
}
.tag(3)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.documentsTab)
NavigationView {
ProfileTabView()
}
.id(refreshID)
.tabItem {
Label("Profile", systemImage: "person.fill")
}
.tag(4)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.profileTab)
}
.tint(Color.appPrimary)
.onChange(of: authManager.isAuthenticated) { _ in

View File

@@ -5,10 +5,11 @@ struct ManageUsersView: View {
let residenceId: Int32
let residenceName: String
let isPrimaryOwner: Bool
let residenceOwnerId: Int32
@Environment(\.dismiss) private var dismiss
@State private var users: [ResidenceUserResponse] = []
@State private var ownerId: Int32?
private var ownerId: Int32 { residenceOwnerId }
@State private var shareCode: ShareCodeResponse?
@State private var isLoading = true
@State private var errorMessage: String?
@@ -97,10 +98,9 @@ struct ManageUsersView: View {
let result = try await APILayer.shared.getResidenceUsers(residenceId: Int32(Int(residenceId)))
await MainActor.run {
if let successResult = result as? ApiResultSuccess<ResidenceUsersResponse>,
let responseData = successResult.data as? ResidenceUsersResponse {
self.users = Array(responseData.users)
self.ownerId = Int32(responseData.owner.id)
if let successResult = result as? ApiResultSuccess<NSArray>,
let responseData = successResult.data as? [ResidenceUserResponse] {
self.users = responseData
self.isLoading = false
} else if let errorResult = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(errorResult.message)
@@ -148,8 +148,9 @@ struct ManageUsersView: View {
let result = try await APILayer.shared.generateShareCode(residenceId: Int32(Int(residenceId)))
await MainActor.run {
if let successResult = result as? ApiResultSuccess<ShareCodeResponse> {
self.shareCode = successResult.data
if let successResult = result as? ApiResultSuccess<GenerateShareCodeResponse>,
let response = successResult.data {
self.shareCode = response.shareCode
self.isGeneratingCode = false
} else if let errorResult = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(errorResult.message)
@@ -195,5 +196,5 @@ struct ManageUsersView: View {
}
#Preview {
ManageUsersView(residenceId: 1, residenceName: "My Home", isPrimaryOwner: true)
ManageUsersView(residenceId: 1, residenceName: "My Home", isPrimaryOwner: true, residenceOwnerId: 1)
}

View File

@@ -120,7 +120,8 @@ struct ResidenceDetailView: View {
ManageUsersView(
residenceId: residence.id,
residenceName: residence.name,
isPrimaryOwner: isCurrentUserOwner(of: residence)
isPrimaryOwner: isCurrentUserOwner(of: residence),
residenceOwnerId: residence.ownerId
)
}
}

View File

@@ -6,6 +6,7 @@ struct ResidencesListView: View {
@State private var showingAddResidence = false
@State private var showingJoinResidence = false
@State private var showingUpgradePrompt = false
@State private var showingSettings = false
@StateObject private var authManager = AuthenticationManager.shared
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@@ -46,6 +47,17 @@ struct ResidencesListView: View {
.navigationTitle(L10n.Residences.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {
showingSettings = true
}) {
Image(systemName: "gearshape.fill")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.settingsButton)
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
Button(action: {
// Check if we should show upgrade prompt before joining
@@ -93,6 +105,11 @@ struct ResidencesListView: View {
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: "add_second_property", isPresented: $showingUpgradePrompt)
}
.sheet(isPresented: $showingSettings) {
NavigationView {
ProfileTabView()
}
}
.onAppear {
if authManager.isAuthenticated {
viewModel.loadMyResidences()

View File

@@ -5,8 +5,11 @@ import ComposeApp
struct iOSApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var themeManager = ThemeManager.shared
@StateObject private var sharingManager = ContractorSharingManager.shared
@Environment(\.scenePhase) private var scenePhase
@State private var deepLinkResetToken: String?
@State private var pendingImportURL: URL?
@State private var showImportConfirmation: Bool = false
init() {
// Initialize DataManager with platform-specific managers
@@ -33,8 +36,9 @@ struct iOSApp: App {
WindowGroup {
RootView()
.environmentObject(themeManager)
.environmentObject(sharingManager)
.onOpenURL { url in
handleDeepLink(url: url)
handleIncomingURL(url: url)
}
.onChange(of: scenePhase) { newPhase in
if newPhase == .active {
@@ -42,17 +46,84 @@ struct iOSApp: App {
PushNotificationManager.shared.checkAndRegisterDeviceIfNeeded()
}
}
// Import confirmation dialog
.alert("Import Contractor", isPresented: $showImportConfirmation) {
Button("Import") {
if let url = pendingImportURL {
sharingManager.importContractor(from: url) { _ in
pendingImportURL = nil
}
}
}
Button("Cancel", role: .cancel) {
pendingImportURL = nil
}
} message: {
Text("Would you like to import this contractor to your contacts?")
}
// Import success dialog
.alert("Contractor Imported", isPresented: $sharingManager.importSuccess) {
Button("OK") {
sharingManager.resetImportState()
}
} message: {
Text("\(sharingManager.importedContractorName ?? "Contractor") has been added to your contacts.")
}
// Import error dialog
.alert("Import Failed", isPresented: .init(
get: { sharingManager.importError != nil },
set: { if !$0 { sharingManager.resetImportState() } }
)) {
Button("OK") {
sharingManager.resetImportState()
}
} message: {
Text(sharingManager.importError ?? "An error occurred while importing the contractor.")
}
}
}
// MARK: - Deep Link Handling
private func handleDeepLink(url: URL) {
print("Deep link received: \(url)")
// MARK: - URL Handling
/// Handles all incoming URLs - both deep links and file opens
private func handleIncomingURL(url: URL) {
print("URL received: \(url)")
// Handle .casera file imports
if url.pathExtension.lowercased() == "casera" {
handleContractorImport(url: url)
return
}
// Handle casera:// deep links
if url.scheme == "casera" {
handleDeepLink(url: url)
return
}
print("Unrecognized URL: \(url)")
}
/// Handles .casera file imports
private func handleContractorImport(url: URL) {
print("Contractor 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"
return
}
// Store URL and show confirmation dialog
pendingImportURL = url
showImportConfirmation = true
}
/// Handles casera:// deep links
private func handleDeepLink(url: URL) {
// Handle casera://reset-password?token=xxx
guard url.scheme == "casera",
url.host == "reset-password" else {
print("Unrecognized deep link scheme or host")
guard url.host == "reset-password" else {
print("Unrecognized deep link host: \(url.host ?? "nil")")
return
}