diff --git a/FairShare.xcodeproj/project.pbxproj b/FairShare.xcodeproj/project.pbxproj index bd4f7ce..4adf11f 100644 --- a/FairShare.xcodeproj/project.pbxproj +++ b/FairShare.xcodeproj/project.pbxproj @@ -39,6 +39,9 @@ 90676E962C3A5C2600828276 /* ReceiptDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90676E952C3A5C2600828276 /* ReceiptDetailViewModel.swift */; }; 90676E982C3A5CDA00828276 /* Double+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90676E972C3A5CDA00828276 /* Double+Helper.swift */; }; 90676E9A2C3A66ED00828276 /* ReceiptTextModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90676E992C3A66ED00828276 /* ReceiptTextModel.swift */; }; + 906F97DC2CE0622B0023B7C1 /* ContactData+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 906F97DA2CE0622B0023B7C1 /* ContactData+CoreDataClass.swift */; }; + 906F97DD2CE0622B0023B7C1 /* ContactData+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 906F97DB2CE0622B0023B7C1 /* ContactData+CoreDataProperties.swift */; }; + 906F97E02CE0695C0023B7C1 /* ContactListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 906F97DF2CE0695C0023B7C1 /* ContactListView.swift */; }; 9072AFC52BE9C46E00DE6FEB /* FairShareApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9072AFC42BE9C46E00DE6FEB /* FairShareApp.swift */; }; 9072AFC72BE9C46E00DE6FEB /* EntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9072AFC62BE9C46E00DE6FEB /* EntryView.swift */; }; 9072AFC92BE9C47000DE6FEB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9072AFC82BE9C47000DE6FEB /* Assets.xcassets */; }; @@ -52,6 +55,7 @@ 909E699C2C02C820009D00D6 /* Image+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 909E699B2C02C820009D00D6 /* Image+Helper.swift */; }; 909E699E2C02C82A009D00D6 /* Color+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 909E699D2C02C82A009D00D6 /* Color+Helper.swift */; }; 909E69A02C02C865009D00D6 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 909E699F2C02C865009D00D6 /* Constants.swift */; }; + 90B98CC12C434443006B6B83 /* NSError+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90B98CC02C434443006B6B83 /* NSError+Helper.swift */; }; 90B98CC32C437B94006B6B83 /* MessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90B98CC22C437B94006B6B83 /* MessageView.swift */; }; 90B98CC52C439451006B6B83 /* String+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90B98CC42C439451006B6B83 /* String+Helper.swift */; }; 90BD783A2C0AEEB300C6A889 /* ReceiptItemsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90BD78392C0AEEB300C6A889 /* ReceiptItemsView.swift */; }; @@ -103,6 +107,9 @@ 90676E952C3A5C2600828276 /* ReceiptDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptDetailViewModel.swift; sourceTree = ""; }; 90676E972C3A5CDA00828276 /* Double+Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Helper.swift"; sourceTree = ""; }; 90676E992C3A66ED00828276 /* ReceiptTextModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptTextModel.swift; sourceTree = ""; }; + 906F97DA2CE0622B0023B7C1 /* ContactData+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContactData+CoreDataClass.swift"; sourceTree = ""; }; + 906F97DB2CE0622B0023B7C1 /* ContactData+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContactData+CoreDataProperties.swift"; sourceTree = ""; }; + 906F97DF2CE0695C0023B7C1 /* ContactListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListView.swift; sourceTree = ""; }; 9072AFC12BE9C46E00DE6FEB /* FairShare.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FairShare.app; sourceTree = BUILT_PRODUCTS_DIR; }; 9072AFC42BE9C46E00DE6FEB /* FairShareApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FairShareApp.swift; sourceTree = ""; }; 9072AFC62BE9C46E00DE6FEB /* EntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntryView.swift; sourceTree = ""; }; @@ -119,6 +126,7 @@ 909E699B2C02C820009D00D6 /* Image+Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Helper.swift"; sourceTree = ""; }; 909E699D2C02C82A009D00D6 /* Color+Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Helper.swift"; sourceTree = ""; }; 909E699F2C02C865009D00D6 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + 90B98CC02C434443006B6B83 /* NSError+Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSError+Helper.swift"; sourceTree = ""; }; 90B98CC22C437B94006B6B83 /* MessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageView.swift; sourceTree = ""; }; 90B98CC42C439451006B6B83 /* String+Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Helper.swift"; sourceTree = ""; }; 90BD78392C0AEEB300C6A889 /* ReceiptItemsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptItemsView.swift; sourceTree = ""; }; @@ -193,6 +201,16 @@ path = Elements; sourceTree = ""; }; + 906F97DE2CE063760023B7C1 /* CoreData */ = { + isa = PBXGroup; + children = ( + 906F97DA2CE0622B0023B7C1 /* ContactData+CoreDataClass.swift */, + 906F97DB2CE0622B0023B7C1 /* ContactData+CoreDataProperties.swift */, + 9072AFCD2BE9C47000DE6FEB /* Persistence.swift */, + ); + path = CoreData; + sourceTree = ""; + }; 9072AFB82BE9C46E00DE6FEB = { isa = PBXGroup; children = ( @@ -217,6 +235,7 @@ isa = PBXGroup; children = ( 90FB96202BF02C880061E83D /* Application */, + 906F97DE2CE063760023B7C1 /* CoreData */, 90FB96212BF02D010061E83D /* Networking */, 90FB961F2BF02C470061E83D /* Errors */, 90FB961E2BF02C120061E83D /* Helpers */, @@ -224,7 +243,6 @@ 90FB961D2BF02BF60061E83D /* Models */, 9072AFC82BE9C47000DE6FEB /* Assets.xcassets */, 902C01F52BF00B0E0004B01A /* GoogleService-Info.plist */, - 9072AFCD2BE9C47000DE6FEB /* Persistence.swift */, 9072AFCF2BE9C47000DE6FEB /* FairShare.xcdatamodeld */, 9072AFCA2BE9C47000DE6FEB /* Preview Content */, ); @@ -260,6 +278,7 @@ isa = PBXGroup; children = ( 90FB962C2BF0753E0061E83D /* ProfileView.swift */, + 906F97DF2CE0695C0023B7C1 /* ContactListView.swift */, 9029DF132C3140A2008586E6 /* ContactPickerView.swift */, 90FB962E2BF0779B0061E83D /* SettingsRowView.swift */, ); @@ -368,6 +387,7 @@ 90676E972C3A5CDA00828276 /* Double+Helper.swift */, 909E699B2C02C820009D00D6 /* Image+Helper.swift */, 90B98CC42C439451006B6B83 /* String+Helper.swift */, + 90B98CC02C434443006B6B83 /* NSError+Helper.swift */, ); path = Extensions; sourceTree = ""; @@ -551,12 +571,16 @@ 90FB962D2BF0753E0061E83D /* ProfileView.swift in Sources */, 90BD783C2C0C051E00C6A889 /* EditableReceiptItemView.swift in Sources */, 9072AFC72BE9C46E00DE6FEB /* EntryView.swift in Sources */, + 90B98CC12C434443006B6B83 /* NSError+Helper.swift in Sources */, 90F4761F2C154FE20075B36B /* AuthService.swift in Sources */, + 906F97DD2CE0622B0023B7C1 /* ContactData+CoreDataProperties.swift in Sources */, + 906F97DC2CE0622B0023B7C1 /* ContactData+CoreDataClass.swift in Sources */, 90FB96172BF023340061E83D /* Date+Helper.swift in Sources */, 909E69942C02A5C7009D00D6 /* CameraView.swift in Sources */, 90B98CC52C439451006B6B83 /* String+Helper.swift in Sources */, 909E69A02C02C865009D00D6 /* Constants.swift in Sources */, 90676E982C3A5CDA00828276 /* Double+Helper.swift in Sources */, + 906F97E02CE0695C0023B7C1 /* ContactListView.swift in Sources */, 9072AFC52BE9C46E00DE6FEB /* FairShareApp.swift in Sources */, 902C01FA2BF017660004B01A /* ReceiptView.swift in Sources */, 902D77022C27766800F7B462 /* ReceiptDetailView.swift in Sources */, @@ -732,6 +756,7 @@ DEVELOPMENT_TEAM = FSUP887MQU; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink"; INFOPLIST_KEY_NSCameraUsageDescription = "This app needs to capture images of your receipt"; INFOPLIST_KEY_NSContactsUsageDescription = "Allow Contact Access"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -764,6 +789,7 @@ DEVELOPMENT_TEAM = FSUP887MQU; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink"; INFOPLIST_KEY_NSCameraUsageDescription = "This app needs to capture images of your receipt"; INFOPLIST_KEY_NSContactsUsageDescription = "Allow Contact Access"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; diff --git a/FairShare.xcodeproj/project.xcworkspace/xcuserdata/stephanie.xcuserdatad/IDEFindNavigatorScopes.plist b/FairShare.xcodeproj/project.xcworkspace/xcuserdata/stephanie.xcuserdatad/IDEFindNavigatorScopes.plist new file mode 100644 index 0000000..5dd5da8 --- /dev/null +++ b/FairShare.xcodeproj/project.xcworkspace/xcuserdata/stephanie.xcuserdatad/IDEFindNavigatorScopes.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/FairShare/Application/FairShareApp.swift b/FairShare/Application/FairShareApp.swift index 60decbf..0ddae6b 100644 --- a/FairShare/Application/FairShareApp.swift +++ b/FairShare/Application/FairShareApp.swift @@ -22,9 +22,12 @@ struct FairShareApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate @StateObject var viewModel = AuthViewModel() + let persistenceController = PersistenceController.shared + var body: some Scene { WindowGroup { EntryView() + .environment(\.managedObjectContext, persistenceController.container.viewContext) .environmentObject(viewModel) } } diff --git a/FairShare/CoreData/ContactData+CoreDataClass.swift b/FairShare/CoreData/ContactData+CoreDataClass.swift new file mode 100644 index 0000000..717bf65 --- /dev/null +++ b/FairShare/CoreData/ContactData+CoreDataClass.swift @@ -0,0 +1,14 @@ +// +// ContactData+CoreDataClass.swift +// FairShare +// +// Created by Stephanie Ramirez on 11/9/24. +// +// + +import Foundation +import CoreData + +public class ContactData: NSManagedObject { + +} diff --git a/FairShare/CoreData/ContactData+CoreDataProperties.swift b/FairShare/CoreData/ContactData+CoreDataProperties.swift new file mode 100644 index 0000000..099e61a --- /dev/null +++ b/FairShare/CoreData/ContactData+CoreDataProperties.swift @@ -0,0 +1,27 @@ +// +// ContactData+CoreDataProperties.swift +// FairShare +// +// Created by Stephanie Ramirez on 11/9/24. +// +// + +import Foundation +import CoreData + +extension ContactData { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "ContactData") + } + + @NSManaged public var id: String + @NSManaged public var firstName: String + @NSManaged public var lastName: String + @NSManaged public var phoneNumber: String? + +} + +extension ContactData : Identifiable { + +} diff --git a/FairShare/CoreData/Persistence.swift b/FairShare/CoreData/Persistence.swift new file mode 100644 index 0000000..5a252af --- /dev/null +++ b/FairShare/CoreData/Persistence.swift @@ -0,0 +1,100 @@ +// +// Persistence.swift +// FairShare +// +// Created by Stephanie Ramirez on 5/6/24. +// + +import CoreData + +struct PersistenceController { + static let shared = PersistenceController() + + static var preview: PersistenceController = { + let result = PersistenceController(inMemory: true) + let viewContext = result.container.viewContext + + for _ in 0..<10 { + let newItem = Item(context: viewContext) + newItem.timestamp = Date() + } + + for contact in ContactModel.dummyArrayData { + let newContact = ContactData(context: viewContext) + newContact.id = contact.id + newContact.firstName = contact.firstName + newContact.lastName = contact.lastName + newContact.phoneNumber = contact.phoneNumber + } + + do { + try viewContext.save() + } catch { + let nsError = error as NSError + fatalError("Unresolved error \(nsError), \(nsError.userInfo)") + } + + return result + }() + + let container: NSPersistentContainer + var context: NSManagedObjectContext { container.viewContext } + + init(inMemory: Bool = false) { + container = NSPersistentContainer(name: "FairShare") + + if inMemory { + container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") + } + + container.viewContext.automaticallyMergesChangesFromParent = true + container.loadPersistentStores( + completionHandler: { (storeDescription, error) in + if let error = error as NSError? { + fatalError("Unresolved error \(error), \(error.userInfo)") + } + } + ) + + container.viewContext.automaticallyMergesChangesFromParent = true + } + + func saveContext() { + if context.hasChanges { + do { + try context.save() + } catch let error as NSError { + NSLog("Unresolved error saving context: \(error), \(error.userInfo)") + } + } + } +} + +///Contacts +extension PersistenceController { + func fetchAllContacts() -> [ContactData] { + let request = NSFetchRequest(entityName: "ContactData") + + do { + return try context.fetch(request) + } catch { + return [] + } + } + + func addContact(contact: ContactModel) { + let newContact = ContactData(context: context) + newContact.id = contact.id + newContact.firstName = contact.firstName + newContact.lastName = contact.lastName + newContact.phoneNumber = contact.phoneNumber + + saveContext() + } + + func deleteContact(_ contactData: ContactData) { + context.delete(contactData) + + saveContext() + } +} diff --git a/FairShare/Errors/FirebaseErrors.swift b/FairShare/Errors/FirebaseErrors.swift index a467e22..2f014a3 100644 --- a/FairShare/Errors/FirebaseErrors.swift +++ b/FairShare/Errors/FirebaseErrors.swift @@ -7,10 +7,13 @@ import Foundation import Firebase +import FirebaseStorage -protocol DBError: Error {} +protocol FirebaseError: Error, LocalizedError { -enum FirebaseAuthError: DBError { +} + +enum FirebaseAuthError: FirebaseError { case emailAlreadyInUse case userNotFound case userDisabled @@ -20,32 +23,15 @@ enum FirebaseAuthError: DBError { case wrongPassword case operationNotAllowed case keychainError - case unknownError(String) + case unknownNSError(NSError) + case unknownError(Error) - init(_ error: NSError) { - let authErrorCode = AuthErrorCode(_nsError: error).code - switch authErrorCode { - case .emailAlreadyInUse: - self = .emailAlreadyInUse - case .userNotFound: - self = .userNotFound - case .userDisabled: - self = .userDisabled - case .invalidEmail, .invalidSender, .invalidRecipientEmail: - self = .invalidEmail - case .networkError: - self = .networkError - case .weakPassword: - self = .weakPassword - case .wrongPassword: - self = .wrongPassword - case .operationNotAllowed: - self = .operationNotAllowed - case .keychainError: - self = .keychainError - default: - self = .unknownError("\(authErrorCode)") + init(_ error: Error) { + if let nsError = error as NSError? { + self = nsError.asFirebaseAuthError() + } else { + self = .unknownError(error) } } @@ -69,8 +55,147 @@ enum FirebaseAuthError: DBError { return "Sign in with this method is currently disabled. Please contact support for further assistance." case .keychainError: return "An error occurred while trying to access secure information. Please try again or contact support if the problem persists." - case .unknownError(let code): - return "Unknown error occurred. Error code: \(code)" + case .unknownError(let error): + return "Unknown Error occurred. Error: \(error)" + case .unknownNSError(let error): + return "Unknown NSError occurred. Error code: \(error.code)" + } + } +} + +enum FirebaseFirestoreError: FirebaseError { + case cancelled + case invalidArgument + case deadlineExceeded + case notFound + case alreadyExists + case permissionDenied + case resourceExhausted + case failedPrecondition + case aborted + case outOfRange + case unimplemented + case internalError + case unavailable + case dataLoss + case unauthenticated + case unknownError(Error) + case unknownNSError(NSError) + + init(_ error: Error) { + if let nsError = error as NSError? { + self = nsError.asFirestoreError() + } else { + self = .unknownError(error) + } + } + + var message: String { + switch self { + case .cancelled: + return "Operation was cancelled." + case .invalidArgument: + return "Invalid argument provided." + case .deadlineExceeded: + return "Deadline for operation exceeded." + case .notFound: + return "Requested document was not found." + case .alreadyExists: + return "Document already exists." + case .permissionDenied: + return "Permission denied." + case .resourceExhausted: + return "Resource exhausted." + case .failedPrecondition: + return "Failed precondition." + case .aborted: + return "Operation aborted." + case .outOfRange: + return "Operation out of range." + case .unimplemented: + return "Operation not implemented." + case .internalError: + return "Internal error occurred." + case .unavailable: + return "Service unavailable." + case .dataLoss: + return "Data loss or corruption." + case .unauthenticated: + return "Unauthenticated request." + case .unknownError(let error): + return "Unknown Error occurred. Error: \(error)" + case .unknownNSError(let error): + return "Unknown NSError occurred. Error code: \(error.code)" + } + } +} + +enum FirebaseStorageError: FirebaseError { + case objectNotFound + case bucketNotFound + case projectNotFound + case quotaExceeded + case unauthenticated + case unauthorized + case retryLimitExceeded + case nonMatchingChecksum + case downloadSizeExceeded + case cancelled + case invalidArgument + case unknownError(Error) + case unknownNSError(NSError) + case unknownStorageError(StorageErrorCode) + + init(_ error: Error) { + if let nsError = error as NSError? { + self = nsError.asFirebaseStorageError() + } else { + self = .unknownError(error) + } + } + + var errorMessage: String { + switch self { + case .retryLimitExceeded: + return "The operation has failed after retrying multiple times. Please try again later." + case .bucketNotFound: + return "The specified bucket could not be found. Please check your configuration." + case .objectNotFound: + return "The specified object could not be found. Please check the object name." + case .quotaExceeded: + return "The quota for Firebase Storage has been exceeded. Please try again later." + case .unauthorized: + return "You are not authorized to perform this operation. Please check your permissions." + case .projectNotFound: + return "The specified project could not be found. Please check your project ID and configuration." + case .unauthenticated: + return "You are not authenticated. Please log in and try again." + case .nonMatchingChecksum: + return "The checksums do not match. The data might be corrupted. Please try again." + case .downloadSizeExceeded: + return "The download size exceeds the allowed limit. Please try downloading a smaller file." + case .cancelled: + return "The operation was cancelled. Please try again if needed." + case .invalidArgument: + return "An invalid argument was provided. Please check the inputs and try again." + case .unknownNSError(let error): + return "An unknown NSError occurred. Error code: \(error.code)" + case .unknownStorageError(let error): + return "An unknown StorageError occurred. Error: \(error)" + case .unknownError(let error): + return "An unknown error occurred. Error: \(error)" + } + } +} + + +enum ImageProcessingError: FirebaseError { + case imageDataConversionFailed + + var errorMessage: String { + switch self { + case .imageDataConversionFailed: + return "Failed to convert the image data." } } } diff --git a/FairShare/FairShare.xcdatamodeld/FairShare.xcdatamodel/contents b/FairShare/FairShare.xcdatamodeld/FairShare.xcdatamodel/contents index 9ed2921..5d31498 100644 --- a/FairShare/FairShare.xcdatamodeld/FairShare.xcdatamodel/contents +++ b/FairShare/FairShare.xcdatamodeld/FairShare.xcdatamodel/contents @@ -1,9 +1,12 @@ - + + + + + + + - - - \ No newline at end of file diff --git a/FairShare/Helpers/Constants.swift b/FairShare/Helpers/Constants.swift index e073ebb..f124df2 100644 --- a/FairShare/Helpers/Constants.swift +++ b/FairShare/Helpers/Constants.swift @@ -49,6 +49,11 @@ struct Images { } struct Strings { + struct ContactListView { + static let navigationTitle = TextAsset(string: "Contacts") + static let emptyState = TextAsset(string: "Click the + to add a new contact") + } + struct LoginView { static let confirmString = "OK" static let welcome = "Welcome Back" diff --git a/FairShare/Helpers/Extensions/NSError+Helper.swift b/FairShare/Helpers/Extensions/NSError+Helper.swift new file mode 100644 index 0000000..c0e645e --- /dev/null +++ b/FairShare/Helpers/Extensions/NSError+Helper.swift @@ -0,0 +1,100 @@ +// +// NSError+Helper.swift +// FairShare +// +// Created by Stephanie Ramirez on 7/13/24. +// + +import Foundation +import FirebaseAuth +import FirebaseFirestore +import FirebaseStorage + +extension NSError { + func asFirebaseAuthError() -> FirebaseAuthError { + let authErrorCode = AuthErrorCode(_nsError: self).code + switch authErrorCode { + case .emailAlreadyInUse: + return .emailAlreadyInUse + case .userNotFound: + return .userNotFound + case .userDisabled: + return .userDisabled + case .invalidEmail, .invalidSender, .invalidRecipientEmail: + return .invalidEmail + case .networkError: + return .networkError + case .weakPassword: + return .weakPassword + case .wrongPassword: + return .wrongPassword + case .operationNotAllowed: + return .operationNotAllowed + case .keychainError: + return .keychainError + default: + return .unknownNSError(self) + } + } + + func asFirestoreError() -> FirebaseFirestoreError { + let firestoreErrorCode = FirestoreErrorCode(_nsError: self) + switch firestoreErrorCode.code { + case .cancelled: + return .cancelled + case .unknown: + return .unknownNSError(self) + case .invalidArgument: + return .invalidArgument + case .deadlineExceeded: + return .deadlineExceeded + case .notFound: + return .notFound + case .alreadyExists: + return .alreadyExists + case .permissionDenied: + return .permissionDenied + case .resourceExhausted: + return .resourceExhausted + case .failedPrecondition: + return .failedPrecondition + case .aborted: + return .aborted + case .outOfRange: + return .outOfRange + case .unimplemented: + return .unimplemented + case .internal: + return .internalError + case .unavailable: + return .unavailable + case .dataLoss: + return .dataLoss + case .unauthenticated: + return .unauthenticated + default: + return .unknownNSError(self) + } + } + + func asFirebaseStorageError() -> FirebaseStorageError { + if let storageErrorCode = StorageErrorCode(rawValue: self.code) { + switch storageErrorCode { + case .retryLimitExceeded: + return .retryLimitExceeded + case .bucketNotFound: + return .bucketNotFound + case .objectNotFound: + return .objectNotFound + case .quotaExceeded: + return .quotaExceeded + case .unauthorized: + return .unauthorized + default: + return .unknownStorageError(storageErrorCode) + } + } else { + return .unknownNSError(self) + } + } +} diff --git a/FairShare/Models/AuthViewModel.swift b/FairShare/Models/AuthViewModel.swift index 373d3a4..975557d 100644 --- a/FairShare/Models/AuthViewModel.swift +++ b/FairShare/Models/AuthViewModel.swift @@ -11,6 +11,7 @@ import FirebaseFirestoreSwift @MainActor class AuthViewModel: ObservableObject { + //TODO: 1) Make sidebar contact reusable enum(3) profile(view, edit, delete- via swipe), receipt items(select), new receipt guests(select). shared items are search bar and add new contact. 2) Make core data contact, 3) Remove contact info from firebase and old model. @Published var userSession: FirebaseAuth.User? @Published var currentUser: UserModel? @Published var receipts: [ReceiptModel] = [] @@ -54,8 +55,10 @@ extension AuthViewModel { let result = try await AuthService.signIn(with: email, password: password) self.userSession = result try await self.fetchUser() - } catch let error as FirebaseAuthError { - self.showError(for: error.errorMessage) + } catch { + let firebaseAuthError = FirebaseAuthError(error) + self.showError(for: firebaseAuthError.errorMessage) + print("Error signing in user: \(firebaseAuthError.errorMessage)") } } @@ -64,8 +67,10 @@ extension AuthViewModel { try await AuthService.signOut() self.userSession = nil self.currentUser = nil - } catch let error as FirebaseAuthError { - self.showError(for: error.errorMessage) + } catch { + let firebaseAuthError = FirebaseAuthError(error) + self.showError(for: firebaseAuthError.errorMessage) + print("Error signing out user: \(firebaseAuthError.errorMessage)") } } @@ -81,8 +86,11 @@ extension AuthViewModel { try await DBService.createUser(from: user) try await self.fetchUser() } catch { - print("Error creating user: \(error)") - throw error + let firebaseAuthError = FirebaseAuthError(error) + self.showError(for: firebaseAuthError.errorMessage) + print("Error creating user: \(firebaseAuthError.errorMessage)") + + throw firebaseAuthError } } @@ -92,26 +100,28 @@ extension AuthViewModel { private func fetchUser() async throws { guard let userId = AuthService.CurrentUser?.uid else { - print("Error: User ID is nil.") - return + throw FirebaseAuthError.userNotFound } self.isLoading = true do { - let fetchedUser = try await DBService.fetchUser(userId: userId) - self.currentUser = fetchedUser + self.currentUser = try await DBService.fetchUser(userId: userId) try await self.fetchUserReceipts() try await self.fetchContacts() } catch { - print("Error fetching user: \(error)") - throw error + let firebaseAuthError = FirebaseAuthError(error) + self.showError(for: firebaseAuthError.errorMessage) + print("Error fetching user: \(firebaseAuthError.errorMessage)") + + throw firebaseAuthError } } } ///Receipt extension AuthViewModel { + //TODO: Update error handling func fetchUserReceipts() async throws { guard let userID = self.currentUser?.id else { print("Error: Current user ID is nil.") @@ -144,14 +154,16 @@ extension AuthViewModel { ///Contacts extension AuthViewModel { func addContacts(contacts: [ContactModel]) async throws { - do { - for contact in contacts { - try await DBService.addContact(contact: contact, creatorID: self.currentUser!.id) - } - } catch { - print("Error writing document: \(error)") - throw error - } +// do { +// for contact in contacts { +// try await DBService.addContact(contact: contact, creatorID: self.currentUser!.id) +// } +// } catch { +// print("Error writing document: \(error)") +// throw error +// } + + } func fetchContacts() async throws { diff --git a/FairShare/Models/ContactModel.swift b/FairShare/Models/ContactModel.swift index ac1173e..5d1a05e 100644 --- a/FairShare/Models/ContactModel.swift +++ b/FairShare/Models/ContactModel.swift @@ -20,7 +20,12 @@ struct ContactModel: PayerProtocol { self.phoneNumber = phoneNumber } - //TODO: active user can create "guest" contact by phone number. If this contact makes an account later, they can be linked to their guest account and updated via matching phone number. + init(_ contactData: ContactData) { + self.id = contactData.id + self.firstName = contactData.firstName + self.lastName = contactData.lastName + self.phoneNumber = contactData.phoneNumber ?? "" + } static let dummyData: ContactModel = dummyArrayData[0] static let dummyArrayData: [ContactModel] = [ diff --git a/FairShare/Networking/AuthService.swift b/FairShare/Networking/AuthService.swift index 36d13f0..568a846 100644 --- a/FairShare/Networking/AuthService.swift +++ b/FairShare/Networking/AuthService.swift @@ -26,18 +26,16 @@ extension AuthService { do { let result = try await Authentication.signIn(withEmail: email, password: password) return result.user - } catch let error as NSError { - print("Error signing in user: \(FirebaseAuthError(error).errorMessage)") - throw FirebaseAuthError(error) + } catch { + throw error } } static func signOut() async throws { do { try Authentication.signOut() - } catch let error as NSError { - print("Error signing out user: \(FirebaseAuthError(error).errorMessage)") - throw FirebaseAuthError(error) + } catch { + throw error } } @@ -46,7 +44,6 @@ extension AuthService { let results = try await Authentication.createUser(withEmail: withEmail, password: password) return results } catch { - print("Error creating user: \(error)") throw error } } diff --git a/FairShare/Persistence.swift b/FairShare/Persistence.swift deleted file mode 100644 index eb5e50c..0000000 --- a/FairShare/Persistence.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// Persistence.swift -// FairShare -// -// Created by Stephanie Ramirez on 5/6/24. -// - -import CoreData - -struct PersistenceController { - static let shared = PersistenceController() - - static var preview: PersistenceController = { - let result = PersistenceController(inMemory: true) - let viewContext = result.container.viewContext - for _ in 0..<10 { - let newItem = Item(context: viewContext) - newItem.timestamp = Date() - } - do { - try viewContext.save() - } catch { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. - let nsError = error as NSError - fatalError("Unresolved error \(nsError), \(nsError.userInfo)") - } - return result - }() - - let container: NSPersistentContainer - - init(inMemory: Bool = false) { - container = NSPersistentContainer(name: "FairShare") - if inMemory { - container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") - } - container.loadPersistentStores(completionHandler: { (storeDescription, error) in - if let error = error as NSError? { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. - - /* - Typical reasons for an error here include: - * The parent directory does not exist, cannot be created, or disallows writing. - * The persistent store is not accessible, due to permissions or data protection when the device is locked. - * The device is out of space. - * The store could not be migrated to the current model version. - Check the error message to determine what the actual problem was. - */ - fatalError("Unresolved error \(error), \(error.userInfo)") - } - }) - container.viewContext.automaticallyMergesChangesFromParent = true - } -} diff --git a/FairShare/UI/Profile/ContactListView.swift b/FairShare/UI/Profile/ContactListView.swift new file mode 100644 index 0000000..dc97c31 --- /dev/null +++ b/FairShare/UI/Profile/ContactListView.swift @@ -0,0 +1,162 @@ +// +// ContactListView.swift +// FairShare +// +// Created by Stephanie Ramirez on 11/9/24. +// + +import CoreData +import SwiftUI + +struct ContactListView: View { + @Environment(\.managedObjectContext) private var viewContext + @StateObject private var viewModel = ContactViewModel() + @State private var showContactPicker = false + @State private var selectedContacts: [ContactModel] = [] + + private var groupedContacts: [String: [ContactModel]] { + Dictionary(grouping: viewModel.contacts, by: { String($0.firstName.prefix(1)) }) + } + + private var sortedSectionKeys: [String] { + groupedContacts.keys.sorted() + } + + var body: some View { + VStack(spacing: 0) { + List { + ForEach(sortedSectionKeys, id: \.self) { key in + Section(header: Text(key)) { + ForEach(groupedContacts[key] ?? [], id: \.self) { contact in + VStack(alignment: .leading) { + Text("\(contact.firstName) \(contact.lastName)") + .font(.headline) + Text(contact.phoneNumber) + .font(.subheadline) + .foregroundColor(.gray) + } + .padding(.vertical, 4) + .swipeActions(allowsFullSwipe: false) { + Button(role: .destructive) { + print("Deleting contact \(contact.firstName)") + } label: { + Label("Delete", systemImage: "trash.fill") + } + + Button { + print("Edit \(contact.firstName)") + } label: { + Text("Edit") + } + .tint(.green) + } + } + } + } + } + .navigationTitle("Contacts") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showContactPicker.toggle() + } label: { + Image(systemName: "plus") + .foregroundColor(.blue) + } + } + } + } + .fullScreenCover( + isPresented: $showContactPicker, + content: { + ContactPickerView(selectedContacts: $selectedContacts) + .edgesIgnoringSafeArea(.all) + } + ) + .onAppear { + viewModel.setContext(viewContext) + viewModel.fetchContacts() + } + .onChange(of: selectedContacts) { + Task { + viewModel.addContacts(selectedContacts) + } + } + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContactListView() + .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) + } +} + +class ContactViewModel: ObservableObject { + @Published var contacts: [ContactModel] = [] + + private var context: NSManagedObjectContext? + + func setContext(_ context: NSManagedObjectContext) { + self.context = context + } + + func fetchContacts() { + guard let context = context else { return } + let fetchRequest: NSFetchRequest = ContactData.fetchRequest() + + do { + let fetchedContacts = try context.fetch(fetchRequest) + self.contacts = convertToContactModels(from: fetchedContacts) + + } catch { + print("Failed to fetch contacts: \(error.localizedDescription)") + } + } + + func addContacts(_ contacts: [ContactModel]) { + guard let context = context else { + return + } + + for contact in contacts { + let newContact = ContactData(context: context) + newContact.id = contact.id + newContact.firstName = contact.firstName + newContact.lastName = contact.lastName + newContact.phoneNumber = contact.phoneNumber + } + + saveContext() + fetchContacts() + } + + private func saveContext() { + guard let context = context else { + return + } + + do { + try context.save() + } catch { + print("Failed to save context: \(error.localizedDescription)") + } + } + +//TODO: replace ContactModel with ContactData to allow for smooth deletion + +// func deleteContact(_ contact: ContactData) { +// guard let context = context else { +// return +// } +// +// context.delete(contact) +// saveContext() +// fetchContacts() +// } + + func convertToContactModels(from contactDataArray: [ContactData]) -> [ContactModel] { + return contactDataArray.map { ContactModel($0) } + } +} diff --git a/FairShare/UI/Profile/ProfileView.swift b/FairShare/UI/Profile/ProfileView.swift index 9e6092c..9e74b26 100644 --- a/FairShare/UI/Profile/ProfileView.swift +++ b/FairShare/UI/Profile/ProfileView.swift @@ -8,72 +8,61 @@ import SwiftUI struct ProfileView: View { + @Environment(\.managedObjectContext) private var viewContext @EnvironmentObject var viewModel: AuthViewModel - @State private var showContactPicker = false @State private var selectedContacts: [ContactModel] = [] var body: some View { - if let user = viewModel.currentUser { - List { - Section { - HStack { - Text(user.initials) - .font(.title) - .fontWeight(.semibold) - .foregroundStyle(.white) - .frame(width: 72, height: 72) - .background(Color(.systemGray3)) - .clipShape(Circle()) - - VStack(alignment: .leading, spacing: 5) { - Text(user.fullName) - .font(.subheadline) + NavigationView { + if let user = viewModel.currentUser { + List { + Section { + HStack { + Text(user.initials) + .font(.title) .fontWeight(.semibold) - .padding(.top, 5) + .foregroundStyle(.white) + .frame(width: 72, height: 72) + .background(Color(.systemGray3)) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 5) { + Text(user.fullName) + .font(.subheadline) + .fontWeight(.semibold) + .padding(.top, 5) - Text(user.email) - .font(.footnote) - .foregroundStyle(.gray) + Text(user.email) + .font(.footnote) + .foregroundStyle(.gray) + } } } - } - Section(Strings.ProfileView.account) { - Button { - Task { - try await viewModel.signOut() + Section(Strings.ProfileView.account) { + Button { + Task { + try await viewModel.signOut() + } + } label: { + SettingsRowView(rowType: .signOut) } - } label: { - SettingsRowView(rowType: .signOut) - } - Button { - print(Strings.ProfileView.delete) - } label: { - SettingsRowView(rowType: .delete) + Button { + print(Strings.ProfileView.delete) + } label: { + SettingsRowView(rowType: .delete) + } } - } - Section(Strings.ProfileView.contacts) { - //TODO: add view Contacts to allow for edits. - Button { - showContactPicker.toggle() - } label: { - SettingsRowView(rowType: .contacts) + Section(Strings.ProfileView.contacts) { + NavigationLink(destination: ContactListView() + .environment(\.managedObjectContext, viewContext)) { + SettingsRowView(rowType: .contacts) + } } } } - .fullScreenCover( - isPresented: $showContactPicker, - content: { - ContactPickerView(selectedContacts: $selectedContacts) - } - ) - .onChange(of: selectedContacts) { - Task { - try await viewModel.addContacts(contacts: selectedContacts) - } - } } } }