diff --git a/.DS_Store b/.DS_Store index 845cac1..238c668 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 6573cf5..a9a134c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ build/ ## Secrets Resell/Supporting/GoogleService-Info.plist -Keys.xcconfig \ No newline at end of file +Resell/Supporting/resell-service.json +Keys.xcconfig +.DS_Store diff --git a/Resell.xcodeproj/project.pbxproj b/Resell.xcodeproj/project.pbxproj index 4635392..2ffe02e 100644 --- a/Resell.xcodeproj/project.pbxproj +++ b/Resell.xcodeproj/project.pbxproj @@ -22,16 +22,10 @@ 2C18FFE62CA139D500564577 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C18FFE52CA139D500564577 /* SettingsView.swift */; }; 2C18FFE82CA1DC9800564577 /* Icon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C18FFE72CA1DC9800564577 /* Icon.swift */; }; 2C18FFEA2CA1E4C900564577 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C18FFE92CA1E4C900564577 /* SettingsViewModel.swift */; }; - 2C30246F2C90FFB40057D3D9 /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = 2C30246E2C90FFB40057D3D9 /* GoogleSignIn */; }; - 2C3024712C90FFB40057D3D9 /* GoogleSignInSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 2C3024702C90FFB40057D3D9 /* GoogleSignInSwift */; }; 2C3024732C90FFE60057D3D9 /* Keys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C3024722C90FFE60057D3D9 /* Keys.swift */; }; - 2C3024852C9221940057D3D9 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 2C3024842C9221940057D3D9 /* GoogleService-Info.plist */; }; - 2C3859E12CCD9FDE00DA20EA /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 2C3859E02CCD9FDE00DA20EA /* FirebaseCore */; }; - 2C3859E32CCD9FEA00DA20EA /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 2C3859E22CCD9FEA00DA20EA /* FirebaseFirestore */; }; 2C41BB832CD8718600EFF69E /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C41BB822CD8718600EFF69E /* KeychainManager.swift */; }; 2C41BB852CD8811400EFF69E /* Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C41BB842CD8811400EFF69E /* Post.swift */; }; 2C41BB882CD90DF800EFF69E /* CachedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C41BB872CD90DF800EFF69E /* CachedImageView.swift */; }; - 2C41BB8A2CD90EA100EFF69E /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 2C41BB892CD90EA100EFF69E /* Kingfisher */; }; 2C41BB8C2CD9276E00EFF69E /* ShimmerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C41BB8B2CD9276E00EFF69E /* ShimmerView.swift */; }; 2C41BB8E2CD97E3E00EFF69E /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C41BB8D2CD97E3E00EFF69E /* SearchView.swift */; }; 2C4DD9792C98CC410055D0AB /* SetupProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4DD9782C98CC410055D0AB /* SetupProfileView.swift */; }; @@ -40,7 +34,6 @@ 2C4DD9812C98DC2D0055D0AB /* LabeledTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4DD9802C98DC2D0055D0AB /* LabeledTextField.swift */; }; 2C4DD9832C98E3110055D0AB /* View + Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4DD9822C98E3110055D0AB /* View + Extensions.swift */; }; 2C4DD9852C98EF5E0055D0AB /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4DD9842C98EF5E0055D0AB /* WebView.swift */; }; - 2C525B7D2CB1DE55007D5B8E /* NotificationsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C525B7C2CB1DE55007D5B8E /* NotificationsSettingsView.swift */; }; 2C525B7F2CB1E884007D5B8E /* SendFeedbackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C525B7E2CB1E884007D5B8E /* SendFeedbackView.swift */; }; 2C525B812CB1F195007D5B8E /* SendFeedbackViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C525B802CB1F195007D5B8E /* SendFeedbackViewModel.swift */; }; 2C52E4F12C926C4B0042312C /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C52E4F02C926C4B0042312C /* HomeView.swift */; }; @@ -48,6 +41,11 @@ 2C52E4F52C926FDA0042312C /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C52E4F42C926FDA0042312C /* MainViewModel.swift */; }; 2C6410942C9A70B400E4B390 /* String + Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6410932C9A70B400E4B390 /* String + Extensions.swift */; }; 2C6410982C9DFD8B00E4B390 /* UIApplication + Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6410972C9DFD8B00E4B390 /* UIApplication + Extensions.swift */; }; + 2C6FB1772CFACB2500B35FF8 /* FirestoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6FB1762CFACB2500B35FF8 /* FirestoreManager.swift */; }; + 2C6FB17E2CFADD5200B35FF8 /* ChatDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6FB17D2CFADD5200B35FF8 /* ChatDocument.swift */; }; + 2C6FB1862CFC08D000B35FF8 /* AvailabilitySelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6FB1852CFC08D000B35FF8 /* AvailabilitySelectorView.swift */; }; + 2C6FB1882CFF8ECD00B35FF8 /* FirebaseNotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6FB1872CFF8ECD00B35FF8 /* FirebaseNotificationService.swift */; }; + 2C6FB18C2CFFBE7C00B35FF8 /* GoogleAuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6FB18B2CFFBE7C00B35FF8 /* GoogleAuthManager.swift */; }; 2C7460892CEEE054004832F5 /* Report.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7460882CEEE054004832F5 /* Report.swift */; }; 2C9337462C92A66C00818C8E /* FilterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C9337452C92A66C00818C8E /* FilterButton.swift */; }; 2C9337482C92AAEE00818C8E /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C9337472C92AAEE00818C8E /* HomeViewModel.swift */; }; @@ -70,7 +68,6 @@ 2C9B4D042C8FC8250029DF61 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C9B4CCC2C8FB7B70029DF61 /* LoginView.swift */; }; 2C9B4D072C8FCB070029DF61 /* PurpleButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C9B4D062C8FCB070029DF61 /* PurpleButton.swift */; }; 2C9B4D092C8FD8200029DF61 /* LoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C9B4D082C8FD8200029DF61 /* LoginViewModel.swift */; }; - 2C9B4D0C2C90EF1D0029DF61 /* Launch Screen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 2C9B4D0B2C90EF1D0029DF61 /* Launch Screen.storyboard */; }; 2C9B4D0E2C90F54D0029DF61 /* LoginGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C9B4D0D2C90F54D0029DF61 /* LoginGradient.swift */; }; 2C9B4D102C90F69C0029DF61 /* UIScreen + Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C9B4D0F2C90F69C0029DF61 /* UIScreen + Extensions.swift */; }; 2C9EAF6F2CF26D9D0010A44C /* Rubik-Medium.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 2C9EAF6D2CF268480010A44C /* Rubik-Medium.ttf */; }; @@ -88,10 +85,8 @@ 2CDCEE5F2CD6BE99008DF5E8 /* NetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CDCEE5E2CD6BE99008DF5E8 /* NetworkManager.swift */; }; 2CDCEE612CD6BEAD008DF5E8 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CDCEE602CD6BEAD008DF5E8 /* APIClient.swift */; }; 2CDCEE642CD6D146008DF5E8 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CDCEE632CD6D146008DF5E8 /* User.swift */; }; - 2CDCEE662CD8708B008DF5E8 /* UserSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CDCEE652CD8708B008DF5E8 /* UserSessionManager.swift */; }; 2CDDF30C2CCD871A0061A564 /* ChatsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CDDF30B2CCD871A0061A564 /* ChatsViewModel.swift */; }; 2CDDF30E2CCD915E0061A564 /* MessagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CDDF30D2CCD915E0061A564 /* MessagesView.swift */; }; - 2CDDF3102CCD99B70061A564 /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CDDF30F2CCD99B70061A564 /* Message.swift */; }; 2CE473932CC2148B00BD7E2C /* ProductDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE473922CC2148B00BD7E2C /* ProductDetailsViewModel.swift */; }; 2CE473952CC2204500BD7E2C /* DraggableSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE473942CC2204500BD7E2C /* DraggableSheetView.swift */; }; 2CE4739E2CC4575F00BD7E2C /* ReportOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE4739D2CC4575F00BD7E2C /* ReportOptionsView.swift */; }; @@ -99,12 +94,77 @@ 2CE473A22CC4577500BD7E2C /* ReportConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE473A12CC4577500BD7E2C /* ReportConfirmationView.swift */; }; 2CE473A42CC4595C00BD7E2C /* ReportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE473A32CC4595C00BD7E2C /* ReportViewModel.swift */; }; 2CE473AC2CC5FF8A00BD7E2C /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE473AB2CC5FF8A00BD7E2C /* Router.swift */; }; - 2CF356152CDD8C020045A173 /* EmptyState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF356142CDD8C020045A173 /* EmptyState.swift */; }; 2CF356172CDDC2110045A173 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF356162CDDC2110045A173 /* Request.swift */; }; 2CF356192CDDD4A30045A173 /* HapticFeedbackGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF356182CDDD4A30045A173 /* HapticFeedbackGenerator.swift */; }; 2CF3561B2CDDD65F0045A173 /* SwipeableRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF3561A2CDDD65F0045A173 /* SwipeableRow.swift */; }; 2CF3561D2CDE91170045A173 /* EditProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF3561C2CDE91170045A173 /* EditProfileView.swift */; }; - 2CF3561F2CDE93E00045A173 /* EditProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF3561E2CDE93E00045A173 /* EditProfileViewModel.swift */; }; + 2CF3CC7A2D017897001B90B5 /* OAuth1 in Frameworks */ = {isa = PBXBuildFile; productRef = 2CF3CC792D017897001B90B5 /* OAuth1 */; }; + 2CF3CC7C2D017897001B90B5 /* OAuth2 in Frameworks */ = {isa = PBXBuildFile; productRef = 2CF3CC7B2D017897001B90B5 /* OAuth2 */; }; + 2CF3CC7E2D017897001B90B5 /* SwiftyBase64 in Frameworks */ = {isa = PBXBuildFile; productRef = 2CF3CC7D2D017897001B90B5 /* SwiftyBase64 */; }; + 2CF3CC802D017897001B90B5 /* TinyHTTPServer in Frameworks */ = {isa = PBXBuildFile; productRef = 2CF3CC7F2D017897001B90B5 /* TinyHTTPServer */; }; + 2CFE42722D4097CF007D503F /* Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CFE42712D4097CF007D503F /* Image.swift */; }; + 2E0A38872F0CBDE000362083 /* FollowListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E0A38862F0CBDE000362083 /* FollowListView.swift */; }; + 2E2DBF4B2E99912700FC0225 /* SuggestionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E2DBF4A2E99912100FC0225 /* SuggestionsView.swift */; }; + 2E2DBF4D2E9AE20C00FC0225 /* UserCredibilityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E2DBF4C2E9AE20700FC0225 /* UserCredibilityView.swift */; }; + 2E3034B72D6D4E4900C1FDA9 /* FilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E3034B62D6D4E4400C1FDA9 /* FilterView.swift */; }; + 2E79E5892E870F950004CEA6 /* FiltersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E79E5882E870F8E0004CEA6 /* FiltersViewModel.swift */; }; + 2E8A5A232DBCC82E00B1F281 /* OAuth1 in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A222DBCC82E00B1F281 /* OAuth1 */; }; + 2E8A5A252DBCC82E00B1F281 /* OAuth2 in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A242DBCC82E00B1F281 /* OAuth2 */; }; + 2E8A5A272DBCC82E00B1F281 /* SwiftyBase64 in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A262DBCC82E00B1F281 /* SwiftyBase64 */; }; + 2E8A5A292DBCC82E00B1F281 /* TinyHTTPServer in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A282DBCC82E00B1F281 /* TinyHTTPServer */; }; + 2E8A5A5D2DBCC87500B1F281 /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A5C2DBCC87500B1F281 /* GoogleSignIn */; }; + 2E8A5A5F2DBCC87500B1F281 /* GoogleSignInSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A5E2DBCC87500B1F281 /* GoogleSignInSwift */; }; + 2E8A5A622DBCC87F00B1F281 /* Flow in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A612DBCC87F00B1F281 /* Flow */; }; + 2E8A5A652DBCC8A100B1F281 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A642DBCC8A100B1F281 /* Kingfisher */; }; + 2E8A5A672DBCCB0900B1F281 /* (null) in Sources */ = {isa = PBXBuildFile; }; + 2E8A5A682DBCCB3400B1F281 /* (null) in Sources */ = {isa = PBXBuildFile; }; + 2E8A5A692DBCCB3A00B1F281 /* (null) in Sources */ = {isa = PBXBuildFile; }; + 2E8A5A6A2DBCCB7C00B1F281 /* (null) in Sources */ = {isa = PBXBuildFile; }; + 2E8A5A6B2DBCCCCD00B1F281 /* (null) in Sources */ = {isa = PBXBuildFile; }; + 2E8A5A822DBCD16500B1F281 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A812DBCD16500B1F281 /* FirebaseAnalytics */; }; + 2E8A5A842DBCD16500B1F281 /* FirebaseAnalyticsOnDeviceConversion in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A832DBCD16500B1F281 /* FirebaseAnalyticsOnDeviceConversion */; }; + 2E8A5A862DBCD16500B1F281 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A852DBCD16500B1F281 /* FirebaseAnalyticsWithoutAdIdSupport */; }; + 2E8A5A882DBCD16500B1F281 /* FirebaseAppCheck in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A872DBCD16500B1F281 /* FirebaseAppCheck */; }; + 2E8A5A8A2DBCD16500B1F281 /* FirebaseAppDistribution-Beta in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A892DBCD16500B1F281 /* FirebaseAppDistribution-Beta */; }; + 2E8A5A8C2DBCD16500B1F281 /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A8B2DBCD16500B1F281 /* FirebaseAuth */; }; + 2E8A5A8E2DBCD16500B1F281 /* FirebaseAuthCombine-Community in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A8D2DBCD16500B1F281 /* FirebaseAuthCombine-Community */; }; + 2E8A5A902DBCD16500B1F281 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A8F2DBCD16500B1F281 /* FirebaseCore */; }; + 2E8A5A922DBCD16500B1F281 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A912DBCD16500B1F281 /* FirebaseCrashlytics */; }; + 2E8A5A942DBCD16500B1F281 /* FirebaseDatabase in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A932DBCD16500B1F281 /* FirebaseDatabase */; }; + 2E8A5A962DBCD16500B1F281 /* FirebaseDynamicLinks in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A952DBCD16500B1F281 /* FirebaseDynamicLinks */; }; + 2E8A5A982DBCD16500B1F281 /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A972DBCD16500B1F281 /* FirebaseFirestore */; }; + 2E8A5A9A2DBCD16500B1F281 /* FirebaseFirestoreCombine-Community in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A992DBCD16500B1F281 /* FirebaseFirestoreCombine-Community */; }; + 2E8A5A9C2DBCD16500B1F281 /* FirebaseFunctions in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A9B2DBCD16500B1F281 /* FirebaseFunctions */; }; + 2E8A5A9E2DBCD16500B1F281 /* FirebaseFunctionsCombine-Community in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A9D2DBCD16500B1F281 /* FirebaseFunctionsCombine-Community */; }; + 2E8A5AA02DBCD16500B1F281 /* FirebaseInAppMessaging-Beta in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A9F2DBCD16500B1F281 /* FirebaseInAppMessaging-Beta */; }; + 2E8A5AA22DBCD16500B1F281 /* FirebaseInstallations in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5AA12DBCD16500B1F281 /* FirebaseInstallations */; }; + 2E8A5AA42DBCD16500B1F281 /* FirebaseMLModelDownloader in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5AA32DBCD16500B1F281 /* FirebaseMLModelDownloader */; }; + 2E8A5AA62DBCD16500B1F281 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5AA52DBCD16500B1F281 /* FirebaseMessaging */; }; + 2E8A5AA82DBCD16500B1F281 /* FirebasePerformance in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5AA72DBCD16500B1F281 /* FirebasePerformance */; }; + 2E8A5AAA2DBCD16500B1F281 /* FirebaseRemoteConfig in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5AA92DBCD16500B1F281 /* FirebaseRemoteConfig */; }; + 2E8A5AAC2DBCD16500B1F281 /* FirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5AAB2DBCD16500B1F281 /* FirebaseStorage */; }; + 2E8A5AAE2DBCD16500B1F281 /* FirebaseStorageCombine-Community in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5AAD2DBCD16500B1F281 /* FirebaseStorageCombine-Community */; }; + 2E8A5AB02DBCD16500B1F281 /* FirebaseVertexAI in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5AAF2DBCD16500B1F281 /* FirebaseVertexAI */; }; + 2E8A5AB52DBD5B4300B1F281 /* SavedRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E8A5AB42DBD5B3200B1F281 /* SavedRow.swift */; }; + 2E8C3D992DBEE07B0074BFAB /* DetailedFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E8C3D982DBEE06E0074BFAB /* DetailedFilterView.swift */; }; + 2E8C3D9D2DBEE35D0074BFAB /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E8C3D9C2DBEE3590074BFAB /* SearchBar.swift */; }; + 2E9F75812EB6D441003FE0E0 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 2E9F757F2EB6D441003FE0E0 /* GoogleService-Info.plist */; }; + 2E9F75822EB6D441003FE0E0 /* resell-service.json in Resources */ = {isa = PBXBuildFile; fileRef = 2E9F75802EB6D441003FE0E0 /* resell-service.json */; }; + 2E9F75842EB6D66D003FE0E0 /* ForYouView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9F75832EB6D66D003FE0E0 /* ForYouView.swift */; }; + 2E9F758B2EB6D680003FE0E0 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9F75892EB6D680003FE0E0 /* SearchViewModel.swift */; }; + 2E9F758C2EB6D680003FE0E0 /* EditProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9F75862EB6D680003FE0E0 /* EditProfileViewModel.swift */; }; + 2E9F758D2EB6D680003FE0E0 /* CurrentUserProfileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9F75852EB6D680003FE0E0 /* CurrentUserProfileManager.swift */; }; + 2E9F758E2EB6D680003FE0E0 /* MessagesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9F75872EB6D680003FE0E0 /* MessagesViewModel.swift */; }; + 2E9F75922EB6D95E003FE0E0 /* MessageCluster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9F75902EB6D95E003FE0E0 /* MessageCluster.swift */; }; + 2E9F75942EB6D95E003FE0E0 /* Chat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9F758F2EB6D95E003FE0E0 /* Chat.swift */; }; + 2E9F75962EB6D968003FE0E0 /* MessageDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9F75952EB6D968003FE0E0 /* MessageDocument.swift */; }; + 2E9F75982EB6DA89003FE0E0 /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9F75972EB6DA89003FE0E0 /* Message.swift */; }; + 2E9F759A2EB6DC93003FE0E0 /* EmptyStateModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9F75992EB6DC93003FE0E0 /* EmptyStateModifier.swift */; }; + 2E9F759C2EB6DCBD003FE0E0 /* RangeSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9F759B2EB6DCBD003FE0E0 /* RangeSlider.swift */; }; + 2EBB64182D8B783800CCAC48 /* Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EBB64172D8B783600CCAC48 /* Filter.swift */; }; + 2ECB2F652E749ADD00CAACA2 /* (null) in Sources */ = {isa = PBXBuildFile; }; + 2ECB2F672E74E03700CAACA2 /* (null) in Sources */ = {isa = PBXBuildFile; }; + 2EEB57262F03C704008D2DF3 /* ReviewSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E02D3802F03C48200CCABE6 /* ReviewSection.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -141,7 +201,6 @@ 2C18FFE72CA1DC9800564577 /* Icon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icon.swift; sourceTree = ""; }; 2C18FFE92CA1E4C900564577 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; 2C3024722C90FFE60057D3D9 /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = ""; }; - 2C3024842C9221940057D3D9 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 2C41BB822CD8718600EFF69E /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = ""; }; 2C41BB842CD8811400EFF69E /* Post.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Post.swift; sourceTree = ""; }; 2C41BB872CD90DF800EFF69E /* CachedImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedImageView.swift; sourceTree = ""; }; @@ -161,6 +220,12 @@ 2C52E4F42C926FDA0042312C /* MainViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewModel.swift; sourceTree = ""; }; 2C6410932C9A70B400E4B390 /* String + Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String + Extensions.swift"; sourceTree = ""; }; 2C6410972C9DFD8B00E4B390 /* UIApplication + Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication + Extensions.swift"; sourceTree = ""; }; + 2C6FB1762CFACB2500B35FF8 /* FirestoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirestoreManager.swift; sourceTree = ""; }; + 2C6FB17D2CFADD5200B35FF8 /* ChatDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDocument.swift; sourceTree = ""; }; + 2C6FB1832CFAEC9400B35FF8 /* Resell.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Resell.entitlements; sourceTree = ""; }; + 2C6FB1852CFC08D000B35FF8 /* AvailabilitySelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvailabilitySelectorView.swift; sourceTree = ""; }; + 2C6FB1872CFF8ECD00B35FF8 /* FirebaseNotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseNotificationService.swift; sourceTree = ""; }; + 2C6FB18B2CFFBE7C00B35FF8 /* GoogleAuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleAuthManager.swift; sourceTree = ""; }; 2C7460882CEEE054004832F5 /* Report.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Report.swift; sourceTree = ""; }; 2C9337452C92A66C00818C8E /* FilterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterButton.swift; sourceTree = ""; }; 2C9337472C92AAEE00818C8E /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; @@ -187,7 +252,6 @@ 2C9B4CFF2C8FBC860029DF61 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 2C9B4D062C8FCB070029DF61 /* PurpleButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurpleButton.swift; sourceTree = ""; }; 2C9B4D082C8FD8200029DF61 /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = ""; }; - 2C9B4D0B2C90EF1D0029DF61 /* Launch Screen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = "Launch Screen.storyboard"; sourceTree = ""; }; 2C9B4D0D2C90F54D0029DF61 /* LoginGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginGradient.swift; sourceTree = ""; }; 2C9B4D0F2C90F69C0029DF61 /* UIScreen + Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScreen + Extensions.swift"; sourceTree = ""; }; 2C9EAF6D2CF268480010A44C /* Rubik-Medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Rubik-Medium.ttf"; sourceTree = ""; }; @@ -206,10 +270,8 @@ 2CDCEE602CD6BEAD008DF5E8 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; 2CDCEE622CD6C4B0008DF5E8 /* Keys.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Keys.xcconfig; sourceTree = ""; }; 2CDCEE632CD6D146008DF5E8 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; - 2CDCEE652CD8708B008DF5E8 /* UserSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionManager.swift; sourceTree = ""; }; 2CDDF30B2CCD871A0061A564 /* ChatsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatsViewModel.swift; sourceTree = ""; }; 2CDDF30D2CCD915E0061A564 /* MessagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesView.swift; sourceTree = ""; }; - 2CDDF30F2CCD99B70061A564 /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = ""; }; 2CE473922CC2148B00BD7E2C /* ProductDetailsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailsViewModel.swift; sourceTree = ""; }; 2CE473942CC2204500BD7E2C /* DraggableSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableSheetView.swift; sourceTree = ""; }; 2CE4739D2CC4575F00BD7E2C /* ReportOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportOptionsView.swift; sourceTree = ""; }; @@ -217,12 +279,35 @@ 2CE473A12CC4577500BD7E2C /* ReportConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportConfirmationView.swift; sourceTree = ""; }; 2CE473A32CC4595C00BD7E2C /* ReportViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportViewModel.swift; sourceTree = ""; }; 2CE473AB2CC5FF8A00BD7E2C /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; }; - 2CF356142CDD8C020045A173 /* EmptyState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyState.swift; sourceTree = ""; }; 2CF356162CDDC2110045A173 /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = ""; }; 2CF356182CDDD4A30045A173 /* HapticFeedbackGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticFeedbackGenerator.swift; sourceTree = ""; }; 2CF3561A2CDDD65F0045A173 /* SwipeableRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeableRow.swift; sourceTree = ""; }; 2CF3561C2CDE91170045A173 /* EditProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditProfileView.swift; sourceTree = ""; }; - 2CF3561E2CDE93E00045A173 /* EditProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditProfileViewModel.swift; sourceTree = ""; }; + 2CFE42712D4097CF007D503F /* Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = ""; }; + 2E02D3802F03C48200CCABE6 /* ReviewSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewSection.swift; sourceTree = ""; }; + 2E0A38862F0CBDE000362083 /* FollowListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowListView.swift; sourceTree = ""; }; + 2E2DBF4A2E99912100FC0225 /* SuggestionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionsView.swift; sourceTree = ""; }; + 2E2DBF4C2E9AE20700FC0225 /* UserCredibilityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCredibilityView.swift; sourceTree = ""; }; + 2E3034B62D6D4E4400C1FDA9 /* FilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterView.swift; sourceTree = ""; }; + 2E79E5882E870F8E0004CEA6 /* FiltersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersViewModel.swift; sourceTree = ""; }; + 2E8A5AB42DBD5B3200B1F281 /* SavedRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedRow.swift; sourceTree = ""; }; + 2E8C3D982DBEE06E0074BFAB /* DetailedFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailedFilterView.swift; sourceTree = ""; }; + 2E8C3D9C2DBEE3590074BFAB /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = ""; }; + 2E9F757F2EB6D441003FE0E0 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; + 2E9F75802EB6D441003FE0E0 /* resell-service.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "resell-service.json"; sourceTree = ""; }; + 2E9F75832EB6D66D003FE0E0 /* ForYouView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForYouView.swift; sourceTree = ""; }; + 2E9F75852EB6D680003FE0E0 /* CurrentUserProfileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentUserProfileManager.swift; sourceTree = ""; }; + 2E9F75862EB6D680003FE0E0 /* EditProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditProfileViewModel.swift; sourceTree = ""; }; + 2E9F75872EB6D680003FE0E0 /* MessagesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesViewModel.swift; sourceTree = ""; }; + 2E9F75882EB6D680003FE0E0 /* NotificationsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsViewModel.swift; sourceTree = ""; }; + 2E9F75892EB6D680003FE0E0 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; }; + 2E9F758F2EB6D95E003FE0E0 /* Chat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chat.swift; sourceTree = ""; }; + 2E9F75902EB6D95E003FE0E0 /* MessageCluster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCluster.swift; sourceTree = ""; }; + 2E9F75952EB6D968003FE0E0 /* MessageDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDocument.swift; sourceTree = ""; }; + 2E9F75972EB6DA89003FE0E0 /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = ""; }; + 2E9F75992EB6DC93003FE0E0 /* EmptyStateModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStateModifier.swift; sourceTree = ""; }; + 2E9F759B2EB6DCBD003FE0E0 /* RangeSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RangeSlider.swift; sourceTree = ""; }; + 2EBB64172D8B783600CCAC48 /* Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Filter.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -230,11 +315,42 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 2C30246F2C90FFB40057D3D9 /* GoogleSignIn in Frameworks */, - 2C3859E32CCD9FEA00DA20EA /* FirebaseFirestore in Frameworks */, - 2C3859E12CCD9FDE00DA20EA /* FirebaseCore in Frameworks */, - 2C3024712C90FFB40057D3D9 /* GoogleSignInSwift in Frameworks */, - 2C41BB8A2CD90EA100EFF69E /* Kingfisher in Frameworks */, + 2E8A5AAA2DBCD16500B1F281 /* FirebaseRemoteConfig in Frameworks */, + 2E8A5AA62DBCD16500B1F281 /* FirebaseMessaging in Frameworks */, + 2E8A5A882DBCD16500B1F281 /* FirebaseAppCheck in Frameworks */, + 2E8A5A232DBCC82E00B1F281 /* OAuth1 in Frameworks */, + 2E8A5A862DBCD16500B1F281 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */, + 2E8A5A922DBCD16500B1F281 /* FirebaseCrashlytics in Frameworks */, + 2E8A5A5D2DBCC87500B1F281 /* GoogleSignIn in Frameworks */, + 2E8A5AA82DBCD16500B1F281 /* FirebasePerformance in Frameworks */, + 2E8A5A982DBCD16500B1F281 /* FirebaseFirestore in Frameworks */, + 2E8A5A292DBCC82E00B1F281 /* TinyHTTPServer in Frameworks */, + 2E8A5A842DBCD16500B1F281 /* FirebaseAnalyticsOnDeviceConversion in Frameworks */, + 2E8A5A9A2DBCD16500B1F281 /* FirebaseFirestoreCombine-Community in Frameworks */, + 2E8A5A9C2DBCD16500B1F281 /* FirebaseFunctions in Frameworks */, + 2E8A5AB02DBCD16500B1F281 /* FirebaseVertexAI in Frameworks */, + 2E8A5A902DBCD16500B1F281 /* FirebaseCore in Frameworks */, + 2E8A5AA22DBCD16500B1F281 /* FirebaseInstallations in Frameworks */, + 2E8A5A942DBCD16500B1F281 /* FirebaseDatabase in Frameworks */, + 2E8A5A962DBCD16500B1F281 /* FirebaseDynamicLinks in Frameworks */, + 2E8A5A272DBCC82E00B1F281 /* SwiftyBase64 in Frameworks */, + 2E8A5A822DBCD16500B1F281 /* FirebaseAnalytics in Frameworks */, + 2E8A5AA02DBCD16500B1F281 /* FirebaseInAppMessaging-Beta in Frameworks */, + 2CF3CC7C2D017897001B90B5 /* OAuth2 in Frameworks */, + 2E8A5A5F2DBCC87500B1F281 /* GoogleSignInSwift in Frameworks */, + 2E8A5AAE2DBCD16500B1F281 /* FirebaseStorageCombine-Community in Frameworks */, + 2E8A5A622DBCC87F00B1F281 /* Flow in Frameworks */, + 2E8A5A652DBCC8A100B1F281 /* Kingfisher in Frameworks */, + 2CF3CC7A2D017897001B90B5 /* OAuth1 in Frameworks */, + 2E8A5A9E2DBCD16500B1F281 /* FirebaseFunctionsCombine-Community in Frameworks */, + 2E8A5A8C2DBCD16500B1F281 /* FirebaseAuth in Frameworks */, + 2E8A5A252DBCC82E00B1F281 /* OAuth2 in Frameworks */, + 2E8A5A8A2DBCD16500B1F281 /* FirebaseAppDistribution-Beta in Frameworks */, + 2E8A5AAC2DBCD16500B1F281 /* FirebaseStorage in Frameworks */, + 2CF3CC802D017897001B90B5 /* TinyHTTPServer in Frameworks */, + 2E8A5AA42DBCD16500B1F281 /* FirebaseMLModelDownloader in Frameworks */, + 2E8A5A8E2DBCD16500B1F281 /* FirebaseAuthCombine-Community in Frameworks */, + 2CF3CC7E2D017897001B90B5 /* SwiftyBase64 in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -258,9 +374,12 @@ 2C02B3A02CC073340020DF90 /* Home */ = { isa = PBXGroup; children = ( - 2C9337532C935C9500818C8E /* ChatsView.swift */, + 2E9F75832EB6D66D003FE0E0 /* ForYouView.swift */, + 2E2DBF4C2E9AE20700FC0225 /* UserCredibilityView.swift */, + 2E2DBF4A2E99912100FC0225 /* SuggestionsView.swift */, + 2E8C3D982DBEE06E0074BFAB /* DetailedFilterView.swift */, + 2E3034B62D6D4E4400C1FDA9 /* FilterView.swift */, 2C52E4F02C926C4B0042312C /* HomeView.swift */, - 2CDDF30D2CCD915E0061A564 /* MessagesView.swift */, 2C9337552C935CB600818C8E /* ProfileView.swift */, 2C9337512C935C8100818C8E /* SavedView.swift */, 2C41BB8D2CD97E3E00EFF69E /* SearchView.swift */, @@ -293,7 +412,8 @@ 2C3024782C91190A0057D3D9 /* Supporting */ = { isa = PBXGroup; children = ( - 2C3024842C9221940057D3D9 /* GoogleService-Info.plist */, + 2E9F757F2EB6D441003FE0E0 /* GoogleService-Info.plist */, + 2E9F75802EB6D441003FE0E0 /* resell-service.json */, ); path = Supporting; sourceTree = ""; @@ -305,18 +425,41 @@ name = Frameworks; sourceTree = ""; }; + 2C6FB17A2CFADCB700B35FF8 /* Firebase Models */ = { + isa = PBXGroup; + children = ( + 2E9F75952EB6D968003FE0E0 /* MessageDocument.swift */, + 2C6FB17D2CFADD5200B35FF8 /* ChatDocument.swift */, + 2CFE42712D4097CF007D503F /* Image.swift */, + ); + path = "Firebase Models"; + sourceTree = ""; + }; + 2C6FB1842CFBC0D600B35FF8 /* Chats */ = { + isa = PBXGroup; + children = ( + 2CDDF30D2CCD915E0061A564 /* MessagesView.swift */, + 2C9337532C935C9500818C8E /* ChatsView.swift */, + ); + path = Chats; + sourceTree = ""; + }; 2C9337442C929B0600818C8E /* Models */ = { isa = PBXGroup; children = ( + 2E9F75972EB6DA89003FE0E0 /* Message.swift */, + 2E9F758F2EB6D95E003FE0E0 /* Chat.swift */, + 2E9F75902EB6D95E003FE0E0 /* MessageCluster.swift */, + 2EBB64172D8B783600CCAC48 /* Filter.swift */, 2C16928C2CE41727009D2291 /* ErrorResponse.swift */, 2C16928E2CE43409009D2291 /* Feedback.swift */, 2C93374D2C92BE3000818C8E /* Item.swift */, 2CD7CAB82CE937B10056209E /* Listing.swift */, - 2CDDF30F2CCD99B70061A564 /* Message.swift */, 2C41BB842CD8811400EFF69E /* Post.swift */, 2C7460882CEEE054004832F5 /* Report.swift */, 2CF356162CDDC2110045A173 /* Request.swift */, 2CDCEE632CD6D146008DF5E8 /* User.swift */, + 2C6FB17A2CFADCB700B35FF8 /* Firebase Models */, ); path = Models; sourceTree = ""; @@ -330,6 +473,7 @@ 2C9B4CE42C8FB7B80029DF61 /* ResellUITests */, 2C9B4CC82C8FB7B70029DF61 /* Products */, 2C3859DF2CCD9FD400DA20EA /* Frameworks */, + 2E9F75762EB6D2BE003FE0E0 /* Recovered References */, ); sourceTree = ""; }; @@ -346,6 +490,7 @@ 2C9B4CC92C8FB7B70029DF61 /* Resell */ = { isa = PBXGroup; children = ( + 2C6FB1832CFAEC9400B35FF8 /* Resell.entitlements */, 2CDCEE5D2CD6BE8D008DF5E8 /* API */, 2C9337442C929B0600818C8E /* Models */, 2C9B4CFF2C8FBC860029DF61 /* Info.plist */, @@ -434,6 +579,7 @@ children = ( 2CBC6B5E2CB75ACD00C842A4 /* MainTabView.swift */, 2C52E4F22C926CFE0042312C /* MainView.swift */, + 2C6FB1842CFBC0D600B35FF8 /* Chats */, 2C02B3A02CC073340020DF90 /* Home */, 2CBC6B6A2CBDF74700C842A4 /* NewListing */, 2C02B3A12CC0734F0020DF90 /* Onboarding */, @@ -449,6 +595,11 @@ 2C9B4D052C8FCAF20029DF61 /* Components */ = { isa = PBXGroup; children = ( + 2E02D3802F03C48200CCABE6 /* ReviewSection.swift */, + 2E9F759B2EB6DCBD003FE0E0 /* RangeSlider.swift */, + 2E8C3D9C2DBEE3590074BFAB /* SearchBar.swift */, + 2E8A5AB42DBD5B3200B1F281 /* SavedRow.swift */, + 2C6FB1852CFC08D000B35FF8 /* AvailabilitySelectorView.swift */, 2C41BB872CD90DF800EFF69E /* CachedImageView.swift */, 2CBC6B682CBDDC8100C842A4 /* CustomPageControlIndicatorView.swift */, 2C1692922CE43737009D2291 /* CustomProgressView.swift */, @@ -476,7 +627,6 @@ isa = PBXGroup; children = ( 2C9B4CCA2C8FB7B70029DF61 /* ResellApp.swift */, - 2C9B4D0B2C90EF1D0029DF61 /* Launch Screen.storyboard */, ); path = Core; sourceTree = ""; @@ -484,8 +634,13 @@ 2C9B4D122C90FE170029DF61 /* ViewModels */ = { isa = PBXGroup; children = ( + 2E9F75852EB6D680003FE0E0 /* CurrentUserProfileManager.swift */, + 2E9F75862EB6D680003FE0E0 /* EditProfileViewModel.swift */, + 2E9F75872EB6D680003FE0E0 /* MessagesViewModel.swift */, + 2E9F75882EB6D680003FE0E0 /* NotificationsViewModel.swift */, + 2E9F75892EB6D680003FE0E0 /* SearchViewModel.swift */, + 2E79E5882E870F8E0004CEA6 /* FiltersViewModel.swift */, 2CDDF30B2CCD871A0061A564 /* ChatsViewModel.swift */, - 2CF3561E2CDE93E00045A173 /* EditProfileViewModel.swift */, 2C9337472C92AAEE00818C8E /* HomeViewModel.swift */, 2C9B4D082C8FD8200029DF61 /* LoginViewModel.swift */, 2C52E4F42C926FDA0042312C /* MainViewModel.swift */, @@ -515,6 +670,7 @@ isa = PBXGroup; children = ( 2C02B3A22CC074150020DF90 /* ProductDetailsView.swift */, + 2E0A38862F0CBDE000362083 /* FollowListView.swift */, 2CD7CABB2CE94ECB0056209E /* ExternalProfileView.swift */, ); path = ProductDetails; @@ -524,9 +680,11 @@ isa = PBXGroup; children = ( 2CDCEE602CD6BEAD008DF5E8 /* APIClient.swift */, + 2C6FB1762CFACB2500B35FF8 /* FirestoreManager.swift */, + 2C6FB18B2CFFBE7C00B35FF8 /* GoogleAuthManager.swift */, 2C41BB822CD8718600EFF69E /* KeychainManager.swift */, 2CDCEE5E2CD6BE99008DF5E8 /* NetworkManager.swift */, - 2CDCEE652CD8708B008DF5E8 /* UserSessionManager.swift */, + 2C6FB1872CFF8ECD00B35FF8 /* FirebaseNotificationService.swift */, ); path = API; sourceTree = ""; @@ -544,13 +702,20 @@ 2CF356132CDD8BD60045A173 /* ViewModifiers */ = { isa = PBXGroup; children = ( - 2CF356142CDD8C020045A173 /* EmptyState.swift */, + 2E9F75992EB6DC93003FE0E0 /* EmptyStateModifier.swift */, 2C1692902CE4361C009D2291 /* LoadingView.swift */, 2CD6CA8B2CB48286005A4F78 /* PopupModal.swift */, ); path = ViewModifiers; sourceTree = ""; }; + 2E9F75762EB6D2BE003FE0E0 /* Recovered References */ = { + isa = PBXGroup; + children = ( + ); + name = "Recovered References"; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -568,11 +733,42 @@ ); name = Resell; packageProductDependencies = ( - 2C30246E2C90FFB40057D3D9 /* GoogleSignIn */, - 2C3024702C90FFB40057D3D9 /* GoogleSignInSwift */, - 2C3859E02CCD9FDE00DA20EA /* FirebaseCore */, - 2C3859E22CCD9FEA00DA20EA /* FirebaseFirestore */, - 2C41BB892CD90EA100EFF69E /* Kingfisher */, + 2CF3CC792D017897001B90B5 /* OAuth1 */, + 2CF3CC7B2D017897001B90B5 /* OAuth2 */, + 2CF3CC7D2D017897001B90B5 /* SwiftyBase64 */, + 2CF3CC7F2D017897001B90B5 /* TinyHTTPServer */, + 2E8A5A222DBCC82E00B1F281 /* OAuth1 */, + 2E8A5A242DBCC82E00B1F281 /* OAuth2 */, + 2E8A5A262DBCC82E00B1F281 /* SwiftyBase64 */, + 2E8A5A282DBCC82E00B1F281 /* TinyHTTPServer */, + 2E8A5A5C2DBCC87500B1F281 /* GoogleSignIn */, + 2E8A5A5E2DBCC87500B1F281 /* GoogleSignInSwift */, + 2E8A5A612DBCC87F00B1F281 /* Flow */, + 2E8A5A642DBCC8A100B1F281 /* Kingfisher */, + 2E8A5A812DBCD16500B1F281 /* FirebaseAnalytics */, + 2E8A5A832DBCD16500B1F281 /* FirebaseAnalyticsOnDeviceConversion */, + 2E8A5A852DBCD16500B1F281 /* FirebaseAnalyticsWithoutAdIdSupport */, + 2E8A5A872DBCD16500B1F281 /* FirebaseAppCheck */, + 2E8A5A892DBCD16500B1F281 /* FirebaseAppDistribution-Beta */, + 2E8A5A8B2DBCD16500B1F281 /* FirebaseAuth */, + 2E8A5A8D2DBCD16500B1F281 /* FirebaseAuthCombine-Community */, + 2E8A5A8F2DBCD16500B1F281 /* FirebaseCore */, + 2E8A5A912DBCD16500B1F281 /* FirebaseCrashlytics */, + 2E8A5A932DBCD16500B1F281 /* FirebaseDatabase */, + 2E8A5A952DBCD16500B1F281 /* FirebaseDynamicLinks */, + 2E8A5A972DBCD16500B1F281 /* FirebaseFirestore */, + 2E8A5A992DBCD16500B1F281 /* FirebaseFirestoreCombine-Community */, + 2E8A5A9B2DBCD16500B1F281 /* FirebaseFunctions */, + 2E8A5A9D2DBCD16500B1F281 /* FirebaseFunctionsCombine-Community */, + 2E8A5A9F2DBCD16500B1F281 /* FirebaseInAppMessaging-Beta */, + 2E8A5AA12DBCD16500B1F281 /* FirebaseInstallations */, + 2E8A5AA32DBCD16500B1F281 /* FirebaseMLModelDownloader */, + 2E8A5AA52DBCD16500B1F281 /* FirebaseMessaging */, + 2E8A5AA72DBCD16500B1F281 /* FirebasePerformance */, + 2E8A5AA92DBCD16500B1F281 /* FirebaseRemoteConfig */, + 2E8A5AAB2DBCD16500B1F281 /* FirebaseStorage */, + 2E8A5AAD2DBCD16500B1F281 /* FirebaseStorageCombine-Community */, + 2E8A5AAF2DBCD16500B1F281 /* FirebaseVertexAI */, ); productName = Resell; productReference = 2C9B4CC72C8FB7B70029DF61 /* Resell.app */; @@ -647,9 +843,11 @@ ); mainGroup = 2C9B4CBE2C8FB7B70029DF61; packageReferences = ( - 2C9B4D112C90FD440029DF61 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */, - 2CDDF3112CCD9B530061A564 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, - 2C41BB862CD90D7300EFF69E /* XCRemoteSwiftPackageReference "Kingfisher" */, + 2E8A5A212DBCC82E00B1F281 /* XCRemoteSwiftPackageReference "google-auth-library-swift" */, + 2E8A5A5B2DBCC87500B1F281 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */, + 2E8A5A602DBCC87F00B1F281 /* XCRemoteSwiftPackageReference "SwiftUI-Flow" */, + 2E8A5A632DBCC8A100B1F281 /* XCRemoteSwiftPackageReference "Kingfisher" */, + 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); productRefGroup = 2C9B4CC82C8FB7B70029DF61 /* Products */; projectDirPath = ""; @@ -667,9 +865,9 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 2C3024852C9221940057D3D9 /* GoogleService-Info.plist in Resources */, + 2E9F75812EB6D441003FE0E0 /* GoogleService-Info.plist in Resources */, + 2E9F75822EB6D441003FE0E0 /* resell-service.json in Resources */, 2C9EAF702CF26DA00010A44C /* Rubik-Regular.ttf in Resources */, - 2C9B4D0C2C90EF1D0029DF61 /* Launch Screen.storyboard in Resources */, 2C9B4CD22C8FB7B80029DF61 /* Preview Assets.xcassets in Resources */, 2C9EAF6F2CF26D9D0010A44C /* Rubik-Medium.ttf in Resources */, 2C9B4CCF2C8FB7B80029DF61 /* Assets.xcassets in Resources */, @@ -698,28 +896,39 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2EEB57262F03C704008D2DF3 /* ReviewSection.swift in Sources */, 2C02B39D2CC069530020DF90 /* NewListingImagesView.swift in Sources */, - 2CDDF3102CCD99B70061A564 /* Message.swift in Sources */, 2C52E4F12C926C4B0042312C /* HomeView.swift in Sources */, + 2E9F75842EB6D66D003FE0E0 /* ForYouView.swift in Sources */, + 2E9F75922EB6D95E003FE0E0 /* MessageCluster.swift in Sources */, + 2E9F75942EB6D95E003FE0E0 /* Chat.swift in Sources */, + 2ECB2F652E749ADD00CAACA2 /* (null) in Sources */, 2C02B3972CC0336B0020DF90 /* NewListingViewModel.swift in Sources */, 2CDCEE642CD6D146008DF5E8 /* User.swift in Sources */, + 2E8C3D992DBEE07B0074BFAB /* DetailedFilterView.swift in Sources */, + 2E9F75962EB6D968003FE0E0 /* MessageDocument.swift in Sources */, 2C9B4D0E2C90F54D0029DF61 /* LoginGradient.swift in Sources */, 2C3024732C90FFE60057D3D9 /* Keys.swift in Sources */, 2C52E4F32C926CFE0042312C /* MainView.swift in Sources */, + 2E8A5A6B2DBCCCCD00B1F281 /* (null) in Sources */, 2C4DD97B2C98CC4D0055D0AB /* VenmoView.swift in Sources */, 2CDCEE612CD6BEAD008DF5E8 /* APIClient.swift in Sources */, + 2E9F759C2EB6DCBD003FE0E0 /* RangeSlider.swift in Sources */, 2C41BB8E2CD97E3E00EFF69E /* SearchView.swift in Sources */, + 2C6FB1882CFF8ECD00B35FF8 /* FirebaseNotificationService.swift in Sources */, 2C9337502C92BF4400818C8E /* Array + Extensions.swift in Sources */, - 2C525B7D2CB1DE55007D5B8E /* NotificationsSettingsView.swift in Sources */, + 2C6FB1862CFC08D000B35FF8 /* AvailabilitySelectorView.swift in Sources */, 2C6410942C9A70B400E4B390 /* String + Extensions.swift in Sources */, - 2CDCEE662CD8708B008DF5E8 /* UserSessionManager.swift in Sources */, + 2E9F75982EB6DA89003FE0E0 /* Message.swift in Sources */, 2CF3561D2CDE91170045A173 /* EditProfileView.swift in Sources */, 2CE473932CC2148B00BD7E2C /* ProductDetailsViewModel.swift in Sources */, 2C93374E2C92BE3000818C8E /* Item.swift in Sources */, + 2C6FB18C2CFFBE7C00B35FF8 /* GoogleAuthManager.swift in Sources */, 2C9337542C935C9500818C8E /* ChatsView.swift in Sources */, + 2E8A5AB52DBD5B4300B1F281 /* SavedRow.swift in Sources */, 2CD7CAB92CE937B10056209E /* Listing.swift in Sources */, + 2E79E5892E870F950004CEA6 /* FiltersViewModel.swift in Sources */, 2CD6CA8C2CB48286005A4F78 /* PopupModal.swift in Sources */, - 2CF3561F2CDE93E00045A173 /* EditProfileViewModel.swift in Sources */, 2C02B3992CC040AE0020DF90 /* PriceInputView.swift in Sources */, 2C7460892CEEE054004832F5 /* Report.swift in Sources */, 2C9337462C92A66C00818C8E /* FilterButton.swift in Sources */, @@ -728,8 +937,11 @@ 2C9B4D042C8FC8250029DF61 /* LoginView.swift in Sources */, 2C93374C2C92B6A500818C8E /* UIImage + Extensions.swift in Sources */, 2CF356172CDDC2110045A173 /* Request.swift in Sources */, + 2E0A38872F0CBDE000362083 /* FollowListView.swift in Sources */, + 2E3034B72D6D4E4900C1FDA9 /* FilterView.swift in Sources */, 2C18FFE42CA1322300564577 /* ProfileViewModel.swift in Sources */, 2CDDF30E2CCD915E0061A564 /* MessagesView.swift in Sources */, + 2E8A5A682DBCCB3400B1F281 /* (null) in Sources */, 2C9B4CCB2C8FB7B70029DF61 /* ResellApp.swift in Sources */, 2C4DD97D2C98D45B0055D0AB /* SetupProfileViewModel.swift in Sources */, 2CE473A42CC4595C00BD7E2C /* ReportViewModel.swift in Sources */, @@ -738,40 +950,55 @@ 2C9B4D072C8FCB070029DF61 /* PurpleButton.swift in Sources */, 2CBC6B652CB7A5A000C842A4 /* BlockedUsersView.swift in Sources */, 2CE473952CC2204500BD7E2C /* DraggableSheetView.swift in Sources */, + 2ECB2F672E74E03700CAACA2 /* (null) in Sources */, 2C41BB852CD8811400EFF69E /* Post.swift in Sources */, 2C525B812CB1F195007D5B8E /* SendFeedbackViewModel.swift in Sources */, 2C9337562C935CB600818C8E /* ProfileView.swift in Sources */, + 2E9F758B2EB6D680003FE0E0 /* SearchViewModel.swift in Sources */, + 2E9F758C2EB6D680003FE0E0 /* EditProfileViewModel.swift in Sources */, + 2E9F758D2EB6D680003FE0E0 /* CurrentUserProfileManager.swift in Sources */, + 2E9F758E2EB6D680003FE0E0 /* MessagesViewModel.swift in Sources */, 2C16928D2CE41727009D2291 /* ErrorResponse.swift in Sources */, + 2C6FB1772CFACB2500B35FF8 /* FirestoreManager.swift in Sources */, 2CE4739E2CC4575F00BD7E2C /* ReportOptionsView.swift in Sources */, + 2EBB64182D8B783800CCAC48 /* Filter.swift in Sources */, 2C9337482C92AAEE00818C8E /* HomeViewModel.swift in Sources */, + 2E8A5A692DBCCB3A00B1F281 /* (null) in Sources */, 2C4DD9832C98E3110055D0AB /* View + Extensions.swift in Sources */, + 2E8C3D9D2DBEE35D0074BFAB /* SearchBar.swift in Sources */, 2C6410982C9DFD8B00E4B390 /* UIApplication + Extensions.swift in Sources */, 2C9B4D102C90F69C0029DF61 /* UIScreen + Extensions.swift in Sources */, 2CF356192CDDD4A30045A173 /* HapticFeedbackGenerator.swift in Sources */, 2C18FFE82CA1DC9800564577 /* Icon.swift in Sources */, 2C16928F2CE43409009D2291 /* Feedback.swift in Sources */, 2CBC6B632CB772E300C842A4 /* NewRequestView.swift in Sources */, + 2C6FB17E2CFADD5200B35FF8 /* ChatDocument.swift in Sources */, 2C02B3A52CC097AB0020DF90 /* OptionsMenuView.swift in Sources */, 2CE473A02CC4576B00BD7E2C /* ReportDetailsView.swift in Sources */, 2CBC6B5B2CB72ED200C842A4 /* ExpandableAddButton.swift in Sources */, + 2CFE42722D4097CF007D503F /* Image.swift in Sources */, 2C41BB882CD90DF800EFF69E /* CachedImageView.swift in Sources */, 2CDDF30C2CCD871A0061A564 /* ChatsViewModel.swift in Sources */, + 2E8A5A672DBCCB0900B1F281 /* (null) in Sources */, 2CBC6B5F2CB75ACD00C842A4 /* MainTabView.swift in Sources */, 2C9B4D092C8FD8200029DF61 /* LoginViewModel.swift in Sources */, 2C9337522C935C8100818C8E /* SavedView.swift in Sources */, 2CBC6B672CBDDB6400C842A4 /* PaginatedImageView.swift in Sources */, 2CE473A22CC4577500BD7E2C /* ReportConfirmationView.swift in Sources */, 2C18FFEA2CA1E4C900564577 /* SettingsViewModel.swift in Sources */, + 2E2DBF4D2E9AE20C00FC0225 /* UserCredibilityView.swift in Sources */, 2C41BB8C2CD9276E00EFF69E /* ShimmerView.swift in Sources */, 2C1692912CE4361C009D2291 /* LoadingView.swift in Sources */, + 2E2DBF4B2E99912700FC0225 /* SuggestionsView.swift in Sources */, 2C525B7F2CB1E884007D5B8E /* SendFeedbackView.swift in Sources */, + 2E9F759A2EB6DC93003FE0E0 /* EmptyStateModifier.swift in Sources */, 2CBC6B692CBDDC8100C842A4 /* CustomPageControlIndicatorView.swift in Sources */, 2C4DD9812C98DC2D0055D0AB /* LabeledTextField.swift in Sources */, 2C18FFE62CA139D500564577 /* SettingsView.swift in Sources */, + 2E8A5A6A2DBCCB7C00B1F281 /* (null) in Sources */, 2C9B4CF82C8FB84F0029DF61 /* Constants.swift in Sources */, 2C93375A2C93667600818C8E /* TabViewIcon.swift in Sources */, 2C02B3A32CC074150020DF90 /* ProductDetailsView.swift in Sources */, - 2CF356152CDD8C020045A173 /* EmptyState.swift in Sources */, 2C93374A2C92AD2D00818C8E /* ProductsGalleryView.swift in Sources */, 2C02B39F2CC06D760020DF90 /* NewRequestViewModel.swift in Sources */, 2C1692932CE43737009D2291 /* CustomProgressView.swift in Sources */, @@ -944,6 +1171,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Resell/Resell.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Resell/Preview Content\""; @@ -951,6 +1179,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Resell/Info.plist; + INFOPLIST_KEY_NSCameraUsageDescription = "This app requires camera access to work properly"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -962,7 +1191,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.cornellappdev.Resell; + PRODUCT_BUNDLE_IDENTIFIER = com.cornellappdev.resell; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -976,6 +1205,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Resell/Resell.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Resell/Preview Content\""; @@ -983,6 +1213,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Resell/Info.plist; + INFOPLIST_KEY_NSCameraUsageDescription = "This app requires camera access to work properly"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -994,7 +1225,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.cornellappdev.Resell; + PRODUCT_BUNDLE_IDENTIFIER = com.cornellappdev.resell; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -1120,15 +1351,15 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 2C41BB862CD90D7300EFF69E /* XCRemoteSwiftPackageReference "Kingfisher" */ = { + 2E8A5A212DBCC82E00B1F281 /* XCRemoteSwiftPackageReference "google-auth-library-swift" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/onevcat/Kingfisher.git"; + repositoryURL = "https://github.com/googleapis/google-auth-library-swift"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 8.1.0; + minimumVersion = 0.5.3; }; }; - 2C9B4D112C90FD440029DF61 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */ = { + 2E8A5A5B2DBCC87500B1F281 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/google/GoogleSignIn-iOS.git"; requirement = { @@ -1136,41 +1367,208 @@ minimumVersion = 8.0.0; }; }; - 2CDDF3112CCD9B530061A564 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + 2E8A5A602DBCC87F00B1F281 /* XCRemoteSwiftPackageReference "SwiftUI-Flow" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tevelee/SwiftUI-Flow.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.0.2; + }; + }; + 2E8A5A632DBCC8A100B1F281 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/onevcat/Kingfisher.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 8.3.2; + }; + }; + 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 11.4.0; + minimumVersion = 11.12.0; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 2C30246E2C90FFB40057D3D9 /* GoogleSignIn */ = { + 2CF3CC792D017897001B90B5 /* OAuth1 */ = { + isa = XCSwiftPackageProductDependency; + productName = OAuth1; + }; + 2CF3CC7B2D017897001B90B5 /* OAuth2 */ = { + isa = XCSwiftPackageProductDependency; + productName = OAuth2; + }; + 2CF3CC7D2D017897001B90B5 /* SwiftyBase64 */ = { + isa = XCSwiftPackageProductDependency; + productName = SwiftyBase64; + }; + 2CF3CC7F2D017897001B90B5 /* TinyHTTPServer */ = { + isa = XCSwiftPackageProductDependency; + productName = TinyHTTPServer; + }; + 2E8A5A222DBCC82E00B1F281 /* OAuth1 */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A212DBCC82E00B1F281 /* XCRemoteSwiftPackageReference "google-auth-library-swift" */; + productName = OAuth1; + }; + 2E8A5A242DBCC82E00B1F281 /* OAuth2 */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A212DBCC82E00B1F281 /* XCRemoteSwiftPackageReference "google-auth-library-swift" */; + productName = OAuth2; + }; + 2E8A5A262DBCC82E00B1F281 /* SwiftyBase64 */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A212DBCC82E00B1F281 /* XCRemoteSwiftPackageReference "google-auth-library-swift" */; + productName = SwiftyBase64; + }; + 2E8A5A282DBCC82E00B1F281 /* TinyHTTPServer */ = { isa = XCSwiftPackageProductDependency; - package = 2C9B4D112C90FD440029DF61 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */; + package = 2E8A5A212DBCC82E00B1F281 /* XCRemoteSwiftPackageReference "google-auth-library-swift" */; + productName = TinyHTTPServer; + }; + 2E8A5A5C2DBCC87500B1F281 /* GoogleSignIn */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A5B2DBCC87500B1F281 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */; productName = GoogleSignIn; }; - 2C3024702C90FFB40057D3D9 /* GoogleSignInSwift */ = { + 2E8A5A5E2DBCC87500B1F281 /* GoogleSignInSwift */ = { isa = XCSwiftPackageProductDependency; - package = 2C9B4D112C90FD440029DF61 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */; + package = 2E8A5A5B2DBCC87500B1F281 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */; productName = GoogleSignInSwift; }; - 2C3859E02CCD9FDE00DA20EA /* FirebaseCore */ = { + 2E8A5A612DBCC87F00B1F281 /* Flow */ = { isa = XCSwiftPackageProductDependency; - package = 2CDDF3112CCD9B530061A564 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + package = 2E8A5A602DBCC87F00B1F281 /* XCRemoteSwiftPackageReference "SwiftUI-Flow" */; + productName = Flow; + }; + 2E8A5A642DBCC8A100B1F281 /* Kingfisher */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A632DBCC8A100B1F281 /* XCRemoteSwiftPackageReference "Kingfisher" */; + productName = Kingfisher; + }; + 2E8A5A812DBCD16500B1F281 /* FirebaseAnalytics */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalytics; + }; + 2E8A5A832DBCD16500B1F281 /* FirebaseAnalyticsOnDeviceConversion */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalyticsOnDeviceConversion; + }; + 2E8A5A852DBCD16500B1F281 /* FirebaseAnalyticsWithoutAdIdSupport */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalyticsWithoutAdIdSupport; + }; + 2E8A5A872DBCD16500B1F281 /* FirebaseAppCheck */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAppCheck; + }; + 2E8A5A892DBCD16500B1F281 /* FirebaseAppDistribution-Beta */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = "FirebaseAppDistribution-Beta"; + }; + 2E8A5A8B2DBCD16500B1F281 /* FirebaseAuth */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAuth; + }; + 2E8A5A8D2DBCD16500B1F281 /* FirebaseAuthCombine-Community */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = "FirebaseAuthCombine-Community"; + }; + 2E8A5A8F2DBCD16500B1F281 /* FirebaseCore */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseCore; }; - 2C3859E22CCD9FEA00DA20EA /* FirebaseFirestore */ = { + 2E8A5A912DBCD16500B1F281 /* FirebaseCrashlytics */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseCrashlytics; + }; + 2E8A5A932DBCD16500B1F281 /* FirebaseDatabase */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseDatabase; + }; + 2E8A5A952DBCD16500B1F281 /* FirebaseDynamicLinks */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseDynamicLinks; + }; + 2E8A5A972DBCD16500B1F281 /* FirebaseFirestore */ = { isa = XCSwiftPackageProductDependency; - package = 2CDDF3112CCD9B530061A564 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseFirestore; }; - 2C41BB892CD90EA100EFF69E /* Kingfisher */ = { + 2E8A5A992DBCD16500B1F281 /* FirebaseFirestoreCombine-Community */ = { isa = XCSwiftPackageProductDependency; - package = 2C41BB862CD90D7300EFF69E /* XCRemoteSwiftPackageReference "Kingfisher" */; - productName = Kingfisher; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = "FirebaseFirestoreCombine-Community"; + }; + 2E8A5A9B2DBCD16500B1F281 /* FirebaseFunctions */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseFunctions; + }; + 2E8A5A9D2DBCD16500B1F281 /* FirebaseFunctionsCombine-Community */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = "FirebaseFunctionsCombine-Community"; + }; + 2E8A5A9F2DBCD16500B1F281 /* FirebaseInAppMessaging-Beta */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = "FirebaseInAppMessaging-Beta"; + }; + 2E8A5AA12DBCD16500B1F281 /* FirebaseInstallations */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseInstallations; + }; + 2E8A5AA32DBCD16500B1F281 /* FirebaseMLModelDownloader */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseMLModelDownloader; + }; + 2E8A5AA52DBCD16500B1F281 /* FirebaseMessaging */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseMessaging; + }; + 2E8A5AA72DBCD16500B1F281 /* FirebasePerformance */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebasePerformance; + }; + 2E8A5AA92DBCD16500B1F281 /* FirebaseRemoteConfig */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseRemoteConfig; + }; + 2E8A5AAB2DBCD16500B1F281 /* FirebaseStorage */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseStorage; + }; + 2E8A5AAD2DBCD16500B1F281 /* FirebaseStorageCombine-Community */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = "FirebaseStorageCombine-Community"; + }; + 2E8A5AAF2DBCD16500B1F281 /* FirebaseVertexAI */ = { + isa = XCSwiftPackageProductDependency; + package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseVertexAI; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/Resell.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Resell.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6dfe8c7..e90f9fe 100644 --- a/Resell.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Resell.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "f2cef11ec486ccaf23c1f80563c7ab9227aed47b3a58c19d86c5f57644c92c1d", + "originHash" : "90964673195459f91f77de033ac190c815b120d6e627e83fa2dc832ed4539609", "pins" : [ { "identity" : "abseil-cpp-binary", "kind" : "remoteSourceControl", "location" : "https://github.com/google/abseil-cpp-binary.git", "state" : { - "revision" : "194a6706acbd25e4ef639bcaddea16e8758a3e27", - "version" : "1.2024011602.0" + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/app-check.git", "state" : { - "revision" : "21fe1af9be463a359aaf8d96789ef73fc3760d09", - "version" : "11.0.1" + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" } }, { @@ -24,8 +24,26 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/openid/AppAuth-iOS.git", "state" : { - "revision" : "c89ed571ae140f8eb1142735e6e23d7bb8c34cb2", - "version" : "1.7.5" + "revision" : "2781038865a80e2c425a1da12cc1327bcd56501f", + "version" : "1.7.6" + } + }, + { + "identity" : "bigint", + "kind" : "remoteSourceControl", + "location" : "https://github.com/attaswift/BigInt", + "state" : { + "revision" : "114343a705df4725dfe7ab8a2a326b8883cfd79c", + "version" : "5.5.1" + } + }, + { + "identity" : "cryptoswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", + "state" : { + "revision" : "af1b58fc569bfde777462349b9f7314b61762be0", + "version" : "1.3.2" } }, { @@ -33,8 +51,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk", "state" : { - "revision" : "8328630971a8fdd8072b36bb22bef732eb15e1f0", - "version" : "11.4.0" + "revision" : "fbd463894af94d90eb4d6a4e54080459a8179519", + "version" : "11.12.0" + } + }, + { + "identity" : "google-auth-library-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleapis/google-auth-library-swift", + "state" : { + "revision" : "4b510d91fc74f1415eae6dabc9836b8c3e1f44f6", + "version" : "0.5.3" } }, { @@ -42,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleAppMeasurement.git", "state" : { - "revision" : "4f234bcbdae841d7015258fbbf8e7743a39b8200", - "version" : "11.4.0" + "revision" : "f7460ea630bddf172115c28493ae8b3798d95ce3", + "version" : "11.12.0" } }, { @@ -78,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/grpc-binary.git", "state" : { - "revision" : "f56d8fc3162de9a498377c7b6cea43431f4f5083", - "version" : "1.65.1" + "revision" : "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71", + "version" : "1.69.0" } }, { @@ -105,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/interop-ios-for-google-sdks.git", "state" : { - "revision" : "2d12673670417654f08f5f90fdd62926dc3a2648", - "version" : "100.0.0" + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" } }, { @@ -114,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher.git", "state" : { - "revision" : "c0940e241945e6378c01fbd45fd3815579d47ef5", - "version" : "8.1.0" + "revision" : "7deda23bbdca612076c5c315003d8638a08ed0f1", + "version" : "8.3.2" } }, { @@ -145,13 +172,58 @@ "version" : "2.4.0" } }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "cd142fd2f64be2100422d658e7411e39489da985", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "6e17bc946821e550b88d22fd964423f70f1ce42d", + "version" : "2.82.0" + } + }, { "identity" : "swift-protobuf", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "ebc7251dd5b37f627c93698e4374084d98409633", - "version" : "1.28.2" + "revision" : "d72aed98f8253ec1aa9ea1141e28150f408cf17f", + "version" : "1.29.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "a34201439c74b53f0fd71ef11741af7e7caf01e1", + "version" : "1.4.2" + } + }, + { + "identity" : "swiftui-flow", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tevelee/SwiftUI-Flow.git", + "state" : { + "revision" : "fd755bc852c738d3b726c6a28fc4640c9a74876f", + "version" : "3.0.2" } } ], diff --git a/Resell.xcodeproj/project.xcworkspace/xcuserdata/sunr.xcuserdatad/UserInterfaceState.xcuserstate b/Resell.xcodeproj/project.xcworkspace/xcuserdata/sunr.xcuserdatad/UserInterfaceState.xcuserstate index 3e09a09..9213bf6 100644 Binary files a/Resell.xcodeproj/project.xcworkspace/xcuserdata/sunr.xcuserdatad/UserInterfaceState.xcuserstate and b/Resell.xcodeproj/project.xcworkspace/xcuserdata/sunr.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Resell.xcodeproj/xcshareddata/xcschemes/Resell.xcscheme b/Resell.xcodeproj/xcshareddata/xcschemes/Resell.xcscheme new file mode 100644 index 0000000..bf4e49b --- /dev/null +++ b/Resell.xcodeproj/xcshareddata/xcschemes/Resell.xcscheme @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resell.xcodeproj/xcuserdata/sunr.xcuserdatad/xcschemes/xcschememanagement.plist b/Resell.xcodeproj/xcuserdata/sunr.xcuserdatad/xcschemes/xcschememanagement.plist index 553c12b..cc2ebc6 100644 --- a/Resell.xcodeproj/xcuserdata/sunr.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Resell.xcodeproj/xcuserdata/sunr.xcuserdatad/xcschemes/xcschememanagement.plist @@ -4,26 +4,215 @@ SchemeUserState - Promises (Playground) 1.xcscheme + CryptoSwift (Playground) 1.xcscheme isShown orderHint 2 - Promises (Playground) 2.xcscheme + CryptoSwift (Playground) 2.xcscheme isShown orderHint 3 + CryptoSwift (Playground) 3.xcscheme + + isShown + + orderHint + 14 + + CryptoSwift (Playground) 4.xcscheme + + isShown + + orderHint + 15 + + CryptoSwift (Playground) 5.xcscheme + + isShown + + orderHint + 16 + + CryptoSwift (Playground) 6.xcscheme + + isShown + + orderHint + 19 + + CryptoSwift (Playground) 7.xcscheme + + isShown + + orderHint + 20 + + CryptoSwift (Playground) 8.xcscheme + + isShown + + orderHint + 21 + + CryptoSwift (Playground).xcscheme + + isShown + + orderHint + 0 + + Demo (Playground) 1.xcscheme + + isShown + + orderHint + 8 + + Demo (Playground) 10.xcscheme + + isShown + + orderHint + 23 + + Demo (Playground) 11.xcscheme + + isShown + + orderHint + 24 + + Demo (Playground) 2.xcscheme + + isShown + + orderHint + 9 + + Demo (Playground) 3.xcscheme + + isShown + + orderHint + 13 + + Demo (Playground) 4.xcscheme + + isShown + + orderHint + 17 + + Demo (Playground) 5.xcscheme + + isShown + + orderHint + 18 + + Demo (Playground) 6.xcscheme + + isShown + + orderHint + 16 + + Demo (Playground) 7.xcscheme + + isShown + + orderHint + 17 + + Demo (Playground) 8.xcscheme + + isShown + + orderHint + 18 + + Demo (Playground) 9.xcscheme + + isShown + + orderHint + 22 + + Demo (Playground).xcscheme + + isShown + + orderHint + 7 + + Promises (Playground) 1.xcscheme + + isShown + + orderHint + 5 + + Promises (Playground) 2.xcscheme + + isShown + + orderHint + 6 + + Promises (Playground) 3.xcscheme + + isShown + + orderHint + 10 + + Promises (Playground) 4.xcscheme + + isShown + + orderHint + 11 + + Promises (Playground) 5.xcscheme + + isShown + + orderHint + 12 + + Promises (Playground) 6.xcscheme + + isShown + + orderHint + 19 + + Promises (Playground) 7.xcscheme + + isShown + + orderHint + 20 + + Promises (Playground) 8.xcscheme + + isShown + + orderHint + 21 + Promises (Playground).xcscheme isShown orderHint - 1 + 4 Resell.xcscheme_^#shared#^_ diff --git a/Resell/.DS_Store b/Resell/.DS_Store index 6143002..f8e9f94 100644 Binary files a/Resell/.DS_Store and b/Resell/.DS_Store differ diff --git a/Resell/API/APIClient.swift b/Resell/API/APIClient.swift index 2c21d17..c5b1d06 100644 --- a/Resell/API/APIClient.swift +++ b/Resell/API/APIClient.swift @@ -10,11 +10,11 @@ import SwiftUI protocol APIClient { - func get(url: URL) async throws -> T + func get(url: URL, attempt: Int) async throws -> T - func post(url: URL, body: U) async throws -> T + func post(url: URL, body: U, attempt: Int) async throws -> T - func delete(url: URL) async throws + func delete(url: URL, attempt: Int) async throws } diff --git a/Resell/API/FirebaseNotificationService.swift b/Resell/API/FirebaseNotificationService.swift new file mode 100644 index 0000000..16aede6 --- /dev/null +++ b/Resell/API/FirebaseNotificationService.swift @@ -0,0 +1,122 @@ +// +// FirebaseNotificationService.swift +// Resell +// +// Created by Richie Sun on 12/3/24. +// + +import Foundation +import FirebaseMessaging +import UserNotifications +import UIKit +import os + +class FirebaseNotificationService: NSObject, MessagingDelegate, UNUserNotificationCenterDelegate { + + // MARK: - Singleton Instance + + static let shared = FirebaseNotificationService() + + // MARK: - Error Logger for Networking + + let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.cornellappdev.Resell", category: #file) + + // MARK: - Properties + + var fcmToken: String? + + // MARK: - Configure Firebase Messaging + + func configure() { + Messaging.messaging().delegate = self + UNUserNotificationCenter.current().delegate = self + + requestNotificationAuthorization() + } + + // MARK: - Request Notification Authorization + + private func requestNotificationAuthorization() { + let options: UNAuthorizationOptions = [.alert, .badge, .sound] + + UNUserNotificationCenter.current().getNotificationSettings { settings in + switch settings.authorizationStatus { + case .notDetermined: + UNUserNotificationCenter.current().requestAuthorization(options: options) { granted, error in + if let error = error { + self.logger.error("Error requesting notifications permission: \(error)") + } else if granted { + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + } else { + self.logger.log("Notifications permission denied.") + } + } + case .authorized, .provisional: + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + case .denied: + self.logger.log("Notifications permission denied. Cannot register for remote notifications.") + case .ephemeral: + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + self.logger.log("App has ephemeral authorization for notifications.") + @unknown default: + break + } + } + } + + // MARK: - Get FCM Registration Token + + func getFCMRegToken() async -> String? { + return await withCheckedContinuation { continuation in + Messaging.messaging().token { token, error in + if let error = error { + self.logger.error("Error fetching FCM registration token: \(error)") + continuation.resume(returning: nil) + } else { + self.fcmToken = token + continuation.resume(returning: token) + } + } + } + } + + + // MARK: - Monitor FCM Reg Token + + func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { + let dataDict: [String: String] = ["token": fcmToken ?? ""] + NotificationCenter.default.post( + name: Notification.Name("FCMToken"), + object: nil, + userInfo: dataDict + ) + } + + // MARK: - Handle Notification Responses + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if let navigationId = response.notification.request.content.userInfo["navigationId"] as? String { + navigateToScreen(with: navigationId) + } + + completionHandler() + } + + // MARK: - Helpers + + private func navigateToScreen(with navigationId: String) { + // TODO: Deeplinking + FirestoreManager.shared.logger.error("Navigating to screen with ID: \(navigationId)") + } +} + diff --git a/Resell/API/FirestoreManager.swift b/Resell/API/FirestoreManager.swift new file mode 100644 index 0000000..e3abe0a --- /dev/null +++ b/Resell/API/FirestoreManager.swift @@ -0,0 +1,258 @@ +// +// FirestoreManager.swift +// Resell +// +// Created by Richie Sun on 11/29/24. +// + +import FirebaseFirestore +import os +import FirebaseVertexAI + +class FirestoreManager { + + // MARK: - Singleton Instance + + static let shared = FirestoreManager() + + // MARK: - Init + + private init() { } + + // MARK: - Properties + + private let chatsCollection = Firestore.firestore().collection("chats_refactored") + var listeners: [String: ListenerRegistration] = [:] + let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.cornellappdev.Resell", category: "FirestoreManager") + + // MARK: - Chat Functions + + func findChatId(listingId: String, buyerId: String, sellerId: String) async throws -> String? { + let query = chatsCollection + .whereField(ChatDocument.listingIdKey, isEqualTo: listingId) + .whereField(ChatDocument.buyerIdKey, isEqualTo: buyerId) + .whereField(ChatDocument.sellerIdKey, isEqualTo: sellerId) + + let snapshot = try await query.getDocuments() + return snapshot.documents.first?.documentID + } + + /// Subscribe to chats where a specific field of the chat document is equal to a specific value + func subscribeToChatsWhereField(_ field: String, isEqualTo: Any, onSnapshotUpdate: @escaping ([Chat]) -> Void) { + let query = chatsCollection + .whereField(field, isEqualTo: isEqualTo) + + // remove the listener if it exists + listeners[field]?.remove() + + // add a new listener + listeners[field] = query.addSnapshotListener { [weak self] querySnapshot, error in + guard let self = self else { return } + + if let error = error { + logger.error("Error loading chat previews: \(error)") + onSnapshotUpdate([]) + return + } + + guard let documents = querySnapshot?.documents else { + logger.log("No documents found.") + onSnapshotUpdate([]) + return + } + + guard let user = GoogleAuthManager.shared.user else { + GoogleAuthManager.shared.logger.error("Error in \(#file) \(#function): User not available.") + onSnapshotUpdate([]) + return + } + + var chats: [Chat] = [] + let group = DispatchGroup() + + for document in documents { + group.enter() + + let chatDocument = try? document.data(as: ChatDocument.self) + let chatId = document.documentID + + Task { + // Fetch messages for this chat + let messagesQuery = self.chatsCollection.document(chatId).collection("messages") + + do { + let messagesSnapshot = try await messagesQuery.getDocuments() +// let messageDocuments = try messagesSnapshot.documents.compactMap({ try $0.data(as: MessageDocument.self) }) + let messageDocuments = try messagesSnapshot.documents.compactMap({ doc in + return try doc.data(as: MessageDocument.self) + }) + + // Create chat with messages + if let chatDocument = chatDocument { + let chat = try await chatDocument.toChat(userId: user.firebaseUid, messages: messageDocuments) + chats.append(chat) + } + } catch { + self.logger.error("Error fetching messages for chat \(chatId): \(error)") + } + + group.leave() + } + } + + group.notify(queue: .main) { + let sortedChats = chats.sorted(by: { $0.updatedAt > $1.updatedAt }) + onSnapshotUpdate(sortedChats) + } + } + } + + /// Subscribe to buyer chats + func subscribeToBuyerChats(onUpdate: @escaping ([Chat]) -> Void) { + guard let user = GoogleAuthManager.shared.user else { + GoogleAuthManager.shared.logger.error("Error in \(#file) \(#function): User id not available.") + onUpdate([]) + return + } + + subscribeToChatsWhereField(ChatDocument.buyerIdKey, isEqualTo: user.firebaseUid, onSnapshotUpdate: onUpdate) + } + + /// Subscribe to seller chats + func subscribeToSellerChats(onUpdate: @escaping ([Chat]) -> Void) { + guard let user = GoogleAuthManager.shared.user else { + GoogleAuthManager.shared.logger.error("Error in \(#file) \(#function): User id not available.") + onUpdate([]) + return + } + + subscribeToChatsWhereField(ChatDocument.sellerIdKey, isEqualTo: user.firebaseUid, onSnapshotUpdate: onUpdate) + } + + /// Subscribe to single chat updates by id + func subscribeToChat( + _ id: String, + onSnapshotUpdate: @escaping ([Chat]) -> Void + ) { + let chatQuery = chatsCollection + .document(id) + + let messagesQuery = chatQuery.collection("messages") + + // remove all listeners from the dictionary + listeners.forEach { _, listener in + listener.remove() + } + + listeners = [:] + + // add a new listener + listeners["chat"] = messagesQuery.addSnapshotListener { [weak self] messagesSnapshot, error in + guard let self = self else { return } + + if let error = error { + logger.error("Error loading chat: \(error)") + onSnapshotUpdate([]) + return + } + + guard let messages = messagesSnapshot else { + logger.log("No document found.") + onSnapshotUpdate([]) + return + } + + let messageDocuments = messages.documents.compactMap({ doc in + do { + return try doc.data(as: MessageDocument.self) + } catch { + self.logger.error("Error decoding message document: \(error)") + } + + return nil + }) + + Task { + guard let chatDocument = try? await self.chatsCollection.document(id).getDocument(as: ChatDocument.self) else { + self.logger.error("Unable to get chat document from collection") + onSnapshotUpdate([]) + return + } + + guard let userId = GoogleAuthManager.shared.user?.firebaseUid else { + self.logger.error("Unable to get user id") + onSnapshotUpdate([]) + return + } + + let chat = try? await chatDocument.toChat(userId: userId, messages: messageDocuments) + + if let chat { onSnapshotUpdate([chat]) } + } + } + } + + /// Stop listening for updates + func stopListening() { + listeners.forEach { _, listener in + listener.remove() + } + + listeners = [:] + } + + /// Stop listening to the purchase and buyer chats + func stopListeningAll() { + listeners[ChatDocument.buyerIdKey]?.remove() + listeners[ChatDocument.sellerIdKey]?.remove() + listeners.removeValue(forKey: ChatDocument.buyerIdKey) + listeners.removeValue(forKey: ChatDocument.sellerIdKey) + } + + /// Stop listening to a single chat + func stopListeningToChat() { + listeners["chat"]?.remove() + listeners.removeValue(forKey: "chat") + } + +} + +extension Date { + func toFormattedString() -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + return formatter.string(from: self) + } + + static func timeAgo(from timestampString: String) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + guard let date = formatter.date(from: timestampString) else { + return "Invalid Date" + } + + let relativeFormatter = RelativeDateTimeFormatter() + relativeFormatter.unitsStyle = .full + + let now = Date() + return relativeFormatter.localizedString(for: date, relativeTo: now) + } + + static func timeAgo(from datetime: Date) -> String { + let relativeFormatter = RelativeDateTimeFormatter() + relativeFormatter.unitsStyle = .full + + let now = Date() + return relativeFormatter.localizedString(for: datetime, relativeTo: now) + } + + var iso8601String: String { + let formatter = ISO8601DateFormatter() + return formatter.string(from: self) + } + + func adding(minutes: Int) -> Date { + return Calendar.current.date(byAdding: .minute, value: minutes, to: self)! + } +} diff --git a/Resell/API/GoogleAuthManager.swift b/Resell/API/GoogleAuthManager.swift new file mode 100644 index 0000000..f0e9e35 --- /dev/null +++ b/Resell/API/GoogleAuthManager.swift @@ -0,0 +1,159 @@ +// +// GoogleAuthManager.swift +// Resell +// +// Created by Richie Sun on 12/3/24. +// + +import FirebaseAuth +import GoogleSignIn +import OAuth2 +import os +import SwiftUI + +class GoogleAuthManager { + + // MARK: - Singleton Instance + + static let shared = GoogleAuthManager() + + // MARK: - Error Logger for Google Auth + + let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.cornellappdev.Resell", category: #file) + + // MARK: - Properties + + var user: User? + + // MARK: - Init + + private init() { } + + // MARK: - Functions + + func getValidToken() async throws -> String { + // Firebase handles caching automatically + guard let token = try await Auth.auth().currentUser?.getIDToken(forcingRefresh: false) else { + throw GoogleAuthError.noUserSignedIn + } + return token + } + + func signIn() async throws { + guard let presentingViewController = await (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.windows.first?.rootViewController else { return } + + // Wait for result of sign-in + let gidSignInResult = try await GIDSignIn.sharedInstance.signIn(withPresenting: presentingViewController) + try await getCredentialsFromGoogleUser(user: gidSignInResult.user) + try await authorizeUser() + } + + /// Try to refresh the access token of the current user if it exists, or the restored user. + /// If this function throws a full logout is needed. + func refreshSignInIfNeeded() async throws { + // Restore or verify sign-in + if GIDSignIn.sharedInstance.currentUser == nil { + try await GIDSignIn.sharedInstance.restorePreviousSignIn() + } + print("check1") + // Get current user or throw + guard let currentUser = GIDSignIn.sharedInstance.currentUser else { + throw GoogleAuthError.noUserSignedIn + } + + print("can't get curr user") + + // Refresh tokens + try await currentUser.refreshTokensIfNeeded() + print("refreshed tokens") + + try await getCredentialsFromGoogleUser(user: currentUser) // this function is cooked + print("check2") + + try await authorizeUser() + print("check3") + + } + + func getCredentialsFromGoogleUser(user: GIDGoogleUser) async throws { + guard let idToken = user.idToken?.tokenString else { + // TODO: Throw a better error + throw GoogleAuthError.noUserSignedIn + } + print("idtoken: \(idToken)") + // Convert to firebase credential + let credentials = GoogleAuthProvider.credential(withIDToken: idToken, accessToken: user.accessToken.tokenString) + print("got credentials") + + let authResult = try await Auth.auth().signIn(with: credentials) + print("got auth result") + + self.user = try User.fromGUser(user, firebaseUserId: authResult.user.uid) + print("got user") + + // Update accessToken and authorize the user with backend + let accessToken = try await getValidToken() + print("accessToken:", accessToken) + } + + func signOut() { + logger.info("Signing out user") + + // Sign out from Google + GIDSignIn.sharedInstance.signOut() + + // Sign out from Firebase + do { + try Auth.auth().signOut() + } catch { + logger.error("Error signing out from Firebase: \(error.localizedDescription)") + } + + // Clear stored credentials + user = nil + + logger.info("User signed out successfully") + } + + /// Force logout user due to authentication failures + /// This should be called when authentication cannot be recovered + func forceLogout(reason: String = "Authentication failed") { + logger.warning("Forcing user logout. Reason: \(reason)") + + // Perform logout + signOut() + + // Notify the app that user needs to be logged out + DispatchQueue.main.async { + NotificationCenter.default.post( + name: Constants.Notifications.LogoutUser, + object: nil + ) + } + } + + private func authorizeUser() async throws { + // Send FCM token to backend + guard let fcmToken = await FirebaseNotificationService.shared.getFCMRegToken() else { + throw GoogleAuthError.noFCMToken + } + + let body = AuthorizeBody(token: fcmToken) + self.user = try await NetworkManager.shared.authorize(authorizeBody: body) + } +} + +enum GoogleAuthError: Error, LocalizedError { + case noUserSignedIn + case noFCMToken + + var errorDescription: String? { + switch self { + case .noUserSignedIn: + return "No user is currently signed in." + case .noFCMToken: + return "Firebase Cloud Messaging token is missing. Please check configuration." + } + } + +} diff --git a/Resell/API/NetworkManager.swift b/Resell/API/NetworkManager.swift index 2c2b4aa..f4924a0 100644 --- a/Resell/API/NetworkManager.swift +++ b/Resell/API/NetworkManager.swift @@ -9,46 +9,82 @@ import Combine import Foundation import os -class NetworkManager: APIClient { - +class NetworkManager { + // MARK: - Singleton Instance - + static let shared = NetworkManager() - + // MARK: - Error Logger for Networking - - let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.cornellappdev.Resell", category: "Network") - + + let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.cornellappdev.Resell", category: "#file") + // MARK: - Properties - - private let hostURL: String = Keys.prodServerURL - + + private let hostURL: String = Keys.localServerURL + private let maxAttempts = 2 + // MARK: - Init - + private init() { } - + // MARK: - Template Helper Functions + + /// Centralized network error handling that determines whether to retry or force logout + private func handleNetworkError(_ error: Error, attempt: Int, retryOperation: () async throws -> T) async throws -> T { + // If we've hit max attempts, force logout and throw max retries error + if attempt >= maxAttempts { + logger.error("Max retry attempts (\(self.maxAttempts)) reached. Forcing user logout.") + GoogleAuthManager.shared.forceLogout(reason: "Max authentication retry attempts exceeded") + throw ErrorResponse.maxRetriesHit + } + + // Check if this is a 401 unauthorized error that we can potentially recover from + if let errorResponse = error as? ErrorResponse, errorResponse.httpCode == 401 { + logger.info("Received 401 error on attempt \(attempt). Attempting to refresh auth token.") + + do { + // Try to refresh the authentication + try await GoogleAuthManager.shared.refreshSignInIfNeeded() + logger.info("Auth token refreshed successfully. Retrying network request.") + + // Retry the operation + return try await retryOperation() + } catch { + logger.error("Failed to refresh auth token: \(error.localizedDescription)") + GoogleAuthManager.shared.forceLogout(reason: "Failed to refresh authentication token") + throw error + } + } + + // For non-401 errors, don't retry and just throw the original error + throw error + } + + /// Template function to FETCH data from URL and decodes it into a specified type `T`, + /// + /// The function fetches data from the network, verifies the + /// HTTP status code, caches the response, decodes the data, and then returns it as a decoded model. + /// + /// - Parameter url: The URL from which data should be fetched. + /// - Returns: A publisher that emits a decoded instance of type `T` or an error if the decoding or network request fails. + /// + func get(url: URL, attempt: Int = 1) async throws -> T { + let request = try await createRequest(url: url, method: "GET") + + let (data, response) = try await URLSession.shared.data(for: request) + + do { + try handleResponse(data: data, response: response) + } catch { + return try await handleNetworkError(error, attempt: attempt) { + try await get(url: url, attempt: attempt + 1) + } + } - /// Template function to FETCH data from URL and decodes it into a specified type `T`, with caching support. - /// - /// This function first checks if a cached response for the given URL is available in `URLCache`. - /// If cached data exists, it decodes and returns it immediately, bypassing the network request. - /// If there is no cached response, the function fetches data from the network, verifies the - /// HTTP status code, caches the response, decodes the data, and then returns it as a decoded model. - /// - /// - Parameter url: The URL from which data should be fetched. - /// - Returns: A publisher that emits a decoded instance of type `T` or an error if the decoding or network request fails. - /// - func get(url: URL) async throws -> T { - let request = try createRequest(url: url, method: "GET") - - let (data, response) = try await URLSession.shared.data(for: request) - - try handleResponse(data: data, response: response) - - return try JSONDecoder().decode(T.self, from: data) - } - + return try JSONDecoder().decode(T.self, from: data) + } + /// Template function to POST data to a specified URL with an encodable body and decodes the response into a specified type `T`. /// /// This function takes a URL and a request body, encodes the body as JSON, and sends it as part of @@ -61,74 +97,102 @@ class NetworkManager: APIClient { /// - body: The data to be sent in the request body, which must conform to `Encodable`. /// - Returns: A publisher that emits a decoded instance of type `T` or an error if the decoding or network request fails. /// - func post(url: URL, body: U) async throws -> T { + func post(url: URL, body: U, attempt: Int = 1) async throws -> T { let requestData = try JSONEncoder().encode(body) - let request = try createRequest(url: url, method: "POST", body: requestData) + let request = try await createRequest(url: url, method: "POST", body: requestData) let (data, response) = try await URLSession.shared.data(for: request) - try handleResponse(data: data, response: response) + do { + try handleResponse(data: data, response: response) + } catch { + return try await handleNetworkError(error, attempt: attempt) { + try await post(url: url, body: body, attempt: attempt + 1) + } + } return try JSONDecoder().decode(T.self, from: data) } /// Overloaded post function for requests without a return - func post(url: URL, body: U) async throws{ + func post(url: URL, body: U, attempt: Int = 1) async throws { let requestData = try JSONEncoder().encode(body) - let request = try createRequest(url: url, method: "POST", body: requestData) + let request = try await createRequest(url: url, method: "POST", body: requestData) let (data, response) = try await URLSession.shared.data(for: request) - try handleResponse(data: data, response: response) + do { + try handleResponse(data: data, response: response) + } catch { + try await handleNetworkError(error, attempt: attempt) { + try await post(url: url, body: body, attempt: attempt + 1) + } + } } /// Overloaded post function for requests without a body - func post(url: URL) async throws -> T { - let request = try createRequest(url: url, method: "POST") + func post(url: URL, attempt: Int = 1) async throws -> T { + let request = try await createRequest(url: url, method: "POST") let (data, response) = try await URLSession.shared.data(for: request) - try handleResponse(data: data, response: response) + do { + try handleResponse(data: data, response: response) + } catch { + return try await handleNetworkError(error, attempt: attempt) { + try await post(url: url, attempt: attempt + 1) + } + } return try JSONDecoder().decode(T.self, from: data) } /// Template function to DELETE data to a specified URL with an encodable body and decodes the response into a specified type `T`. - func delete(url: URL) async throws { - let request = try createRequest(url: url, method: "DELETE") + func delete(url: URL, attempt: Int = 1) async throws { + let request = try await createRequest(url: url, method: "DELETE") let (data, response) = try await URLSession.shared.data(for: request) - try handleResponse(data: data, response: response) + do { + try handleResponse(data: data, response: response) + } catch { + try await handleNetworkError(error, attempt: attempt) { + try await delete(url: url, attempt: attempt + 1) + } + } } + + private func createRequest(url: URL, method: String, body: Data? = nil) async throws -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Content-Type") - private func createRequest(url: URL, method: String, body: Data? = nil) throws -> URLRequest { - var request = URLRequest(url: url) - request.httpMethod = method - request.setValue("application/json", forHTTPHeaderField: "Content-Type") + // refactor to use cached token if valid... + let accessToken = try await GoogleAuthManager.shared.getValidToken() + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - if let accessToken = UserSessionManager.shared.accessToken { - request.setValue("\(accessToken)", forHTTPHeaderField: "Authorization") - } +// if let accessToken = GoogleAuthManager.shared.accessToken { +// request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") +// } - request.httpBody = body - return request + request.httpBody = body + return request } - + private func constructURL(endpoint: String) throws -> URL { guard let url = URL(string: "\(hostURL)\(endpoint)") else { logger.error("Failed to construct URL for endpoint: \(endpoint)") throw URLError(.badURL) } - + return url } - + private func handleResponse(data: Data, response: URLResponse) throws { guard let httpResponse = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } - + if httpResponse.statusCode != 200 { if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: data) { throw errorResponse @@ -137,208 +201,309 @@ class NetworkManager: APIClient { } } } - + // MARK: - Auth Networking Functions - - func getUser() async throws -> UserResponse { + + func authorize(authorizeBody: AuthorizeBody) async throws -> User? { let url = try constructURL(endpoint: "/auth/") - return try await get(url: url) + return try await post(url: url, body: authorizeBody) } - func getUserSession(id: String) async throws -> UserSessionData { - let url = try constructURL(endpoint: "/auth/sessions/\(id)/") - + func getUser() async throws -> UserResponse { + let url = try constructURL(endpoint: "/auth/") + return try await get(url: url) } - + func createUser(user: CreateUserBody) async throws { - let url = try constructURL(endpoint: "/auth/") - + let url = try constructURL(endpoint: "/user/create") + try await post(url: url, body: user) } - + func logout() async throws -> LogoutResponse { let url = try constructURL(endpoint: "/auth/logout/") - + return try await post(url: url) } - + func deleteAccount(userID: String) async throws { let url = try constructURL(endpoint: "/auth/id/\(userID)/") - + try await delete(url: url) } - + // MARK: - User Networking Functions - + func getUserByGoogleID(googleID: String) async throws -> UserResponse { let url = try constructURL(endpoint: "/user/googleId/\(googleID)/") - + return try await get(url: url) } - + func getUserByID(id: String) async throws -> UserResponse { let url = try constructURL(endpoint: "/user/id/\(id)/") - + return try await get(url: url) } - + func updateUserProfile(edit: EditUserBody) async throws -> UserResponse { let url = try constructURL(endpoint: "/user/") - + return try await post(url: url, body: edit) } - + func getBlockedUsers(id: String) async throws -> UsersResponse { let url = try constructURL(endpoint: "/user/blocked/id/\(id)") - + return try await get(url: url) } - + func blockUser(blocked: BlockUserBody) async throws { let url = try constructURL(endpoint: "/user/block/") - + try await post(url: url, body: blocked) } - + func unblockUser(unblocked: UnblockUserBody) async throws { let url = try constructURL(endpoint: "/user/unblock/") - + try await post(url: url, body: unblocked) } - - // MARK: - Post Networking Functions - - func getAllPosts() async throws -> PostsResponse { - let url = try constructURL(endpoint: "/post/") - + + func followUser(follow: FollowUserBody) async throws -> UserResponse { + let url = try constructURL(endpoint: "/user/follow/") + + return try await post(url: url, body: follow) + } + + func unfollowUser(unfollow: UnfollowUserBody) async throws -> UserResponse { + let url = try constructURL(endpoint: "/user/unfollow/") + + return try await post(url: url, body: unfollow) + } + + func getFollowers(id: String) async throws -> UsersResponse { + let url = try constructURL(endpoint: "/user/followers/id/\(id)/") + return try await get(url: url) } + + func getFollowing(id: String) async throws -> UsersResponse { + let url = try constructURL(endpoint: "/user/following/id/\(id)/") + + return try await get(url: url) + } + + // MARK: - Post Networking Functions + + func getAllPosts(page: Int = 1) async throws -> PostsResponse { + let url = try constructURL(endpoint: "/post?page=\(page)") + return try await get(url: url) + } + + func getUnifiedFilteredPosts(filters: FilterPostsUnifiedRequest) async throws -> PostsResponse { + let url = try constructURL(endpoint: "/post/filter/") + + return try await post(url: url, body: filters) + } + func getSavedPosts() async throws -> PostsResponse { let url = try constructURL(endpoint: "/post/save/") - + return try await get(url: url) } + + func getFilteredPostsByCategory(for filters: [String]) async throws -> PostsResponse { + let url = try constructURL(endpoint: "/post/filterByCategories") + return try await post(url: url, body: FilterRequest(categories: filters)) + } + + // this can prob go bye bye func getFilteredPosts(by filter: String) async throws -> PostsResponse { let url = try constructURL(endpoint: "/post/filter/") - - return try await post(url: url, body: FilterRequest(category: filter)) + + return try await post(url: url, body: FilterRequest(categories: [filter])) } - - func getSearchedPosts(with keywords: String) async throws -> PostsResponse { + + func getSearchedPosts(with keywords: String) async throws -> SearchedPostResponse { let url = try constructURL(endpoint: "/post/search/") - + return try await post(url: url, body: SearchRequest(keywords: keywords)) } - + + func getSearchSuggestions(searchIndex: String) async throws -> SuggestionsWrapper { + let url = try constructURL(endpoint: "/post/searchSuggestions/\(searchIndex)") + + return try await get(url: url) + } + func getPostsByUserID(id: String) async throws -> PostsResponse { let url = try constructURL(endpoint: "/post/userId/\(id)/") - + return try await get(url: url) } - + func getArchivedPostsByUserID(id: String) async throws -> PostsResponse { let url = try constructURL(endpoint: "/post/archive/userId/\(id)/") - + return try await get(url: url) } - + func getPostByID(id: String) async throws -> PostResponse { let url = try constructURL(endpoint: "/post/id/\(id)/") - + return try await get(url: url) } - + func getSimilarPostsByID(id: String) async throws -> PostsResponse { let url = try constructURL(endpoint: "/post/similar/postId/\(id)/") - + return try await get(url: url) } - + func savePostByID(id: String) async throws -> PostResponse { let url = try constructURL(endpoint: "/post/save/postId/\(id)/") - + return try await post(url: url) } - + func unsavePostByID(id: String) async throws -> PostResponse { let url = try constructURL(endpoint: "/post/unsave/postId/\(id)/") - + return try await post(url: url) } - + func postIsSaved(id: String) async throws -> SavedResponse { let url = try constructURL(endpoint: "/post/isSaved/postId/\(id)/") - + return try await get(url: url) } - + + func filterByPrice(prices: PriceBody) async throws -> PostsResponse { + let url = try constructURL(endpoint: "/post/filterByPrice/") + + return try await post(url: url, body: prices) + } + + func filterByCondition(conditions: [String]) async throws -> PostsResponse { + let url = try constructURL(endpoint: "/post/filterByCondition/") + + return try await post(url: url, body: ConditionBody(conditions: conditions)) + } + + func filterPriceLowtoHigh() async throws -> PostsResponse { + let url = try constructURL(endpoint: "/post/priceLowtoHigh/") + + return try await get(url: url) + } + + func filterPriceHightoLow() async throws -> PostsResponse { + let url = try constructURL(endpoint: "/post/priceHightoLow/") + + return try await get(url: url) + } + + func filterNewlyListed() async throws -> PostsResponse { + let url = try constructURL(endpoint: "/post/filterNewlyListed/") + + return try await get(url: url) + } + func createPost(postBody: PostBody) async throws -> ListingResponse { let url = try constructURL(endpoint: "/post/") return try await post(url: url, body: postBody) } - + func archivePost(id: String) async throws -> PostResponse { let url = try constructURL(endpoint: "/post/archive/postId/\(id)/") - + return try await post(url: url) } - + func deletePost(id: String) async throws { let url = try constructURL(endpoint: "/post/id/\(id)/") - + try await delete(url: url) } - + // MARK: - Request Networking Functions - + func getRequestsByUserID(id: String) async throws -> RequestsResponse { let url = try constructURL(endpoint: "/request/userId/\(id)/") - + return try await get(url: url) } - + func postRequest(request: RequestBody) async throws -> RequestResponse { let url = try constructURL(endpoint: "/request/") - + return try await post(url: url, body: request) } - + func deleteRequest(id: String) async throws { let url = try constructURL(endpoint: "/request/id/\(id)/") - + try await delete(url: url) } - + // MARK: - Feedback Networking Functions - + func postFeedback(feedback: FeedbackBody) async throws { let url = try constructURL(endpoint: "/feedback/") - + try await post(url: url, body: feedback) } - + // MARK: - Reporting Networking Functions - + func reportPost(reportBody: ReportPostBody) async throws { let url = try constructURL(endpoint: "/report/post/") - + try await post(url: url, body: reportBody) } - + func reportUser(reportBody: ReportUserBody) async throws { let url = try constructURL(endpoint: "/report/user/") - + try await post(url: url, body: reportBody) } - + func reportMessage(reportBody: ReportMessageBody) async throws { let url = try constructURL(endpoint: "/report/message/") - + try await post(url: url, body: reportBody) } + + // MARK: - Chat Networking Functions + + func sendChatMessage(chatId: String, messageBody: MessageBody) async throws { + let url = try constructURL(endpoint: "/chat/message/\(chatId)/") + + return try await post(url: url, body: messageBody) + } + + func sendChatAvailability(chatId: String, messageBody: MessageBody) async throws { + let url = try constructURL(endpoint: "/chat/availability/\(chatId)/") + + return try await post(url: url, body: messageBody) + } + + func markMessageRead(chatId: String, messageId: String) async throws -> ReadMessageRepsonse { + let url = try constructURL(endpoint: "/chat/\(chatId)/message/\(messageId)/") + + return try await post(url: url) + } + + // MARK: - Other Networking Functions + + func uploadImage(image: ImageBody) async throws -> ImageResponse { + let url = try constructURL(endpoint: "/image/") + + return try await post(url: url, body: image) + } } diff --git a/Resell/Core/ResellApp.swift b/Resell/Core/ResellApp.swift index 57d808f..a8c9bb2 100644 --- a/Resell/Core/ResellApp.swift +++ b/Resell/Core/ResellApp.swift @@ -5,16 +5,84 @@ // Created by Richie Sun on 9/9/24. // +import Firebase +import FirebaseMessaging import GoogleSignIn -import Kingfisher import SwiftUI +import UserNotifications +import DeviceCheck +import Kingfisher @main struct ResellApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + @StateObject var mainViewModel = MainViewModel() + + init() { + //TODO: Refactor... + HomeViewModel.shared.configure(mainViewModel: mainViewModel) + setupKingfisher() + } + + private func setupKingfisher() { + // Limit concurrent downloads to 4 (prevents CPU overload) + ImageDownloader.default.sessionConfiguration.httpMaximumConnectionsPerHost = 4 + + // Enable progressive loading for better UX + ImageDownloader.default.sessionConfiguration.timeoutIntervalForRequest = 15 + } + var body: some Scene { WindowGroup { MainView() + .environmentObject(mainViewModel) + .environmentObject(HomeViewModel.shared) } } } + +// MARK: - AppDelegate + +class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + FirebaseApp.configure() + FirebaseNotificationService.shared.configure() + return true + } + + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + Messaging.messaging().apnsToken = deviceToken + Task { + await FirebaseNotificationService.shared.getFCMRegToken() + } + } + + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + + Messaging.messaging().appDidReceiveMessage(userInfo) + FirestoreManager.shared.logger.log("Received remote notification: \(userInfo)") + + completionHandler(.newData) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.sound, .badge]) + } + +} diff --git a/Resell/Core/ResellAppDelegate.swift b/Resell/Core/ResellAppDelegate.swift new file mode 100644 index 0000000..2a408af --- /dev/null +++ b/Resell/Core/ResellAppDelegate.swift @@ -0,0 +1,39 @@ +// +// ResellAppDelegate.swift +// Resell +// +// Created by Angelina Chen on 11/30/24. +// + +import SwiftUI +import UserNotifications + +class ResellAppDelegate: NSObject, UIApplicationDelegate, ObservableObject { + + var app: ResellApp? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + application.registerForRemoteNotifications() + + // Setting notification delegate + UNUserNotificationCenter.current().delegate = self + + return true + } + + func application(_ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let stringifiedToken = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() + print("stringifiedToken:", stringifiedToken) + } +} + +extension ResellAppDelegate: UNUserNotificationCenterDelegate { + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { + print("Got notification title: ", response.notification.request.content.title) + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { + return [.badge, .banner, .list, .sound] + } +} diff --git a/Resell/Info.plist b/Resell/Info.plist index 621cd09..6e13772 100644 --- a/Resell/Info.plist +++ b/Resell/Info.plist @@ -2,16 +2,19 @@ - NSCameraUsageDescription - This app requires camera access to work properly - PROD_DATABASE_ID + NSAppTransportSecurity + + NSExceptionDomains + + NSAllowsLocalNetworking + + + CFBundleInfoDictionaryVersion + 6.0 + CFBundleExecutable - DEV_DATABASE_ID + CFBundleIdentifier - RESELL_PROD_URL - $(RESELL_PROD_URL) - RESELL_DEV_URL - $(RESELL_DEV_URL) CFBundleURLTypes @@ -25,11 +28,29 @@ + FIREBASE_URL + $(FIREBASE_URL) + DEV_DATABASE_ID + + PROD_DATABASE_ID + + RESELL_DEV_URL + $(RESELL_DEV_URL) + RESELL_SSHDEV_URL + $(RESELL_SSHDEV_URL) + RESELL_LOCAL_URL + $(RESELL_LOCAL_URL) + RESELL_PROD_URL + $(RESELL_PROD_URL) UIAppFonts Rubik-Regular.ttf Rubik-Medium.ttf ReemKufi-Regular.ttf + UIBackgroundModes + + remote-notification + diff --git a/Resell/Models/Chat.swift b/Resell/Models/Chat.swift new file mode 100644 index 0000000..497d00e --- /dev/null +++ b/Resell/Models/Chat.swift @@ -0,0 +1,35 @@ +// +// ChatPreview.swift +// Resell +// +// Created by Peter Bidoshi on 2/25/25. +// + +import Foundation + +struct Chat: Identifiable { + let id: String? + let post: Post + let other: User + let lastMessage: String + let updatedAt: Date + let messages: [any Message] +} + +struct ChatInfo: Equatable, Hashable { + let listing: Post + let buyer: User + let seller: User + + static func == (lhs: ChatInfo, rhs: ChatInfo) -> Bool { + return lhs.listing.id == rhs.listing.id + && lhs.buyer.firebaseUid == rhs.buyer.firebaseUid + && lhs.seller.firebaseUid == rhs.seller.firebaseUid + } + + func hash(into hasher: inout Hasher) { + hasher.combine(listing.id) + hasher.combine(buyer.firebaseUid) + hasher.combine(seller.firebaseUid) + } +} diff --git a/Resell/Models/ErrorResponse.swift b/Resell/Models/ErrorResponse.swift index 03e89be..18b4120 100644 --- a/Resell/Models/ErrorResponse.swift +++ b/Resell/Models/ErrorResponse.swift @@ -7,7 +7,17 @@ import Foundation -struct ErrorResponse: Codable, Error { +struct ErrorResponse: Codable, Error, Equatable, LocalizedError { let error: String let httpCode: Int + + static let accountCreationNeeded = ErrorResponse(error: "User not found. Please create an account first.", httpCode: 403) + static let noCornellEmail = ErrorResponse(error: "User not found. Please create an account first.", httpCode: 403) + static let usernameAlreadyExists = ErrorResponse(error: "UserModel with same username already exists!", httpCode: 409) + static let userNotFound = ErrorResponse(error: "User not found.", httpCode: 404) + static let maxRetriesHit = ErrorResponse(error: "Max retries hit. Please try again later.", httpCode: 429) + + var errorDescription: String? { + return error + } } diff --git a/Resell/Models/Feedback.swift b/Resell/Models/Feedback.swift index 2f7f157..9fe78d5 100644 --- a/Resell/Models/Feedback.swift +++ b/Resell/Models/Feedback.swift @@ -7,10 +7,6 @@ import Foundation -struct FeedbackResponse: Codable { - let feedbacks: [Feedback] -} - struct Feedback: Codable { let id: String let description: String @@ -23,3 +19,8 @@ struct FeedbackBody: Codable { let images: [String] let userId: String } + +struct FeedbackResponse: Codable { + let feedbacks: [Feedback] +} + diff --git a/Resell/Models/Filter.swift b/Resell/Models/Filter.swift new file mode 100644 index 0000000..abfba47 --- /dev/null +++ b/Resell/Models/Filter.swift @@ -0,0 +1,25 @@ +// +// Filter.swift +// Resell +// +// Created by Charles Liggins on 3/19/25. +// +import Foundation + +struct FilterPostsUnifiedRequest: Codable { + var sortField: String? + var price: PriceBody? + var categories: [String]? + var condition: [String]? +} + +struct PriceBody: Codable { + let lowerBound: Int + let upperBound: Int +} + +// we can prob refactor this... +struct ConditionBody: Codable { + let conditions: [String] +} + diff --git a/Resell/Models/Firebase Models/ChatDocument.swift b/Resell/Models/Firebase Models/ChatDocument.swift new file mode 100644 index 0000000..1fc7882 --- /dev/null +++ b/Resell/Models/Firebase Models/ChatDocument.swift @@ -0,0 +1,43 @@ +// +// ChatDocument.swift +// Resell +// +// Created by Peter Bidoshi on 2/25/25. +// + +import FirebaseFirestore + +/// Structure of each chat document inside the chats collection on Firestore +struct ChatDocument: Codable { + @DocumentID var id: String? + let buyerID: String + let lastMessage: String + let listingID: String + let sellerID: String + let updatedAt: Date + let userIDs: [String] + + /// Convert chat document to chat, using the current users user id + func toChat(userId: String, messages: [MessageDocument]) async throws -> Chat { + let post = try await NetworkManager.shared.getPostByID(id: listingID).post + let buyer = try await NetworkManager.shared.getUserByID(id: buyerID).user + let seller = try await NetworkManager.shared.getUserByID(id: sellerID).user + + guard let post else { throw ErrorResponse.userNotFound } + + return Chat( + id: id, + post: post, + other: userId == buyerID ? seller : buyer, + lastMessage: lastMessage, + updatedAt: updatedAt, + messages: messages.map { $0.toMessage(buyer: buyer, seller: seller) } + ) + } + + static let buyerIdKey = CodingKeys.buyerID.stringValue + static let sellerIdKey = CodingKeys.sellerID.stringValue + static let idKey = CodingKeys.id.stringValue + static let listingIdKey = CodingKeys.listingID.stringValue +} + diff --git a/Resell/Models/Firebase Models/Image.swift b/Resell/Models/Firebase Models/Image.swift new file mode 100644 index 0000000..f1dfc51 --- /dev/null +++ b/Resell/Models/Firebase Models/Image.swift @@ -0,0 +1,17 @@ +// +// Image.swift +// Resell +// +// Created by Richie Sun on 1/21/25. +// + +import Foundation + +struct ImageBody: Encodable { + let imageBase64: String +} + +struct ImageResponse: Decodable { + let image: String +} + diff --git a/Resell/Models/Firebase Models/MessageDocument.swift b/Resell/Models/Firebase Models/MessageDocument.swift new file mode 100644 index 0000000..a6189bd --- /dev/null +++ b/Resell/Models/Firebase Models/MessageDocument.swift @@ -0,0 +1,77 @@ +// +// MessageDocument.swift +// Resell +// +// Created by Peter Bidoshi on 2/25/25. +// + +import FirebaseFirestore + +/// Structure of each message document inside the messages subcollection of a chat on Firestore +struct MessageDocument: Codable, Hashable { + + @DocumentID var id: String? + let type: MessageType + let senderID: String + let timestamp: Date + var read: Bool? + + // Normal Message Fields + let text: String? + let images: [String]? + + // Availability Message Fields + let availabilities: [Availability]? + + // Proposal Message Fields + let startDate: Date? + let endDate: Date? + let accepted: Bool? + + static func == (lhs: MessageDocument, rhs: MessageDocument) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + /// Converts a MessageDocument to a Message + func toMessage(buyer: User, seller: User) -> any Message { + let from = senderID == buyer.firebaseUid ? buyer : seller + let fromUser: Bool = from.firebaseUid == GoogleAuthManager.shared.user?.firebaseUid ?? "" + let id = id ?? UUID().uuidString + + switch type { + case .chat: + return ChatMessage( + messageId: id, + timestamp: timestamp, + read: read ?? true, + mine: fromUser, + from: from, + text: text ?? "", + images: images ?? [] + ) + case .availability: + return AvailabilityMessage( + messageId: id, + timestamp: timestamp, + mine: fromUser, + from: from, + availabilities: availabilities?.map{ $0 } ?? [] + ) + case .proposal: + return ProposalMessage( + messageId: id, + timestamp: timestamp, + mine: fromUser, + from: from, + startDate: startDate ?? Date(), + endDate: endDate ?? Date(), + accepted: accepted + ) + } + } + +} diff --git a/Resell/Models/Message.swift b/Resell/Models/Message.swift index 13428e6..4bf91a1 100644 --- a/Resell/Models/Message.swift +++ b/Resell/Models/Message.swift @@ -2,25 +2,112 @@ // Message.swift // Resell // -// Created by Richie Sun on 10/26/24. +// Created by Peter Bidoshi on 2/25/25. // -import SwiftUI +import Foundation -struct FirebaseUser: Codable, Identifiable { - var id: String - var avatar: String - var name: String +protocol Message: Codable, Hashable { + var messageId: String { get set } + var messageType: MessageType { get } + var timestamp: Date { get set } + var read: Bool { get set } + var mine: Bool { get set } + var from: User { get set } + /// Has this message been confirmed to have been sent? + var sent: Bool { get set } + + func isEqual(to other: any Message) -> Bool } -struct Message: Codable, Identifiable { - var id: String +struct ChatMessage: Message { + + var messageId: String + var messageType: MessageType = .chat + var timestamp: Date + var read: Bool = true + var mine: Bool + var from: User + var sent: Bool = true var text: String - var createdAt: Date - var user: FirebaseUser - var isSentByCurrentUser: Bool + var images: [String] + + func isEqual(to other: any Message) -> Bool { + guard let otherMessage = other as? ChatMessage else { + return false + } + + return self.sent == otherMessage.sent && self.messageId == otherMessage.messageId + } + +} + +struct AvailabilityMessage: Message { + + var messageId: String + var messageType: MessageType = .availability + var timestamp: Date + var read: Bool = true + var mine: Bool + var from: User + var sent: Bool = true + var availabilities: [Availability] + + func isEqual(to other: any Message) -> Bool { + guard let otherMessage = other as? AvailabilityMessage else { + return false + } + + return self.sent == otherMessage.sent && self.messageId == otherMessage.messageId + } +} + +struct ProposalMessage: Message { + var messageId: String + var messageType: MessageType = .proposal + var timestamp: Date + var read: Bool = true + var mine: Bool + var sent: Bool = true + var from: User + var startDate: Date + var endDate: Date + /// Has this proposal been accepted? `nil` if no action has been taken + var accepted: Bool? + + func isEqual(to other: any Message) -> Bool { + guard let otherMessage = other as? ProposalMessage else { + return false + } + + return self.sent == otherMessage.sent && self.messageId == otherMessage.messageId + } +} + +struct Availability: Codable, Hashable { + let startDate: Date + let endDate: Date +} + +enum MessageType: String, Codable { + case chat = "message" + case availability = "availability" + case proposal = "proposal" } struct MessageBody: Codable { - let id: String + let type: MessageType + let listingId: String + let buyerId: String + let sellerId: String + let senderId: String + let text: String? + let images: [String]? + let availabilities: [Availability]? + let startDate: Date? + let endDate: Date? +} + +struct ReadMessageRepsonse: Codable { + let read: Bool } diff --git a/Resell/Models/MessageCluster.swift b/Resell/Models/MessageCluster.swift new file mode 100644 index 0000000..c643ee2 --- /dev/null +++ b/Resell/Models/MessageCluster.swift @@ -0,0 +1,42 @@ +// +// MessageCluster.swift +// Resell +// +// Created by Peter Bidoshi on 2/26/25. +// + +import SwiftUICore + +struct MessageCluster: Equatable { + + let id: String = UUID().uuidString + let location: MessageLocation + var messages: [any Message] + + static func == (lhs: MessageCluster, rhs: MessageCluster) -> Bool { + if lhs.messages.count != rhs.messages.count { return false } + + for i in 0.. Bool { return lhs.id == rhs.id } - static func sortPostsByDate(_ posts: [Post], ascending: Bool = true) -> [Post] { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - dateFormatter.locale = Locale(identifier: "en_US_POSIX") + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func sortPostsByDate(_ posts: [Post], ascending: Bool = false) -> [Post] { + let isoDateFormatter = ISO8601DateFormatter() + isoDateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return posts.sorted { - guard let date1 = dateFormatter.date(from: $0.created), - let date2 = dateFormatter.date(from: $1.created) else { + guard let date1 = isoDateFormatter.date(from: $0.created), + let date2 = isoDateFormatter.date(from: $1.created) else { return ascending } + return ascending ? date1 < date2 : date1 > date2 } } } +struct PostCategory: Codable { + let id: String + let name: String +} + struct PostsResponse: Codable { let posts: [Post] } struct PostResponse: Codable { - let post: Post + let post: Post? +} + +struct SearchedPostResponse: Codable { + let posts: [Post] + let searchId: String } struct FilterRequest: Codable { - let category: String + let categories: [String] +} + +struct SuggestionsWrapper: Codable { + let postIds: [String] } struct SearchRequest: Codable { @@ -70,7 +91,8 @@ struct PostBody: Codable { let title: String let description: String let categories: [String] - let originalPrice: Double + let condition: String + let original_price: Double let imagesBase64: [String] let userId: String @@ -78,7 +100,8 @@ struct PostBody: Codable { case title case description case categories - case originalPrice = "original_price" + case condition + case original_price = "original_price" case imagesBase64 case userId } diff --git a/Resell/Models/Report.swift b/Resell/Models/Report.swift index cfdfadf..ee7e92d 100644 --- a/Resell/Models/Report.swift +++ b/Resell/Models/Report.swift @@ -23,3 +23,40 @@ struct ReportMessageBody: Codable { let message: MessageBody let reason: String } + +struct Report: Codable, Identifiable { + let id: String + let reporter: User + let reported: User + let post: Post? + let message: ReportMessage? + let reason: String + let type: ReportType + let resolved: Bool + let created: Date + + enum ReportType: String, Codable { + case post + } + + enum CodingKeys: String, CodingKey { + case id + case reporter + case reported + case post + case message + case reason + case type + case resolved + case created + } +} + +struct ReportMessage: Codable { + let id: String + let content: String + let sender: User + let receiver: User + let created: Date + let read: Bool +} diff --git a/Resell/Models/Request.swift b/Resell/Models/Request.swift index fe410b1..dd480ab 100644 --- a/Resell/Models/Request.swift +++ b/Resell/Models/Request.swift @@ -11,7 +11,7 @@ struct Request: Codable { let id: String let title: String let description: String - let user: User + let user: User? } struct RequestsResponse: Codable { diff --git a/Resell/Models/User.swift b/Resell/Models/User.swift index c8a8559..39d7667 100644 --- a/Resell/Models/User.swift +++ b/Resell/Models/User.swift @@ -6,26 +6,93 @@ // import Foundation +import FirebaseAuth +import GoogleSignIn -struct User: Codable { - let id: String +struct User: Codable, Equatable, Hashable { + let firebaseUid: String let username: String let netid: String let givenName: String let familyName: String let admin: Bool + let isActive: Bool + let stars: String + let numReviews: Int + let following: [User]? + let followers: [User]? + let soldPosts: Int? let photoUrl: URL - let venmoHandle: String + let venmoHandle: String? let email: String let googleId: String let bio: String - let isActive: Bool + let posts: [Post]? + let saved: [Post]? + let feedbacks: [Feedback]? + let requests: [Request]? let blocking: [String]? let blockers: [String]? - let reports: [String]? + let reports: [Report]? let reportedBy: [String]? - let posts: [Post]? - let feedbacks: [Feedback]? + + func toCreateUserBody(username: String, bio: String, venmoHandle: String, imageUrl: String, fcmToken: String) -> CreateUserBody { + return CreateUserBody( + username: username, + netid: self.netid, + givenName: self.givenName, + familyName: self.familyName, + photoUrl: imageUrl, + venmoHandle: venmoHandle, + email: self.email, + googleId: self.googleId, + bio: bio, + fcmToken: fcmToken + ) + } + + static func fromGUser(_ user: GIDGoogleUser, firebaseUserId: String) throws -> User { + guard let defaultImageUrl = URL(string: "http://www.gravatar.com/avatar/?d=mp") else { + // TODO: Throw better error + throw URLError(.badServerResponse) + } + + return User( + firebaseUid: firebaseUserId, + username: user.profile?.email ?? "", + netid: String(user.profile?.email.split(separator: "@")[0] ?? ""), + givenName: user.profile?.givenName ?? "", + familyName: user.profile?.familyName ?? "", + admin: false, + isActive: true, + stars: "0.0", + numReviews: 0, + following: [], + followers: [], + soldPosts: 0, + photoUrl: user.profile?.imageURL(withDimension: 512) ?? defaultImageUrl, + venmoHandle: "", + email: user.profile?.email ?? "", + googleId: user.userID ?? "", + bio: "", + posts: [], + saved: [], + feedbacks: [], + requests: [], + blocking: [], + blockers: [], + reports: [], + reportedBy: [] + ) + } + + static func == (lhs: User, rhs: User) -> Bool { + return lhs.firebaseUid == rhs.firebaseUid + } + + func hash(into hasher: inout Hasher) { + hasher.combine(firebaseUid) + } } struct UsersResponse: Codable { @@ -36,27 +103,17 @@ struct UserResponse: Codable { let user: User } -struct UserSessionData: Codable { - let sessions: [UserSession] - - struct UserSession: Codable { - let userId: String - let accessToken: String - let active: Bool - let expiresAt: Int - let refreshToken: String - } -} - struct CreateUserBody: Codable { let username: String let netid: String let givenName: String let familyName: String let photoUrl: String + let venmoHandle: String let email: String - let googleID: String + let googleId: String let bio: String + let fcmToken: String } struct EditUserBody: Codable { @@ -77,3 +134,15 @@ struct UnblockUserBody: Codable { struct LogoutResponse: Codable { let logoutSuccess: Bool } + +struct AuthorizeBody: Codable { + let token: String? +} + +struct FollowUserBody: Codable { + let userId: String +} + +struct UnfollowUserBody: Codable { + let userId: String +} diff --git a/Resell/Resell.entitlements b/Resell/Resell.entitlements new file mode 100644 index 0000000..903def2 --- /dev/null +++ b/Resell/Resell.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/Resell/Resources/Assets.xcassets/Books.imageset/stack of books2.png b/Resell/Resources/Assets.xcassets/Books.imageset/stack of books2.png new file mode 100644 index 0000000..825d90a Binary files /dev/null and b/Resell/Resources/Assets.xcassets/Books.imageset/stack of books2.png differ diff --git a/Resell/Resources/Assets.xcassets/Books.imageset/stack of books3.png b/Resell/Resources/Assets.xcassets/Books.imageset/stack of books3.png new file mode 100644 index 0000000..09fcea1 Binary files /dev/null and b/Resell/Resources/Assets.xcassets/Books.imageset/stack of books3.png differ diff --git a/Resell/Resources/Assets.xcassets/Clothing.imageset/pink sneakers floating2.png b/Resell/Resources/Assets.xcassets/Clothing.imageset/pink sneakers floating2.png new file mode 100644 index 0000000..d63c92a Binary files /dev/null and b/Resell/Resources/Assets.xcassets/Clothing.imageset/pink sneakers floating2.png differ diff --git a/Resell/Resources/Assets.xcassets/Clothing.imageset/pink sneakers floating3.png b/Resell/Resources/Assets.xcassets/Clothing.imageset/pink sneakers floating3.png new file mode 100644 index 0000000..3760c30 Binary files /dev/null and b/Resell/Resources/Assets.xcassets/Clothing.imageset/pink sneakers floating3.png differ diff --git a/Resell/Resources/Assets.xcassets/Electronics.imageset/Contents 2.json b/Resell/Resources/Assets.xcassets/Electronics.imageset/Contents 2.json new file mode 100644 index 0000000..3fa73ec --- /dev/null +++ b/Resell/Resources/Assets.xcassets/Electronics.imageset/Contents 2.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "airpods max pink.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "airpods max pink2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "airpods max pink3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/Electronics.imageset/airpods max pink2.png b/Resell/Resources/Assets.xcassets/Electronics.imageset/airpods max pink2.png new file mode 100644 index 0000000..7ffbc3e Binary files /dev/null and b/Resell/Resources/Assets.xcassets/Electronics.imageset/airpods max pink2.png differ diff --git a/Resell/Resources/Assets.xcassets/Electronics.imageset/airpods max pink3.png b/Resell/Resources/Assets.xcassets/Electronics.imageset/airpods max pink3.png new file mode 100644 index 0000000..1c1315e Binary files /dev/null and b/Resell/Resources/Assets.xcassets/Electronics.imageset/airpods max pink3.png differ diff --git a/Resell/Resources/Assets.xcassets/Handmade.imageset/Contents 2.json b/Resell/Resources/Assets.xcassets/Handmade.imageset/Contents 2.json new file mode 100644 index 0000000..ba86c4b --- /dev/null +++ b/Resell/Resources/Assets.xcassets/Handmade.imageset/Contents 2.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "color palette and brush.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "color palette and brush2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "color palette and brush3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/Handmade.imageset/color palette and brush2.png b/Resell/Resources/Assets.xcassets/Handmade.imageset/color palette and brush2.png new file mode 100644 index 0000000..2aa373f Binary files /dev/null and b/Resell/Resources/Assets.xcassets/Handmade.imageset/color palette and brush2.png differ diff --git a/Resell/Resources/Assets.xcassets/Handmade.imageset/color palette and brush3.png b/Resell/Resources/Assets.xcassets/Handmade.imageset/color palette and brush3.png new file mode 100644 index 0000000..1061fc7 Binary files /dev/null and b/Resell/Resources/Assets.xcassets/Handmade.imageset/color palette and brush3.png differ diff --git a/Resell/Resources/Assets.xcassets/Other.imageset/Contents 2.json b/Resell/Resources/Assets.xcassets/Other.imageset/Contents 2.json new file mode 100644 index 0000000..52b95eb --- /dev/null +++ b/Resell/Resources/Assets.xcassets/Other.imageset/Contents 2.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "orange gift box on ground.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "orange gift box on ground 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "orange gift box on ground 2.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/Other.imageset/Contents.json b/Resell/Resources/Assets.xcassets/Other.imageset/Contents.json new file mode 100644 index 0000000..52b95eb --- /dev/null +++ b/Resell/Resources/Assets.xcassets/Other.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "orange gift box on ground.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "orange gift box on ground 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "orange gift box on ground 2.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/Other.imageset/orange gift box on ground 1.png b/Resell/Resources/Assets.xcassets/Other.imageset/orange gift box on ground 1.png new file mode 100644 index 0000000..edac4a0 Binary files /dev/null and b/Resell/Resources/Assets.xcassets/Other.imageset/orange gift box on ground 1.png differ diff --git a/Resell/Resources/Assets.xcassets/Other.imageset/orange gift box on ground 2.png b/Resell/Resources/Assets.xcassets/Other.imageset/orange gift box on ground 2.png new file mode 100644 index 0000000..edac4a0 Binary files /dev/null and b/Resell/Resources/Assets.xcassets/Other.imageset/orange gift box on ground 2.png differ diff --git a/Resell/Resources/Assets.xcassets/Other.imageset/orange gift box on ground.png b/Resell/Resources/Assets.xcassets/Other.imageset/orange gift box on ground.png new file mode 100644 index 0000000..edac4a0 Binary files /dev/null and b/Resell/Resources/Assets.xcassets/Other.imageset/orange gift box on ground.png differ diff --git a/Resell/Resources/Assets.xcassets/School.imageset/Contents 2.json b/Resell/Resources/Assets.xcassets/School.imageset/Contents 2.json new file mode 100644 index 0000000..b044624 --- /dev/null +++ b/Resell/Resources/Assets.xcassets/School.imageset/Contents 2.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "pencil case.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "pencil case2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "pencil case3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/School.imageset/pencil case2.png b/Resell/Resources/Assets.xcassets/School.imageset/pencil case2.png new file mode 100644 index 0000000..4174784 Binary files /dev/null and b/Resell/Resources/Assets.xcassets/School.imageset/pencil case2.png differ diff --git a/Resell/Resources/Assets.xcassets/School.imageset/pencil case3.png b/Resell/Resources/Assets.xcassets/School.imageset/pencil case3.png new file mode 100644 index 0000000..64ee413 Binary files /dev/null and b/Resell/Resources/Assets.xcassets/School.imageset/pencil case3.png differ diff --git a/Resell/Resources/Assets.xcassets/Sports & Outdoors.imageset/Contents 2.json b/Resell/Resources/Assets.xcassets/Sports & Outdoors.imageset/Contents 2.json new file mode 100644 index 0000000..fdeb570 --- /dev/null +++ b/Resell/Resources/Assets.xcassets/Sports & Outdoors.imageset/Contents 2.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "rugby ball.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "rugby ball2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "rugby ball3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/Sports & Outdoors.imageset/Contents.json b/Resell/Resources/Assets.xcassets/Sports & Outdoors.imageset/Contents.json new file mode 100644 index 0000000..fdeb570 --- /dev/null +++ b/Resell/Resources/Assets.xcassets/Sports & Outdoors.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "rugby ball.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "rugby ball2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "rugby ball3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/Sports & Outdoors.imageset/rugby ball.png b/Resell/Resources/Assets.xcassets/Sports & Outdoors.imageset/rugby ball.png new file mode 100644 index 0000000..785294e Binary files /dev/null and b/Resell/Resources/Assets.xcassets/Sports & Outdoors.imageset/rugby ball.png differ diff --git a/Resell/Resources/Assets.xcassets/Sports & Outdoors.imageset/rugby ball2.png b/Resell/Resources/Assets.xcassets/Sports & Outdoors.imageset/rugby ball2.png new file mode 100644 index 0000000..70848cc Binary files /dev/null and b/Resell/Resources/Assets.xcassets/Sports & Outdoors.imageset/rugby ball2.png differ diff --git a/Resell/Resources/Assets.xcassets/Sports & Outdoors.imageset/rugby ball3.png b/Resell/Resources/Assets.xcassets/Sports & Outdoors.imageset/rugby ball3.png new file mode 100644 index 0000000..bb9fc07 Binary files /dev/null and b/Resell/Resources/Assets.xcassets/Sports & Outdoors.imageset/rugby ball3.png differ diff --git a/Resell/Resources/Assets.xcassets/bell.imageset/Contents 2.json b/Resell/Resources/Assets.xcassets/bell.imageset/Contents 2.json new file mode 100644 index 0000000..8a50b08 --- /dev/null +++ b/Resell/Resources/Assets.xcassets/bell.imageset/Contents 2.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "bell.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "bell 2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "bell 1.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/bell.imageset/Contents.json b/Resell/Resources/Assets.xcassets/bell.imageset/Contents.json new file mode 100644 index 0000000..8a50b08 --- /dev/null +++ b/Resell/Resources/Assets.xcassets/bell.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "bell.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "bell 2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "bell 1.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/bell.imageset/bell 1.png b/Resell/Resources/Assets.xcassets/bell.imageset/bell 1.png new file mode 100644 index 0000000..6e2b3ac Binary files /dev/null and b/Resell/Resources/Assets.xcassets/bell.imageset/bell 1.png differ diff --git a/Resell/Resources/Assets.xcassets/bell.imageset/bell 2.png b/Resell/Resources/Assets.xcassets/bell.imageset/bell 2.png new file mode 100644 index 0000000..6e2b3ac Binary files /dev/null and b/Resell/Resources/Assets.xcassets/bell.imageset/bell 2.png differ diff --git a/Resell/Resources/Assets.xcassets/bell.imageset/bell.png b/Resell/Resources/Assets.xcassets/bell.imageset/bell.png new file mode 100644 index 0000000..6e2b3ac Binary files /dev/null and b/Resell/Resources/Assets.xcassets/bell.imageset/bell.png differ diff --git a/Resell/Resources/Assets.xcassets/books.imageset/Contents.json b/Resell/Resources/Assets.xcassets/books.imageset/Contents.json new file mode 100644 index 0000000..f51f236 --- /dev/null +++ b/Resell/Resources/Assets.xcassets/books.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stack of books.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stack of books2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stack of books3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/books.imageset/stack of books.png b/Resell/Resources/Assets.xcassets/books.imageset/stack of books.png new file mode 100644 index 0000000..02fd7df Binary files /dev/null and b/Resell/Resources/Assets.xcassets/books.imageset/stack of books.png differ diff --git a/Resell/Resources/Assets.xcassets/clothing.imageset/Contents.json b/Resell/Resources/Assets.xcassets/clothing.imageset/Contents.json new file mode 100644 index 0000000..5ebc407 --- /dev/null +++ b/Resell/Resources/Assets.xcassets/clothing.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "pink sneakers floating.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "pink sneakers floating2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "pink sneakers floating3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/clothing.imageset/pink sneakers floating.png b/Resell/Resources/Assets.xcassets/clothing.imageset/pink sneakers floating.png new file mode 100644 index 0000000..c4f7f2d Binary files /dev/null and b/Resell/Resources/Assets.xcassets/clothing.imageset/pink sneakers floating.png differ diff --git a/Resell/Resources/Assets.xcassets/electronics.imageset/Contents.json b/Resell/Resources/Assets.xcassets/electronics.imageset/Contents.json new file mode 100644 index 0000000..3fa73ec --- /dev/null +++ b/Resell/Resources/Assets.xcassets/electronics.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "airpods max pink.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "airpods max pink2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "airpods max pink3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/electronics.imageset/airpods max pink.png b/Resell/Resources/Assets.xcassets/electronics.imageset/airpods max pink.png new file mode 100644 index 0000000..081a87d Binary files /dev/null and b/Resell/Resources/Assets.xcassets/electronics.imageset/airpods max pink.png differ diff --git a/Resell/Resources/Assets.xcassets/filters.imageset/Contents 2.json b/Resell/Resources/Assets.xcassets/filters.imageset/Contents 2.json new file mode 100644 index 0000000..f9da7af --- /dev/null +++ b/Resell/Resources/Assets.xcassets/filters.imageset/Contents 2.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "rivet-icons_filter 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "rivet-icons_filter.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "rivet-icons_filter 2.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/filters.imageset/Contents.json b/Resell/Resources/Assets.xcassets/filters.imageset/Contents.json new file mode 100644 index 0000000..f9da7af --- /dev/null +++ b/Resell/Resources/Assets.xcassets/filters.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "rivet-icons_filter 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "rivet-icons_filter.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "rivet-icons_filter 2.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/filters.imageset/rivet-icons_filter 1.png b/Resell/Resources/Assets.xcassets/filters.imageset/rivet-icons_filter 1.png new file mode 100644 index 0000000..350e202 Binary files /dev/null and b/Resell/Resources/Assets.xcassets/filters.imageset/rivet-icons_filter 1.png differ diff --git a/Resell/Resources/Assets.xcassets/filters.imageset/rivet-icons_filter 2.png b/Resell/Resources/Assets.xcassets/filters.imageset/rivet-icons_filter 2.png new file mode 100644 index 0000000..350e202 Binary files /dev/null and b/Resell/Resources/Assets.xcassets/filters.imageset/rivet-icons_filter 2.png differ diff --git a/Resell/Resources/Assets.xcassets/filters.imageset/rivet-icons_filter.png b/Resell/Resources/Assets.xcassets/filters.imageset/rivet-icons_filter.png new file mode 100644 index 0000000..350e202 Binary files /dev/null and b/Resell/Resources/Assets.xcassets/filters.imageset/rivet-icons_filter.png differ diff --git a/Resell/Resources/Assets.xcassets/follow-button.imageset/Contents 2.json b/Resell/Resources/Assets.xcassets/follow-button.imageset/Contents 2.json new file mode 100644 index 0000000..3127dfe --- /dev/null +++ b/Resell/Resources/Assets.xcassets/follow-button.imageset/Contents 2.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "add_user 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "add_user 2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "add_user 3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/follow-button.imageset/Contents.json b/Resell/Resources/Assets.xcassets/follow-button.imageset/Contents.json new file mode 100644 index 0000000..3127dfe --- /dev/null +++ b/Resell/Resources/Assets.xcassets/follow-button.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "add_user 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "add_user 2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "add_user 3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/follow-button.imageset/add_user 1.png b/Resell/Resources/Assets.xcassets/follow-button.imageset/add_user 1.png new file mode 100644 index 0000000..dc03dad Binary files /dev/null and b/Resell/Resources/Assets.xcassets/follow-button.imageset/add_user 1.png differ diff --git a/Resell/Resources/Assets.xcassets/follow-button.imageset/add_user 2.png b/Resell/Resources/Assets.xcassets/follow-button.imageset/add_user 2.png new file mode 100644 index 0000000..ed0d21f Binary files /dev/null and b/Resell/Resources/Assets.xcassets/follow-button.imageset/add_user 2.png differ diff --git a/Resell/Resources/Assets.xcassets/follow-button.imageset/add_user 3.png b/Resell/Resources/Assets.xcassets/follow-button.imageset/add_user 3.png new file mode 100644 index 0000000..ed0d21f Binary files /dev/null and b/Resell/Resources/Assets.xcassets/follow-button.imageset/add_user 3.png differ diff --git a/Resell/Resources/Assets.xcassets/following.imageset/Component 1.png b/Resell/Resources/Assets.xcassets/following.imageset/Component 1.png new file mode 100644 index 0000000..23ede6e Binary files /dev/null and b/Resell/Resources/Assets.xcassets/following.imageset/Component 1.png differ diff --git a/Resell/Resources/Assets.xcassets/following.imageset/Component 2.png b/Resell/Resources/Assets.xcassets/following.imageset/Component 2.png new file mode 100644 index 0000000..cf30d68 Binary files /dev/null and b/Resell/Resources/Assets.xcassets/following.imageset/Component 2.png differ diff --git a/Resell/Resources/Assets.xcassets/following.imageset/Component 3.png b/Resell/Resources/Assets.xcassets/following.imageset/Component 3.png new file mode 100644 index 0000000..c7a3750 Binary files /dev/null and b/Resell/Resources/Assets.xcassets/following.imageset/Component 3.png differ diff --git a/Resell/Resources/Assets.xcassets/following.imageset/Contents.json b/Resell/Resources/Assets.xcassets/following.imageset/Contents.json new file mode 100644 index 0000000..7169c0a --- /dev/null +++ b/Resell/Resources/Assets.xcassets/following.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Component 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Component 2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Component 3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/handmade.imageset/Contents.json b/Resell/Resources/Assets.xcassets/handmade.imageset/Contents.json new file mode 100644 index 0000000..ba86c4b --- /dev/null +++ b/Resell/Resources/Assets.xcassets/handmade.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "color palette and brush.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "color palette and brush2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "color palette and brush3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/handmade.imageset/color palette and brush.png b/Resell/Resources/Assets.xcassets/handmade.imageset/color palette and brush.png new file mode 100644 index 0000000..3c011fd Binary files /dev/null and b/Resell/Resources/Assets.xcassets/handmade.imageset/color palette and brush.png differ diff --git a/Resell/Resources/Assets.xcassets/listing.imageset/Contents.json b/Resell/Resources/Assets.xcassets/listing.imageset/Contents.json index 49e4361..c7ab95a 100644 --- a/Resell/Resources/Assets.xcassets/listing.imageset/Contents.json +++ b/Resell/Resources/Assets.xcassets/listing.imageset/Contents.json @@ -6,10 +6,12 @@ "scale" : "1x" }, { + "filename" : "icon2 1.svg", "idiom" : "universal", "scale" : "2x" }, { + "filename" : "icon2 2.svg", "idiom" : "universal", "scale" : "3x" } diff --git a/Resell/Resources/Assets.xcassets/listing.imageset/icon2 1.svg b/Resell/Resources/Assets.xcassets/listing.imageset/icon2 1.svg new file mode 100644 index 0000000..c35d0f1 --- /dev/null +++ b/Resell/Resources/Assets.xcassets/listing.imageset/icon2 1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resell/Resources/Assets.xcassets/listing.imageset/icon2 2.svg b/Resell/Resources/Assets.xcassets/listing.imageset/icon2 2.svg new file mode 100644 index 0000000..c35d0f1 --- /dev/null +++ b/Resell/Resources/Assets.xcassets/listing.imageset/icon2 2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resell/Resources/Assets.xcassets/read-notification.imageset/Contents.json b/Resell/Resources/Assets.xcassets/read-notification.imageset/Contents.json new file mode 100644 index 0000000..8a4731e --- /dev/null +++ b/Resell/Resources/Assets.xcassets/read-notification.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "mage_message-round.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "mage_message-round 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "mage_message-round 2.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/read-notification.imageset/mage_message-round 1.png b/Resell/Resources/Assets.xcassets/read-notification.imageset/mage_message-round 1.png new file mode 100644 index 0000000..5d8153f Binary files /dev/null and b/Resell/Resources/Assets.xcassets/read-notification.imageset/mage_message-round 1.png differ diff --git a/Resell/Resources/Assets.xcassets/read-notification.imageset/mage_message-round 2.png b/Resell/Resources/Assets.xcassets/read-notification.imageset/mage_message-round 2.png new file mode 100644 index 0000000..5d8153f Binary files /dev/null and b/Resell/Resources/Assets.xcassets/read-notification.imageset/mage_message-round 2.png differ diff --git a/Resell/Resources/Assets.xcassets/read-notification.imageset/mage_message-round.png b/Resell/Resources/Assets.xcassets/read-notification.imageset/mage_message-round.png new file mode 100644 index 0000000..5d8153f Binary files /dev/null and b/Resell/Resources/Assets.xcassets/read-notification.imageset/mage_message-round.png differ diff --git a/Resell/Resources/Assets.xcassets/school.imageset/Contents.json b/Resell/Resources/Assets.xcassets/school.imageset/Contents.json new file mode 100644 index 0000000..b044624 --- /dev/null +++ b/Resell/Resources/Assets.xcassets/school.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "pencil case.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "pencil case2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "pencil case3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/school.imageset/pencil case.png b/Resell/Resources/Assets.xcassets/school.imageset/pencil case.png new file mode 100644 index 0000000..71825cf Binary files /dev/null and b/Resell/Resources/Assets.xcassets/school.imageset/pencil case.png differ diff --git a/Resell/Resources/Assets.xcassets/sendButton.imageset/Contents.json b/Resell/Resources/Assets.xcassets/sendButton.imageset/Contents.json new file mode 100644 index 0000000..938d291 --- /dev/null +++ b/Resell/Resources/Assets.xcassets/sendButton.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "sendButton.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sendButton 1.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sendButton 2.svg", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/sendButton.imageset/sendButton 1.svg b/Resell/Resources/Assets.xcassets/sendButton.imageset/sendButton 1.svg new file mode 100644 index 0000000..f16c54b --- /dev/null +++ b/Resell/Resources/Assets.xcassets/sendButton.imageset/sendButton 1.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resell/Resources/Assets.xcassets/sendButton.imageset/sendButton 2.svg b/Resell/Resources/Assets.xcassets/sendButton.imageset/sendButton 2.svg new file mode 100644 index 0000000..f16c54b --- /dev/null +++ b/Resell/Resources/Assets.xcassets/sendButton.imageset/sendButton 2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resell/Resources/Assets.xcassets/sendButton.imageset/sendButton.svg b/Resell/Resources/Assets.xcassets/sendButton.imageset/sendButton.svg new file mode 100644 index 0000000..f16c54b --- /dev/null +++ b/Resell/Resources/Assets.xcassets/sendButton.imageset/sendButton.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resell/Resources/Assets.xcassets/toggle-set.imageset/Contents 2.json b/Resell/Resources/Assets.xcassets/toggle-set.imageset/Contents 2.json new file mode 100644 index 0000000..a520e2a --- /dev/null +++ b/Resell/Resources/Assets.xcassets/toggle-set.imageset/Contents 2.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "toggles.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/toggle-set.imageset/Contents 3.json b/Resell/Resources/Assets.xcassets/toggle-set.imageset/Contents 3.json new file mode 100644 index 0000000..a520e2a --- /dev/null +++ b/Resell/Resources/Assets.xcassets/toggle-set.imageset/Contents 3.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "toggles.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/toggle-set.imageset/Contents 4.json b/Resell/Resources/Assets.xcassets/toggle-set.imageset/Contents 4.json new file mode 100644 index 0000000..a520e2a --- /dev/null +++ b/Resell/Resources/Assets.xcassets/toggle-set.imageset/Contents 4.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "toggles.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/toggle-set.imageset/Contents.json b/Resell/Resources/Assets.xcassets/toggle-set.imageset/Contents.json new file mode 100644 index 0000000..a520e2a --- /dev/null +++ b/Resell/Resources/Assets.xcassets/toggle-set.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "toggles.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/toggle-set.imageset/toggles.png b/Resell/Resources/Assets.xcassets/toggle-set.imageset/toggles.png new file mode 100644 index 0000000..96094c2 Binary files /dev/null and b/Resell/Resources/Assets.xcassets/toggle-set.imageset/toggles.png differ diff --git a/Resell/Resources/Assets.xcassets/toggle.imageset/Contents 2.json b/Resell/Resources/Assets.xcassets/toggle.imageset/Contents 2.json new file mode 100644 index 0000000..ec27c10 --- /dev/null +++ b/Resell/Resources/Assets.xcassets/toggle.imageset/Contents 2.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "toggles.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "toggles2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "toggles3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/toggle.imageset/Contents 3.json b/Resell/Resources/Assets.xcassets/toggle.imageset/Contents 3.json new file mode 100644 index 0000000..ec27c10 --- /dev/null +++ b/Resell/Resources/Assets.xcassets/toggle.imageset/Contents 3.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "toggles.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "toggles2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "toggles3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/toggle.imageset/Contents 4.json b/Resell/Resources/Assets.xcassets/toggle.imageset/Contents 4.json new file mode 100644 index 0000000..ec27c10 --- /dev/null +++ b/Resell/Resources/Assets.xcassets/toggle.imageset/Contents 4.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "toggles.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "toggles2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "toggles3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/toggle.imageset/Contents.json b/Resell/Resources/Assets.xcassets/toggle.imageset/Contents.json new file mode 100644 index 0000000..ec27c10 --- /dev/null +++ b/Resell/Resources/Assets.xcassets/toggle.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "toggles.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "toggles2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "toggles3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/toggle.imageset/toggles.png b/Resell/Resources/Assets.xcassets/toggle.imageset/toggles.png new file mode 100644 index 0000000..3932aee Binary files /dev/null and b/Resell/Resources/Assets.xcassets/toggle.imageset/toggles.png differ diff --git a/Resell/Resources/Assets.xcassets/toggle.imageset/toggles2x.png b/Resell/Resources/Assets.xcassets/toggle.imageset/toggles2x.png new file mode 100644 index 0000000..1def66a Binary files /dev/null and b/Resell/Resources/Assets.xcassets/toggle.imageset/toggles2x.png differ diff --git a/Resell/Resources/Assets.xcassets/toggle.imageset/toggles3x.png b/Resell/Resources/Assets.xcassets/toggle.imageset/toggles3x.png new file mode 100644 index 0000000..3ddb5de Binary files /dev/null and b/Resell/Resources/Assets.xcassets/toggle.imageset/toggles3x.png differ diff --git a/Resell/Utils/Constants.swift b/Resell/Utils/Constants.swift index ec70c4e..b4f165f 100644 --- a/Resell/Utils/Constants.swift +++ b/Resell/Utils/Constants.swift @@ -23,6 +23,15 @@ struct Constants { static let tint = Color(red: 0/255, green: 0/255, blue: 0/255, opacity: 20/100) static let wash = Color(red: 244/255, green: 244/255, blue: 244/255) static let white = Color(red: 255/255, green: 255/255, blue: 255/255) + + // filter colors + static let filterGray = Color(red: 214/255, green: 214/255, blue: 214/255) + static let filterPink = Color(red: 202/255, green: 149/255, blue: 163/255) + static let filterGreen = Color(red: 49/255, green: 96/255, blue: 84/255) + static let filterLightGreen = Color(red: 164/255, green: 183/255, blue: 171/255) + static let filterYellow = Color(red: 227/255, green: 181/255, blue: 112/255) + static let filterBlue = Color(red: 115/255, green: 162/255, blue: 171/255) + // Gradients static let resellGradient = LinearGradient(stops: [ @@ -75,24 +84,51 @@ struct Constants { static let horizontalPadding: CGFloat = 24.0 } + /// Notifications for pub/sub + enum Notifications { + static let LogoutUser = Notification.Name("LogoutUser") + } + /// Chat categories used in Resell's design system static let chats = [ - FilterCategory(id: 0, title: "Purchases"), - FilterCategory(id: 1, title: "Offers") + FilterCategory(id: 0, title: ChatTab.purchases.rawValue), + FilterCategory(id: 1, title: ChatTab.offers.rawValue) + ] + + // TODO: Use this whenever working with conditions + static let conditions = [ + FilterCategory(id: 0, title: "Never Used"), + FilterCategory(id: 1, title: "Gently Used"), + FilterCategory(id: 2, title: "Worn") ] /// Product filter categories used in Resell's design system static let filters = [ FilterCategory(id: 0, title: "Recent"), - FilterCategory(id: 1, title: "Clothing"), - FilterCategory(id: 2, title: "Books"), - FilterCategory(id: 3, title: "School"), - FilterCategory(id: 4, title: "Electronics"), + FilterCategory(id: 1, title: "Clothing", color: Constants.Colors.filterPink), + FilterCategory(id: 2, title: "Books", color: Constants.Colors.filterGreen) , + FilterCategory(id: 3, title: "School", color: Constants.Colors.filterLightGreen), + FilterCategory(id: 4, title: "Electronics", color: Constants.Colors.filterPink), FilterCategory(id: 5, title: "Household"), - FilterCategory(id: 6, title: "Handmade"), - FilterCategory(id: 7, title: "Sports & Outdoors"), + FilterCategory(id: 6, title: "Handmade", color: Constants.Colors.filterYellow), + FilterCategory(id: 7, title: "Sports & Outdoors", color: Constants.Colors.filterBlue), FilterCategory(id: 8, title: "Other") ] + + static let notificationFilters = [ + FilterCategory(id: 0, title: "All"), + FilterCategory(id: 1, title: "Messages"), + FilterCategory(id: 2, title: "Requests"), + FilterCategory(id: 3, title: "Bookmarks"), + FilterCategory(id: 4, title: "Your Listings") + ] + + static let chatMessageOptions: [ChatMessageOption] = [ + .negotiate, +// .sendAvailability, + .venmo +// .viewAvailability + ] static let dummyItemsData: [Item] = [ Item(id: UUID(), title: "Justin", image: "justin", price: "100", category: "School"), @@ -108,9 +144,24 @@ struct Constants { Item(id: UUID(), title: "Justin", image: "justin_long", price: "100", category: "School"), Item(id: UUID(), title: "Justin", image: "justin", price: "100", category: "School"), ] + } struct FilterCategory: Hashable { let id: Int let title: String + let color: Color? + + init(id: Int, title: String, color: Color? = nil) { + self.id = id + self.title = title + self.color = color + } +} + +enum ChatMessageOption: String { + case negotiate = "Negotiate" +// case sendAvailability = "Send Availability" + case venmo = "Pay with Venmo" +// case viewAvailability = "View Availability" } diff --git a/Resell/Utils/Extensions/String + Extensions.swift b/Resell/Utils/Extensions/String + Extensions.swift index f2f3a83..03ed321 100644 --- a/Resell/Utils/Extensions/String + Extensions.swift +++ b/Resell/Utils/Extensions/String + Extensions.swift @@ -14,5 +14,12 @@ extension String { return cleanedString } - + + var partBeforeComma: String { + if let commaIndex = self.firstIndex(of: ",") { + return String(self[.. String? { + /// resize an Image so that the longest dimension is maxSize + func resizedToMaxDimension(_ maxSize: CGFloat) -> UIImage { + let largestDimension = max(size.width, size.height) + if largestDimension <= maxSize { + return self + } + + // Calculate the scale factor + let scaleFactor = maxSize / largestDimension + + // Calculate new dimensions + let newWidth = size.width * scaleFactor + let newHeight = size.height * scaleFactor + let newSize = CGSize(width: newWidth, height: newHeight) + + // Create a new context to draw the scaled image + UIGraphicsBeginImageContextWithOptions(newSize, false, 0.0) + defer { UIGraphicsEndImageContext() } + + // Draw the scaled image + draw(in: CGRect(origin: .zero, size: newSize)) + + // Get the new image from the context + guard let scaledImage = UIGraphicsGetImageFromCurrentImageContext() else { + return self // Return original if scaling failed + } + + return scaledImage + } + + func toBase64(compressionQuality: CGFloat = 0.3) -> String? { guard let imageData = self.jpegData(compressionQuality: compressionQuality) else { return nil } return "data:image/jpeg;base64,\(imageData.base64EncodedString())" } diff --git a/Resell/Utils/Extensions/URL + Extensions.swift b/Resell/Utils/Extensions/URL + Extensions.swift new file mode 100644 index 0000000..c87ddef --- /dev/null +++ b/Resell/Utils/Extensions/URL + Extensions.swift @@ -0,0 +1,13 @@ +// +// URL + Extensions.swift +// Resell +// +// Created by Charles Liggins on 10/13/25. +// +import Foundation + +extension URL { + var cacheKey: String { + return absoluteString + } +} diff --git a/Resell/Utils/Keys.swift b/Resell/Utils/Keys.swift index a9e2168..b0b8558 100644 --- a/Resell/Utils/Keys.swift +++ b/Resell/Utils/Keys.swift @@ -10,7 +10,10 @@ import Foundation struct Keys { static let devServerURL = Keys.mainKeyDict(key: "RESELL_DEV_URL") + static let sshdevServerURL = Keys.mainKeyDict(key: "RESELL_SSHDEV_URL") + static let localServerURL = Keys.mainKeyDict(key: "RESELL_LOCAL_URL") static let prodServerURL = Keys.mainKeyDict(key: "RESELL_PROD_URL") + static let firebaseURL = Keys.mainKeyDict(key: "FIREBASE_URL") static let googleClientID = Keys.googleKeyDict["CLIENT_ID"] as? String ?? "" static let googlePlacesKey = Keys.googleKeyDict["GOOGLE_API_KEY"] as? String ?? "" diff --git a/Resell/Utils/Router.swift b/Resell/Utils/Router.swift index ad77e8f..0c68623 100644 --- a/Resell/Utils/Router.swift +++ b/Resell/Utils/Router.swift @@ -7,6 +7,11 @@ import SwiftUI +enum FollowListType { + case followers + case following +} + class Router: ObservableObject { @Published var path: [Route] = [] @@ -16,22 +21,27 @@ class Router: ObservableObject { case saved case chats case editProfile - case messages + case messages(chatInfo: ChatInfo) case newListingDetails case newListingImages case newRequest + case notifications + case filters case profile(String) - case productDetails(String) + case productDetails(Post) case reportOptions(type: String, id: String) case reportDetails case reportConfirmation - case search(String?) + case discover + case detailedFilter(FilterCategory) + case search(String?) // + case recentlySearched case settings(Bool) case blockedUsers case feedback - case notifications - case setupProfile(netid: String, givenName: String, familyName: String, email: String, googleId: String) + case setupProfile case venmo + case followList(userID: String, username: String, initialTab: FollowListType) } func push(_ route: Route) { @@ -57,5 +67,19 @@ class Router: ObservableObject { func lastPushedView() -> Route { return path.last ?? .home } + +// func navigateToProductDetails(post: Post) { +// if let existingIndex = path.firstIndex(where: { +// if case .productDetails = $0 { +// return true +// } +// return false +// }) { +// path[existingIndex] = .productDetails(post) +// popTo(path[existingIndex]) +// } else { +// push(.productDetails(post)) +// } +// } } diff --git a/Resell/ViewModels/ChatsViewModel.swift b/Resell/ViewModels/ChatsViewModel.swift index 928cece..3f46944 100644 --- a/Resell/ViewModels/ChatsViewModel.swift +++ b/Resell/ViewModels/ChatsViewModel.swift @@ -5,68 +5,160 @@ // Created by Richie Sun on 10/26/24. // -import Firebase import FirebaseFirestore import SwiftUI +import os @MainActor class ChatsViewModel: ObservableObject { // MARK: - Properties - @Published var selectedTab: String = "Purchases" - @Published var unreadMessages: [String : Int] = ["Purchaes": 1, "Offers": 1] + @EnvironmentObject private var mainViewModel: MainViewModel - // TODO: Replace with Backend Model - @Published var chats = [ - (0, "DJBustin", "justin", "Speakers", "Message preview", true), - (1, "DJBustin", "justin", "Speakers", "Message preview", true), - (2, "DJBustin", "justin", "Speakers", "Message preview", false), - (3, "DJBustin", "justin", "Speakers", "Message preview", false), - (4, "DJBustin", "justin", "Speakers", "Message preview", false) - ] + @Published var isLoading = false - @Published var messages: [Message] = [] - @Published var messageText: String = "" + @Published var purchaseChats: [Chat] = [] + @Published var offerChats: [Chat] = [] -// private let db = Firestore.firestore() - private let chatId = "rs929@cornell.edu" // TODO: Update with actual user + @Published var purchaseUnread: Int = 0 + @Published var offerUnread: Int = 0 + + @Published var selectedChat: Chat? = nil + @Published var selectedPost: Post? = nil + + @Published var subscribedChat: [MessageCluster] = [] + @Published var selectedTab: ChatTab = .purchases + + @Published var draftMessageText: String = "" + @Published var availabilityDates: [Availability] = [] + + @Published var otherUserProfileImage: UIImage = UIImage(named: "emptyProfile")! + + private var isListening = false + private var blockedUsers: [String] = [] + + var otherUser: User? + var venmoURL: URL? + + // MARK: - Init + + init() { + NotificationCenter.default.addObserver( + self, + selector: #selector(stopListening), + name: Constants.Notifications.LogoutUser, + object: nil + ) + } // MARK: - Functions - func fetchMessages() { -// db.collection("chats") -// .document(chatId) -// .collection("messages") -// .order(by: "createdAt", descending: false) -// .addSnapshotListener { snapshot, error in -// guard let documents = snapshot?.documents else { -// print("No documents or error: \(String(describing: error))") -// return -// } -// -// self.messages = documents.compactMap { document -> Message? in -// try? document.data(as: Message.self) -// } -// } + func checkEmptyState() -> Bool { + switch selectedTab { + case .purchases: + return purchaseChats.isEmpty + case .offers: + return offerChats.isEmpty + } } - func sendMessage() { - guard !messageText.trimmingCharacters(in: .whitespaces).isEmpty else { return } + func emptyStateTitle() -> String { + switch selectedTab { + case .purchases: + return "No messages with sellers yet" + case .offers: + return "No messages with buyers yet" + } + } - let newMessage = Message( - id: UUID().uuidString, - text: messageText, - createdAt: Date(), - user: FirebaseUser(id: "id", avatar: "justin", name: "Justin Guo"), isSentByCurrentUser: true - ) + func emptyStateMessage() -> String { + switch selectedTab { + case .purchases: + return "When you contact a seller, you’ll see your messages here" + case .offers: + return "When a buyer contacts you, you’ll see their messages here" + } + } - messages.append(newMessage) + func getAllChats() { + guard !isListening else { return } + isListening = true + + getPurchaceChats() + getOfferChats() + } + + func refreshChats() { + stopListening() + getAllChats() + } + + @objc func stopListening() { + FirestoreManager.shared.stopListeningAll() + isListening = false + purchaseChats = [] + offerChats = [] + purchaseUnread = 0 + offerUnread = 0 + } + + func getPurchaceChats() { + isLoading = true + FirestoreManager.shared.subscribeToBuyerChats { [weak self] purchaseChats in + guard let self else { return } + + self.purchaseChats = purchaseChats.filter { !self.blockedUsers.contains($0.other.email) } + purchaseUnread = countUnviewedChats(chats: self.purchaseChats) + isLoading = false + } + } - // TODO: - Store to Firebase document + func getOfferChats() { + isLoading = true + FirestoreManager.shared.subscribeToSellerChats { [weak self] offerChats in + guard let self else { return } - messageText = "" + self.offerChats = offerChats.filter { !self.blockedUsers.contains($0.other.email) } + offerUnread = countUnviewedChats(chats: self.offerChats) + isLoading = false + } + } + + func countUnviewedChats(chats: [Chat]) -> Int { + return chats.reduce(into: 0) { $0 += ($1.messages.filter { !$0.read && !$0.mine }.count) } + } + + func getSelectedChatPost(completion: @escaping (Post) -> Void) { + if let postId = selectedChat?.post.id { + isLoading = true + + Task { + defer { Task { @MainActor in withAnimation { isLoading = false } } } + + do { + let postResponse = try await NetworkManager.shared.getPostByID(id: postId) + selectedPost = postResponse.post + + guard let post = postResponse.post else { + // TODO: Better error handling + NetworkManager.shared.logger.error("Error in \(#file) \(#function): Post not available.") + return + } + + completion(post) + isLoading = false + } catch { + NetworkManager.shared.logger.error("Error in \(#file) \(#function): \(error)") + // TODO: Better error handling + } + } + } } } +enum ChatTab: String, CaseIterable { + case purchases = "Purchases" + case offers = "Offers" +} diff --git a/Resell/ViewModels/CurrentUserProfileManager.swift b/Resell/ViewModels/CurrentUserProfileManager.swift new file mode 100644 index 0000000..9364ad9 --- /dev/null +++ b/Resell/ViewModels/CurrentUserProfileManager.swift @@ -0,0 +1,166 @@ +// +// CurrentUserProfileManager.swift +// Resell +// +// Created by Charles Liggins on 10/13/25. +// + +import SwiftUI + +@MainActor +class CurrentUserProfileManager: ObservableObject { + + // MARK: - Singleton + + static let shared = CurrentUserProfileManager() + + // MARK: - Published Properties + + @Published var profilePic: UIImage = UIImage(named: "emptyProfile")! + @Published var username: String = "" + @Published var givenName: String = "" + @Published var bio: String = "" + @Published var venmoHandle: String = "" + + @Published var userPosts: [Post] = [] + @Published var archivedPosts: [Post] = [] + @Published var requests: [Request] = [] + + @Published var isLoading: Bool = false + + // MARK: - Private Properties + + private var hasLoadedData: Bool = false + private var lastFetchTime: Date? + private let cacheValidityDuration: TimeInterval = 300 + + private init() {} + + // MARK: - Public Methods + + func loadProfile(forceRefresh: Bool = false) { + if !forceRefresh && hasLoadedData && shouldUseCachedData() { + print("returned early?") + return + } + + isLoading = true + + Task { + defer { + Task { @MainActor in + withAnimation { isLoading = false } + } + } + + do { + + guard let user = GoogleAuthManager.shared.user else { + GoogleAuthManager.shared.logger.error("Error in \(#file) \(#function): User not available.") + return + } + + let userId = user.firebaseUid + + async let postsResponse = NetworkManager.shared.getPostsByUserID(id: userId) + async let archivedResponse = NetworkManager.shared.getArchivedPostsByUserID(id: userId) + async let requestsResponse = NetworkManager.shared.getRequestsByUserID(id: userId) + + let (posts, archived, reqs) = try await (postsResponse, archivedResponse, requestsResponse) + + userPosts = Post.sortPostsByDate(posts.posts) + archivedPosts = Post.sortPostsByDate(archived.posts) + requests = reqs.requests + + username = user.username + givenName = user.givenName + bio = user.bio + venmoHandle = user.venmoHandle ?? "" + + await decodeProfileImage(url: user.photoUrl) + + hasLoadedData = true + lastFetchTime = Date() + + } catch { + NetworkManager.shared.logger.error("Error in \(#file) \(#function): \(error)") + } + } + } + + func updateProfile(username: String, bio: String, venmoHandle: String, profileImage: UIImage) async throws { + isLoading = true + + defer { + Task { @MainActor in + withAnimation { isLoading = false } + } + } + + let edit = EditUserBody( + username: username, + bio: bio, + venmoHandle: venmoHandle, + photoUrlBase64: profileImage.resizedToMaxDimension(256).toBase64() ?? "" + ) + + // Update local state immediately for UI responsiveness + self.username = username + self.bio = bio + self.venmoHandle = venmoHandle + self.profilePic = profileImage + + // Perform network request + let updatedUserResponse = try await NetworkManager.shared.updateUserProfile(edit: edit) + + // Update GoogleAuthManager user to persist changes across app restarts + GoogleAuthManager.shared.user = updatedUserResponse.user + + // Update cache timestamp so we don't immediately re-fetch old data + lastFetchTime = Date() + } + + func deleteRequest(id: String) async throws { + try await NetworkManager.shared.deleteRequest(id: id) + + requests.removeAll { $0.id == id } + } + + func clearCache() { + hasLoadedData = false + lastFetchTime = nil + userPosts = [] + archivedPosts = [] + requests = [] + + // Clear profile data + profilePic = UIImage(named: "emptyProfile")! + username = "" + givenName = "" + bio = "" + venmoHandle = "" + } + + // MARK: - Private Methods + + private func shouldUseCachedData() -> Bool { + guard let lastFetch = lastFetchTime else { return false } + return Date().timeIntervalSince(lastFetch) < cacheValidityDuration + } + + private func refreshProfile() async { + let wasLoading = isLoading + await loadProfile(forceRefresh: true) + if !wasLoading { + isLoading = false + } + } + + private func decodeProfileImage(url: URL?) async { + guard let url, + let data = try? await URLSession.shared.data(from: url).0, + let image = UIImage(data: data) else { return } + + profilePic = image + } +} diff --git a/Resell/ViewModels/EditProfileViewModel.swift b/Resell/ViewModels/EditProfileViewModel.swift index 75454ac..6dac75f 100644 --- a/Resell/ViewModels/EditProfileViewModel.swift +++ b/Resell/ViewModels/EditProfileViewModel.swift @@ -29,40 +29,40 @@ class EditProfileViewModel: ObservableObject { func getUser() { Task { do { - if let id = UserSessionManager.shared.userID { - user = try await NetworkManager.shared.getUserByID(id: id).user - username = user?.username ?? "" - venmoLink = user?.venmoHandle ?? "" - bio = user?.bio ?? "" - - await decodeProfileImage(url: user?.photoUrl) - } else if let googleID = UserSessionManager.shared.googleID { - user = try await NetworkManager.shared.getUserByGoogleID(googleID: googleID).user - username = user?.username ?? "" - venmoLink = user?.venmoHandle ?? "" - bio = user?.bio ?? "" - - await decodeProfileImage(url: user?.photoUrl) - } else { - UserSessionManager.shared.logger.error("Error in EditProfileViewModel.getUser: No userID or googleID found in UserSessionManager") + try await GoogleAuthManager.shared.refreshSignInIfNeeded() + guard let user = GoogleAuthManager.shared.user else { + GoogleAuthManager.shared.logger.error("Error in \(#file) \(#function): User not available.") + return } + + username = user.username + venmoLink = user.venmoHandle ?? "" + bio = user.bio + + await decodeProfileImage(url: user.photoUrl) } catch { - NetworkManager.shared.logger.error("Error in EditProfileViewModel.getUser: \(error.localizedDescription)") + GoogleAuthManager.shared.logger.error("Error in \(#file) \(#function): \(error)") + return } } } func updateProfile() { + isLoading = true + Task { - isLoading = true + defer { Task { @MainActor in withAnimation { isLoading = false } } } do { - let edit = EditUserBody(username: username, bio: bio, venmoHandle: venmoLink, photoUrlBase64: selectedImage.toBase64() ?? "") - let _ = try await NetworkManager.shared.updateUserProfile(edit: edit) - isLoading = false + // Delegate update to the singleton manager so it updates the UI immediately + try await CurrentUserProfileManager.shared.updateProfile( + username: username, + bio: bio, + venmoHandle: venmoLink, + profileImage: selectedImage + ) } catch { NetworkManager.shared.logger.error("Error in EditProfileViewModel.updateProfile: \(error)") - isLoading = false } } } @@ -79,6 +79,7 @@ class EditProfileViewModel: ObservableObject { } } + // refactor why do we have two view models im gonna kill myself private func decodeProfileImage(url: URL?) async { guard let url, let data = try? await URLSession.shared.data(from: url).0, diff --git a/Resell/ViewModels/FiltersViewModel.swift b/Resell/ViewModels/FiltersViewModel.swift new file mode 100644 index 0000000..5b92a80 --- /dev/null +++ b/Resell/ViewModels/FiltersViewModel.swift @@ -0,0 +1,121 @@ +// +// FiltersViewModel.swift +// Resell +// +// Created by Charles Liggins on 9/26/25. +// + +import SwiftUI +import Combine + +@MainActor +class FiltersViewModel: ObservableObject { + @Published var categoryFilters: Set = [] + @Published var conditionFilters: Set = [] + @Published var lowValue: Double = 0 + @Published var highValue: Double = 1000 + @Published var showSale: Bool = false + @Published var selectedSort: SortOption? = nil + @Published var detailedFilterItems: [Post] = [] + @Published var searchedDetailedFilterItems: [Post] = [] + @Published var isSearching: Bool = false + + private var baseCategory: String? = nil // Store the base category for detailed view + + func initializeDetailedFilter(category: String) async throws { + baseCategory = category + categoryFilters = [category] // Pre-populate with the category + try await applyFilters(homeViewModel: HomeViewModel.shared) + } + + func searchWithinFilter(query: String) { + guard !query.isEmpty else { + searchedDetailedFilterItems = detailedFilterItems + return + } + + isSearching = true + + // Search within already filtered items + searchedDetailedFilterItems = detailedFilterItems.filter { post in + post.title.localizedCaseInsensitiveContains(query) || + post.description.localizedCaseInsensitiveContains(query) == true || + post.user?.username.localizedCaseInsensitiveContains(query) == true + } + + isSearching = false + } + + func clearFilterSearch() { + searchedDetailedFilterItems = detailedFilterItems + } + + let isHome: Bool + + init(isHome: Bool = false) { + self.isHome = isHome + } + + // TODO: Use Unified endpoint + func applyFilters(homeViewModel: HomeViewModel) async throws { + let categoryFiltersList = Array(categoryFilters) + let conditionFiltersList = Array(conditionFilters) + + var sortField: String + + if let selectedSort = selectedSort { + switch selectedSort { + case .any: + sortField = "any" + case .newlyListed: + sortField = "newlyListed" + case .priceHighToLow: + sortField = "priceHighToLow" + case .priceLowToHigh: + sortField = "priceLowToHigh" + } + } else { + sortField = "any" + } + + let priceBody = PriceBody(lowerBound: Int(lowValue), upperBound: Int(highValue)) + let unifiedFilter = FilterPostsUnifiedRequest( + sortField: sortField, + price: priceBody, + categories: categoryFiltersList, + condition: conditionFiltersList + ) + + do { + let postsResponse = try await NetworkManager.shared.getUnifiedFilteredPosts(filters: unifiedFilter) + if isHome { + homeViewModel.filteredItems = postsResponse.posts + } else { + detailedFilterItems = postsResponse.posts + clearFilterSearch() + } + } catch { + NetworkManager.shared.logger.error("Error in FiltersViewModel.applyFilters: \(error)") + } + + } + + func resetFilters(homeViewModel: HomeViewModel) { + categoryFilters.removeAll() + conditionFilters.removeAll() + lowValue = 0 + highValue = 1000 + showSale = false + selectedSort = nil + + if isHome { + homeViewModel.selectedFilter = ["Recent"] + } else if let category = baseCategory { + // For detailed view, reset to just the base category + categoryFilters = [category] + Task { + try? await applyFilters(homeViewModel: homeViewModel) + } + } + } +} diff --git a/Resell/ViewModels/HomeViewModel.swift b/Resell/ViewModels/HomeViewModel.swift index 8e3122a..f40d091 100644 --- a/Resell/ViewModels/HomeViewModel.swift +++ b/Resell/ViewModels/HomeViewModel.swift @@ -6,69 +6,175 @@ // import SwiftUI +import Kingfisher @MainActor class HomeViewModel: ObservableObject { - // MARK: - Shared Instance - + // MARK: - Properties + private var mainViewModel: MainViewModel? + static let shared = HomeViewModel() + + private var searchViewModel = SearchViewModel.shared - // MARK: - Properties + private init() { + configureImageCache() + } + + func configure(mainViewModel: MainViewModel) { + self.mainViewModel = mainViewModel + } + @Published var isLoading: Bool = false @Published var filteredItems: [Post] = [] - @Published var selectedFilter: String = "Recent" { + @Published var cardsLoaded: Bool = false + @Published var selectedFilter: [String] = ["Recent"] { didSet { - if selectedFilter == "Recent" { + if selectedFilter == ["Recent"] { filteredItems = allItems } else { - filterPosts(by: selectedFilter) + filterPosts() } } } - + @Published var savedItems: [Post] = [] private var allItems: [Post] = [] - - // MARK: - Persistent Storage + private var page = 1 + private var hasMorePages = true + private var isFetchingMore = false + + // MARK: - Caching Properties + private var hasLoadedInitialData = false + private var lastFetchTime: Date? + private var lastSavedFetchTime: Date? + private let cacheValidityDuration: TimeInterval = 180 // 3 minutes for home feed @AppStorage("blockedUsers") private var blockedUsersStorage: String = "[]" // MARK: - Functions + + private func configureImageCache() { + let cache = ImageCache.default + + cache.memoryStorage.config.totalCostLimit = 150 * 1024 * 1024 // 150 MB + + cache.diskStorage.config.sizeLimit = 500 * 1024 * 1024 // 500 MB + + cache.memoryStorage.config.expiration = .seconds(600) // 10 minutes + + cache.diskStorage.config.expiration = .days(7) + + ImageDownloader.default.downloadTimeout = 15.0 + KingfisherManager.shared.downloader.downloadTimeout = 15.0 + + } - func getAllPosts() { + func getAllPosts(forceRefresh: Bool = false) { + if !forceRefresh && shouldUseCachedData() { + print("Using cached posts data") + return + } + + isLoading = true + page = 1 + hasMorePages = true + Task { + defer { + Task { @MainActor in + withAnimation { isLoading = false } + } + } + do { let postsResponse = try await NetworkManager.shared.getAllPosts() + allItems = Post.sortPostsByDate(postsResponse.posts) + filteredItems = allItems + + // Update cache state + hasLoadedInitialData = true + lastFetchTime = Date() + } catch { + NetworkManager.shared.logger.error("Error in HomeViewModel.getAllPosts: \(error)") + } + } + } - if selectedFilter == "Recent" { + func fetchMoreItems() { + guard !isFetchingMore && hasMorePages else { + return + } + + isFetchingMore = true + page += 1 + + Task { + defer { + Task { @MainActor in + isFetchingMore = false + } + } + + do { + let postsResponse = try await NetworkManager.shared.getAllPosts(page: page) + let newPosts = Post.sortPostsByDate(postsResponse.posts) + + if newPosts.isEmpty { + hasMorePages = false + return + } + + allItems.append(contentsOf: newPosts) + + if selectedFilter == ["Recent"] { filteredItems = allItems - } else { - filterPosts(by: selectedFilter) } + } catch { - NetworkManager.shared.logger.error("Error in HomeViewModel.getAllPosts: \(error)") + NetworkManager.shared.logger.error("Error in HomeViewModel.fetchMoreItems: \(error)") + page -= 1 // Revert page increment on error } } } - func getSavedPosts() { + func getSavedPosts() async { + if let lastFetch = lastSavedFetchTime, + Date().timeIntervalSince(lastFetch) < cacheValidityDuration, + !savedItems.isEmpty { + return + } + + isLoading = true + Task { + defer { Task { @MainActor in withAnimation { isLoading = false } } } + do { let postsResponse = try await NetworkManager.shared.getSavedPosts() - savedItems = postsResponse.posts + savedItems = Post.sortPostsByDate(postsResponse.posts) + lastSavedFetchTime = Date() } catch { NetworkManager.shared.logger.error("Error in HomeViewModel.getSavedPosts: \(error)") } } } - - func filterPosts(by filter: String) { + + func filterPosts() { Task { + isLoading = true + + defer { + Task { @MainActor in + isLoading = false + } + } + do { - let postsResponse = try await NetworkManager.shared.getFilteredPosts(by: selectedFilter) + let postsResponse = try await NetworkManager.shared.getFilteredPostsByCategory(for: selectedFilter) filteredItems = postsResponse.posts } catch { NetworkManager.shared.logger.error("Error in HomeViewModel.filterPosts: \(error)") @@ -79,20 +185,63 @@ class HomeViewModel: ObservableObject { func getBlockedUsers() { Task { do { - if let userID = UserSessionManager.shared.userID { - let blockedUsers = try await NetworkManager.shared.getBlockedUsers(id: userID).users.map { $0.id } + if let userID = GoogleAuthManager.shared.user?.firebaseUid { + let blockedUsers = try await NetworkManager.shared.getBlockedUsers(id: userID).users.map { $0.firebaseUid } if let jsonData = try? JSONEncoder().encode(blockedUsers), let jsonString = String(data: jsonData, encoding: .utf8) { blockedUsersStorage = jsonString } } else { - UserSessionManager.shared.logger.error("Error in BlockedUsersView: userID not found.") + GoogleAuthManager.shared.logger.error("Error in \(#file) \(#function): User id not available.") } } catch { - NetworkManager.shared.logger.error("Error in BlockedUsersView: \(error.localizedDescription)") + NetworkManager.shared.logger.error("Error in \(#file) \(#function): \(error)") } } } - + + func clearCache() { + hasLoadedInitialData = false + lastFetchTime = nil + lastSavedFetchTime = nil + allItems = [] + filteredItems = [] + savedItems = [] + page = 1 + hasMorePages = true + + ImageCache.default.clearMemoryCache() + } + + func cleanupMemory() { + ImageCache.default.clearMemoryCache() + } + + // MARK: - Private Methods + + private func shouldUseCachedData() -> Bool { + guard hasLoadedInitialData else { return false } + guard let lastFetch = lastFetchTime else { return false } + + let timeSinceLastFetch = Date().timeIntervalSince(lastFetch) + return timeSinceLastFetch < cacheValidityDuration && !allItems.isEmpty + } + + private func getMemoryUsage() -> Double { + var info = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size)/4 + + let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: 1) { + task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count) + } + } + + if kerr == KERN_SUCCESS { + let usedMemory = Double(info.resident_size) / 1024.0 / 1024.0 // Convert to MB + return usedMemory + } + return 0 + } } diff --git a/Resell/ViewModels/LoginViewModel.swift b/Resell/ViewModels/LoginViewModel.swift index ac776f7..753b576 100644 --- a/Resell/ViewModels/LoginViewModel.swift +++ b/Resell/ViewModels/LoginViewModel.swift @@ -13,59 +13,49 @@ class LoginViewModel: ObservableObject { // MARK: - Properties - @Published var didPresentError: Bool = false - @Published var errorText: String = "" + @Published var isLoading = false + @Published var didPresentError = false + var errorText: String = "" // MARK: - Functions - func googleSignIn(success: @escaping () -> Void, failure: @escaping (_ netid: String, _ givenName: String, _ familyName: String, _ email: String, _ googleId: String) -> Void) { - guard let presentingViewController = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.windows.first?.rootViewController else { return } - - GIDSignIn.sharedInstance.signIn(withPresenting: presentingViewController) { [weak self] result, error in - guard error == nil else { return } - guard let self else { return } - - guard let email = result?.user.profile?.email else { return } - - guard email.contains("@cornell.edu") else { - GIDSignIn.sharedInstance.signOut() - self.didPresentError = true - self.errorText = "Please sign in with a Cornell email" - return + func googleSignIn() async -> LoginResponse { + do { + try await GoogleAuthManager.shared.signIn() + + // Force refresh profile data for the new user + await MainActor.run { + CurrentUserProfileManager.shared.loadProfile(forceRefresh: true) + } + + return .success + } catch { + switch error { + case let errorResponse as ErrorResponse: + if errorResponse == ErrorResponse.accountCreationNeeded { + return .accountCreationNeeded + } else { + errorText = "\(errorResponse.error)" + } + default: + errorText = "Any unknown error occured." } - guard let id = result?.user.userID else { return } - - Task { - do { - let user = try await NetworkManager.shared.getUserByGoogleID(googleID: id).user - let userSession = try await NetworkManager.shared.getUserSession(id: user.id).sessions.first - - UserSessionManager.shared.accessToken = userSession?.accessToken - UserSessionManager.shared.googleID = id - UserSessionManager.shared.userID = user.id - - success() - } catch { - NetworkManager.shared.logger.error("Error in LoginViewModel.getUserSession: \(error)") - - guard let givenName = result?.user.profile?.givenName, - let familyName = result?.user.profile?.familyName else { return } + GoogleAuthManager.shared.logger.log("Error in \(#file) \(#function): \(error)") - // User id does not exist, take to onboarding - failure(self.getNetID(email: email), givenName, familyName, email, id) - } + await MainActor.run { + didPresentError = true } + + return .failed } } - private func getNetID(email: String?) -> String { - if let atIndex = email?.firstIndex(of: "@"), - let username = email?[.. [MessageCluster] { + // Sort messages by timestamp + let sortedMessages = messages.sorted(by: { $0.timestamp < $1.timestamp }) + + var clusters: [MessageCluster] = [] + var currentBatch: [any Message] = [] + var lastMessageTimestamp: Date? = nil + + for message in sortedMessages { + // Check if we should create a new cluster: + // 1. If its a new day + let shouldCreateNewCluster = lastMessageTimestamp == nil || + !Calendar.current.isDate(message.timestamp, inSameDayAs: lastMessageTimestamp!) + if shouldCreateNewCluster, !currentBatch.isEmpty, let first = currentBatch.first { + clusters.append( + MessageCluster( + location: first.mine ? .right : .left, + messages: currentBatch + ) + ) + + currentBatch = [] + } + + // Add message to current batch + currentBatch.append(message) + lastMessageTimestamp = message.timestamp + } + + // Don't forget the last batch + if !currentBatch.isEmpty, let first = currentBatch.first { + clusters.append( + MessageCluster( + location: first.mine ? .right : .left, + messages: currentBatch + ) + ) + } + + return clusters + } + + /// Upload the image and return the URL + private func uploadImage(imageBase64: String) async throws -> String { + let requestBody = ImageBody(imageBase64: imageBase64) + let response = try await NetworkManager.shared.uploadImage(image: requestBody) + + return response.image + } + + /// Get the post id from the chat if it exists + func getOrCreateChatId() async throws { + let chatId = try await FirestoreManager.shared.findChatId(listingId: chatInfo.listing.id, buyerId: chatInfo.buyer.firebaseUid, sellerId: chatInfo.seller.firebaseUid) + + self.chatId = chatId ?? UUID().uuidString + } + + /// Parse the Venmo URL + func parsePayWithVenmoURL() { + guard let user = GoogleAuthManager.shared.user else { + GoogleAuthManager.shared.logger.error("Error in \(#file) \(#function): One or both users not available.") + return + } + + let otherUser = chatInfo.buyer.firebaseUid == user.firebaseUid ? chatInfo.seller : chatInfo.buyer + let venmoHandle = otherUser.venmoHandle + + let url = URL(string: "https://venmo.com/u/\(venmoHandle ?? "")") + self.venmoURL = url + } + + } + +} diff --git a/Resell/ViewModels/NewListingViewModel.swift b/Resell/ViewModels/NewListingViewModel.swift index 95b9596..3214533 100644 --- a/Resell/ViewModels/NewListingViewModel.swift +++ b/Resell/ViewModels/NewListingViewModel.swift @@ -16,17 +16,14 @@ class NewListingViewModel: ObservableObject { @Published var didShowActionSheet: Bool = false @Published var didShowCamera: Bool = false @Published var didShowPhotosPicker: Bool = false - @Published var isLoading: Bool = false - @Published var selectedImages: [UIImage] = [] @Published var selectedItem: PhotosPickerItem? = nil - @Published var didShowPriceInput: Bool = false - @Published var descriptionText: String = "" @Published var priceText: String = "" @Published var selectedFilter: String = "Clothing" + @Published var selectedCondition: String = "Never Used" @Published var titleText: String = "" // MARK: - Functions @@ -50,18 +47,29 @@ class NewListingViewModel: ObservableObject { } func createNewListing() { + isLoading = true + Task { - isLoading = true + defer { Task { @MainActor in withAnimation { isLoading = false } } } do { - if let userID = UserSessionManager.shared.userID { - let imagesBase64 = selectedImages.map { $0.toBase64() ?? "" } - let postBody = PostBody(title: titleText, description: descriptionText, categories: [selectedFilter], originalPrice: Double(priceText) ?? 0, imagesBase64: imagesBase64, userId: userID) - let _ = try await NetworkManager.shared.createPost(postBody: postBody) + if let user = GoogleAuthManager.shared.user { + + let imagesToProcess = selectedImages + let imagesBase64: [String] = await Task.detached { + return imagesToProcess.map { image in + image.resizedToMaxDimension(512).toBase64() ?? "" + } + }.value + + + let postBody = PostBody(title: titleText, description: descriptionText, categories: [selectedFilter], condition: selectedCondition, original_price: Double(priceText) ?? 0, imagesBase64: imagesBase64, userId: user.firebaseUid) + + let _ = try await NetworkManager.shared.createPost(postBody: postBody) clear() } else { - UserSessionManager.shared.logger.error("Error in NewListingViewModel.createNewListing: userID not found") + GoogleAuthManager.shared.logger.error("Error in \(#file) \(#function): User not available.") clear() } } catch { @@ -81,6 +89,7 @@ class NewListingViewModel: ObservableObject { descriptionText = "" priceText = "" selectedFilter = "Clothing" + selectedCondition = "Never Worn" isLoading = false } } diff --git a/Resell/ViewModels/NewRequestViewModel.swift b/Resell/ViewModels/NewRequestViewModel.swift index b1eaf4d..b1001c0 100644 --- a/Resell/ViewModels/NewRequestViewModel.swift +++ b/Resell/ViewModels/NewRequestViewModel.swift @@ -29,21 +29,21 @@ class NewRequestViewModel: ObservableObject { } func createNewRequest() { + isLoading = true + Task { - isLoading = true + defer { Task { @MainActor in withAnimation { isLoading = false } } } do { - guard let userID = UserSessionManager.shared.userID else { - UserSessionManager.shared.logger.error("Error in NewRequestViewModel.createNewRequest: userID not found") + guard let userID = GoogleAuthManager.shared.user?.firebaseUid else { + GoogleAuthManager.shared.logger.error("Error in \(#file) \(#function): User not available.") return } let requestBody = RequestBody(title: titleText, description: descriptionText, userId: userID) let _ = try await NetworkManager.shared.postRequest(request: requestBody) - isLoading = false } catch { - NetworkManager.shared.logger.error("Error in NewRequestViewModel.createNewRequest: \(error.localizedDescription)") - isLoading = false + NetworkManager.shared.logger.error("Error in \(#file) \(#function): \(error)") } } } diff --git a/Resell/ViewModels/NotificationsViewModel.swift b/Resell/ViewModels/NotificationsViewModel.swift new file mode 100644 index 0000000..bfa29a4 --- /dev/null +++ b/Resell/ViewModels/NotificationsViewModel.swift @@ -0,0 +1,89 @@ +// +// NotificationsViewModel.swift +// Resell +// +// Created by Angelina Chen on 11/26/24. +// + +import Firebase +import FirebaseFirestore +import SwiftUI + +@MainActor +class NotificationsViewModel: ObservableObject { + + // MARK: - Properties + + @Published var selectedTab: String = "All" + @Published var unreadNotifs: [String: Int] = [ + "All": 10, + "Messages": 2, + "Requests": 3, + "Bookmarks": 1, + "Your Listings": 5 + ] + + @Published var notifications: [Notifications] = [ + Notifications( + userID: "381527oef-42b4-4fdd-b074-dfwbejko229", + title: "New Message", + body: "You have received a new message from Mateo", + data: NotificationData(type: "messages", messageId: "134841-42b4-4fdd-b074-jkfale") + ), + Notifications( + userID: "381527oef-42b4-4fdd-b074-dfwbejko229", + title: "Request Received", + body: "You have a new request from Angelina", + data: NotificationData(type: "requests", messageId: "1") + ), + Notifications( + userID: "381527oef-42b4-4fdd-b074-dfwbejko229", + title: "Bookmarked Item", + body: "Your bookmarked item is back in stock", + data: NotificationData(type: "bookmarks", messageId: "2") + ), + Notifications( + userID: "381527oef-42b4-4fdd-b074-dfwbejko229", + title: "Order Update", + body: "Your listing has been bookmarked", + data: NotificationData(type: "your listings", messageId: "3") + ) + ] + + var filteredNotifications: [Notifications] { + if selectedTab == "All" { + return notifications + } else { + return notifications.filter { $0.data.type.lowercased() == selectedTab.lowercased() } + } + } + + // MARK: - Functions + + /// Mark a notification as read + func markAsRead(notification: Notifications) { + if let index = notifications.firstIndex(where: { $0.data.messageId == notification.data.messageId}) { + notifications[index].isRead = true + } + } + + /// Simulate fetching data + func fetchNotifications() { + notifications = [ + Notifications( + userID: "381527oef-42b4-4fdd-b074-dfwbejko229", + title: "New Message", + body: "You have received a new message from Mateo", + data: NotificationData(type: "messages", messageId: "12345") + ), + Notifications( + userID: "381527oef-42b4-4fdd-b074-dfwbejko229", + title: "New Request", + body: "You have a new request from Angelina", + data: NotificationData(type: "requests", messageId: "23456") + ) + ] + } +} + + diff --git a/Resell/ViewModels/ProductDetailsViewModel.swift b/Resell/ViewModels/ProductDetailsViewModel.swift index f407c81..b4f843f 100644 --- a/Resell/ViewModels/ProductDetailsViewModel.swift +++ b/Resell/ViewModels/ProductDetailsViewModel.swift @@ -6,6 +6,8 @@ // import SwiftUI +import Kingfisher +import UserNotifications @MainActor class ProductDetailsViewModel: ObservableObject { @@ -29,28 +31,54 @@ class ProductDetailsViewModel: ObservableObject { // MARK: - Functions func getPost(id: String) { + isLoading = true + Task { - isLoading = true + defer { Task { @MainActor in withAnimation { isLoading = false } } } do { let postResponse = try await NetworkManager.shared.getPostByID(id: id) item = postResponse.post - images = postResponse.post.images + if let post = postResponse.post { + images = post.images.compactMap { URL(string: $0) } + } await calculateMaxImgRatio() getIsSaved() - - isLoading = false + getSimilarPosts(id: id) } catch { - NetworkManager.shared.logger.error("Error in ProductDetailsViewModel.getPost: \(error.localizedDescription)") - isLoading = false + NetworkManager.shared.logger.error("Error in ProductDetailsViewModel.getPost: \(error)") + } + } + } + + func isMyPost() -> Bool { + if let userID = GoogleAuthManager.shared.user?.firebaseUid { + if userID == item?.user?.firebaseUid { + return true } } + + return false + } + + func setPost(post: Post) { + item = post + images = post.images.compactMap { URL(string: $0) } + + Task { + await calculateMaxImgRatio() + } + + getIsSaved() + getSimilarPosts(id: post.id) } + // Replace once backend endpoint is fix. Currently, making this call blocks all other incoming requests to our backend :( func getSimilarPosts(id: String) { Task { isLoadingImages = true + defer { isLoadingImages = false } do { let postsResponse = try await NetworkManager.shared.getSimilarPostsByID(id: id) @@ -59,15 +87,35 @@ class ProductDetailsViewModel: ObservableObject { } else { similarPosts = postsResponse.posts } + + } catch { + NetworkManager.shared.logger.error("Errror in ProductDetailsViewModel.getSimilarPosts: \(error)") + } + } + } + + func getSimilarPostsNaive(post: Post) { + Task { + do { + // idk if this is what the endpoint even wants... + let postsResponse = try await NetworkManager.shared.getFilteredPosts(by: post.category != nil ? post.category! : "") + var otherPosts = postsResponse.posts + otherPosts.removeAll { $0.id == post.id } + + if otherPosts.count >= 4 { + similarPosts = Array(otherPosts.prefix(4)) + } else { + similarPosts = otherPosts + } isLoadingImages = false } catch { - NetworkManager.shared.logger.error("Errror in ProductDetailsViewModel.getSimilarPosts: \(error.localizedDescription)") + NetworkManager.shared.logger.error("Errror in ProductDetailsViewModel.getSimilarPostsNaive: \(error)") isLoadingImages = false } } } - + func updateItemSaved() { Task { do { @@ -79,7 +127,7 @@ class ProductDetailsViewModel: ObservableObject { } } } catch { - NetworkManager.shared.logger.error("Error in ProductDetailsViewModel:.updateItemSaved \(error.localizedDescription)") + NetworkManager.shared.logger.error("Error in ProductDetailsViewModel:.updateItemSaved \(error)") } } } @@ -91,7 +139,7 @@ class ProductDetailsViewModel: ObservableObject { isSaved = try await NetworkManager.shared.postIsSaved(id: id).isSaved } } catch { - NetworkManager.shared.logger.error("Error in ProductDetailsViewModel.getIsSaved: \(error.localizedDescription)") + NetworkManager.shared.logger.error("Error in ProductDetailsViewModel.getIsSaved: \(error)") } } } @@ -105,7 +153,7 @@ class ProductDetailsViewModel: ObservableObject { didShowDeleteView = false } catch { - NetworkManager.shared.logger.error("Error in ProductDetailsViewModel.archivePost: \(error.localizedDescription)") + NetworkManager.shared.logger.error("Error in ProductDetailsViewModel.archivePost: \(error)") } } } @@ -119,14 +167,14 @@ class ProductDetailsViewModel: ObservableObject { didShowDeleteView = false } catch { - NetworkManager.shared.logger.error("Error in ProductDetailsViewModel.deletePost: \(error.localizedDescription)") + NetworkManager.shared.logger.error("Error in ProductDetailsViewModel.deletePost: \(error)") } } } func isUserPost() -> Bool { - if let userId = UserSessionManager.shared.userID, - let itemUserId = item?.user?.id { + if let userId = GoogleAuthManager.shared.user?.firebaseUid, + let itemUserId = item?.user?.firebaseUid { return userId == itemUserId } @@ -142,6 +190,90 @@ class ProductDetailsViewModel: ObservableObject { item = nil similarPosts = [] } + +// // Creates a new notification for type = bookmarks +// // __(person)__ has bookmarked __(item)__ + +// func createNewNotif() { +// Task { +// do { +// // Checks product exists +// guard let product = item else { +// NetworkManager.shared.logger.error("Error in createNewNotif: Product not available.") +// return +// } +// +// guard let userID = UserSessionManager.shared.userID else { +// UserSessionManager.shared.logger.error("Error in createNewNotif: userID not found") +// return +// } +// +// // Checks +// guard let sellerID = product.user?.id else { +// NetworkManager.shared.logger.error("Error in createNewNotif: Seller ID not found.") +// return +// } +// +// let productName = product.title +// +// // Posts a notification under the sellerID +// let notification = Notification( +// userID: sellerID, +// title: "\(userID) has bookmarked \(productName)", +// body: "\(productName) was bookmarked!", +// data: NotificationData(type: "bookmarks", messageId: UUID().uuidString) +// ) +// +// let _ = try await NetworkManager.shared.createNotif(notifBody: notification) +// +// NetworkManager.shared.logger.info("Notification sent!!") +// } catch { +// +// NetworkManager.shared.logger.error("Error in ProductDetailsViewModel.createNewNotif: \(error.localizedDescription)") +// +// } +// } +// } + + // MARK: I DONT KNOW WHAT THIS DOES - Charles + +// func createNewNotif() { +// print(UserSessionManager.shared.userID) +// Task { +// do { +// guard let product = item else { +// NetworkManager.shared.logger.error("Error: Product details not available.") +// return +// } +// +// guard let sellerID = product.user?.id else { +// NetworkManager.shared.logger.error("Error: Seller ID not found.") +// return +// } +// +// let productName = product.title +// +//// let notification = Notification( +//// // userID: sellerID, +//// //name: "\(UserSessionManager.shared.userID ?? "Someone") has bookmarked \(productName)" +//// // body: "Your item '\(productName)' was bookmarked!" +//// +//// // data: NotificationData(type: "bookmarks", messageId: UUID().uuidString) +//// ) +// +// try await NetworkManager.shared.createNotif(notifBody: notification) +// NetworkManager.shared.logger.info("Notification sent successfully!") +// +// } catch let error as ErrorResponse { +// // Specific error from app +// NetworkManager.shared.logger.error("API Error \(error.localizedDescription)") +// } catch { +// // General error +// NetworkManager.shared.logger.error("Unexpected error \(error.localizedDescription)") +// } +// } +// } + private func calculateMaxImgRatio() async { var maxRatio = 0.0 diff --git a/Resell/ViewModels/ProfileViewModel.swift b/Resell/ViewModels/ProfileViewModel.swift index 269576a..002e986 100644 --- a/Resell/ViewModels/ProfileViewModel.swift +++ b/Resell/ViewModels/ProfileViewModel.swift @@ -15,143 +15,136 @@ class ProfileViewModel: ObservableObject { @Published var didShowOptionsMenu: Bool = false @Published var didShowBlockView: Bool = false @Published var sellerIsBlocked: Bool = false - - @Published var isLoading: Bool = false - - @Published var requests: [Request] = [] - @Published var selectedPosts: [Post] = [] @Published var selectedTab: Tab = .listing - @Published var user: User? = nil - - private var archivedPosts: [Post] = [] - private var userPosts: [Post] = [] + + @Published var isLoadingExternalUser: Bool = false + @Published var externalUser: User? = nil + @Published var externalUserPosts: [Post] = [] + + @Published var isFollowing: Bool = false + @Published var isFollowLoading: Bool = false + @Published var followerCount: Int = 0 enum Tab: String { case listing, archive, wishlist } + + // MARK: - Computed Properties + + var isViewingCurrentUser: Bool { + externalUser == nil + } + + var selectedPosts: [Post] { + if isViewingCurrentUser { + return selectedTab == .listing + ? CurrentUserProfileManager.shared.userPosts + : CurrentUserProfileManager.shared.archivedPosts + } else { + return externalUserPosts + } + } + + var requests: [Request] { + CurrentUserProfileManager.shared.requests + } + + var isLoading: Bool { + isViewingCurrentUser + ? CurrentUserProfileManager.shared.isLoading + : isLoadingExternalUser + } // MARK: - Functions - func updateItemsGallery() { - switch selectedTab { - case .listing: - selectedPosts = userPosts - return - case .archive: - selectedPosts = archivedPosts - return - case .wishlist: - return - } + func loadCurrentUser(forceRefresh: Bool = false) { + CurrentUserProfileManager.shared.loadProfile(forceRefresh: forceRefresh) } - - func getUser() { + + func loadExternalUser(id: String) { + externalUser = nil + externalUserPosts = [] + isFollowing = false + followerCount = 0 + Task { - isLoading = true + isLoadingExternalUser = true + defer { isLoadingExternalUser = false } do { - if let id = UserSessionManager.shared.userID { - user = try await NetworkManager.shared.getUserByID(id: id).user - - let postsResponse = try await NetworkManager.shared.getPostsByUserID(id: id) - let archivedResponse = try await NetworkManager.shared.getArchivedPostsByUserID(id: id) - let requestsResponse = try await NetworkManager.shared.getRequestsByUserID(id: id) - - userPosts = Post.sortPostsByDate(postsResponse.posts) - archivedPosts = Post.sortPostsByDate(archivedResponse.posts) - requests = requestsResponse.requests - selectedPosts = userPosts - - withAnimation { isLoading = false } - } else if let googleId = UserSessionManager.shared.googleID { - user = try await NetworkManager.shared.getUserByGoogleID(googleID: googleId).user - - let postsResponse = try await NetworkManager.shared.getPostsByUserID(id: user?.id ?? "") - let archivedResponse = try await NetworkManager.shared.getArchivedPostsByUserID(id: user?.id ?? "") - let requestsResponse = try await NetworkManager.shared.getRequestsByUserID(id: user?.id ?? "") - - userPosts = Post.sortPostsByDate(postsResponse.posts) - archivedPosts = Post.sortPostsByDate(archivedResponse.posts) - requests = requestsResponse.requests - selectedPosts = userPosts - - withAnimation { isLoading = false } - } else { - UserSessionManager.shared.logger.error("Error in ProfileViewModel.getUser: No userID or googleID found in UserSessionManager") - withAnimation { isLoading = false } - } - + externalUser = try await NetworkManager.shared.getUserByID(id: id).user + followerCount = externalUser?.followers?.count ?? 0 + checkUserIsBlocked(userId: id) + checkUserIsFollowing(userId: id) + externalUserPosts = try await NetworkManager.shared.getPostsByUserID(id: externalUser?.firebaseUid ?? "").posts } catch { - NetworkManager.shared.logger.error("Error in ProfileViewModel.getUser: \(error)") - withAnimation { isLoading = false } + NetworkManager.shared.logger.error("Error in ProfileViewModel.loadExternalUser: \(error)") } } } - - func getExternalUser(id: String) { + + func checkUserIsFollowing(userId: String) { Task { do { - user = try await NetworkManager.shared.getUserByID(id: id).user - checkUserIsBlocked() - selectedPosts = try await NetworkManager.shared.getPostsByUserID(id: user?.id ?? "").posts + if let currentUserId = GoogleAuthManager.shared.user?.firebaseUid { + let followingUsers = try await NetworkManager.shared.getFollowing(id: currentUserId).users.map { $0.firebaseUid } + isFollowing = followingUsers.contains(userId) + } } catch { - NetworkManager.shared.logger.error("Error in ProfileViewModel: \(error.localizedDescription)") + NetworkManager.shared.logger.error("Error in \(#file) \(#function): \(error)") } - } } + + func followUser(id: String) async throws { + isFollowLoading = true + defer { isFollowLoading = false } + + let follow = FollowUserBody(userId: id) + _ = try await NetworkManager.shared.followUser(follow: follow) + isFollowing = true + followerCount += 1 + } + + func unfollowUser(id: String) async throws { + isFollowLoading = true + defer { isFollowLoading = false } + + let unfollow = UnfollowUserBody(userId: id) + _ = try await NetworkManager.shared.unfollowUser(unfollow: unfollow) + isFollowing = false + followerCount = max(0, followerCount - 1) + } - func checkUserIsBlocked() { + func checkUserIsBlocked(userId: String) { Task { do { - if let userID = UserSessionManager.shared.userID { - let blockedUsers = try await NetworkManager.shared.getBlockedUsers(id: userID).users.map { $0.id } - sellerIsBlocked = blockedUsers.contains(user?.id ?? "") - } else { - UserSessionManager.shared.logger.error("Error in BlockedUsersView: userID not found.") + if let currentUserId = GoogleAuthManager.shared.user?.firebaseUid { + let blockedUsers = try await NetworkManager.shared.getBlockedUsers(id: currentUserId).users.map { $0.firebaseUid } + sellerIsBlocked = blockedUsers.contains(userId) } } catch { - NetworkManager.shared.logger.error("Error in BlockedUsersView: \(error.localizedDescription)") + NetworkManager.shared.logger.error("Error in \(#file) \(#function): \(error)") } } } - func blockUser(id: String) { - Task { - isLoading = true - - do { - let blocked = BlockUserBody(blocked: id) - try await NetworkManager.shared.blockUser(blocked: blocked) - - isLoading = false - } catch { - NetworkManager.shared.logger.error("Error in ProfileViewModel.blockUser: \(error.localizedDescription)") - isLoading = false - } - } + func blockUser(id: String) async throws { + let blocked = BlockUserBody(blocked: id) + try await NetworkManager.shared.blockUser(blocked: blocked) + sellerIsBlocked = true } - func unblockUser(id: String) { - Task { - isLoading = true - - do { - let unblocked = UnblockUserBody(unblocked: id) - try await NetworkManager.shared.unblockUser(unblocked: unblocked) - - isLoading = false - } catch { - NetworkManager.shared.logger.error("Error in ProfileViewModel.unblockUser: \(error.localizedDescription)") - isLoading = false - } - } + func unblockUser(id: String) async throws { + let unblocked = UnblockUserBody(unblocked: id) + try await NetworkManager.shared.unblockUser(unblocked: unblocked) + sellerIsBlocked = false } func deleteRequest(id: String) { Task { do { - try await NetworkManager.shared.deleteRequest(id: id) + try await CurrentUserProfileManager.shared.deleteRequest(id: id) } catch { NetworkManager.shared.logger.error("Error in ProfileViewModel.deleteRequest: \(error)") } diff --git a/Resell/ViewModels/ReportViewModel.swift b/Resell/ViewModels/ReportViewModel.swift index daaa5c0..127d9fe 100644 --- a/Resell/ViewModels/ReportViewModel.swift +++ b/Resell/ViewModels/ReportViewModel.swift @@ -23,7 +23,6 @@ class ReportViewModel: ObservableObject { } } - // TODO: Add Logic to change this later @Published var reportType: String = "Post" @Published var selectedOption: String = "" @@ -47,56 +46,53 @@ class ReportViewModel: ObservableObject { } func reportPost() { + isLoading = true + Task { - isLoading = true + defer { Task { @MainActor in withAnimation { isLoading = false } } } do { - if let userID = user?.id, + if let userID = user?.firebaseUid, let postID = post?.id { let reportBody = ReportPostBody(reported: userID, post: postID, reason: selectedOption) try await NetworkManager.shared.reportPost(reportBody: reportBody) } - - withAnimation { isLoading = false } } catch { - NetworkManager.shared.logger.error("Error in ReportViewModel.reportPost: \(error.localizedDescription)") - withAnimation { isLoading = false } + NetworkManager.shared.logger.error("Error in ReportViewModel.reportPost: \(error)") } } } func reportUser() { + isLoading = true + Task { - isLoading = true + defer { Task { @MainActor in withAnimation { isLoading = false } } } do { - if let userID = user?.id { + if let userID = user?.firebaseUid { let reportBody = ReportUserBody(reported: userID, reason: selectedOption) try await NetworkManager.shared.reportUser(reportBody: reportBody) } - - withAnimation { isLoading = false } } catch { - NetworkManager.shared.logger.error("Error in ReportViewModel.reportUser: \(error.localizedDescription)") - withAnimation { isLoading = false } + NetworkManager.shared.logger.error("Error in ReportViewModel.reportUser: \(error)") } } } func blockUser() { + isLoading = true + Task { - isLoading = true + defer { Task { @MainActor in withAnimation { isLoading = false } } } do { - if let id = user?.id { + if let id = user?.firebaseUid { let blocked = BlockUserBody(blocked: id) try await NetworkManager.shared.blockUser(blocked: blocked) } - - isLoading = false } catch { - NetworkManager.shared.logger.error("Error in ProfileViewModel.blockUser: \(error.localizedDescription)") - isLoading = false + NetworkManager.shared.logger.error("Error in ProfileViewModel.blockUser: \(error)") } } } @@ -106,7 +102,7 @@ class ReportViewModel: ObservableObject { do { user = try await NetworkManager.shared.getUserByID(id: id).user } catch { - NetworkManager.shared.logger.error("Error in ReportViewModel.getUser: \(error.localizedDescription)") + NetworkManager.shared.logger.error("Error in ReportViewModel.getUser: \(error)") } } } @@ -117,7 +113,7 @@ class ReportViewModel: ObservableObject { post = try await NetworkManager.shared.getPostByID(id: id).post user = post?.user } catch { - NetworkManager.shared.logger.error("Error in ReportViewModel.getUser: \(error.localizedDescription)") + NetworkManager.shared.logger.error("Error in ReportViewModel.getUser: \(error)") } } } diff --git a/Resell/ViewModels/SearchViewModel.swift b/Resell/ViewModels/SearchViewModel.swift new file mode 100644 index 0000000..fd7bfa9 --- /dev/null +++ b/Resell/ViewModels/SearchViewModel.swift @@ -0,0 +1,215 @@ +// +// SearchViewModel.swift +// Resell +// +// Created by Charles Liggins on 9/12/25. +// + +import SwiftUI + +@MainActor +class SearchViewModel: ObservableObject { + @Published var searchedItems: [Post] = [] + @Published var isLoading: Bool = false + @Published var isSearching: Bool = true + + @AppStorage("recentlySearched") private var recentlySearchedStorage: String = "[]" + + // ✅ Computed property that reads/writes to AppStorage directly + var recentlySearched: [String] { + get { + if let jsonData = recentlySearchedStorage.data(using: .utf8), + let decoded = try? JSONDecoder().decode([String].self, from: jsonData) { + return decoded + } + return [] + } + set { + if let jsonData = try? JSONEncoder().encode(newValue), + let jsonString = String(data: jsonData, encoding: .utf8) { + recentlySearchedStorage = jsonString + // Manually trigger objectWillChange since this isn't @Published + objectWillChange.send() + } + } + } + + static let shared = SearchViewModel() + + private var cachedRecentlySearchedPosts: [Post] = [] + private var lastRecentlySearchedFetchTime: Date? + private let cacheValidityDuration: TimeInterval = 300 // 5 minutes + + + func searchItems(with searchText: String, userID: String?, saveQuery: Bool = false, mainViewModel: MainViewModel? = nil, completion: @escaping () -> Void) { + guard !searchText.isEmpty else { + searchedItems = [] + return + } + + isSearching = false + isLoading = true + + Task { + defer { Task { @MainActor in + withAnimation { isLoading = false } + completion() + } + } + + do { + let postsResponse = try await NetworkManager.shared.getSearchedPosts(with: searchText) + + // ✅ ALWAYS store the searchId for recommendations + if !recentlySearched.contains(postsResponse.searchId) { + recentlySearched.insert(postsResponse.searchId, at: 0) + // Keep only last 5 searches + if recentlySearched.count > 5 { + recentlySearched = Array(recentlySearched.prefix(5)) + } + } + + // Filter or set searchedItems based on userID + if let userID = userID { + searchedItems = postsResponse.posts.filter { $0.user?.firebaseUid == userID } + } else { + searchedItems = postsResponse.posts + } + + print("📝 Recent searches: \(recentlySearched)") + + if saveQuery { + await MainActor.run { + mainViewModel?.saveSearchQuery(searchText) + } + } + } catch { + NetworkManager.shared.logger.error("Error in SearchViewModel.searchItems: \(error.localizedDescription)") + } + } + } + + /// Fetch posts for a specific searchId (for displaying in ForYou card) + func fetchPostsForSearchId(_ searchId: String, limit: Int = 4) async -> [Post] { + do { + let postIds = try await NetworkManager.shared.getSearchSuggestions(searchIndex: searchId) + print("this far?") + + // Take only the number we need + let limitedIds = Array(postIds.postIds.prefix(limit)) + + // Fetch the actual posts by ID + print("this far?") + return await fetchPostsByIds(limitedIds) + + } catch { + NetworkManager.shared.logger.error("Error fetching posts for searchId '\(searchId)': \(error)") + return [] + } + } + + /// Fetch multiple posts by their IDs + /// Note: You'll need to implement this endpoint in your backend if it doesn't exist + private func fetchPostsByIds(_ postIds: [String]) async -> [Post] { + // If you have a bulk fetch endpoint: + // return try? await NetworkManager.shared.getPostsByIds(postIds) + + // Otherwise, fetch individually (less efficient): + var posts: [Post] = [] + for postId in postIds { + if let post = try? await NetworkManager.shared.getPostByID(id: postId) { + posts.append(post.post!) // should be fine... + } + } + return posts + } + + /// Load posts for recently searched card (fetch just enough to display) + func loadRecentlySearchedPosts() async -> [Post] { + if let lastFetch = lastRecentlySearchedFetchTime, + Date().timeIntervalSince(lastFetch) < cacheValidityDuration, + !cachedRecentlySearchedPosts.isEmpty { + print("Using cached recently searched posts") + return cachedRecentlySearchedPosts + } + + print("🔍 Loading recently searched posts...") + print("📋 Recent searches count: \(recentlySearched.count)") + print("📋 Recent searchIds: \(recentlySearched)") + + guard !recentlySearched.isEmpty else { + print("❌ No recent searches found") + return [] + } + + var allPosts: [Post] = [] + var seenIds = Set() + + // Fetch from recent searches until we have 4 unique posts + for searchId in recentlySearched.prefix(3) { + print("🔄 Fetching posts for searchId: \(searchId)") + let posts = await fetchPostsForSearchId(searchId, limit: 2) + print("✅ Got \(posts.count) posts for searchId: \(searchId)") + + for post in posts { + if !seenIds.contains(post.id) { + allPosts.append(post) + seenIds.insert(post.id) + + if allPosts.count >= 4 { + print("✅ Loaded 4 posts, returning early") + let result = Array(allPosts.prefix(4)) + cachedRecentlySearchedPosts = result + lastRecentlySearchedFetchTime = Date() + return result + } + } + } + } + + print("✅ Loaded \(allPosts.count) total posts") + cachedRecentlySearchedPosts = allPosts + lastRecentlySearchedFetchTime = Date() + return allPosts + } + + /// Load all suggestions for SuggestionsView (up to 25 posts) + func loadAllSuggestions() async -> [Post] { + guard !recentlySearched.isEmpty else { return [] } + + var allPosts: [Post] = [] + var seenIds = Set() + + // Take up to 5 recent searches + for searchId in recentlySearched.prefix(5) { + do { + let postIds = try await NetworkManager.shared.getSearchSuggestions(searchIndex: searchId) + + // Take up to 5 suggestions per search + let limitedIds = Array(postIds.postIds.prefix(5)) + let posts = await fetchPostsByIds(limitedIds) + + for post in posts { + if !seenIds.contains(post.id) { + allPosts.append(post) + seenIds.insert(post.id) + + // Stop at 25 total posts + if allPosts.count >= 25 { + return Array(allPosts.prefix(25)) + } + } + } + } catch { + NetworkManager.shared.logger.error("Error loading suggestions for searchId '\(searchId)': \(error)") + } + } + + return allPosts + } + + func clearCache() { + cachedRecentlySearchedPosts = [] + lastRecentlySearchedFetchTime = nil + } +} diff --git a/Resell/ViewModels/SendFeedbackViewModel.swift b/Resell/ViewModels/SendFeedbackViewModel.swift index b307a24..d6d311c 100644 --- a/Resell/ViewModels/SendFeedbackViewModel.swift +++ b/Resell/ViewModels/SendFeedbackViewModel.swift @@ -47,22 +47,20 @@ class SendFeedbackViewModel: ObservableObject { } func submitFeedback() { + isLoading = true Task { - isLoading = true + defer { Task { @MainActor in withAnimation { isLoading = false } } } do { - if let userID = UserSessionManager.shared.userID { + if let user = GoogleAuthManager.shared.user { let imagesBase64 = selectedImages.map { $0.toBase64() ?? "" } - let feedbackBody = FeedbackBody(description: feedbackText, images: imagesBase64, userId: userID) + let feedbackBody = FeedbackBody(description: feedbackText, images: imagesBase64, userId: user.firebaseUid) try await NetworkManager.shared.postFeedback(feedback: feedbackBody) } else { - UserSessionManager.shared.logger.error("Error in SendFeedbackViewModel.submitFeedback: userID not found") + GoogleAuthManager.shared.logger.error("Error in \(#file) \(#function): User not available.") } - - isLoading = false } catch { - NetworkManager.shared.logger.error("Error in SendFeedbackViewModel.submitFeedback: \(error)") - isLoading = false + NetworkManager.shared.logger.error("Error in \(#file) \(#function): \(error)") } } } diff --git a/Resell/ViewModels/SettingsViewModel.swift b/Resell/ViewModels/SettingsViewModel.swift index 8d5dbb4..f58febe 100644 --- a/Resell/ViewModels/SettingsViewModel.swift +++ b/Resell/ViewModels/SettingsViewModel.swift @@ -19,7 +19,8 @@ class SettingsViewModel: ObservableObject { var settings: [Settings] = [ .accountSettings, - .notifications, + // MARK: Omit notifications for release + // .notifications, .sendFeedback, .blockedUsers, .eula, @@ -55,6 +56,7 @@ class SettingsViewModel: ObservableObject { Task { do { let _ = try await NetworkManager.shared.logout() + GoogleAuthManager.shared.signOut() } catch { NetworkManager.shared.logger.error("Error in SettingsViewModel.logout: \(error)") } @@ -65,13 +67,13 @@ class SettingsViewModel: ObservableObject { func deleteAccount() { Task { do { - if let userID = UserSessionManager.shared.userID { + if let userID = GoogleAuthManager.shared.user?.firebaseUid { try await NetworkManager.shared.deleteAccount(userID: userID) } else { - UserSessionManager.shared.logger.error("Error in SettingsViewModel.deleteAccount: userID not found") + GoogleAuthManager.shared.logger.error("Error in \(#file) \(#function): User not available.") } } catch { - NetworkManager.shared.logger.error("Error in SettingsViewModel.deleteAccount: \(error)") + NetworkManager.shared.logger.error("Error in \(#file) \(#function): \(error)") } } } @@ -81,7 +83,7 @@ enum Settings { case editProfile case deleteAccount case accountSettings - case notifications +// case notifications case sendFeedback case blockedUsers case eula diff --git a/Resell/ViewModels/SetupProfileViewModel.swift b/Resell/ViewModels/SetupProfileViewModel.swift index 664fa3d..63515f4 100644 --- a/Resell/ViewModels/SetupProfileViewModel.swift +++ b/Resell/ViewModels/SetupProfileViewModel.swift @@ -13,7 +13,9 @@ class SetupProfileViewModel: ObservableObject { // MARK: - Properties + @Published var errorText: String = "" @Published var didAgreeWithEULA: Bool = false + @Published var didPresentError: Bool = false @Published var didShowPhotosPicker: Bool = false @Published var didShowWebView: Bool = false @Published var isLoading: Bool = false @@ -22,7 +24,7 @@ class SetupProfileViewModel: ObservableObject { @Published var bio: String = "" @Published var venmoHandle: String = "" - @Published var selectedImage: UIImage = UIImage(named: "emptyProfile")! + @Published var selectedImage: UIImage? = nil @Published var selectedItem: PhotosPickerItem? = nil @Published var netid: String = "" @@ -43,28 +45,41 @@ class SetupProfileViewModel: ObservableObject { if let data = try? await newItem.loadTransferable(type: Data.self), let image = UIImage(data: data) { DispatchQueue.main.async { - self.selectedImage = image + + self.selectedImage = image.resizedToMaxDimension(512) } } } } func createNewUser() { + isLoading = true + + if selectedImage == nil { + presentError("Please select a profile picture.") + return + } + Task { - isLoading = true - + defer { Task { @MainActor in withAnimation { isLoading = false } } } + do { - if let imageBase64 = selectedImage.toBase64() { - let user = CreateUserBody(username: username.cleaned(), netid: netid, givenName: givenName, familyName: familyName, photoUrl: imageBase64, email: email, googleID: googleID, bio: bio.cleaned()) - try await NetworkManager.shared.createUser(user: user) - } else { - // TODO: Present Toast Error - } + if let imageBase64 = selectedImage?.resizedToMaxDimension(256).toBase64() { + let imageBody = ImageBody(imageBase64: imageBase64) + let imageUrl = try await NetworkManager.shared.uploadImage(image: imageBody).image - isLoading = false + guard let fcmToken = FirebaseNotificationService.shared.fcmToken, let user = GoogleAuthManager.shared.user else { + return + } + + let userBody = user.toCreateUserBody(username: username, bio: bio, venmoHandle: venmoHandle, imageUrl: imageUrl, fcmToken: fcmToken) + try await NetworkManager.shared.createUser(user: userBody) + } } catch { - NetworkManager.shared.logger.error("Error in SetupProfileViewModel.createNewUser: \(error.localizedDescription)") - isLoading = false + if error as? ErrorResponse == ErrorResponse.usernameAlreadyExists { + presentError("That username is already taken.") + } + NetworkManager.shared.logger.error("Error in SetupProfileViewModel.createNewUser: \(error)") } } } @@ -87,4 +102,9 @@ class SetupProfileViewModel: ObservableObject { googleID = "" } + private func presentError(_ error: String) { + errorText = error + didPresentError = true + } + } diff --git a/Resell/Views/Chats/ChatsView.swift b/Resell/Views/Chats/ChatsView.swift new file mode 100644 index 0000000..432f33d --- /dev/null +++ b/Resell/Views/Chats/ChatsView.swift @@ -0,0 +1,177 @@ +// +// ChatsView.swift +// Resell +// +// Created by Richie Sun on 9/12/24. +// + +import Kingfisher +import SwiftUI + +struct ChatsView: View { + + // MARK: - Properties + + @EnvironmentObject var router: Router + @EnvironmentObject var viewModel: ChatsViewModel + @EnvironmentObject var mainViewModel: MainViewModel + + // MARK: - UI + + var body: some View { + VStack(alignment: .leading) { + headerView + + filtersView + + chatsView + + Spacer() + } + .background(Constants.Colors.white) + .emptyState(isEmpty: viewModel.checkEmptyState(), title: viewModel.emptyStateTitle(), text: viewModel.emptyStateMessage()) + .refreshable { + viewModel.refreshChats() + } + .onAppear { + viewModel.getAllChats() + } + .loadingView(isLoading: viewModel.isLoading) + } + + private var headerView: some View { + HStack { + Text("Messages") + .font(Constants.Fonts.h1) + .foregroundStyle(Constants.Colors.black) + + Spacer() + } + .padding(.horizontal, 25) + } + + private var filtersView: some View { + HStack { + ForEach(Constants.chats, id: \.id) { filter in + let unreadCount = filter.title == ChatTab.purchases.rawValue ? viewModel.purchaseUnread : viewModel.offerUnread + FilterButton(filter: filter, unreadChats: unreadCount, isSelected: viewModel.selectedTab.rawValue == filter.title) { + if filter.title == ChatTab.purchases.rawValue { + viewModel.selectedTab = .purchases + } else { + viewModel.selectedTab = .offers + } + } + } + } + .padding(.leading, Constants.Spacing.horizontalPadding) + .padding(.vertical, 1) + } + + private var chatsView: some View { + ScrollView(.vertical) { + VStack(alignment: .center, spacing: 24) { + ForEach(viewModel.selectedTab == ChatTab.purchases ? viewModel.purchaseChats : viewModel.offerChats) { chat in + chatPreviewRow(chat: chat) + } + } + .padding(.top, 12) + + Spacer() + } + .frame(width: UIScreen.width) + } + + private func chatPreviewRow(chat: Chat) -> some View { + HStack(spacing: 12) { + KFImage(chat.other.photoUrl) + .placeholder { + ShimmerView() + .frame(width: 52, height: 52) + .clipShape(Circle()) + } + .resizable() + .scaledToFill() + .frame(width: 52, height: 52) + .clipShape(Circle()) + + VStack(alignment: .leading) { + HStack { + Text("\(chat.other.givenName) \(chat.other.familyName)") + .font(Constants.Fonts.title1) + .foregroundStyle(Constants.Colors.black) + .lineLimit(1) + .truncationMode(.tail) + + Text(chat.post.title) + .font(Constants.Fonts.title4) + .foregroundStyle(Constants.Colors.secondaryGray) + .lineLimit(1) + .truncationMode(.tail) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(Constants.Colors.stroke, lineWidth: 0.75) + } + } + + HStack(spacing: 0) { + Text(chat.lastMessage) + .font(Constants.Fonts.title4) + .foregroundStyle(Constants.Colors.secondaryGray) + .lineLimit(1) + .truncationMode(.tail) + + Text(" • ") + + Text(Date.timeAgo(from: chat.updatedAt)) + .font(Constants.Fonts.title4) + .foregroundStyle(Constants.Colors.secondaryGray) + } + + } + + Spacer() + + Image(systemName: "chevron.right") + .foregroundStyle(Constants.Colors.inactiveGray) + } + .padding(.horizontal, 15) + .padding(.leading, 15) + .background(Constants.Colors.white) + .overlay(alignment: .leading) { + if !chat.messages.filter({ !$0.read && !$0.mine }).isEmpty { + Circle() + .frame(width: 10, height: 10) + .foregroundStyle(Constants.Colors.resellPurple) + .padding(.leading, 8) + } + } + .onTapGesture { + guard let me = GoogleAuthManager.shared.user else { + GoogleAuthManager.shared.logger.error("Error in \(#file) \(#function): User not available.") + return + } + + viewModel.selectedChat = chat + viewModel.getSelectedChatPost { listing in + guard let seller = listing.user else { + NetworkManager.shared.logger.error("Error in \(#file) \(#function): User not found in post. Can't push messages view.") + return + } + + let buyer = seller.firebaseUid == me.firebaseUid ? chat.other : me + + let chatInfo = ChatInfo( + listing : listing, + buyer: buyer, + seller: seller + ) + + router.push(.messages(chatInfo: chatInfo)) + } + } + } + +} + diff --git a/Resell/Views/Chats/MessagesView.swift b/Resell/Views/Chats/MessagesView.swift new file mode 100644 index 0000000..aec5219 --- /dev/null +++ b/Resell/Views/Chats/MessagesView.swift @@ -0,0 +1,675 @@ +// +// MessagesView.swift +// Resell +// +// Created by Richie Sun on 10/26/24. +// + +import Kingfisher +import PhotosUI +import SwiftUI + +struct MessagesView: View { + + // MARK: - Properties + + @EnvironmentObject var router: Router + @State private var didShowOptionsMenu: Bool = false + @State private var didShowNegotiationView: Bool = false + @State private var didShowAvailabilityView: Bool = false + @State private var didShowWebView: Bool = false + @State private var didSubmitAvailabilities: Bool = false + @State private var isEditing: Bool = true + @State private var priceText: String = "" + @StateObject private var viewModel: ViewModel + + // MARK: - Init + + init(chatInfo: ChatInfo) { + _viewModel = StateObject(wrappedValue: ViewModel(chatInfo: chatInfo)) + } + + // MARK: - UI + + var body: some View { + ZStack { + mainContentView + + if didShowOptionsMenu { + optionsMenuOverlay + } + } + .background(Constants.Colors.white) + .toolbarBackground(Constants.Colors.white, for: .automatic) + .toolbar { + ToolbarItem(placement: .principal) { + headerButton + } + + ToolbarItem(placement: .topBarTrailing) { + optionsButton + } + } + .sheet(isPresented: $didShowNegotiationView, onDismiss: setNegotiationText) { + negotiationView + } + .sheet(isPresented: $didShowAvailabilityView) { + // func that takes in isEditing + availabilityView(isEditing: $isEditing) + } + .sheet(isPresented: $didShowWebView) { + webView + } + .onAppear(perform: setupOnAppear) + .onDisappear { + FirestoreManager.shared.stopListeningToChat() + } + .onChange(of: didSubmitAvailabilities, perform: handleAvailabilitySubmit) + .endEditingOnTap() + } + + // MARK: - Extracted Subviews + + private var mainContentView: some View { + VStack { + messageListView + + Spacer() + + Divider() + + messageInputView + } + } + + private var optionsMenuOverlay: some View { + OptionsMenuView(showMenu: $didShowOptionsMenu, options: [.report(type: "User", id: viewModel.chatInfo.buyer.firebaseUid)]) + .zIndex(100) + } + + private var headerButton: some View { + Button { + navigateToProductDetails() + } label: { + VStack(spacing: 0) { + Text(viewModel.chatInfo.listing.title) + .font(Constants.Fonts.title1) + .foregroundStyle(Constants.Colors.black) + .lineLimit(1) + .truncationMode(.tail) + + Text("\(viewModel.chatInfo.listing.user?.givenName ?? "") \(viewModel.chatInfo.listing.user?.familyName ?? "")") + .font(Constants.Fonts.title3) + .foregroundStyle(Constants.Colors.secondaryGray) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + + private var optionsButton: some View { + Button { + withAnimation { + didShowOptionsMenu.toggle() + } + } label: { + Image(systemName: "ellipsis") + .resizable() + .frame(width: 24, height: 6) + .foregroundStyle(Constants.Colors.black) + } + .padding() + } + + private var messageListView: some View { + VStack { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 12) { + ForEach(viewModel.messageClusters, id: \.id) { cluster in + messageCluster(cluster: cluster) + } + + Color.clear.frame(height: 1).id("BOTTOM") + } + } + .background(Constants.Colors.white) + .onChange(of: viewModel.messageClusters) { _ in + withAnimation { + proxy.scrollTo("BOTTOM", anchor: .bottom) + } + } + .onAppear { + withAnimation { + proxy.scrollTo("BOTTOM", anchor: .bottom) + } + } + } + } + } + + private var messageInputView: some View { + VStack(spacing: 12) { + filtersView + textInputView + } + } + + private var filtersView: some View { + FilterOptionsView( + didShowNegotiationView: $didShowNegotiationView, + didShowAvailabilityView: $didShowAvailabilityView, + didShowWebView: $didShowWebView, + isEditing: $isEditing, + viewModel: viewModel + ) + } + + private func messageCluster(cluster: MessageCluster) -> some View { + return VStack(spacing: 2) { + if let first = cluster.messages.first { + Text("\(first.timestamp.formatted(date: .abbreviated, time: .omitted))") + .font(.caption) + .padding(10) + } + + ForEach(cluster.messages, id: \.hashValue) { message in + MessageBubbleView( + didShowAvailabilityView: $didShowAvailabilityView, + isEditing: $isEditing, + selectedAvailabilities: $viewModel.availability, + message: message, + chatInfo: viewModel.chatInfo + ) + } + } + } + + private var textInputView: some View { + TextInputView(draftMessageText: $viewModel.draftMessageText) { text, images in + let b46Images = images?.compactMap { $0.toBase64() } ?? [] + Task { + do { + try await viewModel.sendMessage(text: text, imagesBase64: b46Images) + } catch { + NetworkManager.shared.logger.error("Error in \(#file) \(#function): \(error)") + } + } + } + } + + private var negotiationView: some View { + NegotiationSheetView( + chatInfo: viewModel.chatInfo, + priceText: $priceText, + isPresented: $didShowNegotiationView + ) + } + + private func availabilityView(isEditing: Binding) -> some View { + AvailabilitySelectorView( + isPresented: $didShowAvailabilityView, + selectedDates: $viewModel.availability, + didSubmit: $didSubmitAvailabilities, + isEditing: $isEditing + // set isEditing + ) + .presentationCornerRadius(25) + .presentationDragIndicator(.hidden) + } + + private var webView: some View { + Group { + if let url = viewModel.venmoURL { + WebView(url: url) + .edgesIgnoringSafeArea(.all) + } else { + EmptyView() + } + } + } + + // MARK: - Helper Methods + + private func setupOnAppear() { + guard GoogleAuthManager.shared.user != nil else { + GoogleAuthManager.shared.logger.error("Error in \(#file) \(#function): User not available.") + return + } + + viewModel.parsePayWithVenmoURL() + + Task { + try await viewModel.getOrCreateChatId() + viewModel.subscribeToChat() + } + } + + private func handleAvailabilitySubmit(_ didSubmit: Bool) { + if didSubmit { + Task { + do { + try await viewModel.sendMessage(availability: viewModel.availability) + } catch { + NetworkManager.shared.logger.error("Error sending availability in \(#file) \(#function): \(error)") + } + viewModel.availability = [] + didSubmitAvailabilities = false + } + } + } + + private func navigateToProductDetails() { + let post = viewModel.chatInfo.listing + if let existingIndex = router.path.firstIndex(where: { + if case let .productDetails(existingPost) = $0, existingPost.id == post.id { + return true + } + return false + }) { + router.popTo(router.path[existingIndex]) + } else { + router.push(.productDetails(post)) + } + } + + private func setNegotiationText() { + viewModel.draftMessageText = "Hi! I'm interested in buying your \(viewModel.chatInfo.listing.title), but would you be open to selling it for $\(priceText)?" + priceText = "" + } +} + +// MARK: - Filter Options View + +struct FilterOptionsView: View { + @Binding var didShowNegotiationView: Bool + @Binding var didShowAvailabilityView: Bool + @Binding var didShowWebView: Bool + @Binding var isEditing: Bool + let viewModel: MessagesView.ViewModel + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(Constants.chatMessageOptions, id: \.self) { option in + switch option { + case .negotiate: + chatOption(title: option.rawValue) { + withAnimation { didShowNegotiationView = true } + } +// case .sendAvailability: +// chatOption(title: option.rawValue) { +// isEditing = true +// withAnimation { didShowAvailabilityView = true } +// } + case .venmo: + chatOption(title: option.rawValue) { + withAnimation { didShowWebView = true } + } +// case .viewAvailability: +// // TODO: Fix this logic. There should be two cases, one for the current user and another for the user we're viewing +// chatOption(title: "View \(viewModel.chatInfo.listing.user?.givenName ?? "")'s Availability") { +// isEditing = false +// withAnimation { didShowAvailabilityView = true } +// } + + } + } + } + .padding(.vertical, 1) + .padding(.leading, 8) + } + } + + private func chatOption(title: String, action: @escaping () -> Void) -> some View { + Button { + action() + } label: { + Text(title) + .font(Constants.Fonts.title3) + .foregroundStyle(Constants.Colors.black) + .lineLimit(1) + } + .padding(12) + .overlay { + RoundedRectangle(cornerRadius: 25) + .stroke(Constants.Colors.resellGradient, lineWidth: 2) + } + } +} + +// MARK: - Negotiation Sheet View + +struct NegotiationSheetView: View { + let chatInfo: ChatInfo? + @Binding var priceText: String + @Binding var isPresented: Bool + + var body: some View { + VStack(spacing: 24) { + HStack(spacing: 16) { + KFImage(URL(string: chatInfo?.listing.images[0] ?? "")) + .placeholder { + ShimmerView() + .frame(width: 128, height: 100) + } + .resizable() + .scaledToFill() + .frame(width: 128, height: 100) + .clipShape(.rect(cornerRadius: 18)) + + VStack(alignment: .leading, spacing: 8) { + Text(chatInfo?.listing.title ?? "") + .font(Constants.Fonts.h2) + .foregroundStyle(Constants.Colors.black) + + Text("$\(chatInfo?.listing.originalPrice ?? "0")") + .font(Constants.Fonts.body1) + .foregroundStyle(Constants.Colors.black) + } + + Spacer() + } + .padding(16) + .frame(width: UIScreen.width - 40, height: 125) + .background(Constants.Colors.white) + .clipShape(.rect(cornerRadius: 18)) + + PriceInputView( + price: $priceText, + isPresented: $isPresented, + titleText: "What price do you want to propose?" + ) + .padding(.bottom, 24) + .background(Constants.Colors.white) + .clipShape(.rect(cornerRadii: .init(topLeading: 25, topTrailing: 25))) + .overlay(alignment: .top) { + Rectangle() + .foregroundStyle(Constants.Colors.stroke) + .frame(width: 66, height: 6) + .clipShape(.capsule) + .padding(.top, 12) + } + } + .presentationDetents([.height(UIScreen.height * 3/4)]) + .presentationBackground(.clear) + .ignoresSafeArea() + } +} + + + +// MARK: - MessageBubbleView + +struct MessageBubbleView: View { + + @Binding var didShowAvailabilityView: Bool + @Binding var isEditing: Bool + @Binding var selectedAvailabilities: [Availability] + + let message: any Message + let chatInfo: ChatInfo + + var body: some View { + HStack { + if message.mine { + Spacer() + } + + messageContentView + .padding(.leading, message.mine ? 64 : 0) + .padding(.trailing, message.mine ? 0 : 64) + + if !message.mine { + Spacer() + } + } + .padding(.horizontal, 12) + } + + @ViewBuilder + private var messageContentView: some View { + switch message.messageType { + case .chat: + chatMessageView + case .availability: + availabilityMessageView + case .proposal: + proposalMessageView + } + } + + @ViewBuilder + private var chatMessageView: some View { + if let message = message as? ChatMessage { + VStack() { + if !message.text.isEmpty { + textBubbleView(message: message) + } + + ForEach(message.images, id: \.self) { image in + imageView(imageUrl: image) + } + } + } else { + EmptyView() + } + } + + @ViewBuilder + private func textBubbleView(message: ChatMessage) -> some View { + HStack { + VStack(alignment: message.mine ? .trailing : .leading, spacing: 8) { + Text(message.text) + .font(Constants.Fonts.body2) + .foregroundStyle(message.mine ? Constants.Colors.white : Constants.Colors.black) + + Text(message.timestamp.formatted(date: .omitted, time: .shortened)) + .font(.caption2) + .foregroundStyle(message.mine ? Constants.Colors.white : Constants.Colors.secondaryGray) + } + .padding(12) + .background(message.mine ? (message.sent ? Constants.Colors.resellPurple : Constants.Colors.resellPurple.opacity(0.5)) : Constants.Colors.wash) + .foregroundColor(message.mine ? Constants.Colors.white : Constants.Colors.black) + .cornerRadius(10) + } + } + + @ViewBuilder + private func imageView(imageUrl: String) -> some View { + HStack { + if message.mine { + Spacer() + } + + if let url = URL(string: imageUrl) { + AsyncImage(url: url) { image in + image.resizable() + .scaledToFill() + .frame(width: 200, height: 200) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } placeholder: { + ProgressView() + } + } + + if !message.mine { + Spacer() + } + } + .padding(.vertical, 6) + } + + @ViewBuilder + private var availabilityMessageView: some View { + if let message = message as? AvailabilityMessage { + Button { + selectedAvailabilities = message.availabilities + didShowAvailabilityView = true + isEditing = false + } label: { + HStack { +// Text("\(message.from.givenName)'s Availability") + // TODO: FIX + Text("\(message.from.givenName)'s Availability") + .font(Constants.Fonts.title2) + .foregroundStyle(Constants.Colors.resellPurple) + + Spacer() + + Image(systemName: "chevron.right") + .foregroundStyle(Constants.Colors.resellPurple) + } + .padding(12) + .background(Constants.Colors.resellPurple.opacity(0.1)) + .clipShape(.rect(cornerRadius: 10)) + .padding(.vertical, 6) + } + } else { + EmptyView() + } + } + + @ViewBuilder + private var proposalMessageView: some View { + if let message = message as? ProposalMessage { + Text("Proposal!") + .font(Constants.Fonts.subtitle1) + .foregroundColor(Constants.Colors.secondaryGray) + } else { + EmptyView() + } + } + +} + + + +// MARK: - TextInputView + +struct TextInputView: View { + + // MARK: - Properties + + @State private var selectedImages: [UIImage] = [] + @State private var showingPhotoPicker = false + @Binding var draftMessageText: String + + let onSend: (String?, [UIImage]?) -> Void + let maxCharacters: Int = 1000 + + // MARK: - UI + + var body: some View { + VStack(spacing: 8) { + // Image preview section + if !selectedImages.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(0.. maxCharacters { + draftMessageText = String(newText.prefix(maxCharacters)) + } + } + + if !draftMessageText.isEmpty || !selectedImages.isEmpty { + Button(action: { + onSend(draftMessageText.isEmpty ? nil : draftMessageText, selectedImages.isEmpty ? nil : selectedImages) + draftMessageText = "" + selectedImages = [] + }) { + Image("sendButton") + .resizable() + .frame(width: 24, height: 24) + } + .padding(.trailing, 8) + } + } + } + .padding(.trailing, 24) + .padding(.leading, 8) + } +} + +// MARK: - ImagePicker View + +struct SingleImagePicker: UIViewControllerRepresentable { + @Binding var selectedImage: UIImage? + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.delegate = context.coordinator + picker.sourceType = .photoLibrary + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + + func makeCoordinator() -> Coordinator { + return Coordinator(self) + } + + class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { + let parent: SingleImagePicker + + init(_ parent: SingleImagePicker) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + if let image = info[.originalImage] as? UIImage { + parent.selectedImage = image + } + picker.dismiss(animated: true) + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true) + } + } +} diff --git a/Resell/Views/Components/AggressiveCachedImageView.swift b/Resell/Views/Components/AggressiveCachedImageView.swift new file mode 100644 index 0000000..86d3a4c --- /dev/null +++ b/Resell/Views/Components/AggressiveCachedImageView.swift @@ -0,0 +1,61 @@ +// +// AggressiveCachedImageView.swift +// Resell +// +// Created by Charles Liggins on 10/13/25. +// +import SwiftUI +import Kingfisher + +struct AggressiveCachedImageView: View { + + @Binding var isImageLoaded: Bool + @State private var shouldLoad: Bool = false + + let imageURL: URL? + + private let targetSize: CGSize = { + let cellWidth = (UIScreen.main.bounds.width - 68) / 2 + return CGSize(width: cellWidth * 2, height: cellWidth * 2) + }() + + var body: some View { + Group { + if shouldLoad { + KFImage(imageURL) + .placeholder { + ShimmerView() + } + .setProcessor( + DownsamplingImageProcessor(size: targetSize) + |> RoundCornerImageProcessor(cornerRadius: 8) + ) + .cacheMemoryOnly() + .fade(duration: 0.2) + .onSuccess { _ in + isImageLoaded = true + } + .onFailure { _ in + isImageLoaded = false + } + .resizable() + .aspectRatio(contentMode: .fill) + } else { + ShimmerView() + .onAppear { + // Delay image loading slightly to prioritize visible items +// DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + shouldLoad = true + // } + } + } + } + .onDisappear { + // Cancel any pending loads when scrolling away + if let url = imageURL { + KingfisherManager.shared.downloader.cancel(url: url) + } + shouldLoad = false + } + } +} diff --git a/Resell/Views/Components/AvailabilitySelectorView.swift b/Resell/Views/Components/AvailabilitySelectorView.swift new file mode 100644 index 0000000..2836954 --- /dev/null +++ b/Resell/Views/Components/AvailabilitySelectorView.swift @@ -0,0 +1,345 @@ +// +// AvailabilitySelectorView.swift +// Resell +// +// Created by Richie Sun on 11/30/24. +// + +import FirebaseFirestore +import SwiftUI + +struct AvailabilitySelectorView: View { + + // MARK: - Properties + + @State private var selectedCells: Set = [] + @State private var draggedCells: Set = [] + @State private var toggleSelectionMode: Bool? = nil + @State private var currentPage: Int = 0 + @State private var isMovingForward: Bool = true + + @Binding var isPresented: Bool + @Binding var selectedDates: [Availability] + @Binding var didSubmit: Bool + @Binding var isEditing: Bool + + var proposerName: String? = nil + let dates: [String] = generateDates() + let times: [String] = generateTimes() + + private var paginatedDates: [ArraySlice] { + stride(from: 0, to: dates.count, by: 3).map { + dates[$0.. 0 ? Constants.Colors.black : Constants.Colors.white) + } + .disabled(currentPage == 0) + + Spacer() + + VStack { + Text(isEditing ? "When are you free to meet?" : "\(proposerName ?? "")'s Availability") + .font(Constants.Fonts.title1) + .foregroundColor(Constants.Colors.black) + .padding(.top) + + Text(isEditing ? "Click and drag cells to select meeting times" : "Select a 30-minute block to propose a meeting.") + .font(Constants.Fonts.body2) + .foregroundColor(Constants.Colors.secondaryGray) + .multilineTextAlignment(.center) + .lineLimit(2) + } + + Spacer() + + Button(action: goToNextPage) { + Image(systemName: "chevron.right") + .font(Constants.Fonts.h1) + .foregroundColor(currentPage < paginatedDates.count - 1 ? Constants.Colors.black : Constants.Colors.secondaryGray) + } + .disabled(currentPage >= paginatedDates.count - 1) + } + + ZStack { + ForEach(Array(paginatedDates.indices), id: \.self) { index in + HStack(spacing: 0) { + VStack(spacing: 0) { + ForEach(times, id: \.self) { time in + VStack { + Text(time) + .font(Constants.Fonts.title2) + .foregroundStyle(Constants.Colors.black) + .multilineTextAlignment(.trailing) + + Spacer() + } + .frame(width: 80, height: cellHeight) + } + } + .padding(.top, 36) + + //title 1, body 1 + + HStack(spacing: 0) { + ForEach(Array(paginatedDates[index]), id: \.self) { date in + VStack(spacing: 0) { + Text(date.partBeforeComma) + .font(Constants.Fonts.title4) + .foregroundStyle(Constants.Colors.black) + .multilineTextAlignment(.center) + .frame(height: 35) + .padding(.bottom, 8) + + ForEach(times, id: \.self) { time in + CellView( + isSelectedTop: selectedCells.contains(CellIdentifier(date: date, time: "\(time) Top")), + isSelectedBottom: selectedCells.contains(CellIdentifier(date: date, time: "\(time) Bottom")), + isHighlightedTop: draggedCells.contains(CellIdentifier(date: date, time: "\(time) Top")), + isHighlightedBottom: draggedCells.contains(CellIdentifier(date: date, time: "\(time) Bottom")) + ) + .frame(width: UIScreen.width / 5 + 10, height: cellHeight) + } + } + } + } + // TODO: Slow down scroll speed and fix functionality for removing cells from availability + .background( + GeometryReader { geo in + Color.clear + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + if isEditing { + if let identifier = mapDragLocationToCell( + location: value.location, + in: geo.frame(in: .local), + dates: Array(paginatedDates[index]), + times: times, + cellHeight: cellHeight + ) { + if toggleSelectionMode == nil { + toggleSelectionMode = selectedCells.contains(identifier) ? false : true + } + draggedCells.insert(identifier) + } + } + } + .onEnded { _ in + if let toggleSelectionMode = toggleSelectionMode { + if toggleSelectionMode { + selectedCells.formUnion(draggedCells) + } else { + selectedCells.subtract(draggedCells) + } + } + draggedCells.removeAll() + toggleSelectionMode = nil + } +// .onTapGesture { +// if isEditing { +// let isTopHalf = geometry.frame(in: .local).midY < cellHeight / 2 +// toggleCellSelection(date: date, time: time, isTopHalf: isTopHalf) +// } +// } + ) + } + ) + } + .offset(x: index < currentPage ? -UIScreen.main.bounds.width : index > currentPage ? UIScreen.main.bounds.width : 0) + .animation(.easeInOut(duration: 0.3), value: currentPage) + } + } + + Spacer() + + PurpleButton(text: isEditing ? "Send" : "Propose", action: saveAvailability) + + Spacer() + } + .padding(.horizontal) + .padding(.top, 32) + .background(Constants.Colors.white) + .onAppear(perform: initializeSelectedCells) + } + + // MARK: - Functions + private func mapDragLocationToCell( + location: CGPoint, + in frame: CGRect, + dates: [String], + times: [String], + cellHeight: CGFloat + ) -> CellIdentifier? { + let columnWidth = (UIScreen.width / 5 + 10) + let rowHeight = cellHeight + + // Column index: which date + let col = Int(location.x / columnWidth) + guard col >= 0, col < dates.count else { return nil } + + // Row index: which time slot + let row = Int((location.y - 35) / rowHeight) // adjust for header height + guard row >= 0, row < times.count else { return nil } + + let isTopHalf = (location.y.truncatingRemainder(dividingBy: rowHeight)) < rowHeight / 2 + let date = dates[col] + let time = times[row] + + return CellIdentifier(date: date, time: isTopHalf ? "\(time) Top" : "\(time) Bottom") + } + + + private func initializeSelectedCells() { + for block in selectedDates { + let startDate = block.startDate + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "E \nMMM d, yyyy" + let dateString = dateFormatter.string(from: startDate) + + let timeFormatter = DateFormatter() + timeFormatter.dateFormat = "h:mm a" + + let calendar = Calendar.current + let minute = calendar.component(.minute, from: startDate) + let isTopHalf = (minute != 30) + + let adjustedTime = isTopHalf ? startDate : startDate.adding(minutes: -30) + let timeString = timeFormatter.string(from: adjustedTime) + + let halfIdentifier = isTopHalf ? "\(timeString) Top" : "\(timeString) Bottom" + let identifier = CellIdentifier(date: dateString, time: halfIdentifier) + selectedCells.insert(identifier) + } + } + + private func goToPreviousPage() { + if currentPage > 0 { + isMovingForward = false + currentPage -= 1 + } + } + + private func goToNextPage() { + if currentPage < paginatedDates.count - 1 { + isMovingForward = true + currentPage += 1 + } + } + + private func toggleCellSelection(date: String, time: String, isTopHalf: Bool) { + let halfIdentifier = isTopHalf ? "\(time) Top" : "\(time) Bottom" + let identifier = CellIdentifier(date: date, time: halfIdentifier) + + if selectedCells.contains(identifier) { + selectedCells.remove(identifier) + } else { + selectedCells.insert(identifier) + } + } + + private func saveAvailability() { + selectedDates = selectedCells.compactMap { createDate(from: $0.date, timeString: $0.time) } + + didSubmit = true + isPresented = false + } + + private func createDate(from dateString: String, timeString: String) -> Availability? { + let cleanDateString = dateString.replacingOccurrences(of: "\n", with: " ") + let cleanTimeString = timeString.replacingOccurrences(of: " Top", with: "").replacingOccurrences(of: " Bottom", with: "") + + let combinedString = "\(cleanDateString) \(cleanTimeString)" + + let formatter = DateFormatter() + formatter.dateFormat = "E MMM d, yyyy h:mm a" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + + if var parsedDate = formatter.date(from: combinedString) { + if timeString.contains("Bottom") { + parsedDate = parsedDate.adding(minutes: 30) + } + + return Availability(startDate: parsedDate, endDate: parsedDate.adding(minutes: 30)) + } else { + return nil + } + } +} + +// MARK: - CellView + +struct CellView: View { + + let isSelectedTop: Bool + let isSelectedBottom: Bool + let isHighlightedTop: Bool + let isHighlightedBottom: Bool + + private let cellHeight = UIScreen.height / 12 - 25 + + var body: some View { + ZStack { + Rectangle() + .fill(isHighlightedTop + ? (isSelectedTop ? Constants.Colors.resellPurple.opacity(0.3) : Constants.Colors.resellPurple.opacity(0.5)) + : (isSelectedTop ? Constants.Colors.resellPurple : Color.clear)) + .frame(width: UIScreen.width / 5 + 10, height: cellHeight / 2) + .offset(y: -cellHeight / 4) + + Rectangle() + .fill(isHighlightedBottom + ? (isSelectedBottom ? Constants.Colors.resellPurple.opacity(0.3) : Constants.Colors.resellPurple.opacity(0.5)) + : (isSelectedBottom ? Constants.Colors.resellPurple : Color.clear)) + .frame(width: UIScreen.width / 5 + 10, height: cellHeight / 2) + .offset(y: cellHeight / 4) + + Rectangle() + .stroke(Color.gray.opacity(0.5), lineWidth: 0.5) + .frame(width: UIScreen.width / 5 + 10, height: cellHeight) + } + } +} + + +// MARK: - CellIdentifier +struct CellIdentifier: Hashable { + let date: String + let time: String +} + +// MARK: - Helper Functions +func generateDates() -> [String] { + let formatter = DateFormatter() + formatter.dateFormat = "E \nMMM d, yyyy" + + return (0..<30).compactMap { + Calendar.current.date(byAdding: .day, value: $0, to: Date()) + }.map { formatter.string(from: $0) } +} + +func generateTimes() -> [String] { + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + + let startHour = 9 + let endHour = 20 + return (startHour...endHour).map { hour in + let date = Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: Date())! + return formatter.string(from: date) + } +} diff --git a/Resell/Views/Components/AvailabilitySettingsView.swift b/Resell/Views/Components/AvailabilitySettingsView.swift new file mode 100644 index 0000000..e69de29 diff --git a/Resell/Views/Components/CachedImageView.swift b/Resell/Views/Components/CachedImageView.swift index 336ef3f..c6302ee 100644 --- a/Resell/Views/Components/CachedImageView.swift +++ b/Resell/Views/Components/CachedImageView.swift @@ -10,32 +10,32 @@ import SwiftUI /// A reusable view that displays an image from a URL with caching support using Kingfisher. struct CachedImageView: View { - - // MARK: - Properties - + @Binding var isImageLoaded: Bool - let imageURL: URL? - - // MARK: - UI - + + private let targetSize: CGSize = { + let cellWidth = (UIScreen.main.bounds.width - 68) / 2 + return CGSize(width: cellWidth * 2, height: cellWidth * 2) + }() + var body: some View { KFImage(imageURL) .placeholder { ShimmerView() - .clipShape(RoundedRectangle(cornerRadius: 8)) } + .setProcessor( + DownsamplingImageProcessor(size: targetSize) + ) + .cacheOriginalImage() + .fade(duration: 0.2) .onSuccess { _ in - withAnimation(.easeInOut(duration: 0.3)) { - isImageLoaded = true - } + isImageLoaded = true + } + .onFailure { _ in + isImageLoaded = false } - .fade(duration: 0.3) - .scaleFactor(UIScreen.main.scale) - .backgroundDecode() - .cacheOriginalImage() .resizable() .aspectRatio(contentMode: .fill) - .clipped() } } diff --git a/Resell/Views/Components/FilterButton.swift b/Resell/Views/Components/FilterButton.swift index 8c15e70..29e3b61 100644 --- a/Resell/Views/Components/FilterButton.swift +++ b/Resell/Views/Components/FilterButton.swift @@ -51,3 +51,25 @@ struct FilterButton: View { } } +struct CircularFilterButton: View { + + // MARK: - Properties + let filter: FilterCategory + let action : () -> Void + + var body: some View { + Button(action: action, label: { + ZStack{ + Circle() + .frame(width: 80, height: 80) + .foregroundStyle((filter.color?.opacity(0.5)) ?? Constants.Colors.filterGray) + + Image(filter.title) + .resizable() + .scaledToFit() // ✅ Maintains aspect ratio + .frame(width: 56, height: 56) + } + }) + } +} + diff --git a/Resell/Views/Components/ProductsGalleryView.swift b/Resell/Views/Components/ProductsGalleryView.swift index d0566df..b7d7c69 100644 --- a/Resell/Views/Components/ProductsGalleryView.swift +++ b/Resell/Views/Components/ProductsGalleryView.swift @@ -15,59 +15,80 @@ struct ProductsGalleryView: View { @State private var selectedItem: Post? = nil @EnvironmentObject var router: Router + let items: [Post] let column1: [Post] let column2: [Post] + let onScrollToBottom: (() -> Void)? + // MARK: Init - init(items: [Post]) { + init(items: [Post], onScrollToBottom: (() -> Void)? = nil) { + self.items = items let (items1, items2): ([Post], [Post]) = items.splitIntoTwo() self.column1 = items1 self.column2 = items2 + self.onScrollToBottom = onScrollToBottom } // MARK: UI var body: some View { - ScrollView(.vertical, showsIndicators: true) { - HStack(alignment: .top, spacing: 20) { - LazyVStack(spacing: 20) { - ForEach(column1) { post in - ProductGalleryCell(selectedItem: $selectedItem, post: post) - } + HStack(alignment: .top, spacing: 20) { + LazyVStack(spacing: 20) { + ForEach(column1, id: \.id) { post in + ProductGalleryCell(selectedItem: $selectedItem, post: post, savedCell: false) + .onAppear { + checkAndLoadMore(for: post) + } } - - LazyVStack(spacing: 20) { - ForEach(column2) { post in - ProductGalleryCell(selectedItem: $selectedItem, post: post) - } + } + + LazyVStack(spacing: 20) { + ForEach(column2, id: \.id) { post in + ProductGalleryCell(selectedItem: $selectedItem, post: post, savedCell: false) + .onAppear { + checkAndLoadMore(for: post) + } } } - .padding(.horizontal, Constants.Spacing.horizontalPadding) - .padding(.top, Constants.Spacing.horizontalPadding) } + .padding(.horizontal, Constants.Spacing.horizontalPadding) + .padding(.bottom, Constants.Spacing.horizontalPadding) .onChange(of: selectedItem) { item in if let selectedItem { - navigateToProductDetails(postID: selectedItem.id) + navigateToProductDetails(post: selectedItem) self.selectedItem = nil } } } + + // MARK: - Private Methods + + private func checkAndLoadMore(for post: Post) { + guard let index = items.firstIndex(where: { $0.id == post.id }) else { + return + } + + let threshold = items.count - 5 + if index >= threshold { + onScrollToBottom?() + } + } - private func navigateToProductDetails(postID: String) { + private func navigateToProductDetails(post: Post) { if let existingIndex = router.path.firstIndex(where: { if case .productDetails = $0 { return true } return false }) { - router.path[existingIndex] = .productDetails(postID) + router.path[existingIndex] = .productDetails(post) router.popTo(router.path[existingIndex]) } else { - router.push(.productDetails(postID)) + router.push(.productDetails(post)) } } - } struct ProductGalleryCell: View { @@ -78,15 +99,18 @@ struct ProductGalleryCell: View { @State private var isImageLoaded: Bool = false let post: Post - + let savedCell : Bool private let cellWidth = (UIScreen.width - 68) / 2 // MARK: UI var body: some View { VStack(spacing: 0) { - CachedImageView(isImageLoaded: $isImageLoaded, imageURL: post.images.first) - .frame(width: cellWidth, height: cellWidth / 0.75) + let url = URL(string: post.images.first ?? "") + CachedImageView(isImageLoaded: $isImageLoaded, imageURL: url) + .frame(width: cellWidth, height: (savedCell ? cellWidth - 20 : cellWidth / 0.75)) + .clipped() + HStack { Text(post.title) .font(Constants.Fonts.title3) @@ -97,18 +121,17 @@ struct ProductGalleryCell: View { .foregroundStyle(Constants.Colors.black) } .padding(8) + .background(Constants.Colors.white) } .frame(width: cellWidth) .clipped() .clipShape(.rect(cornerRadius: 8)) - .scaleEffect(isImageLoaded ? CGSize(width: 1, height: 1) : CGSize(width: 1, height: 0.9), anchor: .center) .onTapGesture { selectedItem = post } .overlay { RoundedRectangle(cornerRadius: 8) .stroke(Constants.Colors.stroke, lineWidth: 1) - .scaleEffect(isImageLoaded ? CGSize(width: 1, height: 1) : CGSize(width: 1, height: 0.9), anchor: .center) } } } diff --git a/Resell/Views/Components/RangeSlider.swift b/Resell/Views/Components/RangeSlider.swift new file mode 100644 index 0000000..9b02997 --- /dev/null +++ b/Resell/Views/Components/RangeSlider.swift @@ -0,0 +1,82 @@ +// +// RangeSlider.swift +// Resell +// +// Created by Charles Liggins on 10/13/25. +// + +import SwiftUI + +struct RangeSlider: View { + @Binding var lowValue: Double + @Binding var highValue: Double + let range: ClosedRange + let step: Double = 5 // Define the step value + + // Track width constant + private let trackWidth: CGFloat = 344 + private let handleDiameter: CGFloat = 14 + + // Calculate position from value + private func position(for value: Double) -> CGFloat { + let percentage = (value - range.lowerBound) / (range.upperBound - range.lowerBound) + return CGFloat(percentage) * (trackWidth - handleDiameter) + } + + // Calculate value from position + private func value(for position: CGFloat) -> Double { + let percentage = Double(position) / Double(trackWidth - handleDiameter) + let value = percentage * (range.upperBound - range.lowerBound) + range.lowerBound + return round(value / step) * step + } + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + // Track + Rectangle() + .fill(Constants.Colors.resellPurple.opacity(0.2)) + .frame(width: trackWidth, height: 4) + .cornerRadius(4) + + // Low handle + Circle() + .fill(Color.white) + .frame(width: handleDiameter, height: handleDiameter) + .shadow(radius: 4) + .position(x: position(for: lowValue) + handleDiameter/2, y: geometry.size.height/2) + .gesture( + DragGesture() + .onChanged { value in + let newPosition = min(max(0, value.location.x - handleDiameter/2), position(for: highValue) - handleDiameter) + let newValue = self.value(for: newPosition) + // Ensure minimum distance between handles + if newValue <= highValue - step { + lowValue = newValue + } + } + ) + + // High handle + Circle() + .fill(Color.white) + .frame(width: handleDiameter, height: handleDiameter) + .shadow(radius: 4) + .position(x: position(for: highValue) + handleDiameter/2, y: geometry.size.height/2) + .gesture( + DragGesture() + .onChanged { value in + let newPosition = min(max(position(for: lowValue) + handleDiameter, value.location.x - handleDiameter/2), trackWidth - handleDiameter) + let newValue = self.value(for: newPosition) + // Ensure minimum distance between handles + if newValue >= lowValue + step { + highValue = newValue + } + } + ) + } + } + .frame(height: 44) + } +} + diff --git a/Resell/Views/Components/ReviewSection.swift b/Resell/Views/Components/ReviewSection.swift new file mode 100644 index 0000000..1d1d779 --- /dev/null +++ b/Resell/Views/Components/ReviewSection.swift @@ -0,0 +1,23 @@ +// +// ReviewSection.swift +// Resell +// +// Created by Charles Liggins on 12/30/25. +// + +import SwiftUI + +struct ReviewSection: View { + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 15) + .stroke(.gray, lineWidth: 1) + .frame(width: 366, height: 118) + + + Text("No Reviews Yet") + .font(Constants.Fonts.body2) + .foregroundColor(.gray) + } + } +} diff --git a/Resell/Views/Components/SavedRow.swift b/Resell/Views/Components/SavedRow.swift new file mode 100644 index 0000000..8133f8a --- /dev/null +++ b/Resell/Views/Components/SavedRow.swift @@ -0,0 +1,27 @@ +// +// SavedRow.swift +// Resell +// +// Created by Charles Liggins on 4/26/25. +// + +import SwiftUI + +struct SavedRow: View { + + @State private var selectedItem: Post? = nil + @EnvironmentObject var router: Router + + let row : [Post] + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 20) { + ForEach(row) { post in + ProductGalleryCell(selectedItem: $selectedItem, post: post, savedCell: true) + } + } + .padding(.horizontal, Constants.Spacing.horizontalPadding) + } + } +} diff --git a/Resell/Views/Components/SearchBar.swift b/Resell/Views/Components/SearchBar.swift new file mode 100644 index 0000000..d65b840 --- /dev/null +++ b/Resell/Views/Components/SearchBar.swift @@ -0,0 +1,64 @@ +// +// SearchBar.swift +// Resell +// +// Created by Charles Liggins on 4/27/25. +// + +import SwiftUI + +struct SearchBar: View { + var text: Binding? + var placeholder: String = "Search" + var isEditable: Bool = false + + @State private var internalText: String = "" + + private var textBinding: Binding { + text ?? $internalText + } + + var body: some View { + RoundedRectangle(cornerRadius: 40) + .frame(width: 309, height: 43) + .overlay { + HStack { + Image(systemName: "magnifyingglass") + .foregroundStyle(.black) + .padding(.leading, 16) + + if isEditable { + ZStack(alignment: .leading) { + // ✅ Custom placeholder that respects your color + if textBinding.wrappedValue.isEmpty { + Text(placeholder) + .font(Constants.Fonts.body1) + .foregroundColor(Constants.Colors.secondaryGray) // Use a visible gray + } + + TextField("", text: textBinding) + .font(Constants.Fonts.body1) + .foregroundColor(Constants.Colors.black) + } + + if !textBinding.wrappedValue.isEmpty { + Button(action: { + textBinding.wrappedValue = "" + }) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Constants.Colors.stroke) + } + .padding(.trailing, 8) + } + } else { + Text(placeholder) + .font(Constants.Fonts.body1) + .foregroundColor(Constants.Colors.black) + } + + Spacer() + } + } + .foregroundColor(Constants.Colors.wash) + } +} diff --git a/Resell/Views/Components/ShimmerView.swift b/Resell/Views/Components/ShimmerView.swift index 426aa8b..bcef20f 100644 --- a/Resell/Views/Components/ShimmerView.swift +++ b/Resell/Views/Components/ShimmerView.swift @@ -13,6 +13,7 @@ struct ShimmerView: View { // MARK: - Properties @State private var shimmerOffset: CGFloat = -UIScreen.main.bounds.width + private let animationDuration: Double = 1.5 // MARK: - UI @@ -32,18 +33,24 @@ struct ShimmerView: View { .frame(width: gradientWidth) .offset(x: shimmerOffset) .onAppear { - shimmerOffset = -gradientWidth - withAnimation( - Animation.linear(duration: 1) - .repeatForever(autoreverses: false) - ) { - shimmerOffset = geometry.size.width + gradientWidth - } + startShimmerAnimation(geometry: geometry, gradientWidth: gradientWidth) } } .clipShape(Rectangle()) } } + + // MARK: - Helper Functions + + private func startShimmerAnimation(geometry: GeometryProxy, gradientWidth: CGFloat) { + shimmerOffset = -gradientWidth + withAnimation( + Animation.linear(duration: animationDuration) + .repeatForever(autoreverses: false) + ) { + shimmerOffset = geometry.size.width + gradientWidth + } + } } #Preview { diff --git a/Resell/Views/Components/TabViewIcon.swift b/Resell/Views/Components/TabViewIcon.swift index f93e0f3..cd714ae 100644 --- a/Resell/Views/Components/TabViewIcon.swift +++ b/Resell/Views/Components/TabViewIcon.swift @@ -15,7 +15,7 @@ struct TabViewIcon: View { @Binding var selectionIndex: Int let itemIndex: Int - private let tabItems = ["home", "bookmark", "messages", "user"] + private let tabItems = ["home", "messages", "user"] // MARK: - UI diff --git a/Resell/Views/Home/DetailedFilterView.swift b/Resell/Views/Home/DetailedFilterView.swift new file mode 100644 index 0000000..1a86ead --- /dev/null +++ b/Resell/Views/Home/DetailedFilterView.swift @@ -0,0 +1,91 @@ +// +// DetailedFilterView.swift +// Resell +// +// Created by Charles Liggins on 4/27/25. +// + +import SwiftUI + +// TODO: Consolidate SavedView and DetailedFilterView into one view... +struct DetailedFilterView: View { + @State var presentPopup = false + @State var searchText = "" + @EnvironmentObject var router: Router + let filter: FilterCategory + + @StateObject private var filtersViewModel = FiltersViewModel(isHome: false) + @StateObject private var viewModel = HomeViewModel.shared + + // Computed property to show either searched or all filtered items + private var displayedItems: [Post] { + searchText.isEmpty ? filtersViewModel.detailedFilterItems : filtersViewModel.searchedDetailedFilterItems + } + + var body: some View { + ZStack { + VStack(spacing: 0) { + headerView + ScrollView(.vertical) { + ProductsGalleryView(items: displayedItems) + } + } + } + .background(Constants.Colors.white) + .loadingView(isLoading: viewModel.isLoading) + .emptyState( + isEmpty: (displayedItems.isEmpty), + title: searchText.isEmpty ? "No \(filter.title) posts" : "No results", + text: searchText.isEmpty ? "Posts in the \(filter.title) category will be displayed here." : "No posts match '\(searchText)'" + ) + .onAppear { + viewModel.getBlockedUsers() + Task { + try await filtersViewModel.initializeDetailedFilter(category: filter.title) + filtersViewModel.clearFilterSearch() + } + } + .sheet(isPresented: $presentPopup) { + FilterView(home: false, isPresented: $presentPopup) + .environmentObject(filtersViewModel) + } + } + + private var headerView: some View { + VStack { + HStack(spacing: 64) { + Button { + router.pop() + } label: { + Image("chevron.left.white") + .resizable() + .frame(width: 36, height: 24) + } + + Text(filter.title) + .font(Constants.Fonts.h1) + .foregroundStyle(Constants.Colors.black) + + Spacer() + } + .padding(.horizontal, 25) + + HStack { + SearchBar(text: $searchText, placeholder: "Search in \(filter.title)", isEditable: true) + .onChange(of: searchText) { newValue in + filtersViewModel.searchWithinFilter(query: newValue) + } + + Button(action: { + presentPopup = true + }, label: { + Image("filters") + .resizable() + .frame(width: 40, height: 40) + }) + } + .padding(.bottom, 12) + .padding(.horizontal, Constants.Spacing.horizontalPadding) + } + } +} diff --git a/Resell/Views/Home/FilterView.swift b/Resell/Views/Home/FilterView.swift new file mode 100644 index 0000000..f62c4c4 --- /dev/null +++ b/Resell/Views/Home/FilterView.swift @@ -0,0 +1,343 @@ +// +// FilterView.swift +// Resell +// +// Created by Charles Liggins on 2/24/25. +// + +import SwiftUI +import Flow + +// TODO: Implement Apply Filters button. +struct FilterView: View { + @Binding var isPresented: Bool + @State var presentPopup = false + @EnvironmentObject var filtersVM: FiltersViewModel // Change to @EnvironmentObject + + private var categories : [String] = ["Clothing", "Books", "School", "Electronics", "Handmade", "Sports & Outdoors", "Other"] + private var conditions : [String] = ["Gently Used", "Worn", "Never Used"] + + let columns = [ + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()) + ] + + let gridItem = GridItem(.adaptive(minimum: 100), spacing: 10) + + let home : Bool + + init(home: Bool, isPresented: Binding) { + self.home = home + _isPresented = isPresented + } + + @StateObject private var homeViewModel = HomeViewModel.shared + + var body: some View { + ZStack { + Color.white + .ignoresSafeArea() + + ZStack { + VStack{ + RoundedRectangle(cornerRadius: 10) + .frame(width: 66, height: 6) + .foregroundStyle(Constants.Colors.filterGray) + .padding(.bottom, 16) + .padding(.top, home ? 0 : 16) // hacky solultion... + + Text("Filters") + .font(.custom("Rubik-Medium", size: 22)) + .foregroundStyle(.black) + + Divider() + + HStack(spacing: 120) { + Text("Sort by") + .font(.custom("Rubik-Medium", size: 20)) + .foregroundStyle(.black) + + Button{ + presentPopup.toggle() + } label: { + Text("\(filtersVM.selectedSort?.title ?? "Any")") + .font(.custom("Rubik-Regular", size: 20)) + .foregroundStyle(.gray) + + Image(systemName: "chevron.down") + .foregroundStyle(.gray) + .padding(.leading, -2) + } + }.padding(.top, 12) + .frame(width: 320) + + Divider() + .frame(width: 344, height: 1) + .padding(.top, 12) + + VStack(alignment: .leading){ + VStack { + Text("Price Range") + .font(.custom("Rubik-Medium", size: 20)) + .padding(.leading, 28) + .padding(.bottom, 8) + + if filtersVM.lowValue == 0 && filtersVM.highValue == 1000 { + Text("Any") + .font(.custom("Rubik-Regular", size: 20)) + .foregroundStyle(.gray) + .padding(.trailing, 52) + } else if filtersVM.lowValue == 0 { + Text("Up to $\(Int(filtersVM.highValue))") + .font(.custom("Rubik-Regular", size: 20)) + .foregroundStyle(.gray) + } else if filtersVM.highValue == 1000 { + Text("$\(Int(filtersVM.lowValue)) +") + .font(.custom("Rubik-Regular", size: 20)) + .foregroundStyle(.gray) + .padding(.trailing, filtersVM.lowValue > 99 ? 24 : 36) + } else { + Text("$\(Int(filtersVM.lowValue)) to $\(Int(filtersVM.highValue))") + .font(.custom("Rubik-Regular", size: 20)) + .foregroundStyle(.gray) + } + } + + // SLIDER + RangeSlider(lowValue: $filtersVM.lowValue, highValue: $filtersVM.highValue, range: 0...1000) + .padding(.leading, 28) + .offset(y: -20) + + HStack{ + Text("Items On Sale") + .font(.custom("Rubik-Regular", size: 20)) + .foregroundStyle(.gray) + .padding(.leading, 28) + + Spacer() + + Button { + filtersVM.showSale.toggle() + } label: { + Image(filtersVM.showSale ? "toggle-set" : "toggle" ) + }.padding(.trailing, 28) + + + } + .offset(y: -28) + } + if home { + Divider() + .frame(width: 344, height: 1) + .offset(y: -16) + + + VStack{ + Text("Product Category") + .font(.custom("Rubik-Medium", size: 20)) + .padding(.bottom, 8) + .padding(.trailing, 72) + .foregroundStyle(.black) + + HFlow { + ForEach(categories, id: \.self) { category in + HStack { + Button { + // TODO: change logic for uppercasing... + if filtersVM.categoryFilters.contains(category){ + filtersVM.categoryFilters.remove(category) + } else { + filtersVM.categoryFilters.insert(category) + } + } label: { + if filtersVM.categoryFilters.contains(category) { + HStack{ + Text(category) + .font(.custom("Rubik-Medium", size: 14)) + .foregroundStyle(Constants.Colors.resellPurple) + + Image(systemName: "xmark") + .font(.custom("Rubik-Medium", size: 14)) + .foregroundStyle(Constants.Colors.resellPurple) + } + } else { + Text(category) + .font(.custom("Rubik-Medium", size: 14)) + .foregroundStyle(Color.black) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 20) + .stroke(filtersVM.categoryFilters.contains(category) ? Constants.Colors.resellPurple : Constants.Colors.filterGray, lineWidth: 1) + + .background( + RoundedRectangle(cornerRadius: 20) + .fill(filtersVM.categoryFilters.contains(category) ? Constants.Colors.purpleWash : Color.white) + ) + ) + } + } + } + .frame(width: 320, alignment: .leading) + .offset(x: 36) + } + .padding(.trailing, 96) + + Divider() + .frame(width: 344, height: 1) + .offset(y: 16) + + } + + VStack{ + Text("Condition") + .font(.custom("Rubik-Medium", size: 20)) + .padding(.trailing, 232) + .padding(.bottom, 8) + .padding(.top, home ? 28 : 0) + .foregroundStyle(.black) + + HStack { + ForEach(conditions, id: \.self){ condition in + Button { + if filtersVM.conditionFilters.contains(condition){ + filtersVM.conditionFilters.remove(condition) + } else { + filtersVM.conditionFilters.insert(condition) + } + } label: { + if filtersVM.conditionFilters.contains(condition) { + HStack{ + Text(condition) + .font(.custom("Rubik-Medium", size: 14)) + .foregroundStyle(Constants.Colors.resellPurple) + + Image(systemName: "xmark") + .font(.custom("Rubik-Medium", size: 14)) + .foregroundStyle(Constants.Colors.resellPurple) + } + } else { + Text(condition) + .font(.custom("Rubik-Medium", size: 14)) + .foregroundStyle(Color.black) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 20) + .stroke(filtersVM.conditionFilters.contains(condition) ? Constants.Colors.resellPurple : Constants.Colors.filterGray, lineWidth: 1) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(filtersVM.conditionFilters.contains(condition) ? Constants.Colors.purpleWash : Color.white) + ) + ) + } + + } + .frame(width: 320, alignment: .leading) + .padding(.leading, -8) + + if !home { + Spacer() + } + } + .padding(.trailing, 32) + + HStack{ + Button { + filtersVM.resetFilters(homeViewModel: homeViewModel) + } label: { + Text("Reset") + .font(.custom("Rubik-Medium", size: 20)) + .padding(.leading, 40) + .foregroundStyle(.black) + } + + Spacer() + + Button{ + Task { + try await filtersVM.applyFilters(homeViewModel: homeViewModel) + } + // MARK: This should wait for the above request to complete + isPresented = false + } label: { + Text("Apply filters") + .font(.custom("Rubik-Medium", size: 20)) + .foregroundStyle(Color.white) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(filtersVM.categoryFilters.isEmpty && filtersVM.conditionFilters.isEmpty ? Constants.Colors.resellPurple.opacity(0.4) : Constants.Colors.resellPurple) + .cornerRadius(20) + } + .padding(.trailing, 40) + } + .padding(.top, 32) + } + + if presentPopup { + SortByView(selectedSort: $filtersVM.selectedSort) + .offset(x: 88, y: home ? -142 : 0) + .onTapGesture { + presentPopup.toggle() + } + } + } + } +// .frame(width: 414, height: home ? 786 : 686) +// .background(Color.white) + + // TODO: Add border to filter view + } + + struct SortByView: View { + @Binding var selectedSort: SortOption? + + let sortOptions = SortOption.allCases + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(sortOptions) { option in + Button(action: { + selectedSort = option + }) { + VStack(alignment: .leading, spacing: 0) { + Text(option.title) + .font(.system(size: 17, weight: selectedSort == option ? .bold : .regular)) + .foregroundColor(.black) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + + if option != sortOptions.last { + Divider() + } + } + } + } + } + .padding(.horizontal, 16) + .background(Color.white) + .frame(width: 171) + .overlay( + RoundedRectangle(cornerRadius: 13) + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + ) + } + } +} + + +enum SortOption: String, CaseIterable, Identifiable { + case any = "Any" + case newlyListed = "Newly listed" + case priceHighToLow = "Price: High to Low" + case priceLowToHigh = "Price: Low to High" + + var id: String { rawValue } + + var title: String { rawValue } +} diff --git a/Resell/Views/Home/ForYouView.swift b/Resell/Views/Home/ForYouView.swift new file mode 100644 index 0000000..2cedeeb --- /dev/null +++ b/Resell/Views/Home/ForYouView.swift @@ -0,0 +1,143 @@ +// +// ForYouView.swift +// Resell +// +// Created by Charles Liggins on 9/12/25. +// + +import SwiftUI +import Kingfisher + +struct ForYouView: View { + @StateObject private var viewModel = HomeViewModel.shared + @StateObject private var searchViewModel = SearchViewModel.shared + @EnvironmentObject var router: Router + + @State private var recentPosts: [Post] = [] // Fetched on demand + + private var titles: [String] = ["Saved By You", "Recently Searched"] + + @State private var savedLoadedStates: [Bool] = Array(repeating: false, count: 4) + @State private var recentLoadedStates: [Bool] = Array(repeating: false, count: 4) + + var body: some View { + VStack(alignment: .leading) { + Text("For You") + .font(.custom("Rubik-Medium", size: 22)) + .foregroundStyle(.black) + .padding(.leading, 24) + + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 20) { + if !viewModel.savedItems.isEmpty || !recentPosts.isEmpty { + if !viewModel.savedItems.isEmpty { + forYouCard(title: titles[0], posts: viewModel.savedItems, loaded: $savedLoadedStates) + } + if !recentPosts.isEmpty { + forYouCard(title: titles[1], posts: recentPosts, loaded: $recentLoadedStates) + } + } else { + // Empty state + ZStack { + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray, lineWidth: 1) + .frame(width: 378, height: 110) + .foregroundStyle(.white) + + VStack { + Text("You haven't saved any listings yet.") + .foregroundStyle(Constants.Colors.black) + Text("Tap \(Image(systemName: "bookmark")) on a listing to save.") + .foregroundStyle(Constants.Colors.black) + } + } + } + } + .padding(.leading, 24) + } + } + .onAppear() { + Task { + async let saved: () = viewModel.getSavedPosts() + + // Fetch posts for recent searches + print("trying to load recently searched") + let recent = await searchViewModel.loadRecentlySearchedPosts() + recentPosts = recent + + await saved + } + } + } + + func forYouCard(title: String, posts: [Post], loaded: Binding<[Bool]>) -> some View { + Button { + if title == "Saved By You" { + router.push(.saved) + } else if title == "Recently Searched" { + router.push(.discover) + } + } label: { + ZStack { + if posts.count >= 4 { + LazyVGrid(columns: [GridItem(.fixed(120), spacing: 0), GridItem(.fixed(120), spacing: 0)], spacing: 0) { + ForEach(Array(posts.enumerated().prefix(4)), id: \.element.id) { index, item in + CachedImageView( + isImageLoaded: loaded[index], + imageURL: URL(string: item.images.first ?? "") + ) + .aspectRatio(contentMode: .fill) + .frame(width: 120, height: 120) + .clipped() + .overlay( + index >= 2 ? + LinearGradient( + gradient: Gradient(colors: [ + Color.black.opacity(0.8), + Color.black.opacity(0.5), + Color.clear + ]), + startPoint: .bottom, endPoint: .top + ) + .frame(height: 60) + .frame(maxHeight: .infinity, alignment: .bottom) + : nil + ) + } + } + } else if posts.count > 0 { + CachedImageView( + isImageLoaded: loaded[0], + imageURL: URL(string: posts[0].images.first ?? "") + ) + .aspectRatio(contentMode: .fill) + .frame(width: 240, height: 240) + .clipped() + .overlay( + LinearGradient( + gradient: Gradient(colors: [ + Color.black.opacity(0.8), + Color.black.opacity(0.5), + Color.clear + ]), + startPoint: .bottom, endPoint: .top + ) + .frame(height: 120) + .frame(maxHeight: .infinity, alignment: .bottom) + ) + } + + Text(title) + .foregroundStyle(Color.white) + .font(Constants.Fonts.title1) + .offset(x: -24, y: 94) + } + .frame(width: 240, height: 240) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + } +} + +#Preview { + ForYouView() +} diff --git a/Resell/Views/Home/HomeView.swift b/Resell/Views/Home/HomeView.swift index 09aaf29..ffa2467 100644 --- a/Resell/Views/Home/HomeView.swift +++ b/Resell/Views/Home/HomeView.swift @@ -6,71 +6,172 @@ // import Kingfisher +import OAuth2 import SwiftUI struct HomeView: View { @EnvironmentObject private var mainViewModel: MainViewModel + @EnvironmentObject private var searchViewModel: SearchViewModel @EnvironmentObject var router: Router + @StateObject private var viewModel = HomeViewModel.shared + @StateObject private var filtersViewModel = FiltersViewModel(isHome: true) + + @State var forYouPosts: [[Post]] = [] + @State private var presentPopup = false var body: some View { - NavigationStack(path: $router.path) { - VStack(spacing: 0) { - headerView - - filtersView - - ProductsGalleryView(items: viewModel.filteredItems) - } - .background(Constants.Colors.white) - .overlay(alignment: .bottomTrailing) { - ExpandableAddButton() - .padding(.bottom, 40) + ScrollView(.vertical, showsIndicators: true) { + VStack { + headerView + + filtersView + .padding(.top, 12) + .padding(.bottom, 32) + + ForYouView() + .padding(.bottom, 32) + + HStack { + Text("Recent Listings") + .font(.custom("Rubik-Medium", size: 22)) + .foregroundStyle(.black) + .frame(maxWidth: .infinity, alignment: .leading) // <-- Align text left + .padding(.leading, 24) + + Button(action: { + presentPopup = true + }, label: { + Image("filters") + .resizable() + .frame(width: 40, height: 40) + }) + .padding(.trailing, 24) + } + + ProductsGalleryView(items: viewModel.filteredItems, onScrollToBottom: viewModel.fetchMoreItems) } - .onAppear { - viewModel.getAllPosts() - viewModel.getBlockedUsers() - - withAnimation { - mainViewModel.hidesTabBar = false + .padding(.top, 12) + } + .onAppear { + // Only fetch if we don't have cached data + viewModel.getAllPosts() + viewModel.getBlockedUsers() + withAnimation { mainViewModel.hidesTabBar = false } + } + .onDisappear { + // Clean up image cache when leaving home view + viewModel.cleanupMemory() + } + .background(Constants.Colors.white) + .overlay(alignment: .bottomTrailing) { + ExpandableAddButton().padding(.bottom, 40) + } + .refreshable { + // Force refresh when user pulls to refresh + viewModel.getAllPosts(forceRefresh: true) + } + .loadingView(isLoading: viewModel.isLoading) + .navigationBarBackButtonHidden() + .sheet(isPresented: $presentPopup) { + FilterView(home: true, isPresented: $presentPopup) + .environmentObject(filtersViewModel) + } + } + + private var savedByYou: some View { + VStack{ + HStack(spacing: 220) { + Text("For You") + .font(.custom("Rubik-Medium", size: 22)) + .foregroundStyle(.black) + + // TODO: Add new for you ... + Button { + router.push(.saved) + } label: { + Text("See all") + .font(Constants.Fonts.body1) + .underline() + .multilineTextAlignment(.center) + .foregroundStyle(Constants.Colors.secondaryGray) + } + } + if viewModel.savedItems.isEmpty { + ZStack{ + RoundedRectangle(cornerRadius: 8) + .fill(Color.white) + .frame(width: 366, height: 110) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Constants.Colors.stroke, lineWidth: 2) + ) + + VStack{ + Text("You haven't saved any listings yet.") + .foregroundStyle(Constants.Colors.secondaryGray) + HStack{ + Text("Tap") + .foregroundStyle(Constants.Colors.secondaryGray) + + Image("saved") + + Text("on a listing to save.") + .foregroundStyle(Constants.Colors.secondaryGray) + } + } + } + } else { + SavedRow(row: viewModel.savedItems) } - } - .refreshable { - viewModel.getAllPosts() - } - .navigationBarBackButtonHidden() } } private var headerView: some View { - HStack { - Text("resell") - .font(Constants.Fonts.resellHeader) - .foregroundStyle(Constants.Colors.resellGradient) + HStack { + Text("resell") + .font(Constants.Fonts.resellHeader) + .foregroundStyle(Constants.Colors.resellGradient) + + Spacer() - Spacer() + Button(action: { + router.push(.search(nil)) + }, label: { + Icon(image: "search") + }) + } + .padding(.horizontal, Constants.Spacing.horizontalPadding) - Button(action: { - router.push(.search(nil)) - }, label: { - Icon(image: "search") - }) } - .padding(.horizontal, Constants.Spacing.horizontalPadding) - } + private var filtersView: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - ForEach(Constants.filters, id: \.id) { filter in - FilterButton(filter: filter, isSelected: viewModel.selectedFilter == filter.title) { - viewModel.selectedFilter = filter.title + VStack(alignment: .leading) { + Text("Shop By Category") + .font(.custom("Rubik-Medium", size: 22)) + .foregroundStyle(.black) + .padding(.leading, Constants.Spacing.horizontalPadding) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top) { + ForEach(Constants.filters.filter { $0.color != nil }, id: \.id) { filter in + VStack { + CircularFilterButton(filter: filter) { router.push(.detailedFilter(filter)) } + + Text(filter.title) + .font(Constants.Fonts.title4) + .frame(width: 80) + .multilineTextAlignment(.center) + .foregroundStyle(Constants.Colors.black) + } + } + .padding(.trailing, 30) } + .padding(.leading, Constants.Spacing.horizontalPadding) + .padding(.vertical, 1) } } - .padding(.leading, Constants.Spacing.horizontalPadding) - .padding(.vertical, 1) } } -} diff --git a/Resell/Views/Home/NotificationsView.swift b/Resell/Views/Home/NotificationsView.swift new file mode 100644 index 0000000..0bc35ea --- /dev/null +++ b/Resell/Views/Home/NotificationsView.swift @@ -0,0 +1,109 @@ +// +// NotificationsView.swift +// Resell +// +// Created by Angelina Chen on 11/26/24. +// + +import SwiftUI + +struct NotificationsView: View { + + // MARK: Properties + + @EnvironmentObject var router: Router + @StateObject private var viewModel = NotificationsViewModel() + + + var body: some View { + VStack { + filtersView + .padding(.leading, 15) + Text("New") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 30) + .padding(.vertical, 10) + List(viewModel.filteredNotifications, id: \.data.messageId) { notification in + notificationView(for: notification) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + } + .listStyle(PlainListStyle()) + } + .padding(.top, 5) + .padding(.vertical, 1) + .navigationTitle("Notifications") + } + + // Creates the filter for notifications sorting + private var filtersView: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(Constants.notificationFilters, id: \.id) { filter in + FilterButton( + filter: filter, isSelected: viewModel.selectedTab == filter.title + ) { + viewModel.selectedTab = filter.title + } + } + .padding(.top, 20) + } + .padding(.leading, 15) + } + } + + // Creates individual notification rows / components + private func notificationView(for notification: Notifications) -> some View { + HStack(alignment: .top) { + Image("justin_long") + .resizable() + .frame(width: 56, height: 56) + .cornerRadius(5) + + VStack(alignment: .leading) { + Spacer() + notifText(for: notification) + .font(.system(size: 14)) + Text("5 days ago") + .font(.footnote) + .foregroundColor(.gray) + Spacer() + } + .padding(.leading, 20) + Spacer() + } + .padding(15) + .padding(.horizontal, 15) + .background(notification.isRead ? Color.white : Color.purple.opacity(0.1)) + .swipeActions(edge: .leading) { + Button(action: { + viewModel.markAsRead(notification: notification) + }) { + Image("read-notification") + } + .tint(Color.purple.opacity(0.7)) + } + } + + private func notifText(for notification: Notifications) -> some View { + switch notification.data.type { + case "message": + return Text(notification.userID).bold() + Text(" sent you a message") + case "requests": + return Text("Your request for ") + + Text(notification.data.messageId).bold() + + Text(" has been met") + case "bookmarks": + return Text("\(notification.userID) discounted ") + + Text(notification.data.messageId).bold() + case "your listings": + return Text("\(notification.userID) bookmarked ") + + Text(notification.data.messageId).bold() + default: + return Text(notification.title) + } + } + +} + diff --git a/Resell/Views/Home/ProfileView.swift b/Resell/Views/Home/ProfileView.swift index 6f02d74..c30554d 100644 --- a/Resell/Views/Home/ProfileView.swift +++ b/Resell/Views/Home/ProfileView.swift @@ -14,85 +14,84 @@ struct ProfileView: View { @EnvironmentObject var router: Router @StateObject private var viewModel = ProfileViewModel() + @ObservedObject private var currentUser = CurrentUserProfileManager.shared // MARK: - UI var body: some View { - NavigationStack(path: $router.path) { + ScrollView { VStack(spacing: 0) { profileImageView .padding(.bottom, 12) - - Text(viewModel.user?.username ?? "") + + Text(currentUser.username) .font(Constants.Fonts.h3) .foregroundStyle(Constants.Colors.black) .padding(.bottom, 4) - - Text(viewModel.user?.givenName ?? "") + + Text(currentUser.givenName) .font(Constants.Fonts.body2) .foregroundStyle(Constants.Colors.secondaryGray) .padding(.bottom, 16) - - Text(viewModel.user?.bio ?? "") + + Text(currentUser.bio) .font(Constants.Fonts.body2) .foregroundStyle(Constants.Colors.black) .padding(.bottom, 28) .lineLimit(3) - + profileTabsView - + if viewModel.selectedTab == .wishlist { requestsView - .emptyState(isEmpty: viewModel.requests.isEmpty, title: "No active requests", text: "Submit a request and get notified when someone lists something similar") + .emptyState( + isEmpty: viewModel.requests.isEmpty, + title: "No active requests", + text: "Submit a request and get notified when someone lists something similar" + ) } else { - ProductsGalleryView(items: viewModel.selectedPosts) - .emptyState(isEmpty: viewModel.selectedPosts.isEmpty && !viewModel.isLoading, title: viewModel.selectedTab == .listing ? "No listings posted" : "No items archived", text: viewModel.selectedTab == .listing ? "When you post a listing, it will be displayed here" : "When a listing is sold or archived, it will be displayed here") - .loadingView(isLoading: viewModel.isLoading) - } - } - .background(Constants.Colors.white) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button { - router.push(.settings(false)) - } label: { - Icon(image: "settings") - } - } - - ToolbarItem(placement: .topBarTrailing) { - Button { - router.push(.search(viewModel.user?.id)) - } label: { - Icon(image: "search") + ScrollView{ + ProductsGalleryView(items: viewModel.selectedPosts) + .emptyState( + isEmpty: viewModel.selectedPosts.isEmpty && !viewModel.isLoading, + title: viewModel.selectedTab == .listing ? "No listings posted" : "No items archived", + text: viewModel.selectedTab == .listing + ? "When you post a listing, it will be displayed here" + : "When a listing is sold or archived, it will be displayed here" + ) + .padding(.top, 24) + .loadingView(isLoading: viewModel.isLoading) } } } - .overlay(alignment: .bottomTrailing) { - ExpandableAddButton() - .padding(.bottom, 40) - } - .onChange(of: viewModel.selectedTab) { _ in - viewModel.updateItemsGallery() - } - .onAppear { - viewModel.getUser() - } - .refreshable { - viewModel.getUser() + } + .background(Constants.Colors.white) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + router.push(.settings(false)) + } label: { + Icon(image: "settings") + } } } + .overlay(alignment: .bottomTrailing) { + ExpandableAddButton() + .padding(.bottom, 40) + } + .onAppear { + viewModel.loadCurrentUser() + } + .refreshable { + viewModel.loadCurrentUser(forceRefresh: true) + } } private var profileImageView: some View { - KFImage(viewModel.user?.photoUrl) - .cacheOriginalImage() - .placeholder { - ShimmerView() - .frame(width: 90, height: 90) - } + Image(uiImage: currentUser.profilePic) .resizable() .frame(width: 90, height: 90) + .background(Constants.Colors.stroke) .clipShape(.circle) } @@ -148,7 +147,6 @@ struct ProfileView: View { } } onDelete: { viewModel.deleteRequest(id: request.id) - viewModel.requests.removeAll { $0.id == request.id } } } } diff --git a/Resell/Views/Home/SavedView.swift b/Resell/Views/Home/SavedView.swift index ba13cd3..48a90bc 100644 --- a/Resell/Views/Home/SavedView.swift +++ b/Resell/Views/Home/SavedView.swift @@ -8,39 +8,38 @@ import SwiftUI struct SavedView: View { - + // TODO: This should be the same as the detailed filter view imo... @EnvironmentObject var router: Router - @StateObject private var viewModel = HomeViewModel.shared - + @EnvironmentObject private var viewModel: HomeViewModel + var body: some View { - NavigationStack(path: $router.path) { - ZStack { - VStack(spacing: 0) { - headerView - ProductsGalleryView(items: viewModel.savedItems) - } + ScrollView(.vertical){ + VStack(spacing: 12) { + headerView + + ProductsGalleryView(items: viewModel.savedItems) } - .background(Constants.Colors.white) - .onAppear { - viewModel.getSavedPosts() + } + .background(Constants.Colors.white) + .loadingView(isLoading: viewModel.isLoading) + .emptyState(isEmpty: $viewModel.savedItems.isEmpty, title: "No saved posts", text: "Posts you have bookmarked will be displayed here.") + .refreshable { + Task { + await viewModel.getSavedPosts() + } + } + .onAppear { + Task { + await viewModel.getSavedPosts() } - .emptyState(isEmpty: $viewModel.savedItems.isEmpty, title: "No saved posts", text: "Posts you have bookmarked will be displayed here.") } } - + private var headerView: some View { - HStack { - Text("Saved") + VStack { + Text("Saved By You") .font(Constants.Fonts.h1) .foregroundStyle(Constants.Colors.black) - - Spacer() - - Button(action: { - //TODO: Search Endpoint - }, label: { - Icon(image: "search") - }) } .padding(.horizontal, 25) } diff --git a/Resell/Views/Home/SearchView.swift b/Resell/Views/Home/SearchView.swift index 3076a83..4532eae 100644 --- a/Resell/Views/Home/SearchView.swift +++ b/Resell/Views/Home/SearchView.swift @@ -12,13 +12,10 @@ struct SearchView: View { // MARK: - Properties @EnvironmentObject private var mainViewModel: MainViewModel + @EnvironmentObject private var searchViewModel: SearchViewModel @EnvironmentObject var router: Router @FocusState private var isFocused: Bool - @State private var isLoading: Bool = false - @State private var isSearching: Bool = true - - @State private var searchedItems: [Post] = [] @State private var searchText: String = "" var userID: String? = nil @@ -37,7 +34,7 @@ struct SearchView: View { .clipShape(.capsule) .focused($isFocused) .onSubmit { - searchItems() + searchViewModel.searchItems(with: searchText, userID: userID, saveQuery: false, mainViewModel: mainViewModel) {} } Button { @@ -51,33 +48,33 @@ struct SearchView: View { } .padding(Constants.Spacing.horizontalPadding) - if isSearching { + if searchViewModel.isSearching { searchHistoryView Spacer() - } else if isLoading { + } else if searchViewModel.isLoading { Spacer() ProgressView() Spacer() } else { - if searchedItems.isEmpty { + if searchViewModel.searchedItems.isEmpty { Spacer() - emptyState - Spacer() } else { - ProductsGalleryView(items: searchedItems) + ScrollView(.vertical) { + ProductsGalleryView(items: searchViewModel.searchedItems) + } } } } .navigationBarBackButtonHidden() .background(Constants.Colors.white) - .loadingView(isLoading: isLoading) + .loadingView(isLoading: searchViewModel.isLoading) .onChange(of: isFocused) { newValue in - isSearching = newValue + searchViewModel.isSearching = newValue } } @@ -112,7 +109,7 @@ struct SearchView: View { ForEach(mainViewModel.searchHistory, id: \.self) { query in Button { searchText = query - searchItems() + searchViewModel.searchItems(with: searchText, userID: userID, saveQuery: true, mainViewModel: mainViewModel) {} } label: { Text(query) .font(Constants.Fonts.body1) @@ -124,30 +121,5 @@ struct SearchView: View { } .padding(.horizontal, Constants.Spacing.horizontalPadding) } - } - - // MARK: - Functions - - private func searchItems() { - isSearching = false - isLoading = true - - Task { - do { - let postsResponse = try await NetworkManager.shared.getSearchedPosts(with: searchText) - - if let userID { - searchedItems = postsResponse.posts.filter { $0.user?.id == userID } - } else { - searchedItems = postsResponse.posts - } - - mainViewModel.saveSearchQuery(searchText) - withAnimation { isLoading = false } - } catch { - NetworkManager.shared.logger.error("Error in SearchView.searchItems: \(error.localizedDescription)") - withAnimation { isLoading = false } - } - } - } + } } diff --git a/Resell/Views/Home/SuggestionsView.swift b/Resell/Views/Home/SuggestionsView.swift new file mode 100644 index 0000000..ffaf93e --- /dev/null +++ b/Resell/Views/Home/SuggestionsView.swift @@ -0,0 +1,81 @@ +// +// SuggestionsView.swift +// Resell +// +// Created by Charles Liggins on 10/10/25. +// + +// MARK: This is also not used anywhere currently.... + +// MARK: This will likely be refactored I'm just going based off of not too many designs... +// This is essentially a recently searched view as of now, as I'm unsure how else we can use it... + +import SwiftUI + +struct SuggestionsView: View { + // Take a few (<= 5) recent searches, then show the posts here... + @StateObject private var searchViewModel = SearchViewModel.shared + @EnvironmentObject var router: Router + + @State private var suggestedPosts: [Post] = [] + @State private var isLoading = false + + var body: some View { + ScrollView{ + VStack { + Text("Suggested For You") + .font(.custom("Rubik-Medium", size: 22)) + .foregroundStyle(.black) + .padding(.horizontal, 24) + .padding(.top, 12) + + if isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + + } else if suggestedPosts.isEmpty { + VStack(spacing: 16) { + Image(systemName: "magnifyingglass") + .font(.system(size: 48)) + .foregroundStyle(.gray) + + Text("No suggestions yet") + .font(Constants.Fonts.title2) + + Text("Search for items to get personalized suggestions") + .font(Constants.Fonts.body1) + .foregroundStyle(Constants.Colors.secondaryGray) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } else { + ProductsGalleryView( + items: suggestedPosts, + onScrollToBottom: {} + ) + } + } + .navigationTitle("Suggestions") + .navigationBarTitleDisplayMode(.inline) + .onAppear { + loadSuggestions() + } + } + .background(Constants.Colors.white) + } + + private func loadSuggestions() { + guard suggestedPosts.isEmpty else { return } + + isLoading = true + Task { + defer { isLoading = false } + suggestedPosts = await searchViewModel.loadAllSuggestions() + } + } +} + +#Preview { + SuggestionsView() +} diff --git a/Resell/Views/Home/UserCredibilityView.swift b/Resell/Views/Home/UserCredibilityView.swift new file mode 100644 index 0000000..ba92205 --- /dev/null +++ b/Resell/Views/Home/UserCredibilityView.swift @@ -0,0 +1,181 @@ +// +// UserCredibilityView.swift +// Resell +// +// Created by Charles Liggins on 10/11/25. +// + +// MARK: This isn't being used currently... + +// MARK: This applies when viewing external profiles, but I think a lot of things can be ported to the current user viewing their own profile, such as the follower count... + +import SwiftUI + +struct UserCredibilityView: View { + @EnvironmentObject var router: Router + + var body: some View { + VStack{ + headerView + usernameView + userAnalyticsView + following + } + } + + var headerView: some View { + VStack{ + HStack{ + Button { + router.pop() + } label: { + Image("chevron.left") + .resizable() + .frame(width: 36, height: 24) + .foregroundStyle(.black) + } + + Spacer() + + Text("@xhether.resell") + .font(Constants.Fonts.h3) + + Spacer() + + Image(systemName: "ellipsis") + .resizable() + .frame(width: 24, height: 6) + .foregroundStyle(Constants.Colors.black) + .padding(.trailing, 16) + } + Divider() + } + .padding(.bottom, 25) + } + + var usernameView: some View { + VStack{ + HStack(spacing: 16){ + ZStack { + Image("pfp") + .resizable() + .frame(width: 64, height: 64) + .clipShape(Circle()) + + Circle() + .stroke(Color.white, lineWidth: 3) + .background(Circle().fill(.gray)) + .frame(width: 21, height: 21) + .offset(x:22, y:22) + } + + VStack(spacing: 16){ + Text("Charles Liggins") + .font(Constants.Fonts.h2) + + HStack{ + ForEach(0..<5) { _ in + // if we could get away from using images here, it might be faster/better... + Image(systemName: "star.fill") + .resizable() + .foregroundStyle(.gray) + .frame(width: 12, height: 12) + } + + Text("(0)") + .underline() + } + .padding(.trailing, 32) + } + } + .padding(.trailing, 92) + .padding(.bottom, 24) + + Text("I wish I had something to say but I wish I had Kapital Jeans even more...") + .font(Constants.Fonts.body2) + .frame(width: 364) + } + .padding(.bottom, 24) + } + + var userAnalyticsView: some View { + HStack{ + Spacer() + + (Text("0").fontWeight(.medium) + Text(" sold")) + .font(Constants.Fonts.body2) + + Spacer() + + Divider() + .frame(height: 14) + + Spacer() + + (Text("0").fontWeight(.medium) + Text(" followers")) + .font(Constants.Fonts.body2) + + Spacer() + + Divider() + .frame(height: 14) + + Spacer() + + (Text("0").fontWeight(.medium) + Text(" following")) + .font(Constants.Fonts.body2) + + Spacer() + } + .padding(.bottom, 24) + } + + var following: some View { + HStack{ + ZStack{ + RoundedRectangle(cornerRadius: 90) + .frame(width: 304, height: 39) + .foregroundStyle(Constants.Colors.resellPurple) + + HStack{ + Image("follow-button") + .resizable() + .frame(width: 12, height: 12) + .foregroundStyle(Color.white) + + Text("Follow") + .fontWeight(.medium) + .font(Constants.Fonts.body2) + .foregroundStyle(Color.white) + } + } + ZStack{ + Circle() + .stroke(Constants.Colors.resellPurple, lineWidth: 1) + .frame(width: 39, height: 39) + + Image(systemName: "envelope") + .resizable() + .foregroundStyle(Constants.Colors.resellPurple) + .frame(width: 20, height: 15) + } + } + } + + var reviews: some View { + Text("Reviews go here...") + } + + var listings: some View { + Text("Listings go here...") + } + // MARK: Idk if theres a convention for this app.. + var storefront_review_tab: some View { + Text("Tab") + } + +} + +#Preview { + UserCredibilityView() +} diff --git a/Resell/Views/MainTabView.swift b/Resell/Views/MainTabView.swift index 72e0ff8..7ae2a1a 100644 --- a/Resell/Views/MainTabView.swift +++ b/Resell/Views/MainTabView.swift @@ -11,7 +11,6 @@ struct MainTabView: View { // MARK: - Properties - @EnvironmentObject private var mainViewModel: MainViewModel @EnvironmentObject var router: Router @Binding var isHidden: Bool @@ -19,94 +18,130 @@ struct MainTabView: View { // MARK: - ViewModels - @StateObject private var newListingViewModel = NewListingViewModel() - @StateObject private var reportViewModel = ReportViewModel() + @EnvironmentObject private var chatsViewModel: ChatsViewModel + @EnvironmentObject private var mainViewModel: MainViewModel + @EnvironmentObject private var newListingViewModel: NewListingViewModel + @EnvironmentObject private var onboardingViewModel: SetupProfileViewModel + @EnvironmentObject private var reportViewModel: ReportViewModel // MARK: - UI var body: some View { NavigationStack(path: $router.path) { - ZStack(alignment: .bottom) { - ZStack() { - if selection == 0 { - HomeView() - } else if selection == 1 { - SavedView() - } else if selection == 2 { - ChatsView() - } else if selection == 3 { - ProfileView() - } - } - .navigationDestination(for: Router.Route.self) { route in - switch route { - case .newListingDetails: - NewListingDetailsView() - .environmentObject(newListingViewModel) - case .newListingImages: - NewListingImagesView() - .environmentObject(newListingViewModel) - case .newRequest: - NewRequestView() - case .messages: - MessagesView() - case .productDetails(let itemID): - ProductDetailsView(id: itemID) - case .reportConfirmation: - ReportConfirmationView() - .environmentObject(reportViewModel) - case .reportDetails: - ReportDetailsView() - .environmentObject(reportViewModel) - case .reportOptions(let type, let id): - ReportOptionsView(type: type, id: id) - .environmentObject(reportViewModel) - case .search(let id): - SearchView(userID: id) - case .settings(let isAccountSettings): - SettingsView(isAccountSettings: isAccountSettings) - case .blockedUsers: - BlockedUsersView() - case .editProfile: - EditProfileView() - case .feedback: - SendFeedbackView() - case .notifications: - NotificationsSettingsView() - case .login: - LoginView(userDidLogin: $mainViewModel.userDidLogin) - case .profile(let id): - ExternalProfileView(userID: id) - default: - EmptyView() + Group { + if mainViewModel.userDidLogin { + VStack(spacing: 0) { + mainView + + if !isHidden { + tabBarView + } + } + .ignoresSafeArea(edges: .bottom) + .transition(.opacity) + .background(.white) + .environmentObject(router) + } else { + LoginView() + .transition(.opacity) + .environmentObject(onboardingViewModel) + .environmentObject(router) } + } + .navigationDestination(for: Router.Route.self) { route in + switch route { + case .newListingDetails: + NewListingDetailsView() + .environmentObject(newListingViewModel) + case .newListingImages: + NewListingImagesView() + .environmentObject(newListingViewModel) + case .newRequest: + NewRequestView() + case .messages(let chatInfo): + MessagesView(chatInfo: chatInfo) + case .discover: + SuggestionsView() + case .productDetails(let item): + ProductDetailsView(post: item) + case .reportConfirmation: + ReportConfirmationView() + .environmentObject(reportViewModel) + case .reportDetails: + ReportDetailsView() + .environmentObject(reportViewModel) + case .reportOptions(let type, let id): + ReportOptionsView(type: type, id: id) + .environmentObject(reportViewModel) + case .search(let id): + SearchView(userID: id) + case .settings(let isAccountSettings): + SettingsView(isAccountSettings: isAccountSettings) + case .blockedUsers: + BlockedUsersView() + case .editProfile: + EditProfileView() + case .feedback: + SendFeedbackView() + case .detailedFilter(let filter): + DetailedFilterView(filter: filter) + case .saved: + SavedView() +// case .notifications: +// NotificationsSettingsView() + case .login: + LoginView() + .environmentObject(onboardingViewModel) + case .profile(let id): + ExternalProfileView(userID: id) + case .followList(let userID, let username, let initialTab): + FollowListView(userID: userID, username: username, initialTab: initialTab) + case .setupProfile: + SetupProfileView(userDidLogin: $mainViewModel.userDidLogin, user: GoogleAuthManager.shared.user) + .environmentObject(onboardingViewModel) + case .venmo: + VenmoView(userDidLogin: $mainViewModel.userDidLogin) + .environmentObject(onboardingViewModel) + default: + EmptyView() } + } + } + } + private var mainView: some View { + ZStack() { + if selection == 0 { + HomeView() + } else if selection == 1 { + ChatsView() + .environmentObject(chatsViewModel) + } else if selection == 2 { + ProfileView() + } + } + } - if !isHidden { - HStack { - ForEach(0..<4, id: \.self) { index in - TabViewIcon(selectionIndex: $selection, itemIndex: index) - .frame(width: 28, height: 28) + private var tabBarView: some View { + HStack { + ForEach(0..<3, id: \.self) { index in + TabViewIcon(selectionIndex: $selection, itemIndex: index) + .frame(width: 28, height: 28) - if index != 3 { - Spacer() - } - } - } - .ignoresSafeArea(edges: .bottom) - .padding(.horizontal, 40) - .padding(.top, 16) - .padding(.bottom, 36) - .frame(width: UIScreen.width) - .background(Constants.Colors.white) - .clipShape(RoundedRectangle(cornerRadius: 30, style: .continuous)) - .shadow(radius: 4) - .offset(y: 34) - .transition(.move(edge: .bottom)) - .animation(.easeInOut, value: isHidden) + if index != 2 { + Spacer() } } } + .ignoresSafeArea(edges: .bottom) + .padding(.horizontal, 40) + .padding(.top, 16) + .padding(.bottom, 46) + .frame(width: UIScreen.width) + .background(Constants.Colors.white) + .clipShape(RoundedRectangle(cornerRadius: 30, style: .continuous)) + .shadow(radius: 4) + .transition(.move(edge: .bottom)) + .animation(.easeInOut, value: isHidden) } } diff --git a/Resell/Views/MainView.swift b/Resell/Views/MainView.swift index 21f0f1d..012f4a9 100644 --- a/Resell/Views/MainView.swift +++ b/Resell/Views/MainView.swift @@ -8,43 +8,41 @@ import GoogleSignIn import SwiftUI -import GoogleSignIn -import SwiftUI - struct MainView: View { // MARK: - Properties - @StateObject private var mainViewModel = MainViewModel() + @EnvironmentObject private var mainViewModel: MainViewModel @StateObject private var router = Router() + @StateObject private var chatsViewModel = ChatsViewModel() + @StateObject private var newListingViewModel = NewListingViewModel() + @StateObject private var onboardingViewModel = SetupProfileViewModel() + @StateObject private var reportViewModel = ReportViewModel() + @StateObject private var searchViewModel = SearchViewModel() + @StateObject private var filterViewModel = FiltersViewModel() // MARK: - UI var body: some View { - ZStack { - if mainViewModel.userDidLogin { - MainTabView(isHidden: $mainViewModel.hidesTabBar, selection: $mainViewModel.selection) - .transition(.opacity) - .animation(.easeInOut, value: mainViewModel.userDidLogin) - .environmentObject(router) - } else { - LoginView(userDidLogin: $mainViewModel.userDidLogin) - .transition(.opacity) - .animation(.easeInOut, value: mainViewModel.userDidLogin) - .environmentObject(router) + MainTabView(isHidden: $mainViewModel.hidesTabBar, selection: $mainViewModel.selection) + .environmentObject(searchViewModel) + .environmentObject(router) + .environmentObject(mainViewModel) + .environmentObject(chatsViewModel) + .environmentObject(newListingViewModel) + .environmentObject(filterViewModel) + .environmentObject(onboardingViewModel) + .environmentObject(reportViewModel) + .background(Constants.Colors.white) + .onAppear { + let signInConfig = GIDConfiguration.init(clientID: Keys.googleClientID) + GIDSignIn.sharedInstance.configuration = signInConfig + mainViewModel.restoreSignIn() + mainViewModel.setupNavBar() + mainViewModel.hidesTabBar = false + } + .onOpenURL { url in + GIDSignIn.sharedInstance.handle(url) } - } - .background(Constants.Colors.white) - .environmentObject(mainViewModel) - .onAppear { - let signInConfig = GIDConfiguration.init(clientID: Keys.googleClientID) - GIDSignIn.sharedInstance.configuration = signInConfig - mainViewModel.restoreSignIn() - mainViewModel.setupNavBar() - mainViewModel.hidesTabBar = false - } - .onOpenURL { url in - GIDSignIn.sharedInstance.handle(url) - } } } diff --git a/Resell/Views/NewListing/NewListingDetailsView.swift b/Resell/Views/NewListing/NewListingDetailsView.swift index 312d521..02cec34 100644 --- a/Resell/Views/NewListing/NewListingDetailsView.swift +++ b/Resell/Views/NewListing/NewListingDetailsView.swift @@ -42,6 +42,7 @@ struct NewListingDetailsView: View { Spacer() PurpleButton(isLoading: viewModel.isLoading, isActive: viewModel.checkInputIsValid(), text: "Continue") { + // Create New Listing viewModel.createNewListing() withAnimation { @@ -111,22 +112,43 @@ struct NewListingDetailsView: View { private var filtersView: some View { VStack(alignment: .leading, spacing: 8) { - Text("Select Categories") - .font(Constants.Fonts.title1) - .foregroundStyle(Constants.Colors.black) - - ScrollView(.horizontal, showsIndicators: false) { - HStack { - ForEach(Constants.filters, id: \.id) { filter in - if filter.title != "Recent" { - FilterButton(filter: filter, isSelected: viewModel.selectedFilter == filter.title) { - viewModel.selectedFilter = filter.title + VStack { + Text("Select Categories") + .font(Constants.Fonts.title1) + .foregroundStyle(Constants.Colors.black) + + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(Constants.filters, id: \.id) { filter in + if filter.title != "Recent" { + FilterButton(filter: filter, isSelected: viewModel.selectedFilter == filter.title) { + viewModel.selectedFilter = filter.title + } } } } + .padding(.vertical, 1) + .padding(.horizontal, 1) + } + } + // Condition Filters + VStack { + Text("Select Conditions") + .font(Constants.Fonts.title1) + .foregroundStyle(Constants.Colors.black) + + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(Constants.conditions, id: \.id) { condition in + FilterButton(filter: condition, isSelected: viewModel.selectedCondition == condition.title) { + viewModel.selectedCondition = condition.title + } + } + } + .padding(.vertical, 1) + .padding(.horizontal, 1) } - .padding(.vertical, 1) - .padding(.horizontal, 1) + } } } diff --git a/Resell/Views/NewListing/NewRequestView.swift b/Resell/Views/NewListing/NewRequestView.swift index 1b46420..eb4a061 100644 --- a/Resell/Views/NewListing/NewRequestView.swift +++ b/Resell/Views/NewListing/NewRequestView.swift @@ -64,6 +64,7 @@ struct NewRequestView: View { mainViewModel.hidesTabBar = false } } label: { + //TODO: Place this in constants Image(systemName: "xmark") .resizable() .frame(width: 20, height: 20) diff --git a/Resell/Views/Onboarding/LoginView.swift b/Resell/Views/Onboarding/LoginView.swift index 16789fa..a45ca80 100644 --- a/Resell/Views/Onboarding/LoginView.swift +++ b/Resell/Views/Onboarding/LoginView.swift @@ -10,64 +10,52 @@ import SwiftUI struct LoginView: View { @EnvironmentObject var router: Router + @EnvironmentObject private var mainViewModel: MainViewModel + @EnvironmentObject private var onboardingViewModel: SetupProfileViewModel @StateObject private var viewModel = LoginViewModel() - @StateObject private var onboardingViewModel = SetupProfileViewModel() - @Binding var userDidLogin: Bool var body: some View { - NavigationStack(path: $router.path) { - VStack { - Image("resell") - .padding(.top, 180) + VStack { + Image("resell") + .padding(.top, 180) - Text("resell") - .font(Constants.Fonts.resellLogo) - .foregroundStyle(Constants.Colors.resellGradient) - .multilineTextAlignment(.center) + Text("resell") + .font(Constants.Fonts.resellLogo) + .foregroundStyle(Constants.Colors.resellGradient) + .multilineTextAlignment(.center) - Spacer() + Spacer() + if !mainViewModel.hidesSignInButton { PurpleButton(text: "Login with NetID", horizontalPadding: 28) { - viewModel.googleSignIn { - userDidLogin = true - } failure: { netid, givenName, familyName, email, googleId in - userDidLogin = false - router.push(.setupProfile(netid: netid, givenName: givenName, familyName: familyName, email: email, googleId: googleId)) + viewModel.isLoading = true + Task { + let signInResult = await viewModel.googleSignIn() + viewModel.isLoading = false + switch signInResult { + case .success: + mainViewModel.userDidLogin = true + case .accountCreationNeeded: + router.push(.setupProfile) + break + default: + break + } } - - } - } - .background(LoginGradient()) - .onAppear { - onboardingViewModel.clear() - } - .navigationDestination(for: Router.Route.self) { route in - switch route { - case .setupProfile(let netid, let givenName, let familyName, let email, let googleId): - SetupProfileView(userDidLogin: $userDidLogin, netid: netid, givenName: givenName, familyName: familyName, email: email, googleID: googleId) - .environmentObject(onboardingViewModel) - case .venmo: - VenmoView(userDidLogin: $userDidLogin) - .environmentObject(onboardingViewModel) - default: - EmptyView() } + } else { + Image("appdev") + .padding(.bottom, 24) } } + .background(LoginGradient()) + .onAppear { + onboardingViewModel.clear() + } .sheet(isPresented: $viewModel.didPresentError) { loginSheetView } - .navigationDestination(for: Router.Route.self) { route in - switch route { - case .setupProfile: - SetupProfileView(userDidLogin: $userDidLogin) - case .venmo: - VenmoView(userDidLogin: $userDidLogin) - default: - EmptyView() - } - } } private var loginSheetView: some View { @@ -81,13 +69,23 @@ struct LoginView: View { Spacer() PurpleButton(text: "Try Again", horizontalPadding: 60) { - viewModel.googleSignIn { - userDidLogin = true - } failure: { netid, givenName, familyName, email, googleId in - userDidLogin = false - router.push(.setupProfile(netid: netid, givenName: givenName, familyName: familyName, email: email, googleId: googleId)) + Task { + viewModel.didPresentError = false + viewModel.isLoading = true + Task { + let signInResult = await viewModel.googleSignIn() + viewModel.isLoading = false + switch signInResult { + case .success: + mainViewModel.userDidLogin = true + case .accountCreationNeeded: + router.push(.setupProfile) + break + default: + break + } + } } - viewModel.didPresentError = false } } .presentationDetents([.height(200)]) diff --git a/Resell/Views/Onboarding/SetupProfileView.swift b/Resell/Views/Onboarding/SetupProfileView.swift index a145012..236658b 100644 --- a/Resell/Views/Onboarding/SetupProfileView.swift +++ b/Resell/Views/Onboarding/SetupProfileView.swift @@ -17,11 +17,7 @@ struct SetupProfileView: View { @Binding var userDidLogin: Bool - let netid: String - let givenName: String - let familyName: String - let email: String - let googleID: String + let user: User? // MARK: - UI @@ -41,6 +37,7 @@ struct SetupProfileView: View { Spacer() PurpleButton(isActive: viewModel.checkInputIsValid(), text: "Next", horizontalPadding: 80) { + viewModel.createNewUser() router.push(.venmo) } } @@ -50,6 +47,9 @@ struct SetupProfileView: View { WebView(url: URL(string: "https://www.cornellappdev.com/license/resell")!) .edgesIgnoringSafeArea(.all) } + .sheet(isPresented: $viewModel.didPresentError) { + errorSheetView + } .toolbar { ToolbarItem(placement: .principal) { Text("Setup your profile") @@ -58,18 +58,17 @@ struct SetupProfileView: View { } } .onAppear { - viewModel.netid = netid - viewModel.givenName = givenName - viewModel.familyName = familyName - viewModel.email = email - viewModel.googleID = googleID + viewModel.netid = user?.netid ?? "" + viewModel.givenName = user?.givenName ?? "" + viewModel.familyName = user?.familyName ?? "" + viewModel.email = user?.email ?? "" } .endEditingOnTap() } private var profileImageView: some View { ZStack(alignment: .bottomTrailing) { - Image(uiImage: viewModel.selectedImage) + Image(uiImage: viewModel.selectedImage ?? UIImage(named: "emptyProfile")!) .resizable() .frame(width: 132, height: 132) .background(Constants.Colors.stroke) @@ -125,4 +124,25 @@ struct SetupProfileView: View { } } } + + private var errorSheetView: some View { + VStack { + Text(viewModel.errorText) + .font(Constants.Fonts.h3) + .multilineTextAlignment(.center) + .frame(width: 190) + .padding(.top, 48) + + Spacer() + + PurpleButton(text: "OK", horizontalPadding: 60) { + Task { + viewModel.didPresentError = false + } + } + } + .presentationDetents([.height(200)]) + .presentationDragIndicator(.visible) + .presentationCornerRadius(25) + } } diff --git a/Resell/Views/Onboarding/VenmoView.swift b/Resell/Views/Onboarding/VenmoView.swift index 5b080ec..bca920e 100644 --- a/Resell/Views/Onboarding/VenmoView.swift +++ b/Resell/Views/Onboarding/VenmoView.swift @@ -12,39 +12,39 @@ struct VenmoView: View { // MARK: - Properties @EnvironmentObject private var router: Router + @EnvironmentObject private var mainViewModel: MainViewModel @EnvironmentObject private var viewModel: SetupProfileViewModel + @Binding var userDidLogin: Bool // MARK: - UI var body: some View { - NavigationStack { - VStack(alignment: .center) { - Text("Your Venmo handle will only be visible to people interested in buying your listing.") - .font(Constants.Fonts.body1) - .foregroundStyle(Constants.Colors.secondaryGray) - .padding(.top, 24) - - LabeledTextField(label: "Venmo Handle", text: $viewModel.venmoHandle) - .padding(.top, 46) + VStack(alignment: .center) { + Text("Your Venmo handle will only be visible to people interested in buying your listing.") + .font(Constants.Fonts.body1) + .foregroundStyle(Constants.Colors.secondaryGray) + .padding(.top, 24) - Spacer() + LabeledTextField(label: "Venmo Handle", text: $viewModel.venmoHandle) + .padding(.top, 46) - PurpleButton(isLoading: viewModel.isLoading, isActive: !viewModel.venmoHandle.cleaned().isEmpty,text: "Continue") { - viewModel.createNewUser() - } + Spacer() - Button(action: { - withAnimation { - userDidLogin = true - } - }, label: { - Text("Skip") - .font(Constants.Fonts.title1) - .foregroundStyle(Constants.Colors.resellPurple) - .padding(.top, 14) - }) + PurpleButton(isLoading: viewModel.isLoading, isActive: !viewModel.venmoHandle.cleaned().isEmpty,text: "Continue") { + // viewModel.createNewUser() } + + Button(action: { + withAnimation { + userDidLogin = true + } + }, label: { + Text("Skip") + .font(Constants.Fonts.title1) + .foregroundStyle(Constants.Colors.resellPurple) + .padding(.top, 14) + }) } .padding(.horizontal, Constants.Spacing.horizontalPadding) .background(Constants.Colors.white) @@ -54,7 +54,7 @@ struct VenmoView: View { Text("Link your") .font(Constants.Fonts.h3) .foregroundStyle(Constants.Colors.black) - + Image("venmoLogo") } } diff --git a/Resell/Views/ProductDetails/ExternalProfileView.swift b/Resell/Views/ProductDetails/ExternalProfileView.swift index 8db730b..d0bf25e 100644 --- a/Resell/Views/ProductDetails/ExternalProfileView.swift +++ b/Resell/Views/ProductDetails/ExternalProfileView.swift @@ -14,119 +14,341 @@ struct ExternalProfileView: View { @EnvironmentObject var router: Router @StateObject private var viewModel = ProfileViewModel() + @State var listingViewIsPresented: Bool = true + @State private var didShowUnfollowPopup: Bool = false var userID: String // MARK: - UI + // TODO: It should be impossible for the externalUser to be inactive/null var body: some View { - ZStack { - VStack(spacing: 0) { + VStack(spacing: 0) { + customToolbar + + ZStack { + VStack(alignment: .leading) { + profileView + .padding(.top, 25) + .padding(.leading, 26) + + profileTabBar + + if listingViewIsPresented { + ScrollView { + ProductsGalleryView(items: viewModel.externalUserPosts) + .loadingView(isLoading: viewModel.isLoadingExternalUser) + .padding(.top, 16) + } + .background(Constants.Colors.white) + } else { + ScrollView { + // review sections + ReviewSection() + } + } + } + .background(Constants.Colors.white) + .onAppear { + viewModel.loadExternalUser(id: userID) + } + + if viewModel.sellerIsBlocked { + ZStack { + Constants.Colors.black + .opacity(0.75) + .ignoresSafeArea() + + Text("This profile is blocked") + .font(Constants.Fonts.title1) + .foregroundStyle(Constants.Colors.white) + } + .animation(.easeInOut, value: viewModel.sellerIsBlocked) + } + + if viewModel.didShowOptionsMenu { + OptionsMenuView(showMenu: $viewModel.didShowOptionsMenu, didShowBlockView: $viewModel.didShowBlockView, options: { + var options: [Option] = [ + .report(type: "User", id: userID), + ] + if viewModel.sellerIsBlocked { + options.append(.unblock) + } else { + options.append(.block) + } + return options + }()) + .zIndex(1) + } + } + .popupModal(isPresented: $viewModel.didShowBlockView) { + popupModalContent + } + .sheet(isPresented: $didShowUnfollowPopup) { + unfollowSheetContent + .presentationDetents([.height(375)]) + .presentationDragIndicator(.visible) + } + // MARK: We should not be able to click into our own posts... + + } + .toolbar(.hidden, for: .navigationBar) + } + + private var profileView: some View { + VStack(alignment: .leading, spacing: 24) { + HStack(spacing: 16) { profileImageView - .padding(.bottom, 12) - - Text(viewModel.user?.username ?? "") - .font(Constants.Fonts.h3) - .foregroundStyle(Constants.Colors.black) - .padding(.bottom, 4) + + VStack (alignment: .leading, spacing: 10.5) { + Text(viewModel.externalUser?.givenName ?? "") + .font(Constants.Fonts.h2) + .foregroundStyle(.black) + + HStack { + ForEach(0..<5) { _ in + // if we could get away from using images here, it might be faster/better... + Image(systemName: "star.fill") + .resizable() + .foregroundStyle(.gray) + .frame(width: 12, height: 12) + } + + Text("(0)") + .underline() + .foregroundStyle(Constants.Colors.inactiveGray) + } + } + } + + // bio + Text(viewModel.externalUser?.bio ?? "no bio found....") + .font(Constants.Fonts.body2) + .foregroundStyle(.black) + + // metrics bar + HStack { + Text("\(viewModel.externalUser?.soldPosts ?? 0)") + .font(Constants.Fonts.body2) + .fontWeight(.medium) + .foregroundColor(.black) + + Text(" sold") + .font(Constants.Fonts.body2) + .foregroundColor(.gray) + + Divider() + .frame(height: 14) + .padding(.horizontal, 28.75) - Text(viewModel.user?.givenName ?? "") + Button { + router.push(.followList( + userID: userID, + username: viewModel.externalUser?.username ?? "", + initialTab: .followers + )) + } label: { + Text("\(viewModel.followerCount)") .font(Constants.Fonts.body2) - .foregroundStyle(Constants.Colors.secondaryGray) - .padding(.bottom, 16) - - Text(viewModel.user?.bio ?? "") + .fontWeight(.medium) + .foregroundColor(.black) + + Text(" followers") .font(Constants.Fonts.body2) - .foregroundStyle(Constants.Colors.black) - .padding(.bottom, 28) - .lineLimit(3) - + .foregroundColor(.gray) + } + Divider() - - ProductsGalleryView(items: viewModel.selectedPosts) + .frame(height: 14) + .padding(.horizontal, 28.75) + + Button { + router.push(.followList( + userID: userID, + username: viewModel.externalUser?.username ?? "", + initialTab: .following + )) + } label: { + Text("\(viewModel.externalUser?.following?.count ?? 0)") + .font(Constants.Fonts.body2) + .fontWeight(.medium) + .foregroundColor(.black) + + Text(" following") + .font(Constants.Fonts.body2) + .foregroundColor(.gray) + } } - .background(Constants.Colors.white) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - HStack { - Button { - router.push(.search(userID)) - } label: { - Icon(image: "search") + + HStack { + Button { + if viewModel.isFollowing { + withAnimation { + didShowUnfollowPopup = true } - - Button { - withAnimation { - viewModel.didShowOptionsMenu.toggle() + } else { + Task { + do { + try await viewModel.followUser(id: userID) + } catch { + NetworkManager.shared.logger.error("Error following user: \(error)") } - } label: { - Image(systemName: "ellipsis") - .resizable() - .frame(width: 24, height: 6) - .foregroundStyle(viewModel.sellerIsBlocked ? Constants.Colors.white : Constants.Colors.black) + } + } + } label: { + ZStack { + RoundedRectangle(cornerRadius: 90.79) + .foregroundStyle(viewModel.isFollowing ? Constants.Colors.white : Constants.Colors.resellPurple) + .overlay( + RoundedRectangle(cornerRadius: 90.79) + .stroke(Constants.Colors.resellPurple, lineWidth: viewModel.isFollowing ? 1.5 : 0) + ) + .frame(width: 313, height: 38.79) + + if viewModel.isFollowLoading { + ProgressView() + .tint(viewModel.isFollowing ? Constants.Colors.resellPurple : .white) + } else { + HStack { + Image(viewModel.isFollowing ? "following" : "following") + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 13, height: 13) + + Text(viewModel.isFollowing ? "Following" : "Follow") + .font(Constants.Fonts.title3) + } + .foregroundColor(viewModel.isFollowing ? Constants.Colors.resellPurple : .white) } } } - - ToolbarItem(placement: .topBarLeading) { - Image(systemName: "chevron.left") - .resizable() - .frame(width: 18, height: 24) - .foregroundStyle(Constants.Colors.white) - .offset(x: -20) + .disabled(viewModel.isFollowLoading) + + ZStack { + Image(systemName: "envelope") + .foregroundStyle(Constants.Colors.resellPurple) + .frame(width: 20, height: 16) + + Circle() + .stroke(Constants.Colors.resellPurple, lineWidth: 1.2) + .frame(width: 39, height: 39) } } - .onAppear { - viewModel.getExternalUser(id: userID) - } + } + .padding(.trailing, 26) - if viewModel.sellerIsBlocked { - ZStack { - Constants.Colors.black - .opacity(0.75) - .ignoresSafeArea() + } - Text("This profile is blocked") - .font(Constants.Fonts.title1) - .foregroundStyle(Constants.Colors.white) - } - .animation(.easeInOut, value: viewModel.sellerIsBlocked) + private var customToolbar: some View { + HStack { + Button { + router.pop() + } label: { + Image(systemName: "chevron.left") + .resizable() + .scaledToFit() + .frame(height: 20) + .foregroundStyle(Constants.Colors.black) } - - if viewModel.didShowOptionsMenu { - OptionsMenuView(showMenu: $viewModel.didShowOptionsMenu, didShowBlockView: $viewModel.didShowBlockView, options: { - var options: [Option] = [ - .report(type: "User", id: userID), - ] - if viewModel.sellerIsBlocked { - options.append(.unblock) - } else { - options.append(.block) - } - return options - }()) - .zIndex(1) + .frame(width: 24, alignment: .leading) + + Spacer() + + Text("@\(viewModel.externalUser?.username ?? "username")") + .font(Constants.Fonts.h3) + .foregroundStyle(Constants.Colors.black) + + Spacer() + + Button { + withAnimation { + viewModel.didShowOptionsMenu.toggle() + } + } label: { + Image(systemName: "ellipsis") + .resizable() + .frame(width: 24, height: 6) + .foregroundStyle(viewModel.sellerIsBlocked ? Constants.Colors.white : Constants.Colors.black) } + .frame(width: 24, alignment: .trailing) } - .popupModal(isPresented: $viewModel.didShowBlockView) { - popupModalContent + .padding(.horizontal, 24) + .padding(.bottom, 26) + .padding(.top, 10) + .overlay(alignment: .bottom) { + Divider() } - .onChange(of: viewModel.isLoading) { newValue in - if !newValue { - router.popToRoot() + .background(Constants.Colors.white) + } + + private var profileTabBar: some View { + HStack { + // Listings tab + Button { + withAnimation { + listingViewIsPresented = true + } + } label: { + HStack(spacing: 8) { + Image("listing") + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + + Text("(\(viewModel.externalUserPosts.count))") + .font(Constants.Fonts.body2) + } + .foregroundColor(listingViewIsPresented ? Constants.Colors.resellPurple : Constants.Colors.inactiveGray) + } + + Spacer() + + // Reviews tab + Button { + withAnimation { + listingViewIsPresented = false + } + } label: { + HStack(spacing: 8) { + Image(systemName: "star.fill") + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + + Text(viewModel.externalUser?.stars ?? "0") + .font(Constants.Fonts.body2) + .fontWeight(.medium) + + Text(" (\(viewModel.externalUser?.numReviews ?? 0))") + .font(Constants.Fonts.body2) + } + .foregroundColor(listingViewIsPresented ? Constants.Colors.inactiveGray : Constants.Colors.resellPurple) } } + .padding(.horizontal, 48) + .padding(.vertical, 16) + .overlay(alignment: .bottom) { + GeometryReader { geo in + Rectangle() + .fill(Constants.Colors.resellPurple) + .frame(width: geo.size.width / 2, height: 2) + .offset(x: listingViewIsPresented ? 0 : geo.size.width / 2) + .animation(.easeInOut(duration: 0.2), value: listingViewIsPresented) + } + .frame(height: 2) + } + .overlay(alignment: .bottom) { + Divider() + } } private var profileImageView: some View { - KFImage(viewModel.user?.photoUrl) + KFImage(viewModel.externalUser?.photoUrl) .cacheOriginalImage() .placeholder { ShimmerView() - .frame(width: 90, height: 90) + .frame(width: 67, height: 67) } .resizable() - .frame(width: 90, height: 90) + .frame(width: 67, height: 67) .clipShape(.circle) } @@ -144,9 +366,13 @@ struct ExternalProfileView: View { PurpleButton(isLoading: viewModel.isLoading,text: viewModel.sellerIsBlocked ? "Unblock" : "Block", horizontalPadding: 100) { if viewModel.sellerIsBlocked { - viewModel.unblockUser(id: userID) + Task { + try await viewModel.unblockUser(id: userID) + } } else { - viewModel.blockUser(id: userID) + Task{ + try await viewModel.blockUser(id: userID) + } } } @@ -163,4 +389,85 @@ struct ExternalProfileView: View { .padding(Constants.Spacing.horizontalPadding) } + private var unfollowSheetContent: some View { + VStack(spacing: 24) { + Spacer() + + ZStack(alignment: .bottomTrailing) { + KFImage(viewModel.externalUser?.photoUrl) + .cacheOriginalImage() + .placeholder { + ShimmerView() + .frame(width: 100, height: 100) + } + .resizable() + .frame(width: 100, height: 100) + .clipShape(.circle) + + ZStack { + Circle() + .fill(Constants.Colors.resellPurple) + .frame(width: 32, height: 32) + + Image(systemName: "minus") + .foregroundColor(.white) + .font(.system(size: 16, weight: .bold)) + } + .offset(x: 4, y: 4) + } + .padding(.top, 16) + + Text("Unfollow @\(viewModel.externalUser?.username ?? "user")") + .font(Constants.Fonts.h3) + .foregroundStyle(Constants.Colors.black) + + VStack(spacing: 16) { + Button { + Task { + do { + try await viewModel.unfollowUser(id: userID) + didShowUnfollowPopup = false + } catch { + NetworkManager.shared.logger.error("Error unfollowing user: \(error)") + } + } + } label: { + ZStack { + RoundedRectangle(cornerRadius: 36) + .foregroundStyle(Constants.Colors.resellPurple) + .frame(height: 56) + + if viewModel.isFollowLoading { + ProgressView() + .tint(.white) + } else { + Text("Yes, Unfollow") + .font(Constants.Fonts.title1) + .foregroundColor(.white) + } + } + } + .disabled(viewModel.isFollowLoading) + + Button { + didShowUnfollowPopup = false + } label: { + ZStack { + RoundedRectangle(cornerRadius: 25) + .foregroundStyle(Constants.Colors.wash) + .frame(height: 56) + + Text("No, Keep Following") + .font(Constants.Fonts.title1) + .foregroundColor(Constants.Colors.black) + } + } + } + .padding(.horizontal, 40) + + } + .frame(maxWidth: .infinity) + .background(Constants.Colors.white) + } + } diff --git a/Resell/Views/ProductDetails/FollowListView.swift b/Resell/Views/ProductDetails/FollowListView.swift new file mode 100644 index 0000000..288dd91 --- /dev/null +++ b/Resell/Views/ProductDetails/FollowListView.swift @@ -0,0 +1,242 @@ +// +// FollowListView.swift +// Resell +// +// Created on 1/2/26. +// + +import Kingfisher +import SwiftUI + +struct FollowListView: View { + + // MARK: - Properties + + @EnvironmentObject var router: Router + @State private var selectedTab: FollowListType + @State private var followers: [User] = [] + @State private var following: [User] = [] + @State private var followingStatus: [String: Bool] = [:] + @State private var isLoading: Bool = false + + let userID: String + let username: String + let initialTab: FollowListType + + init(userID: String, username: String, initialTab: FollowListType) { + self.userID = userID + self.username = username + self.initialTab = initialTab + self._selectedTab = State(initialValue: initialTab) + } + + // MARK: - UI + + var body: some View { + VStack(spacing: 0) { + customToolbar + + tabBar + + ScrollView { + LazyVStack(spacing: 0) { + if isLoading { + ProgressView() + .padding(.top, 40) + } else { + ForEach(selectedTab == .followers ? followers : following, id: \.firebaseUid) { user in + userRow(user: user) + } + } + } + } + + } +// .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Constants.Colors.white) + .toolbar(.hidden, for: .navigationBar) + .onAppear { + loadData() + } + } + + private var customToolbar: some View { + HStack { + Button { + router.pop() + } label: { + Image(systemName: "chevron.left") + .resizable() + .scaledToFit() + .frame(height: 20) + .foregroundStyle(Constants.Colors.black) + } + .frame(width: 24, alignment: .leading) + + Spacer() + + Text("@\(username)") + .font(Constants.Fonts.h3) + .foregroundStyle(Constants.Colors.black) + + Spacer() + + Color.clear + .frame(width: 24) + } + .frame(height: 44) + .padding(.horizontal, 24) + .background(Constants.Colors.white) + } + + private var tabBar: some View { + HStack { + Button { + withAnimation { + selectedTab = .followers + } + } label: { + Text("\(followers.count) Followers") + .font(Constants.Fonts.title1) + .foregroundColor(selectedTab == .followers ? Constants.Colors.black : Constants.Colors.inactiveGray) + } + + Spacer() + + Button { + withAnimation { + selectedTab = .following + } + } label: { + Text("\(following.count) Following") + .font(Constants.Fonts.title1) + .foregroundColor(selectedTab == .following ? Constants.Colors.black : Constants.Colors.inactiveGray) + } + } + .frame(height: 44) + .padding(.horizontal, 48) + .background(Constants.Colors.white) + .overlay(alignment: .bottom) { + GeometryReader { geo in + Rectangle() + .fill(Constants.Colors.resellPurple) + .frame(width: geo.size.width / 2, height: 2) + .offset(x: selectedTab == .followers ? 0 : geo.size.width / 2) + .animation(.easeInOut(duration: 0.2), value: selectedTab) + } + .frame(height: 2) + } + .overlay(alignment: .bottom) { + Divider() + } + } + + private func userRow(user: User) -> some View { + HStack(spacing: 12) { + Button { + router.push(.profile(user.firebaseUid)) + } label: { + HStack(spacing: 12) { + KFImage(user.photoUrl) + .cacheOriginalImage() + .placeholder { + Circle() + .fill(Constants.Colors.wash) + .frame(width: 50, height: 50) + } + .resizable() + .frame(width: 50, height: 50) + .clipShape(.circle) + + VStack(alignment: .leading, spacing: 4) { + Text(user.givenName) + .font(Constants.Fonts.title1) + .foregroundColor(Constants.Colors.black) + + Text("@\(user.username)") + .font(Constants.Fonts.body2) + .foregroundColor(Constants.Colors.secondaryGray) + } + } + } + + Spacer() + + if user.firebaseUid != GoogleAuthManager.shared.user?.firebaseUid { + followButton(for: user) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 12) + } + + private func followButton(for user: User) -> some View { + let isFollowing = followingStatus[user.firebaseUid] ?? false + + return Button { + Task { + await toggleFollow(for: user) + } + } label: { + Text(isFollowing ? "Following" : "Follow") + .font(Constants.Fonts.title3) + .foregroundColor(isFollowing ? Constants.Colors.resellPurple : .white) + .frame(width: 100, height: 36) + .background( + RoundedRectangle(cornerRadius: 18) + .fill(isFollowing ? Constants.Colors.white : Constants.Colors.resellPurple) + ) + .overlay( + RoundedRectangle(cornerRadius: 18) + .stroke(Constants.Colors.resellPurple, lineWidth: isFollowing ? 1.5 : 0) + ) + } + } + + // MARK: - Functions + + private func loadData() { + Task { + isLoading = true + defer { isLoading = false } + + do { + async let fetchedFollowers = NetworkManager.shared.getFollowers(id: userID).users + async let fetchedFollowing = NetworkManager.shared.getFollowing(id: userID).users + + followers = try await fetchedFollowers + following = try await fetchedFollowing + + // Check which users the current user is following + if let currentUserId = GoogleAuthManager.shared.user?.firebaseUid { + let currentUserFollowing = try await NetworkManager.shared.getFollowing(id: currentUserId).users + let followingIds = Set(currentUserFollowing.map { $0.firebaseUid }) + + for user in followers + following { + followingStatus[user.firebaseUid] = followingIds.contains(user.firebaseUid) + } + } + } catch { + NetworkManager.shared.logger.error("Error loading follow data: \(error)") + } + } + } + + private func toggleFollow(for user: User) async { + let isCurrentlyFollowing = followingStatus[user.firebaseUid] ?? false + + do { + if isCurrentlyFollowing { + let unfollow = UnfollowUserBody(userId: user.firebaseUid) + _ = try await NetworkManager.shared.unfollowUser(unfollow: unfollow) + followingStatus[user.firebaseUid] = false + } else { + let follow = FollowUserBody(userId: user.firebaseUid) + _ = try await NetworkManager.shared.followUser(follow: follow) + followingStatus[user.firebaseUid] = true + } + } catch { + NetworkManager.shared.logger.error("Error toggling follow: \(error)") + } + } +} diff --git a/Resell/Views/ProductDetails/ProductDetailsView.swift b/Resell/Views/ProductDetails/ProductDetailsView.swift index 4d64720..972840e 100644 --- a/Resell/Views/ProductDetails/ProductDetailsView.swift +++ b/Resell/Views/ProductDetails/ProductDetailsView.swift @@ -7,6 +7,7 @@ import Kingfisher import SwiftUI +import UserNotifications struct ProductDetailsView: View { @@ -16,7 +17,8 @@ struct ProductDetailsView: View { @EnvironmentObject var router: Router @StateObject private var viewModel = ProductDetailsViewModel() - @State var id: String + + var post: Post // MARK: - UI @@ -42,13 +44,15 @@ struct ProductDetailsView: View { } .ignoresSafeArea() - buttonGradientView + if !viewModel.isMyPost() { + buttonGradientView + } if viewModel.didShowOptionsMenu { OptionsMenuView(showMenu: $viewModel.didShowOptionsMenu, didShowDeleteView: $viewModel.didShowDeleteView, options: { var options: [Option] = [ .share(url: URL(string: "https://www.google.com")!, itemName: viewModel.item?.title ?? ""), - .report(type: "Post", id: id) + .report(type: "Post", id: post.id) ] if viewModel.isUserPost() { options.append(.delete) @@ -91,10 +95,8 @@ struct ProductDetailsView: View { deletePostView .background(Constants.Colors.white) } - .loadingView(isLoading: viewModel.isLoading) .onAppear { - viewModel.getPost(id: id) - viewModel.getSimilarPosts(id: id) + viewModel.setPost(post: post) withAnimation { mainViewModel.hidesTabBar = true @@ -192,7 +194,7 @@ struct ProductDetailsView: View { Spacer() - Text("$\(viewModel.item?.originalPrice ?? "")") + Text("$\(viewModel.item?.originalPrice ?? "0")") .font(Constants.Fonts.h2) .foregroundStyle(Constants.Colors.black) } @@ -200,7 +202,7 @@ struct ProductDetailsView: View { private var sellerProfileView: some View { Button { - router.push(.profile(viewModel.item?.user?.id ?? "")) + router.push(.profile(viewModel.item?.user?.firebaseUid ?? "")) } label: { HStack { KFImage(viewModel.item?.user?.photoUrl) @@ -245,30 +247,31 @@ struct ProductDetailsView: View { } } else { ForEach(viewModel.similarPosts, id: \.self.id) { item in - KFImage(item.images.first) - .placeholder { - ShimmerView() - .frame(width: imageSize, height: imageSize) - .clipShape(.rect(cornerRadius: 10)) - } - .resizable() - .scaledToFill() - .frame(width: imageSize, height: imageSize) - .clipShape(.rect(cornerRadius: 10)) - .onTapGesture { - changeItem(postID: item.id) - } + let url = URL(string: item.images.first ?? "") + if let url = url { + KFImage(url) + .placeholder { + ShimmerView() + .frame(width: imageSize, height: imageSize) + .clipShape(.rect(cornerRadius: 10)) + } + .resizable() + .scaledToFill() + .frame(width: imageSize, height: imageSize) + .clipShape(.rect(cornerRadius: 10)) + .onTapGesture { + changeItem(post: item) + } + } } } } } } - private func changeItem(postID: String) { - id = postID + private func changeItem(post: Post) { viewModel.clear() - viewModel.getPost(id: postID) - viewModel.getSimilarPosts(id: postID) + viewModel.setPost(post: post) withAnimation { mainViewModel.hidesTabBar = true @@ -282,16 +285,24 @@ struct ProductDetailsView: View { } return false }) { - router.path[existingIndex] = .productDetails(postID) + router.path[existingIndex] = .productDetails(post) } else { - router.push(.productDetails(postID)) + router.push(.productDetails(post)) } } private var buttonGradientView: some View { VStack { PurpleButton(text: "Contact Seller") { - // TODO: Chat with Seller + if let item = viewModel.item, let user = item.user, let me = GoogleAuthManager.shared.user { + let chatInfo = ChatInfo( + listing: item, + buyer: me, + seller: user + ) + + navigateToChats(chatInfo: chatInfo) + } } } .frame(width: UIScreen.width, height: 50) @@ -304,22 +315,91 @@ struct ProductDetailsView: View { ], startPoint: .top, endPoint: .bottom) ) } - + + // TODO: FIX + + func sendNotification() { + let content = UNMutableNotificationContent() + content.title = "New Post" + content.subtitle = "Testing bookmarks" + content.sound = UNNotificationSound.default + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 2, repeats: false) + + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + print("Error sending notification: \(error.localizedDescription)") + } else { + print("Push notification sent successfully!") + } + } + } + + func requestNotificationAuthorization() { + @AppStorage("isNotificationAuthorized") var isNotificationAuthorized = false + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { (granted, error) in + if let error = error { + print("Error sending notification: \(error.localizedDescription)") + return + } + + if granted { + isNotificationAuthorized = true + print("Notification permission granted.") + } else { + isNotificationAuthorized = false + print("Notification permission denied.") + } + } + } + + @AppStorage("isNotificationAuthorized") var isNotificationAuthorized = false + private var saveButton: some View { - Button { - viewModel.isSaved.toggle() - viewModel.updateItemSaved() - } label: { - ZStack { - Circle() - .frame(width: 72, height: 72) - .foregroundStyle(Constants.Colors.white) - .opacity(viewModel.isSaved ? 1.0 : 0.9) - .shadow(radius: 2) - - Image(viewModel.isSaved ? "saved.fill" : "saved") - .resizable() - .frame(width: 21, height: 27) + if isNotificationAuthorized { + Button { + viewModel.isSaved.toggle() + if viewModel.isSaved { + // items saved += 1 + } else { + // items saved -= 1 + } + viewModel.updateItemSaved() + sendNotification() + //viewModel.createNewNotif() + } label: { + ZStack { + Circle() + .frame(width: 72, height: 72) + .foregroundStyle(Constants.Colors.white) + .opacity(viewModel.isSaved ? 1.0 : 0.9) + .shadow(radius: 2) + + Image(viewModel.isSaved ? "saved.fill" : "saved") + .resizable() + .frame(width: 21, height: 27) + } + } + } else { + Button { + viewModel.isSaved.toggle() + viewModel.updateItemSaved() + requestNotificationAuthorization() + print("Test1") + } label: { + ZStack { + Circle() + .frame(width: 72, height: 72) + .foregroundStyle(Constants.Colors.white) + .opacity(viewModel.isSaved ? 1.0 : 0.9) + .shadow(radius: 2) + + Image(viewModel.isSaved ? "saved.fill" : "saved") + .resizable() + .frame(width: 21, height: 27) + } } } } @@ -354,4 +434,20 @@ struct ProductDetailsView: View { .presentationCornerRadius(25) .presentationBackground(Constants.Colors.white) } + + // MARK: - Functions + + private func navigateToChats(chatInfo: ChatInfo) { + if let existingIndex = router.path.firstIndex(where: { + if case .messages = $0 { + return true + } + return false + }) { + router.path[existingIndex] = .messages(chatInfo: chatInfo) + router.popTo(router.path[existingIndex]) + } else { + router.push(.messages(chatInfo: chatInfo)) + } + } } diff --git a/Resell/Views/Settings/BlockedUsersView.swift b/Resell/Views/Settings/BlockedUsersView.swift index 9d7ff0c..375dfe1 100644 --- a/Resell/Views/Settings/BlockedUsersView.swift +++ b/Resell/Views/Settings/BlockedUsersView.swift @@ -26,7 +26,7 @@ struct BlockedUsersView: View { var body: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 12) { - ForEach(blockedUsers, id: \.self.id) { user in + ForEach(blockedUsers, id: \.self.firebaseUid) { user in blockedUserInfoView(user: user) } .padding(.horizontal, Constants.Spacing.horizontalPadding) @@ -90,7 +90,7 @@ struct BlockedUsersView: View { } } .onTapGesture { - router.push(.profile(user.id)) + router.push(.profile(user.firebaseUid)) } } @@ -141,20 +141,19 @@ struct BlockedUsersView: View { // MARK: - Functions private func getBlockedUsers() { + isLoading = true + Task { - isLoading = true + defer { Task { @MainActor in withAnimation { isLoading = false } } } do { - if let userID = UserSessionManager.shared.userID { - blockedUsers = try await NetworkManager.shared.getBlockedUsers(id: userID).users + if let user = GoogleAuthManager.shared.user { + blockedUsers = try await NetworkManager.shared.getBlockedUsers(id: user.firebaseUid).users } else { - UserSessionManager.shared.logger.error("Error in BlockedUsersView: userID not found.") + GoogleAuthManager.shared.logger.error("Error in \(#file) \(#function): User not available.") } - - isLoading = false } catch { - NetworkManager.shared.logger.error("Error in BlockedUsersView: \(error.localizedDescription)") - isLoading = false + NetworkManager.shared.logger.error("Error in \(#file) \(#function): \(error)") } } } @@ -162,14 +161,14 @@ struct BlockedUsersView: View { private func unblockUser() { Task { do { - if let id = selectedUser?.id { + if let id = selectedUser?.firebaseUid { let unblocked = UnblockUserBody(unblocked: id) let _ = try await NetworkManager.shared.unblockUser(unblocked: unblocked) getBlockedUsers() } } catch { - NetworkManager.shared.logger.error("Error in BlockedUsersView: \(error.localizedDescription)") + NetworkManager.shared.logger.error("Error in BlockedUsersView: \(error)") } } } diff --git a/Resell/Views/Settings/EditProfileView.swift b/Resell/Views/Settings/EditProfileView.swift index 73bf023..651906b 100644 --- a/Resell/Views/Settings/EditProfileView.swift +++ b/Resell/Views/Settings/EditProfileView.swift @@ -5,6 +5,7 @@ // Created by Richie Sun on 11/8/24. // +import PhotosUI import SwiftUI struct EditProfileView: View { @@ -12,7 +13,15 @@ struct EditProfileView: View { // MARK: - Properties @EnvironmentObject var router: Router - @StateObject private var viewModel = EditProfileViewModel() + @ObservedObject private var profileManager = CurrentUserProfileManager.shared + + @State private var editedUsername: String = "" + @State private var editedBio: String = "" + @State private var editedVenmo: String = "" + @State private var editedProfilePic: UIImage = UIImage(named: "emptyProfile")! + + @State private var selectedItem: PhotosPickerItem? = nil + @State private var didShowPhotosPicker: Bool = false // MARK: - UI @@ -38,7 +47,7 @@ struct EditProfileView: View { ToolbarItem(placement: .topBarTrailing) { Button { - viewModel.updateProfile() + saveProfile() } label: { Text("Save") .font(Constants.Fonts.title1) @@ -46,11 +55,11 @@ struct EditProfileView: View { } } } - .loadingView(isLoading: viewModel.isLoading) + .loadingView(isLoading: profileManager.isLoading) .onAppear { - viewModel.getUser() + loadCurrentValues() } - .onChange(of: viewModel.isLoading) { newValue in + .onChange(of: profileManager.isLoading) { newValue in if !newValue { router.popToRoot() } @@ -60,24 +69,24 @@ struct EditProfileView: View { private var profileImageView: some View { ZStack(alignment: .bottomTrailing) { - Image(uiImage: viewModel.selectedImage) + Image(uiImage: editedProfilePic) .resizable() .frame(width: 132, height: 132) .background(Constants.Colors.stroke) .clipShape(.circle) Button { - viewModel.didShowPhotosPicker = true + didShowPhotosPicker = true } label: { Image("pencil.circle") .shadow(radius: 2) } .buttonStyle(.borderless) } - .photosPicker(isPresented: $viewModel.didShowPhotosPicker, selection: $viewModel.selectedItem, matching: .images, photoLibrary: .shared()) - .onChange(of: viewModel.selectedItem) { newItem in + .photosPicker(isPresented: $didShowPhotosPicker, selection: $selectedItem, matching: .images, photoLibrary: .shared()) + .onChange(of: selectedItem) { newItem in Task { - await viewModel.updateUserProfile(newItem: newItem) + await updateProfileImage(newItem: newItem) } } } @@ -91,7 +100,7 @@ struct EditProfileView: View { Spacer() - Text("\(viewModel.user?.givenName ?? "") \(viewModel.user?.familyName ?? "")") + Text("\(profileManager.givenName) \(GoogleAuthManager.shared.user?.familyName ?? "")") .font(Constants.Fonts.body1) .foregroundStyle(Constants.Colors.black) } @@ -103,7 +112,7 @@ struct EditProfileView: View { Spacer() - Text(viewModel.user?.netid ?? "") + Text(GoogleAuthManager.shared.user?.netid ?? "") .font(Constants.Fonts.body1) .foregroundStyle(Constants.Colors.black) } @@ -118,7 +127,7 @@ struct EditProfileView: View { .font(Constants.Fonts.title1) .foregroundStyle(Constants.Colors.black) - TextField("", text: $viewModel.username) + TextField("", text: $editedUsername) .font(Constants.Fonts.body1) .foregroundStyle(Constants.Colors.black) .multilineTextAlignment(.trailing) @@ -133,7 +142,7 @@ struct EditProfileView: View { .font(Constants.Fonts.title1) .foregroundStyle(Constants.Colors.black) - TextField("", text: $viewModel.venmoLink) + TextField("", text: $editedVenmo) .font(Constants.Fonts.body1) .foregroundStyle(Constants.Colors.black) .multilineTextAlignment(.trailing) @@ -148,7 +157,7 @@ struct EditProfileView: View { .font(Constants.Fonts.title1) .foregroundStyle(Constants.Colors.black) - TextEditor(text: $viewModel.bio) + TextEditor(text: $editedBio) .font(Constants.Fonts.body1) .foregroundColor(Constants.Colors.black) .padding(.horizontal, 16) @@ -157,9 +166,9 @@ struct EditProfileView: View { .background(Constants.Colors.wash) .cornerRadius(10) .frame(height: 100) - .onChange(of: viewModel.bio) { newText in + .onChange(of: editedBio) { newText in if newText.count > 1000 { - viewModel.bio = String(newText.prefix(1000)) + editedBio = String(newText.prefix(1000)) } } } @@ -167,6 +176,40 @@ struct EditProfileView: View { .padding(.top, 40) .padding(.horizontal, Constants.Spacing.horizontalPadding) } + + // MARK: - Functions + + private func loadCurrentValues() { + // Load current profile data into editable state + editedUsername = profileManager.username + editedBio = profileManager.bio + editedVenmo = profileManager.venmoHandle + editedProfilePic = profileManager.profilePic + } + + private func saveProfile() { + Task { + do { + try await profileManager.updateProfile( + username: editedUsername, + bio: editedBio, + venmoHandle: editedVenmo, + profileImage: editedProfilePic + ) + } catch { + NetworkManager.shared.logger.error("Error in EditProfileView.saveProfile: \(error)") + } + } + } + + private func updateProfileImage(newItem: PhotosPickerItem?) async { + guard let newItem = newItem else { return } + + if let data = try? await newItem.loadTransferable(type: Data.self), + let image = UIImage(data: data) { + editedProfilePic = image + } + } } #Preview { diff --git a/Resell/Views/Settings/NotificationsSettingsView.swift b/Resell/Views/Settings/NotificationsSettingsView.swift index 9d976be..3bd37c4 100644 --- a/Resell/Views/Settings/NotificationsSettingsView.swift +++ b/Resell/Views/Settings/NotificationsSettingsView.swift @@ -26,10 +26,17 @@ struct NotificationsSettingsView: View { get: { allNotificationsEnabled }, set: { paused in mainViewModel.toggleAllNotifications(paused: paused) + handleNotificationToggle(chatNotificationsDisabled: !mainViewModel.chatNotificationsEnabled) } )) - notificationSetting(name: "Chat Notifications", isOn: $mainViewModel.chatNotificationsEnabled) + notificationSetting(name: "Chat Notifications", isOn: Binding( + get: { mainViewModel.chatNotificationsEnabled }, + set: { enabled in + mainViewModel.chatNotificationsEnabled = enabled + handleNotificationToggle(chatNotificationsDisabled: !enabled) + } + )) notificationSetting(name: "New Listings", isOn: $mainViewModel.newListingsEnabled) @@ -39,7 +46,6 @@ struct NotificationsSettingsView: View { .padding(.top, 40) .background(Constants.Colors.white) .toolbar { - ToolbarItem(placement: .principal) { Text("Notification Preferences") .font(Constants.Fonts.h3) @@ -59,4 +65,8 @@ struct NotificationsSettingsView: View { .tint(Constants.Colors.resellPurple) } + /// Handles toggling notifications and updates Firestore as needed + private func handleNotificationToggle(chatNotificationsDisabled: Bool) { + // TODO: NETWORKING REQUEST TO TOGGLE NOTIFICATIONS FOR THE USER + } } diff --git a/Resell/Views/Settings/SettingsView.swift b/Resell/Views/Settings/SettingsView.swift index 6b59714..4b50653 100644 --- a/Resell/Views/Settings/SettingsView.swift +++ b/Resell/Views/Settings/SettingsView.swift @@ -41,10 +41,6 @@ struct SettingsView: View { settingsRow(isRed: true, title: "Delete Account", icon: "") { withAnimation { viewModel.didShowDeleteAccountView = true } } - case .notifications: - settingsRow(title: "Notifications", icon: "notifications") { - router.push(.notifications) - } case .sendFeedback: settingsRow(title: "Send Feedback", icon: "feedback") { router.push(.feedback) @@ -125,11 +121,9 @@ struct SettingsView: View { .padding(.top, 48) PurpleButton(isAlert: true, text: "Logout", horizontalPadding: 70) { - UserSessionManager.shared.logout() viewModel.logout() + NotificationCenter.default.post(name: Constants.Notifications.LogoutUser, object: nil) router.popToRoot() - mainViewModel.selection = 0 - mainViewModel.userDidLogin = false } Button{ diff --git a/Resell/Views/ViewModifiers/EmptyStateModifier.swift b/Resell/Views/ViewModifiers/EmptyStateModifier.swift new file mode 100644 index 0000000..5cd7547 --- /dev/null +++ b/Resell/Views/ViewModifiers/EmptyStateModifier.swift @@ -0,0 +1,64 @@ +// +// EmptyState.swift +// Resell +// +// Created by Richie Sun on 11/7/24. +// + +import SwiftUI + +/// A reusable view modifier that overlays an empty state view with a title and message when a specified condition is met. +struct EmptyStateModifier: ViewModifier { + + // MARK: - Properties + + /// Determines if the empty state overlay should be displayed. + let isEmpty: Bool + + /// The title displayed in the empty state view. + let title: String + + /// The descriptive text displayed in the empty state view. + let text: String + + // MARK: - ViewModifier + + func body(content: Content) -> some View { + ZStack { + content + + if isEmpty { + VStack(spacing: 16) { + Spacer() + + Text(title) + .font(Constants.Fonts.h2) + .multilineTextAlignment(.center) + .foregroundStyle(Constants.Colors.black) + + Text(text) + .font(Constants.Fonts.body1) + .multilineTextAlignment(.center) + .foregroundStyle(Constants.Colors.secondaryGray) + + Spacer() + } + .frame(width: 300) + } + } + } + +} + +// MARK: - View Extension + +extension View { + + /// - Displays an overlay with a title and text when `isEmpty` is true. + /// - `title` is shown in a bold, large font . + /// - `text` is displayed in a regular font with center alignment. + func emptyState(isEmpty: Bool, title: String, text: String) -> some View { + self.modifier(EmptyStateModifier(isEmpty: isEmpty, title: title, text: text)) + } + +} diff --git a/Resell/Views/ViewModifiers/LoadingView.swift b/Resell/Views/ViewModifiers/LoadingView.swift index 1633398..b530187 100644 --- a/Resell/Views/ViewModifiers/LoadingView.swift +++ b/Resell/Views/ViewModifiers/LoadingView.swift @@ -17,16 +17,12 @@ struct LoadingViewModifier: ViewModifier { ZStack { content - if isLoading { - Color.black.opacity(0.2) - .ignoresSafeArea() - .transition(.opacity) - .animation(.easeInOut, value: isLoading) - - CustomProgressView(size: size) - .transition(.opacity) - .animation(.easeInOut, value: isLoading) - } + Color.black.opacity(0.2) + .ignoresSafeArea() + .opacity(isLoading ? 1 : 0) + + CustomProgressView(size: size) + .opacity(isLoading ? 1 : 0) } }