Refactor iOS forms and integrate notification API with APILayer
- Refactored ContractorFormSheet to follow SwiftUI best practices - Moved Field enum outside struct and renamed to ContractorFormField - Extracted body into computed properties for better readability - Replaced deprecated NavigationView with NavigationStack - Fixed input field contrast in light mode by adding borders - Fixed force cast in loadContractorSpecialties - Refactored TaskFormView to eliminate screen flickering - Moved Field enum outside struct and renamed to TaskFormField - Fixed conditional view structure that caused flicker on load - Used ZStack with overlay instead of if/else for loading state - Changed to .task modifier for proper async initialization - Made loadLookups properly async and fixed force casts - Replaced deprecated NavigationView with NavigationStack - Integrated PushNotificationManager with APILayer - Updated registerDeviceWithBackend to use APILayer.shared.registerDevice() - Updated updateNotificationPreferences to use APILayer - Updated getNotificationPreferences to use APILayer - Added proper error handling with try-catch pattern - Added notification operations to APILayer - Added NotificationApi instance - Implemented registerDevice, unregisterDevice - Implemented getNotificationPreferences, updateNotificationPreferences - Implemented getNotificationHistory, markNotificationAsRead - Implemented markAllNotificationsAsRead, getUnreadCount - All methods follow consistent pattern with auth token handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ object APILayer {
|
||||
private val contractorApi = ContractorApi()
|
||||
private val authApi = AuthApi()
|
||||
private val lookupsApi = LookupsApi()
|
||||
private val notificationApi = NotificationApi()
|
||||
private val prefetchManager = DataPrefetchManager.getInstance()
|
||||
|
||||
// ==================== Lookups Operations ====================
|
||||
@@ -852,4 +853,46 @@ object APILayer {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ==================== Notification Operations ====================
|
||||
|
||||
suspend fun registerDevice(request: DeviceRegistrationRequest): ApiResult<DeviceRegistrationResponse> {
|
||||
val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
return notificationApi.registerDevice(token, request)
|
||||
}
|
||||
|
||||
suspend fun unregisterDevice(registrationId: String): ApiResult<Unit> {
|
||||
val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
return notificationApi.unregisterDevice(token, registrationId)
|
||||
}
|
||||
|
||||
suspend fun getNotificationPreferences(): ApiResult<NotificationPreference> {
|
||||
val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
return notificationApi.getNotificationPreferences(token)
|
||||
}
|
||||
|
||||
suspend fun updateNotificationPreferences(request: UpdateNotificationPreferencesRequest): ApiResult<NotificationPreference> {
|
||||
val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
return notificationApi.updateNotificationPreferences(token, request)
|
||||
}
|
||||
|
||||
suspend fun getNotificationHistory(): ApiResult<List<Notification>> {
|
||||
val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
return notificationApi.getNotificationHistory(token)
|
||||
}
|
||||
|
||||
suspend fun markNotificationAsRead(notificationId: Int): ApiResult<Notification> {
|
||||
val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
return notificationApi.markNotificationAsRead(token, notificationId)
|
||||
}
|
||||
|
||||
suspend fun markAllNotificationsAsRead(): ApiResult<Map<String, Int>> {
|
||||
val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
return notificationApi.markAllNotificationsAsRead(token)
|
||||
}
|
||||
|
||||
suspend fun getUnreadCount(): ApiResult<UnreadCountResponse> {
|
||||
val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
return notificationApi.getUnreadCount(token)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
// MARK: - Field Focus Enum
|
||||
enum ContractorFormField: Hashable {
|
||||
case name, company, phone, email, secondaryPhone, specialty, licenseNumber, website
|
||||
case address, city, state, zipCode, notes
|
||||
}
|
||||
|
||||
// MARK: - Contractor Form Sheet
|
||||
struct ContractorFormSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var viewModel = ContractorViewModel()
|
||||
@@ -25,207 +32,33 @@ struct ContractorFormSheet: View {
|
||||
@State private var isFavorite = false
|
||||
|
||||
@State private var showingSpecialtyPicker = false
|
||||
@FocusState private var focusedField: Field?
|
||||
@FocusState private var focusedField: ContractorFormField?
|
||||
|
||||
// Lookups from DataCache
|
||||
@State private var contractorSpecialties: [ContractorSpecialty] = []
|
||||
|
||||
var specialties: [String] {
|
||||
private var specialties: [String] {
|
||||
contractorSpecialties.map { $0.name }
|
||||
}
|
||||
|
||||
enum Field: Hashable {
|
||||
case name, company, phone, email, secondaryPhone, specialty, licenseNumber, website
|
||||
case address, city, state, zipCode, notes
|
||||
private var canSave: Bool {
|
||||
!name.isEmpty && !phone.isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
AppColors.background.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: AppSpacing.lg) {
|
||||
// Basic Information
|
||||
SectionHeader(title: "Basic Information")
|
||||
|
||||
VStack(spacing: AppSpacing.sm) {
|
||||
FormTextField(
|
||||
title: "Name *",
|
||||
text: $name,
|
||||
icon: "person",
|
||||
focused: $focusedField,
|
||||
field: .name
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
title: "Company",
|
||||
text: $company,
|
||||
icon: "building.2",
|
||||
focused: $focusedField,
|
||||
field: .company
|
||||
)
|
||||
}
|
||||
|
||||
// Contact Information
|
||||
SectionHeader(title: "Contact Information")
|
||||
|
||||
VStack(spacing: AppSpacing.sm) {
|
||||
FormTextField(
|
||||
title: "Phone *",
|
||||
text: $phone,
|
||||
icon: "phone",
|
||||
keyboardType: .phonePad,
|
||||
focused: $focusedField,
|
||||
field: .phone
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
title: "Email",
|
||||
text: $email,
|
||||
icon: "envelope",
|
||||
keyboardType: .emailAddress,
|
||||
focused: $focusedField,
|
||||
field: .email
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
title: "Secondary Phone",
|
||||
text: $secondaryPhone,
|
||||
icon: "phone",
|
||||
keyboardType: .phonePad,
|
||||
focused: $focusedField,
|
||||
field: .secondaryPhone
|
||||
)
|
||||
}
|
||||
|
||||
// Business Details
|
||||
SectionHeader(title: "Business Details")
|
||||
|
||||
VStack(spacing: AppSpacing.sm) {
|
||||
// Specialty Picker
|
||||
Button(action: { showingSpecialtyPicker = true }) {
|
||||
HStack {
|
||||
Image(systemName: "wrench.and.screwdriver")
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
.frame(width: 20)
|
||||
|
||||
Text(specialty.isEmpty ? "Specialty" : specialty)
|
||||
.foregroundColor(specialty.isEmpty ? AppColors.textTertiary : AppColors.textPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption)
|
||||
.foregroundColor(AppColors.textTertiary)
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
.background(AppColors.surfaceSecondary)
|
||||
.cornerRadius(AppRadius.md)
|
||||
}
|
||||
|
||||
FormTextField(
|
||||
title: "License Number",
|
||||
text: $licenseNumber,
|
||||
icon: "doc.badge",
|
||||
focused: $focusedField,
|
||||
field: .licenseNumber
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
title: "Website",
|
||||
text: $website,
|
||||
icon: "globe",
|
||||
keyboardType: .URL,
|
||||
focused: $focusedField,
|
||||
field: .website
|
||||
)
|
||||
}
|
||||
|
||||
// Address
|
||||
SectionHeader(title: "Address")
|
||||
|
||||
VStack(spacing: AppSpacing.sm) {
|
||||
FormTextField(
|
||||
title: "Street Address",
|
||||
text: $address,
|
||||
icon: "mappin",
|
||||
focused: $focusedField,
|
||||
field: .address
|
||||
)
|
||||
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
FormTextField(
|
||||
title: "City",
|
||||
text: $city,
|
||||
focused: $focusedField,
|
||||
field: .city
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
title: "State",
|
||||
text: $state,
|
||||
focused: $focusedField,
|
||||
field: .state
|
||||
)
|
||||
.frame(maxWidth: 100)
|
||||
}
|
||||
|
||||
FormTextField(
|
||||
title: "ZIP Code",
|
||||
text: $zipCode,
|
||||
keyboardType: .numberPad,
|
||||
focused: $focusedField,
|
||||
field: .zipCode
|
||||
)
|
||||
}
|
||||
|
||||
// Notes
|
||||
SectionHeader(title: "Notes")
|
||||
|
||||
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
|
||||
HStack {
|
||||
Image(systemName: "note.text")
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
.frame(width: 20)
|
||||
|
||||
Text("Private Notes")
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
}
|
||||
|
||||
TextEditor(text: $notes)
|
||||
.frame(height: 100)
|
||||
.padding(AppSpacing.sm)
|
||||
.background(AppColors.surfaceSecondary)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.focused($focusedField, equals: .notes)
|
||||
}
|
||||
|
||||
// Favorite Toggle
|
||||
Toggle(isOn: $isFavorite) {
|
||||
HStack {
|
||||
Image(systemName: "star.fill")
|
||||
.foregroundColor(isFavorite ? AppColors.warning : AppColors.textSecondary)
|
||||
Text("Mark as Favorite")
|
||||
.font(.body)
|
||||
.foregroundColor(AppColors.textPrimary)
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
.background(AppColors.surface)
|
||||
.cornerRadius(AppRadius.md)
|
||||
|
||||
// Error Message
|
||||
if let error = viewModel.errorMessage {
|
||||
Text(error)
|
||||
.font(.callout)
|
||||
.foregroundColor(AppColors.error)
|
||||
.padding(AppSpacing.sm)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(AppColors.error.opacity(0.1))
|
||||
.cornerRadius(AppRadius.md)
|
||||
}
|
||||
basicInformationSection
|
||||
contactInformationSection
|
||||
businessDetailsSection
|
||||
addressSection
|
||||
notesSection
|
||||
favoriteToggle
|
||||
errorMessage
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
}
|
||||
@@ -234,22 +67,11 @@ struct ContractorFormSheet: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
cancelButton
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: saveContractor) {
|
||||
if viewModel.isCreating || viewModel.isUpdating {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text(contractor == nil ? "Add" : "Save")
|
||||
.foregroundColor(canSave ? AppColors.primary : AppColors.textTertiary)
|
||||
}
|
||||
}
|
||||
.disabled(!canSave || viewModel.isCreating || viewModel.isUpdating)
|
||||
saveButton
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingSpecialtyPicker) {
|
||||
@@ -265,10 +87,231 @@ struct ContractorFormSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
!name.isEmpty && !phone.isEmpty
|
||||
// MARK: - Toolbar Items
|
||||
|
||||
private var cancelButton: some View {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
}
|
||||
|
||||
private var saveButton: some View {
|
||||
Button(action: saveContractor) {
|
||||
if viewModel.isCreating || viewModel.isUpdating {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text(contractor == nil ? "Add" : "Save")
|
||||
.foregroundColor(canSave ? AppColors.primary : AppColors.textTertiary)
|
||||
}
|
||||
}
|
||||
.disabled(!canSave || viewModel.isCreating || viewModel.isUpdating)
|
||||
}
|
||||
|
||||
// MARK: - Form Sections
|
||||
|
||||
private var basicInformationSection: some View {
|
||||
VStack(spacing: AppSpacing.sm) {
|
||||
SectionHeader(title: "Basic Information")
|
||||
|
||||
FormTextField(
|
||||
title: "Name *",
|
||||
text: $name,
|
||||
icon: "person",
|
||||
focused: $focusedField,
|
||||
field: .name
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
title: "Company",
|
||||
text: $company,
|
||||
icon: "building.2",
|
||||
focused: $focusedField,
|
||||
field: .company
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var contactInformationSection: some View {
|
||||
VStack(spacing: AppSpacing.sm) {
|
||||
SectionHeader(title: "Contact Information")
|
||||
|
||||
FormTextField(
|
||||
title: "Phone *",
|
||||
text: $phone,
|
||||
icon: "phone",
|
||||
keyboardType: .phonePad,
|
||||
focused: $focusedField,
|
||||
field: .phone
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
title: "Email",
|
||||
text: $email,
|
||||
icon: "envelope",
|
||||
keyboardType: .emailAddress,
|
||||
focused: $focusedField,
|
||||
field: .email
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
title: "Secondary Phone",
|
||||
text: $secondaryPhone,
|
||||
icon: "phone",
|
||||
keyboardType: .phonePad,
|
||||
focused: $focusedField,
|
||||
field: .secondaryPhone
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var businessDetailsSection: some View {
|
||||
VStack(spacing: AppSpacing.sm) {
|
||||
SectionHeader(title: "Business Details")
|
||||
|
||||
specialtyPickerButton
|
||||
|
||||
FormTextField(
|
||||
title: "License Number",
|
||||
text: $licenseNumber,
|
||||
icon: "doc.badge",
|
||||
focused: $focusedField,
|
||||
field: .licenseNumber
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
title: "Website",
|
||||
text: $website,
|
||||
icon: "globe",
|
||||
keyboardType: .URL,
|
||||
focused: $focusedField,
|
||||
field: .website
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var specialtyPickerButton: some View {
|
||||
Button(action: { showingSpecialtyPicker = true }) {
|
||||
HStack {
|
||||
Image(systemName: "wrench.and.screwdriver")
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
.frame(width: 20)
|
||||
|
||||
Text(specialty.isEmpty ? "Specialty" : specialty)
|
||||
.foregroundColor(specialty.isEmpty ? AppColors.textTertiary : AppColors.textPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption)
|
||||
.foregroundColor(AppColors.textTertiary)
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
.background(AppColors.surface)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppRadius.md)
|
||||
.stroke(AppColors.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var addressSection: some View {
|
||||
VStack(spacing: AppSpacing.sm) {
|
||||
SectionHeader(title: "Address")
|
||||
|
||||
FormTextField(
|
||||
title: "Street Address",
|
||||
text: $address,
|
||||
icon: "mappin",
|
||||
focused: $focusedField,
|
||||
field: .address
|
||||
)
|
||||
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
FormTextField(
|
||||
title: "City",
|
||||
text: $city,
|
||||
focused: $focusedField,
|
||||
field: .city
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
title: "State",
|
||||
text: $state,
|
||||
focused: $focusedField,
|
||||
field: .state
|
||||
)
|
||||
.frame(maxWidth: 100)
|
||||
}
|
||||
|
||||
FormTextField(
|
||||
title: "ZIP Code",
|
||||
text: $zipCode,
|
||||
keyboardType: .numberPad,
|
||||
focused: $focusedField,
|
||||
field: .zipCode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var notesSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
|
||||
SectionHeader(title: "Notes")
|
||||
|
||||
HStack {
|
||||
Image(systemName: "note.text")
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
.frame(width: 20)
|
||||
|
||||
Text("Private Notes")
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
}
|
||||
|
||||
TextEditor(text: $notes)
|
||||
.frame(height: 100)
|
||||
.padding(AppSpacing.sm)
|
||||
.background(AppColors.surface)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppRadius.md)
|
||||
.stroke(AppColors.border, lineWidth: 1)
|
||||
)
|
||||
.focused($focusedField, equals: .notes)
|
||||
}
|
||||
}
|
||||
|
||||
private var favoriteToggle: some View {
|
||||
Toggle(isOn: $isFavorite) {
|
||||
HStack {
|
||||
Image(systemName: "star.fill")
|
||||
.foregroundColor(isFavorite ? AppColors.warning : AppColors.textSecondary)
|
||||
Text("Mark as Favorite")
|
||||
.font(.body)
|
||||
.foregroundColor(AppColors.textPrimary)
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
.background(AppColors.surface)
|
||||
.cornerRadius(AppRadius.md)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var errorMessage: some View {
|
||||
if let error = viewModel.errorMessage {
|
||||
Text(error)
|
||||
.font(.callout)
|
||||
.foregroundColor(AppColors.error)
|
||||
.padding(AppSpacing.sm)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(AppColors.error.opacity(0.1))
|
||||
.cornerRadius(AppRadius.md)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Loading
|
||||
|
||||
private func loadContractorData() {
|
||||
guard let contractor = contractor else { return }
|
||||
|
||||
@@ -291,11 +334,15 @@ struct ContractorFormSheet: View {
|
||||
private func loadContractorSpecialties() {
|
||||
Task {
|
||||
await MainActor.run {
|
||||
self.contractorSpecialties = DataCache.shared.contractorSpecialties.value as! [ContractorSpecialty]
|
||||
if let specialties = DataCache.shared.contractorSpecialties.value as? [ContractorSpecialty] {
|
||||
self.contractorSpecialties = specialties
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Save Action
|
||||
|
||||
private func saveContractor() {
|
||||
if let contractor = contractor {
|
||||
// Update existing contractor
|
||||
@@ -373,8 +420,8 @@ struct FormTextField: View {
|
||||
@Binding var text: String
|
||||
var icon: String? = nil
|
||||
var keyboardType: UIKeyboardType = .default
|
||||
var focused: FocusState<ContractorFormSheet.Field?>.Binding
|
||||
var field: ContractorFormSheet.Field
|
||||
var focused: FocusState<ContractorFormField?>.Binding
|
||||
var field: ContractorFormField
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
|
||||
@@ -398,8 +445,12 @@ struct FormTextField: View {
|
||||
.keyboardType(keyboardType)
|
||||
.autocapitalization(keyboardType == .emailAddress ? .none : .words)
|
||||
.padding(AppSpacing.md)
|
||||
.background(AppColors.surfaceSecondary)
|
||||
.background(AppColors.surface)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppRadius.md)
|
||||
.stroke(AppColors.border, lineWidth: 1)
|
||||
)
|
||||
.focused(focused, equals: field)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
import ComposeApp
|
||||
|
||||
@@ -8,8 +9,6 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
@Published var deviceToken: String?
|
||||
@Published var notificationPermissionGranted = false
|
||||
|
||||
// private let notificationApi = NotificationApi()
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
@@ -19,26 +18,25 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
func requestNotificationPermission() async -> Bool {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
|
||||
// do {
|
||||
// let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||
// notificationPermissionGranted = granted
|
||||
//
|
||||
// if granted {
|
||||
// print("✅ Notification permission granted")
|
||||
// // Register for remote notifications on main thread
|
||||
// await MainActor.run {
|
||||
// UIApplication.shared.registerForRemoteNotifications()
|
||||
// }
|
||||
// } else {
|
||||
// print("❌ Notification permission denied")
|
||||
// }
|
||||
//
|
||||
// return granted
|
||||
// } catch {
|
||||
// print("❌ Error requesting notification permission: \(error)")
|
||||
// return false
|
||||
// }
|
||||
return true
|
||||
do {
|
||||
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||
notificationPermissionGranted = granted
|
||||
|
||||
if granted {
|
||||
print("✅ Notification permission granted")
|
||||
// Register for remote notifications on main thread
|
||||
await MainActor.run {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
} else {
|
||||
print("❌ Notification permission denied")
|
||||
}
|
||||
|
||||
return granted
|
||||
} catch {
|
||||
print("❌ Error requesting notification permission: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Token Management
|
||||
@@ -61,26 +59,29 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
// MARK: - Backend Registration
|
||||
|
||||
private func registerDeviceWithBackend(token: String) async {
|
||||
guard let authToken = TokenStorage.shared.getToken() else {
|
||||
guard TokenStorage.shared.getToken() != nil else {
|
||||
print("⚠️ No auth token available, will register device after login")
|
||||
return
|
||||
}
|
||||
|
||||
// let request = DeviceRegistrationRequest(
|
||||
// registrationId: token,
|
||||
// platform: "ios"
|
||||
// )
|
||||
//
|
||||
// let result = await notificationApi.registerDevice(token: authToken, request: request)
|
||||
//
|
||||
// switch result {
|
||||
// case let success as ApiResultSuccess<DeviceRegistrationResponse>:
|
||||
// print("✅ Device registered successfully: \(success.data)")
|
||||
// case let error as ApiResultError:
|
||||
// print("❌ Failed to register device: \(error.message)")
|
||||
// default:
|
||||
// print("⚠️ Unexpected result type from device registration")
|
||||
// }
|
||||
let request = DeviceRegistrationRequest(
|
||||
registrationId: token,
|
||||
platform: "ios"
|
||||
)
|
||||
|
||||
do {
|
||||
let result = try await APILayer.shared.registerDevice(request: request)
|
||||
|
||||
if let success = result as? ApiResultSuccess<DeviceRegistrationResponse> {
|
||||
print("✅ Device registered successfully: \(success.data)")
|
||||
} else if let error = result as? ApiResultError {
|
||||
print("❌ Failed to register device: \(error.message)")
|
||||
} else {
|
||||
print("⚠️ Unexpected result type from device registration")
|
||||
}
|
||||
} catch {
|
||||
print("❌ Error registering device: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handle Notifications
|
||||
@@ -92,10 +93,10 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
if let notificationId = userInfo["notification_id"] as? String {
|
||||
print("Notification ID: \(notificationId)")
|
||||
|
||||
// Mark as read when user taps notification
|
||||
Task {
|
||||
await markNotificationAsRead(notificationId: notificationId)
|
||||
}
|
||||
// // Mark as read when user taps notification
|
||||
// Task {
|
||||
// await markNotificationAsRead(notificationId: notificationId)
|
||||
// }
|
||||
}
|
||||
|
||||
if let type = userInfo["type"] as? String {
|
||||
@@ -129,80 +130,79 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func markNotificationAsRead(notificationId: String) async {
|
||||
guard let authToken = TokenStorage.shared.getToken(),
|
||||
let notificationIdInt = Int32(notificationId) else {
|
||||
return
|
||||
}
|
||||
|
||||
// let result = await notificationApi.markNotificationAsRead(
|
||||
// token: authToken,
|
||||
// notificationId: notificationIdInt
|
||||
// )
|
||||
//
|
||||
// switch result {
|
||||
// case is ApiResultSuccess<Notification>:
|
||||
// print("✅ Notification marked as read")
|
||||
// case let error as ApiResultError:
|
||||
// print("❌ Failed to mark notification as read: \(error.message)")
|
||||
// default:
|
||||
// break
|
||||
// private func markNotificationAsRead(notificationId: String) async {
|
||||
// guard TokenStorage.shared.getToken() != nil,
|
||||
// let notificationIdInt = Int32(notificationId) else {
|
||||
// return
|
||||
// }
|
||||
}
|
||||
//
|
||||
// do {
|
||||
// let result = try await APILayer.shared.markNotificationAsRead(notificationId: notificationIdInt)
|
||||
//
|
||||
// if result is ApiResultSuccess<Notification> {
|
||||
// print("✅ Notification marked as read")
|
||||
// } else if let error = result as? ApiResultError {
|
||||
// print("❌ Failed to mark notification as read: \(error.message)")
|
||||
// }
|
||||
// } catch {
|
||||
// print("❌ Error marking notification as read: \(error.localizedDescription)")
|
||||
// }
|
||||
// }
|
||||
|
||||
// MARK: - Notification Preferences
|
||||
|
||||
func updateNotificationPreferences(_ preferences: UpdateNotificationPreferencesRequest) async -> Bool {
|
||||
guard let authToken = TokenStorage.shared.getToken() else {
|
||||
guard TokenStorage.shared.getToken() != nil else {
|
||||
print("⚠️ No auth token available")
|
||||
return false
|
||||
}
|
||||
|
||||
// let result = await notificationApi.updateNotificationPreferences(
|
||||
// token: authToken,
|
||||
// request: preferences
|
||||
// )
|
||||
//
|
||||
// switch result {
|
||||
// case is ApiResultSuccess<NotificationPreference>:
|
||||
// print("✅ Notification preferences updated")
|
||||
// return true
|
||||
// case let error as ApiResultError:
|
||||
// print("❌ Failed to update preferences: \(error.message)")
|
||||
// return false
|
||||
// default:
|
||||
// return false
|
||||
// }
|
||||
return false
|
||||
do {
|
||||
let result = try await APILayer.shared.updateNotificationPreferences(request: preferences)
|
||||
|
||||
if result is ApiResultSuccess<NotificationPreference> {
|
||||
print("✅ Notification preferences updated")
|
||||
return true
|
||||
} else if let error = result as? ApiResultError {
|
||||
print("❌ Failed to update preferences: \(error.message)")
|
||||
return false
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
print("❌ Error updating notification preferences: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func getNotificationPreferences() async -> NotificationPreference? {
|
||||
guard let authToken = TokenStorage.shared.getToken() else {
|
||||
guard TokenStorage.shared.getToken() != nil else {
|
||||
print("⚠️ No auth token available")
|
||||
return nil
|
||||
}
|
||||
|
||||
// let result = await notificationApi.getNotificationPreferences(token: authToken)
|
||||
//
|
||||
// switch result {
|
||||
// case let success as ApiResultSuccess<NotificationPreference>:
|
||||
// return success.data
|
||||
// case let error as ApiResultError:
|
||||
// print("❌ Failed to get preferences: \(error.message)")
|
||||
// return nil
|
||||
// default:
|
||||
// return nil
|
||||
// }
|
||||
return nil
|
||||
do {
|
||||
let result = try await APILayer.shared.getNotificationPreferences()
|
||||
|
||||
if let success = result as? ApiResultSuccess<NotificationPreference> {
|
||||
return success.data
|
||||
} else if let error = result as? ApiResultError {
|
||||
print("❌ Failed to get preferences: \(error.message)")
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
} catch {
|
||||
print("❌ Error getting notification preferences: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Badge Management
|
||||
|
||||
// func clearBadge() {
|
||||
// UIApplication.shared.applicationIconBadgeNumber = 0
|
||||
// }
|
||||
//
|
||||
// func setBadge(count: Int) {
|
||||
// UIApplication.shared.applicationIconBadgeNumber = count
|
||||
// }
|
||||
func clearBadge() {
|
||||
UIApplication.shared.applicationIconBadgeNumber = 0
|
||||
}
|
||||
|
||||
func setBadge(count: Int) {
|
||||
UIApplication.shared.applicationIconBadgeNumber = count
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
// MARK: - Field Focus Enum
|
||||
enum TaskFormField {
|
||||
case title, description, intervalDays, estimatedCost
|
||||
}
|
||||
|
||||
// MARK: - Task Form View
|
||||
struct TaskFormView: View {
|
||||
let residenceId: Int32?
|
||||
let residences: [Residence]?
|
||||
@Binding var isPresented: Bool
|
||||
@StateObject private var viewModel = TaskViewModel()
|
||||
@FocusState private var focusedField: Field?
|
||||
@FocusState private var focusedField: TaskFormField?
|
||||
|
||||
private var needsResidenceSelection: Bool {
|
||||
residenceId == nil
|
||||
@@ -17,7 +23,7 @@ struct TaskFormView: View {
|
||||
@State private var taskFrequencies: [TaskFrequency] = []
|
||||
@State private var taskPriorities: [TaskPriority] = []
|
||||
@State private var taskStatuses: [TaskStatus] = []
|
||||
@State private var isLoadingLookups: Bool = false
|
||||
@State private var isLoadingLookups: Bool = true
|
||||
|
||||
// Form fields
|
||||
@State private var selectedResidence: Residence?
|
||||
@@ -35,19 +41,9 @@ struct TaskFormView: View {
|
||||
@State private var titleError: String = ""
|
||||
@State private var residenceError: String = ""
|
||||
|
||||
enum Field {
|
||||
case title, description, intervalDays, estimatedCost
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
if isLoadingLookups {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text("Loading...")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Form {
|
||||
// Residence Picker (only if needed)
|
||||
if needsResidenceSelection, let residences = residences {
|
||||
@@ -138,49 +134,68 @@ struct TaskFormView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Add Task")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
.disabled(isLoadingLookups)
|
||||
.blur(radius: isLoadingLookups ? 3 : 0)
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
submitForm()
|
||||
}
|
||||
.disabled(viewModel.isLoading)
|
||||
if isLoadingLookups {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
Text("Loading...")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(uiColor: .systemBackground).opacity(0.8))
|
||||
}
|
||||
.onAppear {
|
||||
loadLookups()
|
||||
}
|
||||
.onChange(of: viewModel.taskCreated) { created in
|
||||
if created {
|
||||
}
|
||||
.navigationTitle("Add Task")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
isPresented = false
|
||||
}
|
||||
.disabled(isLoadingLookups)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
submitForm()
|
||||
}
|
||||
.disabled(viewModel.isLoading || isLoadingLookups)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await loadLookups()
|
||||
}
|
||||
.onChange(of: viewModel.taskCreated) { created in
|
||||
if created {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadLookups() {
|
||||
Task {
|
||||
isLoadingLookups = true
|
||||
|
||||
// Load all lookups from DataCache
|
||||
await MainActor.run {
|
||||
self.taskCategories = DataCache.shared.taskCategories.value as! [TaskCategory]
|
||||
self.taskFrequencies = DataCache.shared.taskFrequencies.value as! [TaskFrequency]
|
||||
self.taskPriorities = DataCache.shared.taskPriorities.value as! [TaskPriority]
|
||||
self.taskStatuses = DataCache.shared.taskStatuses.value as! [TaskStatus]
|
||||
self.isLoadingLookups = false
|
||||
}
|
||||
|
||||
setDefaults()
|
||||
private func loadLookups() async {
|
||||
// Load all lookups from DataCache
|
||||
if let categories = DataCache.shared.taskCategories.value as? [TaskCategory] {
|
||||
taskCategories = categories
|
||||
}
|
||||
|
||||
if let frequencies = DataCache.shared.taskFrequencies.value as? [TaskFrequency] {
|
||||
taskFrequencies = frequencies
|
||||
}
|
||||
|
||||
if let priorities = DataCache.shared.taskPriorities.value as? [TaskPriority] {
|
||||
taskPriorities = priorities
|
||||
}
|
||||
|
||||
if let statuses = DataCache.shared.taskStatuses.value as? [TaskStatus] {
|
||||
taskStatuses = statuses
|
||||
}
|
||||
|
||||
setDefaults()
|
||||
isLoadingLookups = false
|
||||
}
|
||||
|
||||
private func setDefaults() {
|
||||
|
||||
Reference in New Issue
Block a user