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 contractorApi = ContractorApi()
|
||||||
private val authApi = AuthApi()
|
private val authApi = AuthApi()
|
||||||
private val lookupsApi = LookupsApi()
|
private val lookupsApi = LookupsApi()
|
||||||
|
private val notificationApi = NotificationApi()
|
||||||
private val prefetchManager = DataPrefetchManager.getInstance()
|
private val prefetchManager = DataPrefetchManager.getInstance()
|
||||||
|
|
||||||
// ==================== Lookups Operations ====================
|
// ==================== Lookups Operations ====================
|
||||||
@@ -852,4 +853,46 @@ object APILayer {
|
|||||||
|
|
||||||
return result
|
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 SwiftUI
|
||||||
import ComposeApp
|
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 {
|
struct ContractorFormSheet: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@StateObject private var viewModel = ContractorViewModel()
|
@StateObject private var viewModel = ContractorViewModel()
|
||||||
@@ -25,207 +32,33 @@ struct ContractorFormSheet: View {
|
|||||||
@State private var isFavorite = false
|
@State private var isFavorite = false
|
||||||
|
|
||||||
@State private var showingSpecialtyPicker = false
|
@State private var showingSpecialtyPicker = false
|
||||||
@FocusState private var focusedField: Field?
|
@FocusState private var focusedField: ContractorFormField?
|
||||||
|
|
||||||
// Lookups from DataCache
|
// Lookups from DataCache
|
||||||
@State private var contractorSpecialties: [ContractorSpecialty] = []
|
@State private var contractorSpecialties: [ContractorSpecialty] = []
|
||||||
|
|
||||||
var specialties: [String] {
|
private var specialties: [String] {
|
||||||
contractorSpecialties.map { $0.name }
|
contractorSpecialties.map { $0.name }
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Field: Hashable {
|
private var canSave: Bool {
|
||||||
case name, company, phone, email, secondaryPhone, specialty, licenseNumber, website
|
!name.isEmpty && !phone.isEmpty
|
||||||
case address, city, state, zipCode, notes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
AppColors.background.ignoresSafeArea()
|
AppColors.background.ignoresSafeArea()
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: AppSpacing.lg) {
|
VStack(spacing: AppSpacing.lg) {
|
||||||
// Basic Information
|
basicInformationSection
|
||||||
SectionHeader(title: "Basic Information")
|
contactInformationSection
|
||||||
|
businessDetailsSection
|
||||||
VStack(spacing: AppSpacing.sm) {
|
addressSection
|
||||||
FormTextField(
|
notesSection
|
||||||
title: "Name *",
|
favoriteToggle
|
||||||
text: $name,
|
errorMessage
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(AppSpacing.md)
|
.padding(AppSpacing.md)
|
||||||
}
|
}
|
||||||
@@ -234,22 +67,11 @@ struct ContractorFormSheet: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
Button("Cancel") {
|
cancelButton
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
.foregroundColor(AppColors.textSecondary)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button(action: saveContractor) {
|
saveButton
|
||||||
if viewModel.isCreating || viewModel.isUpdating {
|
|
||||||
ProgressView()
|
|
||||||
} else {
|
|
||||||
Text(contractor == nil ? "Add" : "Save")
|
|
||||||
.foregroundColor(canSave ? AppColors.primary : AppColors.textTertiary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(!canSave || viewModel.isCreating || viewModel.isUpdating)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingSpecialtyPicker) {
|
.sheet(isPresented: $showingSpecialtyPicker) {
|
||||||
@@ -265,10 +87,231 @@ struct ContractorFormSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var canSave: Bool {
|
// MARK: - Toolbar Items
|
||||||
!name.isEmpty && !phone.isEmpty
|
|
||||||
|
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() {
|
private func loadContractorData() {
|
||||||
guard let contractor = contractor else { return }
|
guard let contractor = contractor else { return }
|
||||||
|
|
||||||
@@ -291,11 +334,15 @@ struct ContractorFormSheet: View {
|
|||||||
private func loadContractorSpecialties() {
|
private func loadContractorSpecialties() {
|
||||||
Task {
|
Task {
|
||||||
await MainActor.run {
|
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() {
|
private func saveContractor() {
|
||||||
if let contractor = contractor {
|
if let contractor = contractor {
|
||||||
// Update existing contractor
|
// Update existing contractor
|
||||||
@@ -373,8 +420,8 @@ struct FormTextField: View {
|
|||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
var icon: String? = nil
|
var icon: String? = nil
|
||||||
var keyboardType: UIKeyboardType = .default
|
var keyboardType: UIKeyboardType = .default
|
||||||
var focused: FocusState<ContractorFormSheet.Field?>.Binding
|
var focused: FocusState<ContractorFormField?>.Binding
|
||||||
var field: ContractorFormSheet.Field
|
var field: ContractorFormField
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
|
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
|
||||||
@@ -398,8 +445,12 @@ struct FormTextField: View {
|
|||||||
.keyboardType(keyboardType)
|
.keyboardType(keyboardType)
|
||||||
.autocapitalization(keyboardType == .emailAddress ? .none : .words)
|
.autocapitalization(keyboardType == .emailAddress ? .none : .words)
|
||||||
.padding(AppSpacing.md)
|
.padding(AppSpacing.md)
|
||||||
.background(AppColors.surfaceSecondary)
|
.background(AppColors.surface)
|
||||||
.cornerRadius(AppRadius.md)
|
.cornerRadius(AppRadius.md)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: AppRadius.md)
|
||||||
|
.stroke(AppColors.border, lineWidth: 1)
|
||||||
|
)
|
||||||
.focused(focused, equals: field)
|
.focused(focused, equals: field)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import UIKit
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
import ComposeApp
|
import ComposeApp
|
||||||
|
|
||||||
@@ -8,8 +9,6 @@ class PushNotificationManager: NSObject, ObservableObject {
|
|||||||
@Published var deviceToken: String?
|
@Published var deviceToken: String?
|
||||||
@Published var notificationPermissionGranted = false
|
@Published var notificationPermissionGranted = false
|
||||||
|
|
||||||
// private let notificationApi = NotificationApi()
|
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
}
|
}
|
||||||
@@ -19,26 +18,25 @@ class PushNotificationManager: NSObject, ObservableObject {
|
|||||||
func requestNotificationPermission() async -> Bool {
|
func requestNotificationPermission() async -> Bool {
|
||||||
let center = UNUserNotificationCenter.current()
|
let center = UNUserNotificationCenter.current()
|
||||||
|
|
||||||
// do {
|
do {
|
||||||
// let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||||
// notificationPermissionGranted = granted
|
notificationPermissionGranted = granted
|
||||||
//
|
|
||||||
// if granted {
|
if granted {
|
||||||
// print("✅ Notification permission granted")
|
print("✅ Notification permission granted")
|
||||||
// // Register for remote notifications on main thread
|
// Register for remote notifications on main thread
|
||||||
// await MainActor.run {
|
await MainActor.run {
|
||||||
// UIApplication.shared.registerForRemoteNotifications()
|
UIApplication.shared.registerForRemoteNotifications()
|
||||||
// }
|
}
|
||||||
// } else {
|
} else {
|
||||||
// print("❌ Notification permission denied")
|
print("❌ Notification permission denied")
|
||||||
// }
|
}
|
||||||
//
|
|
||||||
// return granted
|
return granted
|
||||||
// } catch {
|
} catch {
|
||||||
// print("❌ Error requesting notification permission: \(error)")
|
print("❌ Error requesting notification permission: \(error)")
|
||||||
// return false
|
return false
|
||||||
// }
|
}
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Token Management
|
// MARK: - Token Management
|
||||||
@@ -61,26 +59,29 @@ class PushNotificationManager: NSObject, ObservableObject {
|
|||||||
// MARK: - Backend Registration
|
// MARK: - Backend Registration
|
||||||
|
|
||||||
private func registerDeviceWithBackend(token: String) async {
|
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")
|
print("⚠️ No auth token available, will register device after login")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// let request = DeviceRegistrationRequest(
|
let request = DeviceRegistrationRequest(
|
||||||
// registrationId: token,
|
registrationId: token,
|
||||||
// platform: "ios"
|
platform: "ios"
|
||||||
// )
|
)
|
||||||
//
|
|
||||||
// let result = await notificationApi.registerDevice(token: authToken, request: request)
|
do {
|
||||||
//
|
let result = try await APILayer.shared.registerDevice(request: request)
|
||||||
// switch result {
|
|
||||||
// case let success as ApiResultSuccess<DeviceRegistrationResponse>:
|
if let success = result as? ApiResultSuccess<DeviceRegistrationResponse> {
|
||||||
// print("✅ Device registered successfully: \(success.data)")
|
print("✅ Device registered successfully: \(success.data)")
|
||||||
// case let error as ApiResultError:
|
} else if let error = result as? ApiResultError {
|
||||||
// print("❌ Failed to register device: \(error.message)")
|
print("❌ Failed to register device: \(error.message)")
|
||||||
// default:
|
} else {
|
||||||
// print("⚠️ Unexpected result type from device registration")
|
print("⚠️ Unexpected result type from device registration")
|
||||||
// }
|
}
|
||||||
|
} catch {
|
||||||
|
print("❌ Error registering device: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Handle Notifications
|
// MARK: - Handle Notifications
|
||||||
@@ -92,10 +93,10 @@ class PushNotificationManager: NSObject, ObservableObject {
|
|||||||
if let notificationId = userInfo["notification_id"] as? String {
|
if let notificationId = userInfo["notification_id"] as? String {
|
||||||
print("Notification ID: \(notificationId)")
|
print("Notification ID: \(notificationId)")
|
||||||
|
|
||||||
// Mark as read when user taps notification
|
// // Mark as read when user taps notification
|
||||||
Task {
|
// Task {
|
||||||
await markNotificationAsRead(notificationId: notificationId)
|
// await markNotificationAsRead(notificationId: notificationId)
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
if let type = userInfo["type"] as? String {
|
if let type = userInfo["type"] as? String {
|
||||||
@@ -129,80 +130,79 @@ class PushNotificationManager: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func markNotificationAsRead(notificationId: String) async {
|
// private func markNotificationAsRead(notificationId: String) async {
|
||||||
guard let authToken = TokenStorage.shared.getToken(),
|
// guard TokenStorage.shared.getToken() != nil,
|
||||||
let notificationIdInt = Int32(notificationId) else {
|
// let notificationIdInt = Int32(notificationId) else {
|
||||||
return
|
// 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
|
|
||||||
// }
|
// }
|
||||||
}
|
//
|
||||||
|
// 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
|
// MARK: - Notification Preferences
|
||||||
|
|
||||||
func updateNotificationPreferences(_ preferences: UpdateNotificationPreferencesRequest) async -> Bool {
|
func updateNotificationPreferences(_ preferences: UpdateNotificationPreferencesRequest) async -> Bool {
|
||||||
guard let authToken = TokenStorage.shared.getToken() else {
|
guard TokenStorage.shared.getToken() != nil else {
|
||||||
print("⚠️ No auth token available")
|
print("⚠️ No auth token available")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// let result = await notificationApi.updateNotificationPreferences(
|
do {
|
||||||
// token: authToken,
|
let result = try await APILayer.shared.updateNotificationPreferences(request: preferences)
|
||||||
// request: preferences
|
|
||||||
// )
|
if result is ApiResultSuccess<NotificationPreference> {
|
||||||
//
|
print("✅ Notification preferences updated")
|
||||||
// switch result {
|
return true
|
||||||
// case is ApiResultSuccess<NotificationPreference>:
|
} else if let error = result as? ApiResultError {
|
||||||
// print("✅ Notification preferences updated")
|
print("❌ Failed to update preferences: \(error.message)")
|
||||||
// return true
|
return false
|
||||||
// case let error as ApiResultError:
|
}
|
||||||
// print("❌ Failed to update preferences: \(error.message)")
|
return false
|
||||||
// return false
|
} catch {
|
||||||
// default:
|
print("❌ Error updating notification preferences: \(error.localizedDescription)")
|
||||||
// return false
|
return false
|
||||||
// }
|
}
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getNotificationPreferences() async -> NotificationPreference? {
|
func getNotificationPreferences() async -> NotificationPreference? {
|
||||||
guard let authToken = TokenStorage.shared.getToken() else {
|
guard TokenStorage.shared.getToken() != nil else {
|
||||||
print("⚠️ No auth token available")
|
print("⚠️ No auth token available")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// let result = await notificationApi.getNotificationPreferences(token: authToken)
|
do {
|
||||||
//
|
let result = try await APILayer.shared.getNotificationPreferences()
|
||||||
// switch result {
|
|
||||||
// case let success as ApiResultSuccess<NotificationPreference>:
|
if let success = result as? ApiResultSuccess<NotificationPreference> {
|
||||||
// return success.data
|
return success.data
|
||||||
// case let error as ApiResultError:
|
} else if let error = result as? ApiResultError {
|
||||||
// print("❌ Failed to get preferences: \(error.message)")
|
print("❌ Failed to get preferences: \(error.message)")
|
||||||
// return nil
|
return nil
|
||||||
// default:
|
}
|
||||||
// return nil
|
return nil
|
||||||
// }
|
} catch {
|
||||||
return nil
|
print("❌ Error getting notification preferences: \(error.localizedDescription)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Badge Management
|
// MARK: - Badge Management
|
||||||
|
|
||||||
// func clearBadge() {
|
func clearBadge() {
|
||||||
// UIApplication.shared.applicationIconBadgeNumber = 0
|
UIApplication.shared.applicationIconBadgeNumber = 0
|
||||||
// }
|
}
|
||||||
//
|
|
||||||
// func setBadge(count: Int) {
|
func setBadge(count: Int) {
|
||||||
// UIApplication.shared.applicationIconBadgeNumber = count
|
UIApplication.shared.applicationIconBadgeNumber = count
|
||||||
// }
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ComposeApp
|
import ComposeApp
|
||||||
|
|
||||||
|
// MARK: - Field Focus Enum
|
||||||
|
enum TaskFormField {
|
||||||
|
case title, description, intervalDays, estimatedCost
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Task Form View
|
||||||
struct TaskFormView: View {
|
struct TaskFormView: View {
|
||||||
let residenceId: Int32?
|
let residenceId: Int32?
|
||||||
let residences: [Residence]?
|
let residences: [Residence]?
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@StateObject private var viewModel = TaskViewModel()
|
@StateObject private var viewModel = TaskViewModel()
|
||||||
@FocusState private var focusedField: Field?
|
@FocusState private var focusedField: TaskFormField?
|
||||||
|
|
||||||
private var needsResidenceSelection: Bool {
|
private var needsResidenceSelection: Bool {
|
||||||
residenceId == nil
|
residenceId == nil
|
||||||
@@ -17,7 +23,7 @@ struct TaskFormView: View {
|
|||||||
@State private var taskFrequencies: [TaskFrequency] = []
|
@State private var taskFrequencies: [TaskFrequency] = []
|
||||||
@State private var taskPriorities: [TaskPriority] = []
|
@State private var taskPriorities: [TaskPriority] = []
|
||||||
@State private var taskStatuses: [TaskStatus] = []
|
@State private var taskStatuses: [TaskStatus] = []
|
||||||
@State private var isLoadingLookups: Bool = false
|
@State private var isLoadingLookups: Bool = true
|
||||||
|
|
||||||
// Form fields
|
// Form fields
|
||||||
@State private var selectedResidence: Residence?
|
@State private var selectedResidence: Residence?
|
||||||
@@ -35,19 +41,9 @@ struct TaskFormView: View {
|
|||||||
@State private var titleError: String = ""
|
@State private var titleError: String = ""
|
||||||
@State private var residenceError: String = ""
|
@State private var residenceError: String = ""
|
||||||
|
|
||||||
enum Field {
|
|
||||||
case title, description, intervalDays, estimatedCost
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationStack {
|
||||||
if isLoadingLookups {
|
ZStack {
|
||||||
VStack(spacing: 16) {
|
|
||||||
ProgressView()
|
|
||||||
Text("Loading...")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Form {
|
Form {
|
||||||
// Residence Picker (only if needed)
|
// Residence Picker (only if needed)
|
||||||
if needsResidenceSelection, let residences = residences {
|
if needsResidenceSelection, let residences = residences {
|
||||||
@@ -138,49 +134,68 @@ struct TaskFormView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Add Task")
|
.disabled(isLoadingLookups)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.blur(radius: isLoadingLookups ? 3 : 0)
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
|
||||||
Button("Cancel") {
|
|
||||||
isPresented = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
if isLoadingLookups {
|
||||||
Button("Save") {
|
VStack(spacing: 16) {
|
||||||
submitForm()
|
ProgressView()
|
||||||
}
|
.scaleEffect(1.5)
|
||||||
.disabled(viewModel.isLoading)
|
Text("Loading...")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color(uiColor: .systemBackground).opacity(0.8))
|
||||||
}
|
}
|
||||||
.onAppear {
|
}
|
||||||
loadLookups()
|
.navigationTitle("Add Task")
|
||||||
}
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.onChange(of: viewModel.taskCreated) { created in
|
.toolbar {
|
||||||
if created {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button("Cancel") {
|
||||||
isPresented = false
|
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() {
|
private func loadLookups() async {
|
||||||
Task {
|
// Load all lookups from DataCache
|
||||||
isLoadingLookups = true
|
if let categories = DataCache.shared.taskCategories.value as? [TaskCategory] {
|
||||||
|
taskCategories = categories
|
||||||
// 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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
private func setDefaults() {
|
||||||
|
|||||||
Reference in New Issue
Block a user