diff --git a/.DS_Store b/.DS_Store index 845cac1..b66ac23 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 6573cf5..182a7dc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ build/ ## Secrets Resell/Supporting/GoogleService-Info.plist +Resell/Supporting/resell-service.json Keys.xcconfig \ No newline at end of file diff --git a/Resell.xcodeproj/project.pbxproj b/Resell.xcodeproj/project.pbxproj index 4635392..fafc25a 100644 --- a/Resell.xcodeproj/project.pbxproj +++ b/Resell.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 2C0104E92D5D169A00742D04 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 2C0104E82D5D169A00742D04 /* Kingfisher */; }; + 2C0104EC2D5D17B200742D04 /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 2C0104EB2D5D17B200742D04 /* FirebaseFirestore */; }; + 2C0104EE2D5D17B200742D04 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 2C0104ED2D5D17B200742D04 /* FirebaseMessaging */; }; 2C02B3952CC031D00020DF90 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C02B3942CC031D00020DF90 /* ImagePicker.swift */; }; 2C02B3972CC0336B0020DF90 /* NewListingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C02B3962CC0336B0020DF90 /* NewListingViewModel.swift */; }; 2C02B3992CC040AE0020DF90 /* PriceInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C02B3982CC040AE0020DF90 /* PriceInputView.swift */; }; @@ -22,16 +25,11 @@ 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 */; }; @@ -48,7 +46,23 @@ 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 */; }; + 2C6FB1752CFAC91900B35FF8 /* Chat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6FB1742CFAC91900B35FF8 /* Chat.swift */; }; + 2C6FB1772CFACB2500B35FF8 /* FirestoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6FB1762CFACB2500B35FF8 /* FirestoreManager.swift */; }; + 2C6FB1792CFAD93D00B35FF8 /* FirebaseDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6FB1782CFAD93D00B35FF8 /* FirebaseDocument.swift */; }; + 2C6FB17C2CFADCE000B35FF8 /* TransactionSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6FB17B2CFADCE000B35FF8 /* TransactionSummary.swift */; }; + 2C6FB17E2CFADD5200B35FF8 /* ChatDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6FB17D2CFADD5200B35FF8 /* ChatDocument.swift */; }; + 2C6FB1802CFADD9100B35FF8 /* UserDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6FB17F2CFADD9100B35FF8 /* UserDocument.swift */; }; + 2C6FB1862CFC08D000B35FF8 /* AvailabilitySelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6FB1852CFC08D000B35FF8 /* AvailabilitySelectorView.swift */; }; + 2C6FB1882CFF8ECD00B35FF8 /* FirebaseNotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6FB1872CFF8ECD00B35FF8 /* FirebaseNotificationService.swift */; }; + 2C6FB18A2CFFBC9E00B35FF8 /* FCMNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6FB1892CFFBC9E00B35FF8 /* FCMNotification.swift */; }; + 2C6FB18C2CFFBE7C00B35FF8 /* GoogleAuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6FB18B2CFFBE7C00B35FF8 /* GoogleAuthManager.swift */; }; 2C7460892CEEE054004832F5 /* Report.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7460882CEEE054004832F5 /* Report.swift */; }; + 2C798A852D5CF8EF00266CA7 /* OAuth1 in Frameworks */ = {isa = PBXBuildFile; productRef = 2C798A842D5CF8EF00266CA7 /* OAuth1 */; }; + 2C798A872D5CF8EF00266CA7 /* OAuth2 in Frameworks */ = {isa = PBXBuildFile; productRef = 2C798A862D5CF8EF00266CA7 /* OAuth2 */; }; + 2C798A892D5CF8EF00266CA7 /* SwiftyBase64 in Frameworks */ = {isa = PBXBuildFile; productRef = 2C798A882D5CF8EF00266CA7 /* SwiftyBase64 */; }; + 2C798A8B2D5CF8EF00266CA7 /* TinyHTTPServer in Frameworks */ = {isa = PBXBuildFile; productRef = 2C798A8A2D5CF8EF00266CA7 /* TinyHTTPServer */; }; + 2C798A8E2D5CF91500266CA7 /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = 2C798A8D2D5CF91500266CA7 /* GoogleSignIn */; }; + 2C798A902D5CF91500266CA7 /* GoogleSignInSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 2C798A8F2D5CF91500266CA7 /* GoogleSignInSwift */; }; 2C9337462C92A66C00818C8E /* FilterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C9337452C92A66C00818C8E /* FilterButton.swift */; }; 2C9337482C92AAEE00818C8E /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C9337472C92AAEE00818C8E /* HomeViewModel.swift */; }; 2C93374A2C92AD2D00818C8E /* ProductsGalleryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C9337492C92AD2D00818C8E /* ProductsGalleryView.swift */; }; @@ -91,7 +105,6 @@ 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 */; }; @@ -105,6 +118,12 @@ 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 */; }; + 2CF3CC622D012611001B90B5 /* resell-service.json in Resources */ = {isa = PBXBuildFile; fileRef = 2CF3CC612D012611001B90B5 /* resell-service.json */; }; + 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 */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -161,6 +180,17 @@ 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 = ""; }; + 2C6FB1742CFAC91900B35FF8 /* Chat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chat.swift; sourceTree = ""; }; + 2C6FB1762CFACB2500B35FF8 /* FirestoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirestoreManager.swift; sourceTree = ""; }; + 2C6FB1782CFAD93D00B35FF8 /* FirebaseDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseDocument.swift; sourceTree = ""; }; + 2C6FB17B2CFADCE000B35FF8 /* TransactionSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionSummary.swift; sourceTree = ""; }; + 2C6FB17D2CFADD5200B35FF8 /* ChatDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDocument.swift; sourceTree = ""; }; + 2C6FB17F2CFADD9100B35FF8 /* UserDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDocument.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 = ""; }; + 2C6FB1892CFFBC9E00B35FF8 /* FCMNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FCMNotification.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 = ""; }; @@ -209,7 +239,6 @@ 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 = ""; }; @@ -223,6 +252,8 @@ 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 = ""; }; + 2CF3CC612D012611001B90B5 /* resell-service.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "resell-service.json"; sourceTree = ""; }; + 2CFE42712D4097CF007D503F /* Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -230,11 +261,19 @@ 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 */, + 2C798A902D5CF91500266CA7 /* GoogleSignInSwift in Frameworks */, + 2C798A872D5CF8EF00266CA7 /* OAuth2 in Frameworks */, + 2C0104E92D5D169A00742D04 /* Kingfisher in Frameworks */, + 2C798A852D5CF8EF00266CA7 /* OAuth1 in Frameworks */, + 2CF3CC7C2D017897001B90B5 /* OAuth2 in Frameworks */, + 2C0104EC2D5D17B200742D04 /* FirebaseFirestore in Frameworks */, + 2CF3CC7A2D017897001B90B5 /* OAuth1 in Frameworks */, + 2CF3CC802D017897001B90B5 /* TinyHTTPServer in Frameworks */, + 2C0104EE2D5D17B200742D04 /* FirebaseMessaging in Frameworks */, + 2C798A892D5CF8EF00266CA7 /* SwiftyBase64 in Frameworks */, + 2CF3CC7E2D017897001B90B5 /* SwiftyBase64 in Frameworks */, + 2C798A8B2D5CF8EF00266CA7 /* TinyHTTPServer in Frameworks */, + 2C798A8E2D5CF91500266CA7 /* GoogleSignIn in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -258,9 +297,7 @@ 2C02B3A02CC073340020DF90 /* Home */ = { isa = PBXGroup; children = ( - 2C9337532C935C9500818C8E /* ChatsView.swift */, 2C52E4F02C926C4B0042312C /* HomeView.swift */, - 2CDDF30D2CCD915E0061A564 /* MessagesView.swift */, 2C9337552C935CB600818C8E /* ProfileView.swift */, 2C9337512C935C8100818C8E /* SavedView.swift */, 2C41BB8D2CD97E3E00EFF69E /* SearchView.swift */, @@ -293,6 +330,7 @@ 2C3024782C91190A0057D3D9 /* Supporting */ = { isa = PBXGroup; children = ( + 2CF3CC612D012611001B90B5 /* resell-service.json */, 2C3024842C9221940057D3D9 /* GoogleService-Info.plist */, ); path = Supporting; @@ -305,14 +343,37 @@ name = Frameworks; sourceTree = ""; }; + 2C6FB17A2CFADCB700B35FF8 /* Firebase Models */ = { + isa = PBXGroup; + children = ( + 2C6FB1742CFAC91900B35FF8 /* Chat.swift */, + 2C6FB17D2CFADD5200B35FF8 /* ChatDocument.swift */, + 2C6FB1892CFFBC9E00B35FF8 /* FCMNotification.swift */, + 2C6FB1782CFAD93D00B35FF8 /* FirebaseDocument.swift */, + 2CFE42712D4097CF007D503F /* Image.swift */, + 2C6FB17B2CFADCE000B35FF8 /* TransactionSummary.swift */, + 2C6FB17F2CFADD9100B35FF8 /* UserDocument.swift */, + ); + path = "Firebase Models"; + sourceTree = ""; + }; + 2C6FB1842CFBC0D600B35FF8 /* Chats */ = { + isa = PBXGroup; + children = ( + 2C9337532C935C9500818C8E /* ChatsView.swift */, + 2CDDF30D2CCD915E0061A564 /* MessagesView.swift */, + ); + path = Chats; + sourceTree = ""; + }; 2C9337442C929B0600818C8E /* Models */ = { isa = PBXGroup; children = ( 2C16928C2CE41727009D2291 /* ErrorResponse.swift */, + 2C6FB17A2CFADCB700B35FF8 /* Firebase Models */, 2C16928E2CE43409009D2291 /* Feedback.swift */, 2C93374D2C92BE3000818C8E /* Item.swift */, 2CD7CAB82CE937B10056209E /* Listing.swift */, - 2CDDF30F2CCD99B70061A564 /* Message.swift */, 2C41BB842CD8811400EFF69E /* Post.swift */, 2C7460882CEEE054004832F5 /* Report.swift */, 2CF356162CDDC2110045A173 /* Request.swift */, @@ -346,6 +407,7 @@ 2C9B4CC92C8FB7B70029DF61 /* Resell */ = { isa = PBXGroup; children = ( + 2C6FB1832CFAEC9400B35FF8 /* Resell.entitlements */, 2CDCEE5D2CD6BE8D008DF5E8 /* API */, 2C9337442C929B0600818C8E /* Models */, 2C9B4CFF2C8FBC860029DF61 /* Info.plist */, @@ -434,6 +496,7 @@ children = ( 2CBC6B5E2CB75ACD00C842A4 /* MainTabView.swift */, 2C52E4F22C926CFE0042312C /* MainView.swift */, + 2C6FB1842CFBC0D600B35FF8 /* Chats */, 2C02B3A02CC073340020DF90 /* Home */, 2CBC6B6A2CBDF74700C842A4 /* NewListing */, 2C02B3A12CC0734F0020DF90 /* Onboarding */, @@ -449,6 +512,7 @@ 2C9B4D052C8FCAF20029DF61 /* Components */ = { isa = PBXGroup; children = ( + 2C6FB1852CFC08D000B35FF8 /* AvailabilitySelectorView.swift */, 2C41BB872CD90DF800EFF69E /* CachedImageView.swift */, 2CBC6B682CBDDC8100C842A4 /* CustomPageControlIndicatorView.swift */, 2C1692922CE43737009D2291 /* CustomProgressView.swift */, @@ -524,9 +588,12 @@ 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 = ""; @@ -568,11 +635,19 @@ ); name = Resell; packageProductDependencies = ( - 2C30246E2C90FFB40057D3D9 /* GoogleSignIn */, - 2C3024702C90FFB40057D3D9 /* GoogleSignInSwift */, - 2C3859E02CCD9FDE00DA20EA /* FirebaseCore */, - 2C3859E22CCD9FEA00DA20EA /* FirebaseFirestore */, - 2C41BB892CD90EA100EFF69E /* Kingfisher */, + 2CF3CC792D017897001B90B5 /* OAuth1 */, + 2CF3CC7B2D017897001B90B5 /* OAuth2 */, + 2CF3CC7D2D017897001B90B5 /* SwiftyBase64 */, + 2CF3CC7F2D017897001B90B5 /* TinyHTTPServer */, + 2C798A842D5CF8EF00266CA7 /* OAuth1 */, + 2C798A862D5CF8EF00266CA7 /* OAuth2 */, + 2C798A882D5CF8EF00266CA7 /* SwiftyBase64 */, + 2C798A8A2D5CF8EF00266CA7 /* TinyHTTPServer */, + 2C798A8D2D5CF91500266CA7 /* GoogleSignIn */, + 2C798A8F2D5CF91500266CA7 /* GoogleSignInSwift */, + 2C0104E82D5D169A00742D04 /* Kingfisher */, + 2C0104EB2D5D17B200742D04 /* FirebaseFirestore */, + 2C0104ED2D5D17B200742D04 /* FirebaseMessaging */, ); productName = Resell; productReference = 2C9B4CC72C8FB7B70029DF61 /* Resell.app */; @@ -647,9 +722,10 @@ ); mainGroup = 2C9B4CBE2C8FB7B70029DF61; packageReferences = ( - 2C9B4D112C90FD440029DF61 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */, - 2CDDF3112CCD9B530061A564 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, - 2C41BB862CD90D7300EFF69E /* XCRemoteSwiftPackageReference "Kingfisher" */, + 2C798A832D5CF8EF00266CA7 /* XCRemoteSwiftPackageReference "google-auth-library-swift" */, + 2C798A8C2D5CF91500266CA7 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */, + 2C0104E72D5D169A00742D04 /* XCRemoteSwiftPackageReference "Kingfisher" */, + 2C0104EA2D5D17B200742D04 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); productRefGroup = 2C9B4CC82C8FB7B70029DF61 /* Products */; projectDirPath = ""; @@ -667,6 +743,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2CF3CC622D012611001B90B5 /* resell-service.json in Resources */, 2C3024852C9221940057D3D9 /* GoogleService-Info.plist in Resources */, 2C9EAF702CF26DA00010A44C /* Rubik-Regular.ttf in Resources */, 2C9B4D0C2C90EF1D0029DF61 /* Launch Screen.storyboard in Resources */, @@ -699,8 +776,8 @@ buildActionMask = 2147483647; files = ( 2C02B39D2CC069530020DF90 /* NewListingImagesView.swift in Sources */, - 2CDDF3102CCD99B70061A564 /* Message.swift in Sources */, 2C52E4F12C926C4B0042312C /* HomeView.swift in Sources */, + 2C6FB1792CFAD93D00B35FF8 /* FirebaseDocument.swift in Sources */, 2C02B3972CC0336B0020DF90 /* NewListingViewModel.swift in Sources */, 2CDCEE642CD6D146008DF5E8 /* User.swift in Sources */, 2C9B4D0E2C90F54D0029DF61 /* LoginGradient.swift in Sources */, @@ -709,13 +786,16 @@ 2C4DD97B2C98CC4D0055D0AB /* VenmoView.swift in Sources */, 2CDCEE612CD6BEAD008DF5E8 /* APIClient.swift in Sources */, 2C41BB8E2CD97E3E00EFF69E /* SearchView.swift in Sources */, + 2C6FB1882CFF8ECD00B35FF8 /* FirebaseNotificationService.swift in Sources */, 2C9337502C92BF4400818C8E /* Array + Extensions.swift in Sources */, + 2C6FB1862CFC08D000B35FF8 /* AvailabilitySelectorView.swift in Sources */, 2C525B7D2CB1DE55007D5B8E /* NotificationsSettingsView.swift in Sources */, 2C6410942C9A70B400E4B390 /* String + Extensions.swift in Sources */, 2CDCEE662CD8708B008DF5E8 /* UserSessionManager.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 */, 2CD7CAB92CE937B10056209E /* Listing.swift in Sources */, 2CD6CA8C2CB48286005A4F78 /* PopupModal.swift in Sources */, @@ -725,6 +805,7 @@ 2C9337462C92A66C00818C8E /* FilterButton.swift in Sources */, 2C02B3952CC031D00020DF90 /* ImagePicker.swift in Sources */, 2CDCEE5F2CD6BE99008DF5E8 /* NetworkManager.swift in Sources */, + 2C6FB1802CFADD9100B35FF8 /* UserDocument.swift in Sources */, 2C9B4D042C8FC8250029DF61 /* LoginView.swift in Sources */, 2C93374C2C92B6A500818C8E /* UIImage + Extensions.swift in Sources */, 2CF356172CDDC2110045A173 /* Request.swift in Sources */, @@ -742,18 +823,23 @@ 2C525B812CB1F195007D5B8E /* SendFeedbackViewModel.swift in Sources */, 2C9337562C935CB600818C8E /* ProfileView.swift in Sources */, 2C16928D2CE41727009D2291 /* ErrorResponse.swift in Sources */, + 2C6FB1772CFACB2500B35FF8 /* FirestoreManager.swift in Sources */, 2CE4739E2CC4575F00BD7E2C /* ReportOptionsView.swift in Sources */, 2C9337482C92AAEE00818C8E /* HomeViewModel.swift in Sources */, 2C4DD9832C98E3110055D0AB /* View + Extensions.swift in Sources */, + 2C6FB1752CFAC91900B35FF8 /* Chat.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 */, + 2C6FB17C2CFADCE000B35FF8 /* TransactionSummary.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 */, 2CBC6B5F2CB75ACD00C842A4 /* MainTabView.swift in Sources */, @@ -766,6 +852,7 @@ 2C1692912CE4361C009D2291 /* LoadingView.swift in Sources */, 2C525B7F2CB1E884007D5B8E /* SendFeedbackView.swift in Sources */, 2CBC6B692CBDDC8100C842A4 /* CustomPageControlIndicatorView.swift in Sources */, + 2C6FB18A2CFFBC9E00B35FF8 /* FCMNotification.swift in Sources */, 2C4DD9812C98DC2D0055D0AB /* LabeledTextField.swift in Sources */, 2C18FFE62CA139D500564577 /* SettingsView.swift in Sources */, 2C9B4CF82C8FB84F0029DF61 /* Constants.swift in Sources */, @@ -944,6 +1031,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 +1039,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 +1051,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 +1065,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 +1073,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 +1085,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,57 +1211,101 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 2C41BB862CD90D7300EFF69E /* XCRemoteSwiftPackageReference "Kingfisher" */ = { + 2C0104E72D5D169A00742D04 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/onevcat/Kingfisher.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 8.1.0; + minimumVersion = 8.2.0; }; }; - 2C9B4D112C90FD440029DF61 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */ = { + 2C0104EA2D5D17B200742D04 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/google/GoogleSignIn-iOS.git"; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 8.0.0; + minimumVersion = 11.8.1; }; }; - 2CDDF3112CCD9B530061A564 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + 2C798A832D5CF8EF00266CA7 /* XCRemoteSwiftPackageReference "google-auth-library-swift" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; + repositoryURL = "git@github.com:googleapis/google-auth-library-swift.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 11.4.0; + minimumVersion = 0.5.3; + }; + }; + 2C798A8C2D5CF91500266CA7 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/google/GoogleSignIn-iOS.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 8.0.0; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 2C30246E2C90FFB40057D3D9 /* GoogleSignIn */ = { + 2C0104E82D5D169A00742D04 /* Kingfisher */ = { + isa = XCSwiftPackageProductDependency; + package = 2C0104E72D5D169A00742D04 /* XCRemoteSwiftPackageReference "Kingfisher" */; + productName = Kingfisher; + }; + 2C0104EB2D5D17B200742D04 /* FirebaseFirestore */ = { + isa = XCSwiftPackageProductDependency; + package = 2C0104EA2D5D17B200742D04 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseFirestore; + }; + 2C0104ED2D5D17B200742D04 /* FirebaseMessaging */ = { + isa = XCSwiftPackageProductDependency; + package = 2C0104EA2D5D17B200742D04 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseMessaging; + }; + 2C798A842D5CF8EF00266CA7 /* OAuth1 */ = { + isa = XCSwiftPackageProductDependency; + package = 2C798A832D5CF8EF00266CA7 /* XCRemoteSwiftPackageReference "google-auth-library-swift" */; + productName = OAuth1; + }; + 2C798A862D5CF8EF00266CA7 /* OAuth2 */ = { isa = XCSwiftPackageProductDependency; - package = 2C9B4D112C90FD440029DF61 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */; + package = 2C798A832D5CF8EF00266CA7 /* XCRemoteSwiftPackageReference "google-auth-library-swift" */; + productName = OAuth2; + }; + 2C798A882D5CF8EF00266CA7 /* SwiftyBase64 */ = { + isa = XCSwiftPackageProductDependency; + package = 2C798A832D5CF8EF00266CA7 /* XCRemoteSwiftPackageReference "google-auth-library-swift" */; + productName = SwiftyBase64; + }; + 2C798A8A2D5CF8EF00266CA7 /* TinyHTTPServer */ = { + isa = XCSwiftPackageProductDependency; + package = 2C798A832D5CF8EF00266CA7 /* XCRemoteSwiftPackageReference "google-auth-library-swift" */; + productName = TinyHTTPServer; + }; + 2C798A8D2D5CF91500266CA7 /* GoogleSignIn */ = { + isa = XCSwiftPackageProductDependency; + package = 2C798A8C2D5CF91500266CA7 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */; productName = GoogleSignIn; }; - 2C3024702C90FFB40057D3D9 /* GoogleSignInSwift */ = { + 2C798A8F2D5CF91500266CA7 /* GoogleSignInSwift */ = { isa = XCSwiftPackageProductDependency; - package = 2C9B4D112C90FD440029DF61 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */; + package = 2C798A8C2D5CF91500266CA7 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */; productName = GoogleSignInSwift; }; - 2C3859E02CCD9FDE00DA20EA /* FirebaseCore */ = { + 2CF3CC792D017897001B90B5 /* OAuth1 */ = { isa = XCSwiftPackageProductDependency; - package = 2CDDF3112CCD9B530061A564 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseCore; + productName = OAuth1; }; - 2C3859E22CCD9FEA00DA20EA /* FirebaseFirestore */ = { + 2CF3CC7B2D017897001B90B5 /* OAuth2 */ = { isa = XCSwiftPackageProductDependency; - package = 2CDDF3112CCD9B530061A564 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseFirestore; + productName = OAuth2; }; - 2C41BB892CD90EA100EFF69E /* Kingfisher */ = { + 2CF3CC7D2D017897001B90B5 /* SwiftyBase64 */ = { isa = XCSwiftPackageProductDependency; - package = 2C41BB862CD90D7300EFF69E /* XCRemoteSwiftPackageReference "Kingfisher" */; - productName = Kingfisher; + productName = SwiftyBase64; + }; + 2CF3CC7F2D017897001B90B5 /* TinyHTTPServer */ = { + isa = XCSwiftPackageProductDependency; + productName = TinyHTTPServer; }; /* 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..7d80adc 100644 --- a/Resell.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Resell.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f2cef11ec486ccaf23c1f80563c7ab9227aed47b3a58c19d86c5f57644c92c1d", + "originHash" : "3a10bb4aec24b5447b1945235a1a3b57b99c9edd72d94cf3eec11db0b7390735", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -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,17 +24,44 @@ "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" } }, { "identity" : "firebase-ios-sdk", "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/firebase-ios-sdk", + "location" : "https://github.com/firebase/firebase-ios-sdk.git", "state" : { - "revision" : "8328630971a8fdd8072b36bb22bef732eb15e1f0", - "version" : "11.4.0" + "revision" : "6318278e8e64d21f0fdcc69004395e4d34048aaf", + "version" : "11.8.1" + } + }, + { + "identity" : "google-auth-library-swift", + "kind" : "remoteSourceControl", + "location" : "git@github.com:googleapis/google-auth-library-swift.git", + "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" : "be0881ff728eca210ccb628092af400c086abda3", + "version" : "11.7.0" } }, { @@ -114,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher.git", "state" : { - "revision" : "c0940e241945e6378c01fbd45fd3815579d47ef5", - "version" : "8.1.0" + "revision" : "3db26ab625d194c38e68c1a40e43d1bc12743fe0", + "version" : "8.2.0" } }, { @@ -145,6 +172,33 @@ "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" : "c51907a839e63ebf0ba2076bba73dd96436bd1b9", + "version" : "2.81.0" + } + }, { "identity" : "swift-protobuf", "kind" : "remoteSourceControl", @@ -153,6 +207,15 @@ "revision" : "ebc7251dd5b37f627c93698e4374084d98409633", "version" : "1.28.2" } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "c8a44d836fe7913603e246acab7c528c2e780168", + "version" : "1.4.0" + } } ], "version" : 3 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/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..5e740e5 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..9db2b72 100644 --- a/Resell/API/APIClient.swift +++ b/Resell/API/APIClient.swift @@ -10,7 +10,7 @@ import SwiftUI protocol APIClient { - func get(url: URL) async throws -> T + func get(url: URL, isRefresh: Bool) async throws -> T func post(url: URL, body: U) async throws -> T diff --git a/Resell/API/FirebaseNotificationService.swift b/Resell/API/FirebaseNotificationService.swift new file mode 100644 index 0000000..7014126 --- /dev/null +++ b/Resell/API/FirebaseNotificationService.swift @@ -0,0 +1,187 @@ +// +// FirebaseNotificationService.swift +// Resell +// +// Created by Richie Sun on 12/3/24. +// + +import Foundation +import FirebaseMessaging +import UserNotifications +import UIKit + +class FirebaseNotificationService: NSObject, MessagingDelegate, UNUserNotificationCenterDelegate { + + // MARK: - Singleton Instance + + static let shared = FirebaseNotificationService() + var fcmRegToken: String = "" + + private let endpoint = "\(Keys.firebaseURL)/v1/projects/resell-e99a2/messages:send" + + // 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 { + FirestoreManager.shared.logger.error("Error requesting notifications permission: \(error.localizedDescription)") + } else if granted { + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + } else { + FirestoreManager.shared.logger.log("Notifications permission denied.") + } + } + case .authorized, .provisional: + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + case .denied: + FirestoreManager.shared.logger.log("Notifications permission denied. Cannot register for remote notifications.") + case .ephemeral: + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + FirestoreManager.shared.logger.log("App has ephemeral authorization for notifications.") + @unknown default: + break + } + } + } + + // MARK: - Get FCM Registration Token + + func getFCMRegToken() { + Messaging.messaging().token { token, error in + if let error = error { + FirestoreManager.shared.logger.error("Error fetching FCM registration token: \(error)") + } else if let token = token { + self.fcmRegToken = (token) + } + } + } + + // MARK: - Setup FCM Token + + func setupFCMToken() { + let messaging = Messaging.messaging() + guard let email = UserSessionManager.shared.email else { + UserSessionManager.shared.logger.error("Email not found") + return + } + + // Check if user has allowed notifications + UNUserNotificationCenter.current().getNotificationSettings { settings in + Task { + do { + let notificationsAllowed = settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional + + // Save the notification status to Firestore + try await FirestoreManager.shared.saveNotificationsEnabled(userEmail: email, notificationsEnabled: notificationsAllowed) + + // Get the FCM token from Firestore + let firestoreToken = try await FirestoreManager.shared.getUserFCMToken(email: email) + + // Get the device token from Firebase Messaging + guard let deviceToken = messaging.fcmToken else { + FirestoreManager.shared.logger.error("Device FCM token is missing.") + return + } + + // Save the device token to Firestore if it's not already there or if it has changed + if firestoreToken == nil || firestoreToken != deviceToken { + try await FirestoreManager.shared.saveDeviceToken(userEmail: email, deviceToken: deviceToken) + FirestoreManager.shared.logger.log("FCM token successfully added for \(email).") + } + } catch { + FirestoreManager.shared.logger.error("Error saving notification status or FCM token: \(error.localizedDescription)") + } + } + } + } + + // 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 + ) { + FirestoreManager.shared.logger.log("User interacted with notification: \(response.notification.request.content.userInfo)") + + if let navigationId = response.notification.request.content.userInfo["navigationId"] as? String { + navigateToScreen(with: navigationId) + } + + completionHandler() + } + + // MARK: - Show Notification + + func sendNotification( + title: String?, + body: String?, + recipientToken: String, + navigationId: String, + authToken: String + ) async throws { + guard let url = URL(string: endpoint) else { + throw URLError(.badURL) + } + + let notification = title != nil && body != nil ? FcmNotification(title: title!, body: body!) : nil + let notificationData = NotificationData(navigationId: navigationId) + let message = FcmMessage(token: recipientToken, notification: notification, data: notificationData) + let fcmBody = FcmBody(message: message) + + let requestBody = try JSONEncoder().encode(fcmBody) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + request.httpBody = requestBody + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw URLError(.badServerResponse) + } + + FirestoreManager.shared.logger.log("Notification sent successfully: \(String(data: data, encoding: .utf8) ?? "")") + } + + // 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..1839025 --- /dev/null +++ b/Resell/API/FirestoreManager.swift @@ -0,0 +1,615 @@ +// +// FirestoreManager.swift +// Resell +// +// Created by Richie Sun on 11/29/24. +// + +import Foundation +import FirebaseFirestore +import os +import SwiftUI + +class FirestoreManager { + + // MARK: - Singleton Instance + + static let shared = FirestoreManager() + let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.cornellappdev.Resell", category: "FirestoreManager") + + // MARK: - Properties + + private let firestore = Firestore.firestore() + + private let historyCollection = Firestore.firestore().collection("history") + private let chatsCollection = Firestore.firestore().collection("chats") + + private var listener: ListenerRegistration? + + // MARK: - User Functions + + // Check if a user is onboarded + func getUserOnboarded(email: String) async throws -> Bool { + do { + let document = try await firestore.collection("user").document(email).getDocument() + let user = try document.data(as: FirebaseDocument.self) + return user.onboarded + } catch { + logger.error("Error fetching onboarding status for \(email): \(error.localizedDescription)") + throw error + } + } + + // Fetch Venmo handle + func getVenmoHandle(email: String) async throws -> String { + do { + let document = try await firestore.collection("user").document(email).getDocument() + let user = try document.data(as: FirebaseDocument.self) + return user.venmo + } catch { + logger.error("Error fetching Venmo handle for \(email): \(error.localizedDescription)") + throw error + } + } + + // Fetch FCM Token + func getUserFCMToken(email: String) async throws -> String? { + do { + let document = try await firestore.collection("user").document(email).getDocument() + let user = try document.data(as: FirebaseDocument.self) + return user.fcmToken + } catch { + logger.error("Error fetching FCM token for \(email): \(error.localizedDescription)") + throw error + } + } + + // Fetch Notifications Enabled Status + func getNotificationsEnabled(email: String) async throws -> Bool { + do { + let document = try await firestore.collection("user").document(email).getDocument() + let user = try document.data(as: FirebaseDocument.self) + return user.notificationsEnabled + } catch { + logger.error("Error fetching notifications status for \(email): \(error.localizedDescription)") + throw error + } + } + + // Save Device Token + func saveDeviceToken(userEmail: String, deviceToken: String) async throws { + do { + try await firestore.collection("user").document(userEmail).updateData(["fcmToken": deviceToken]) + } catch { + logger.error("Error saving device token for \(userEmail): \(error.localizedDescription)") + throw error + } + } + + // Save Onboarding Status + func saveOnboarded(userEmail: String) async throws { + do { + try await firestore.collection("user").document(userEmail).updateData(["onboarded": true]) + } catch { + logger.error("Error saving onboarding status for \(userEmail): \(error.localizedDescription)") + throw error + } + } + + // Save Venmo Handle + func saveVenmo(userEmail: String, venmo: String) async throws { + do { + try await firestore.collection("user").document(userEmail).updateData(["venmo": venmo]) + } catch { + logger.error("Error saving Venmo handle for \(userEmail): \(error.localizedDescription)") + throw error + } + } + + // Save Notifications Enabled + func saveNotificationsEnabled(userEmail: String, notificationsEnabled: Bool) async throws { + do { + try await firestore.collection("user").document(userEmail).updateData(["notificationsEnabled": notificationsEnabled]) + } catch { + logger.error("Error saving notifications enabled status for \(userEmail): \(error.localizedDescription)") + throw error + } + } + + // MARK: - Chat Functions + + func getPurchaseChats(completion: @escaping ([ChatPreview]) -> Void) { + guard let userEmail = UserSessionManager.shared.email else { + UserSessionManager.shared.logger.error("Error in ChatsViewModel: User email not available.") + completion([]) + return + } + + let sellersQuery = historyCollection + .document(userEmail) + .collection("sellers") + + listener = sellersQuery.addSnapshotListener { [weak self] querySnapshot, error in + guard let self = self else { return } + + if let error = error { + logger.error("Error loading chat previews: \(error.localizedDescription)") + completion([]) + return + } + + guard let documents = querySnapshot?.documents else { + logger.log("No documents found.") + completion([]) + return + } + + var tempPurchases: [ChatPreview] = [] + + let group = DispatchGroup() + + for document in documents { + group.enter() + + let data = document.data() + let sellerId = document.documentID + + guard let sellerName = data["name"] as? String, + let image = data["image"] as? String, + let recentMessage = data["recentMessage"] as? String, + let recentSender = data["recentSender"] as? String, + let recentMessageTime = data["recentMessageTime"] as? String, + let viewed = data["viewed"] as? Bool, + let confirmedTime = data["confirmedTime"] as? String else { + group.leave() + continue + } + + let sellerHistoryRef = historyCollection + .document(sellerId) + .collection("buyers") + .document(userEmail) + + let itemsCollection = sellerHistoryRef.collection("items") + + itemsCollection.getDocuments { snapshot, error in + if let error = error { + self.logger.error("Error fetching items: \(error.localizedDescription)") + group.leave() + return + } + + let items = snapshot?.documents.compactMap { $0.data() } ?? [] + + let chatPreview = ChatPreview( + sellerName: sellerName, + email: sellerId, + recentItem: data["item"] as? [String: Any] ?? [:], + image: URL(string: image), + recentMessage: recentMessage, + recentSender: recentSender == userEmail ? 1 : 0, + viewed: viewed, + confirmedTime: confirmedTime, + proposedTime: data["proposedTime"] as? String, + proposedViewed: data["proposedViewed"] as? Bool ?? false, + recentMessageTime: recentMessageTime, + proposer: data["proposer"] as? String, + items: items + ) + + tempPurchases.append(chatPreview) + group.leave() + } + } + + group.notify(queue: .main) { + let sortedPurchases = tempPurchases.sorted(by: { $0.recentMessageTime > $1.recentMessageTime }) + completion(sortedPurchases) + } + } + } + + func getOfferChats(completion: @escaping ([ChatPreview]) -> Void) { + guard let userEmail = UserSessionManager.shared.email else { + UserSessionManager.shared.logger.error("Error in ChatsViewModel: User email not available.") + completion([]) + return + } + + let buyersQuery = historyCollection + .document(userEmail) + .collection("buyers") + + listener = buyersQuery.addSnapshotListener { [weak self] querySnapshot, error in + guard let self = self else { return } + + if let error = error { + logger.error("Error loading chat previews: \(error.localizedDescription)") + completion([]) + return + } + + guard let documents = querySnapshot?.documents else { + logger.log("No documents found.") + completion([]) + return + } + + var tempOffers: [ChatPreview] = [] + + let group = DispatchGroup() + + for document in documents { + group.enter() + + let data = document.data() + let buyerId = document.documentID + + guard let buyerName = data["name"] as? String, + let image = data["image"] as? String, + let recentMessage = data["recentMessage"] as? String, + let recentSender = data["recentSender"] as? String, + let recentMessageTime = data["recentMessageTime"] as? String, + let viewed = data["viewed"] as? Bool, + let confirmedTime = data["confirmedTime"] as? String else { + group.leave() + continue + } + + let buyerHistoryRef = historyCollection + .document(buyerId) + .collection("sellers") + .document(userEmail) + + let itemsCollection = buyerHistoryRef.collection("items") + + itemsCollection.getDocuments { snapshot, error in + if let error = error { + self.logger.error("Error fetching items: \(error.localizedDescription)") + group.leave() + return + } + + let items = snapshot?.documents.compactMap { $0.data() } ?? [] + + let chatPreview = ChatPreview( + sellerName: buyerName, + email: buyerId, + recentItem: data["item"] as? [String: Any] ?? [:], + image: URL(string: image), + recentMessage: recentMessage, + recentSender: recentSender == userEmail ? 1 : 0, + viewed: viewed, + confirmedTime: confirmedTime, + proposedTime: data["proposedTime"] as? String, + proposedViewed: data["proposedViewed"] as? Bool ?? false, + recentMessageTime: recentMessageTime, + proposer: data["proposer"] as? String, + items: items + ) + + tempOffers.append(chatPreview) + group.leave() + } + } + + group.notify(queue: .main) { + let sortedOffers = tempOffers.sorted(by: { $0.recentMessageTime > $1.recentMessageTime }) + completion(sortedOffers) + } + } + } + + func updateChatViewedStatus(chatType: String, userEmail: String, chatId: String, isViewed: Bool) { + let collectionType = chatType == "Purchases" ? "sellers" : "buyers" + let chatDocument = historyCollection.document(userEmail).collection(collectionType).document(chatId) + + chatDocument.updateData(["viewed": isViewed]) { error in + if let error = error { + FirestoreManager.shared.logger.error("Error updating chat viewed status: \(error.localizedDescription)") + } else { + FirestoreManager.shared.logger.log("Successfully updated chat viewed status for chat \(chatId).") + } + } + } + + + /// Stop listening for updates + func stopListening() { + listener?.remove() + } + + // Fetch Buyer History + func getBuyerHistory(email: String) async throws -> [TransactionSummary] { + do { + let documents = try await historyCollection.document(email).collection("buyers").getDocuments() + return documents.documents.compactMap { try? $0.data(as: TransactionSummary.self) } + } catch { + logger.error("Error fetching buyer history for \(email): \(error.localizedDescription)") + throw error + } + } + + // Fetch Seller History + func getSellerHistory(email: String) async throws -> [TransactionSummary] { + do { + let documents = try await historyCollection.document(email).collection("sellers").getDocuments() + return documents.documents.compactMap { try? $0.data(as: TransactionSummary.self) } + } catch { + logger.error("Error fetching seller history for \(email): \(error.localizedDescription)") + throw error + } + } + + // Subscribe to Chat Updates + func subscribeToChat( + buyerEmail: String, + sellerEmail: String, + onSnapshotUpdate: @escaping ([ChatDocument]) -> Void + ) { + // Remove any existing listener + listener?.remove() + + // Reference Firestore collection + let chatDocRef = firestore.collection("chats") + .document(buyerEmail) + .collection(sellerEmail) + .order(by: "createdAt", descending: false) + + // Add snapshot listener + listener = chatDocRef.addSnapshotListener { snapshot, error in + if let error = error { + // Log Firestore error + self.logger.error("Firestore subscription error: \(error.localizedDescription)") + return + } + + // Ensure snapshot exists + guard let snapshot = snapshot else { + self.logger.error("Firestore subscription returned no data.") + return + } + + // Parse snapshot documents manually + var messages: [ChatDocument] = snapshot.documents.compactMap { document in + let data = document.data() + + // Parse user + let userMap = data["user"] as? [String: Any] + let user = userMap.flatMap { + UserDocument( + _id: $0["_id"] as? String ?? "", + avatar: $0["avatar"] as? URL, + name: $0["name"] as? String ?? "" + ) + } + + // Parse product + let productMap = data["product"] as? [String: Any] + let product = productMap.flatMap { + Post( + id: $0["id"] as? String ?? "", + title: $0["title"] as? String ?? "", + description: $0["description"] as? String ?? "", + categories: $0["categories"] as? [String] ?? [], + originalPrice: $0["price"] as? String ?? "", + + alteredPrice: $0["altered_price"] as? String ?? "", + images: $0["images"] as? [URL] ?? [], + created: $0["created"] as? String ?? "", + location: $0["location"] as? String ?? "", + archive: ($0["archive"] as? Bool) ?? false, + user: nil + ) + } + + // Parse availability + let availabilityArray = data["availability"] as? [[String: Any]] + let availability = availabilityArray.flatMap { array in + AvailabilityDocument(availabilities: array.compactMap { availabilityItem in + guard let startDate = availabilityItem["startDate"] as? Timestamp, + let id = availabilityItem["id"] as? Int, + let color = availabilityItem["color"] as? String else { + return nil + } + return AvailabilityBlock(startDate: startDate, color: color, id: id) + }) + } + + // Parse meeting info + let meetingInfoMap = data["meetingInfo"] as? [String: Any] + let meetingInfo = meetingInfoMap.flatMap { + MeetingInfo( + state: $0["state"] as? String ?? "", + proposeTime: $0["proposeTime"] as? String ?? "", + proposer: $0["proposer"] as? String, + canceler: $0["canceler"] as? String, + mostRecent: $0["mostRecent"] as? Bool ?? false + ) + } + + // Create ChatDocument manually + return ChatDocument( + _id: data["_id"] as? String ?? "", + createdAt: data["createdAt"] as? Timestamp ?? Timestamp(seconds: 0, nanoseconds: 0), + user: user ?? UserDocument(_id: "", avatar: nil, name: ""), + availability: availability, + product: product, + image: data["image"] as? String ?? "", + text: data["text"] as? String ?? "", + meetingInfo: meetingInfo + ) + } + + messages = messages.sorted { $0.createdAt.dateValue() < $1.createdAt.dateValue() } + + // Pass messages to the callback + onSnapshotUpdate(messages) + } + } + + // Send Text Message + func sendChatMessage( + buyerEmail: String, + sellerEmail: String, + chatDocument: ChatDocumentSendable + ) async throws { + let chatRef = firestore.collection("chats") + .document(buyerEmail) + .collection(sellerEmail) + var data = try chatDocument.toFirebaseDictionary() + + // Convert availabilities to a Firestore-friendly format + if var availabilities = data["availability"] as? [[String: Any]] { + availabilities = availabilities.map { availability in + var updatedAvailability = availability + + // Convert startDate to Timestamp + if let startDate = availability["startDate"] as? [String: Any], + let seconds = startDate["seconds"] as? Int64, + let nanoseconds = startDate["nanoseconds"] as? Int32 { + updatedAvailability["startDate"] = Timestamp(seconds: seconds, nanoseconds: nanoseconds) + } + + // Convert endDate to Timestamp + if let endDate = availability["endDate"] as? [String: Any], + let seconds = endDate["seconds"] as? Int64, + let nanoseconds = endDate["nanoseconds"] as? Int32 { + updatedAvailability["endDate"] = Timestamp(seconds: seconds, nanoseconds: nanoseconds) + } + + return updatedAvailability + } + data["availability"] = availabilities + } + + data["createdAt"] = Timestamp() + try await chatRef.addDocument(data: data) + } + + + // Send Product Message + func sendProductMessage( + buyerEmail: String, + sellerEmail: String, + otherDocument: ChatDocument, + post: Post + ) async throws { + var chatDocument = otherDocument + chatDocument._id = "\(Date().timeIntervalSince1970)" + chatDocument.createdAt = Timestamp(date: Date()) + chatDocument.image = "" + chatDocument.text = "" + chatDocument.availability = nil + chatDocument.product = post + + let chatRef = firestore.collection("chats") + .document(buyerEmail) + .collection(sellerEmail) + + let data = try chatDocument.toFirebaseDictionary() + try await chatRef.addDocument(data: data) + } + + // Update Buyer History + func updateBuyerHistory( + sellerEmail: String, + buyerEmail: String, + data: TransactionSummary + ) async throws { + let docRef = firestore.collection("history") + .document(sellerEmail) + .collection("buyers") + .document(buyerEmail) + + try docRef.setData(from: data) + } + + // Update Seller History + func updateSellerHistory( + buyerEmail: String, + sellerEmail: String, + data: TransactionSummary + ) async throws { + let docRef = firestore.collection("history") + .document(buyerEmail) + .collection("sellers") + .document(sellerEmail) + + try docRef.setData(from: data) + } + + // Update Items + func updateItems( + email: String, + postId: String, + post: Post + ) async throws { + let docRef = firestore.collection("history") + .document(email) + .collection("items") + .document(postId) + + try docRef.setData(from: post) + } +} + +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) + } + + 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)! + } +} + +extension Encodable { + /// Converts an Encodable object to a [String: Any] dictionary, + /// preserving `Timestamp` objects as-is. + func toFirebaseDictionary() throws -> [String: Any] { + // Create a custom encoder + let encoder = JSONEncoder() + + // Use `JSONSerialization` with Foundation objects to preserve `Timestamp` + let data = try encoder.encode(self) + let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) + + // Ensure the result is a dictionary + guard var dictionary = jsonObject as? [String: Any] else { + throw NSError(domain: "toFirebaseDictionary", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to convert object to dictionary"]) + } + + // Manually check for and preserve any `Timestamp` properties + for (key, value) in dictionary { + if let timestampValue = value as? Timestamp { + dictionary[key] = timestampValue // Preserve as `Timestamp` + } + } + + return dictionary + } +} + diff --git a/Resell/API/GoogleAuthManager.swift b/Resell/API/GoogleAuthManager.swift new file mode 100644 index 0000000..e1e97c5 --- /dev/null +++ b/Resell/API/GoogleAuthManager.swift @@ -0,0 +1,102 @@ +// +// GoogleAuthManager.swift +// Resell +// +// Created by Richie Sun on 12/3/24. +// + +import GoogleSignIn +import OAuth2 +import os +import SwiftUI + +class GoogleAuthManager { + + static let shared = GoogleAuthManager() + + let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.cornellappdev.Resell", category: "GoogleAuthManager") + + private init() { } + + func getOAuthToken(completion: @escaping (String) -> Void) throws { + // 1. Locate the service account JSON file + guard let credentialsPath = Bundle.main.path(forResource: "resell-service", ofType: "json") else { + logger.error("Error in GoogleAuthManager: Service account file not found.") + return + } + + // 2. Initialize the ServiceAccountTokenProvider with the credentials and scopes + let scopes = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/firebase.messaging" + ] + + guard let provider = ServiceAccountTokenProvider(credentialsURL: URL(fileURLWithPath: credentialsPath), scopes: scopes) else { + logger.error("Error in GoogleAuthManager: Failed to initialize ServiceAccountTokenProvider.") + return + } + + // 3. Fetch the token synchronously + try provider.withToken { token, error in + if let error = error { + self.logger.error("Error in GoogleAuthManager: Error fetching token: \(error)") + } + if let accessToken = token?.AccessToken { + completion(accessToken) + } + } + } + + // MARK: - Service Account Structure + struct ServiceAccount: Decodable { + let type: String + let project_id: String + let private_key_id: String + let private_key: String + let client_email: String + let client_id: String + let auth_uri: String + let token_uri: String + let auth_provider_x509_cert_url: String + let client_x509_cert_url: String + } + + struct OAuthTokenResponse: Decodable { + let access_token: String + let token_type: String + let expires_in: Int + } + + func signIn() async -> GIDGoogleUser? { + do { + guard let presentingViewController = await (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.windows.first?.rootViewController else { return nil } + + let result = try await GIDSignIn.sharedInstance.signIn(withPresenting: presentingViewController) + + return result.user + } catch { + logger.error("Error in GoogleAuthManager: Error restoring Google Sign-In: \(error.localizedDescription)") + return nil + } + } + + func restorePreviousSignIn() async throws -> GIDGoogleUser? { + if let user = GIDSignIn.sharedInstance.currentUser { + return user + } else { + do { + let user = try await GIDSignIn.sharedInstance.restorePreviousSignIn() + + return user + } catch { + logger.error("Error in GoogleAuthManager: Error restoring Google Sign-In: \(error.localizedDescription)") + return nil + } + } + } + + func signOut() { + GIDSignIn.sharedInstance.signOut() + } +} + diff --git a/Resell/API/NetworkManager.swift b/Resell/API/NetworkManager.swift index 2c2b4aa..fdd6dd9 100644 --- a/Resell/API/NetworkManager.swift +++ b/Resell/API/NetworkManager.swift @@ -29,18 +29,16 @@ class NetworkManager: APIClient { // MARK: - Template Helper Functions - /// Template function to FETCH data from URL and decodes it into a specified type `T`, with caching support. + /// Template function to FETCH data from URL and decodes it into a specified type `T`, /// - /// 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 + /// 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") + func get(url: URL, isRefresh: Bool = false) async throws -> T { + let request = try createRequest(url: url, method: "GET", isRefresh: isRefresh) let (data, response) = try await URLSession.shared.data(for: request) @@ -102,13 +100,15 @@ class NetworkManager: APIClient { try handleResponse(data: data, response: response) } - private func createRequest(url: URL, method: String, body: Data? = nil) throws -> URLRequest { + private func createRequest(url: URL, method: String, body: Data? = nil, isRefresh: Bool = false) throws -> URLRequest { var request = URLRequest(url: url) request.httpMethod = method request.setValue("application/json", forHTTPHeaderField: "Content-Type") - if let accessToken = UserSessionManager.shared.accessToken { + if let accessToken = UserSessionManager.shared.accessToken, !isRefresh { request.setValue("\(accessToken)", forHTTPHeaderField: "Authorization") + } else if let refreshToken = UserSessionManager.shared.refreshToken, isRefresh { + request.setValue("\(refreshToken)", forHTTPHeaderField: "Authorization") } request.httpBody = body @@ -146,6 +146,12 @@ class NetworkManager: APIClient { return try await get(url: url) } + func refreshToken() async throws -> UserSession { + let url = try constructURL(endpoint: "/auth/refresh/") + + return try await get(url: url, isRefresh: true) + } + func getUserSession(id: String) async throws -> UserSessionData { let url = try constructURL(endpoint: "/auth/sessions/\(id)/") @@ -184,6 +190,13 @@ class NetworkManager: APIClient { return try await get(url: url) } + func getUserByEmail(email: String) async throws -> UserResponse { + let url = try constructURL(endpoint: "/user/email/") + let emailBody = UserEmailBody(email: email) + + return try await post(url: url, body: emailBody) + } + func updateUserProfile(edit: EditUserBody) async throws -> UserResponse { let url = try constructURL(endpoint: "/user/") @@ -341,4 +354,12 @@ class NetworkManager: APIClient { try await post(url: url, body: reportBody) } + + // 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/API/UserSessionManager.swift b/Resell/API/UserSessionManager.swift index c9a687b..db65eb9 100644 --- a/Resell/API/UserSessionManager.swift +++ b/Resell/API/UserSessionManager.swift @@ -17,6 +17,8 @@ class UserSessionManager: ObservableObject { // MARK: - Properties + let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.cornellappdev.Resell", category: "UserSession") + @Published var accessToken: String? { didSet { if let token = accessToken { @@ -27,10 +29,20 @@ class UserSessionManager: ObservableObject { } } + @Published var refreshToken: String? { + didSet { + if let token = refreshToken { + KeychainManager.shared.save(token, forKey: "refreshToken") + } else { + KeychainManager.shared.delete(forKey: "refreshToken") + } + } + } + @Published var googleID: String? { didSet { - if let token = accessToken { - KeychainManager.shared.save(token, forKey: "googleID") + if let googleID { + KeychainManager.shared.save(googleID, forKey: "googleID") } else { KeychainManager.shared.delete(forKey: "googleID") } @@ -47,20 +59,69 @@ class UserSessionManager: ObservableObject { } } - let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.cornellappdev.Resell", category: "UserSession") + @Published var email: String? { + didSet { + if let email { + KeychainManager.shared.save(email, forKey: "email") + } else { + KeychainManager.shared.delete(forKey: "email") + } + } + } + + @Published var profileURL: URL? { + didSet { + if let profileURL { + KeychainManager.shared.save(profileURL.absoluteString, forKey: "profileURL") + } else { + KeychainManager.shared.delete(forKey: "profileURL") + } + } + } + + @Published var name: String? { + didSet { + if let name { + KeychainManager.shared.save(name, forKey: "name") + } else { + KeychainManager.shared.delete(forKey: "name") + } + } + } + + @Published var oAuthToken: String? { + didSet { + if let token = oAuthToken { + KeychainManager.shared.save(token, forKey: "oAuthToken") + } else { + KeychainManager.shared.delete(forKey: "oAuthToken") + } + } + } // MARK: - Init private init() { self.accessToken = KeychainManager.shared.get(forKey: "accessToken") + self.refreshToken = KeychainManager.shared.get(forKey: "refreshToken") self.googleID = KeychainManager.shared.get(forKey: "googleID") self.userID = KeychainManager.shared.get(forKey: "userID") + self.email = KeychainManager.shared.get(forKey: "email") + self.profileURL = URL(string: KeychainManager.shared.get(forKey: "profileURL") ?? "") + self.name = KeychainManager.shared.get(forKey: "name") + self.oAuthToken = KeychainManager.shared.get(forKey: "oAuthToken") } // MARK: - Functions func logout() { accessToken = nil + refreshToken = nil + googleID = nil userID = nil + email = nil + profileURL = nil + name = nil + oAuthToken = nil } } diff --git a/Resell/Core/ResellApp.swift b/Resell/Core/ResellApp.swift index 57d808f..bbc4277 100644 --- a/Resell/Core/ResellApp.swift +++ b/Resell/Core/ResellApp.swift @@ -5,16 +5,64 @@ // Created by Richie Sun on 9/9/24. // +import Firebase +import FirebaseMessaging import GoogleSignIn -import Kingfisher import SwiftUI +import UserNotifications + @main struct ResellApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + var body: some Scene { WindowGroup { MainView() } } } + +// 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 + 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/Info.plist b/Resell/Info.plist index 621cd09..81ed759 100644 --- a/Resell/Info.plist +++ b/Resell/Info.plist @@ -2,16 +2,6 @@ - NSCameraUsageDescription - This app requires camera access to work properly - PROD_DATABASE_ID - - DEV_DATABASE_ID - - RESELL_PROD_URL - $(RESELL_PROD_URL) - RESELL_DEV_URL - $(RESELL_DEV_URL) CFBundleURLTypes @@ -25,11 +15,25 @@ + FIREBASE_URL + $(FIREBASE_URL) + DEV_DATABASE_ID + + PROD_DATABASE_ID + + RESELL_DEV_URL + $(RESELL_DEV_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/Firebase Models/Chat.swift b/Resell/Models/Firebase Models/Chat.swift new file mode 100644 index 0000000..77f069d --- /dev/null +++ b/Resell/Models/Firebase Models/Chat.swift @@ -0,0 +1,72 @@ +// +// Chat.swift +// Resell +// +// Created by Richie Sun on 11/29/24. +// + +import FirebaseFirestore +import Foundation + +struct ChatPreview { + let sellerName: String + let email: String + let recentItem: [String: Any] + let image: URL? + let recentMessage: String + let recentSender: Int + let viewed: Bool + let confirmedTime: String + let proposedTime: String? + let proposedViewed: Bool + let recentMessageTime: String + let proposer: String? + let items: [[String: Any]] +} + +extension ChatPreview: Identifiable { + var id: String { email } +} + +struct Chat { + var history: [ChatMessageCluster] +} + +struct ChatMessageData: Identifiable { + let id: String + let timestamp: Timestamp + let content: String + let messageType: MessageType + let imageUrl: String + let post: Post? + var timestampString: String { + let date = timestamp.dateValue() + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + return formatter.string(from: date) + } + var availability: AvailabilityDocument? = nil +} + +struct ChatMessageCluster: Identifiable { + var id: UUID = UUID() + let senderId: String + let senderImage: String? + let fromUser: Bool + let messages: [ChatMessageData] +} + +enum MessageType: String { + case image = "Image" + case card = "Card" + case message = "Message" + case availability = "Availability" + case state = "State" +} + +enum MeetingProposalState: String { + case userProposal = "UserProposal" + case otherProposal = "OtherProposal" + case userDecline = "UserDecline" + case otherDecline = "OtherDecline" +} diff --git a/Resell/Models/Firebase Models/ChatDocument.swift b/Resell/Models/Firebase Models/ChatDocument.swift new file mode 100644 index 0000000..0648d7b --- /dev/null +++ b/Resell/Models/Firebase Models/ChatDocument.swift @@ -0,0 +1,85 @@ +// +// ChatDocument.swift +// Resell +// +// Created by Richie Sun on 11/30/24. +// + +import Foundation +import FirebaseFirestore + +struct ChatDocument: Codable, Identifiable { + var id: String { _id } + var _id: String + var createdAt: Timestamp + var user: UserDocument + var availability: AvailabilityDocument? + var product: Post? + var image: String + var text: String + var meetingInfo: MeetingInfo? +} + +struct ChatDocumentSendable: Codable, Identifiable { + var id: String { _id } + var _id: String + var createdAt: Timestamp + var user: UserDocument + var availability: [AvailabilityBlock]? + var product: [String : String] + var image: String + var text: String + var meetingInfo: MeetingInfo? +} + +struct AvailabilityDocument: Codable { + let availabilities: [AvailabilityBlock] +} + +struct AvailabilityBlock: Codable, Identifiable { + let startDate: Timestamp + let color: String + var id: Int + var endDate: Timestamp + + init(startDate: Timestamp, color: String = AvailabilityBlock.defaultColor, id: Int? = nil) { + self.startDate = startDate + self.color = color + self.id = id ?? Int.random(in: 0...9999) + + let startDateTime = startDate.dateValue() + let endDateTime = Calendar.current.date(byAdding: .minute, value: 30, to: startDateTime) ?? startDateTime + self.endDate = Timestamp(date: endDateTime) + } + + static var defaultColor: String { + let color = Constants.Colors.resellPurple + guard let components = color.cgColor?.components, components.count >= 3 else { + return "#000000" + } + + let red = components[0] + let green = components[1] + let blue = components[2] + + return String(format: "#%02X%02X%02X", Int(red * 255), Int(green * 255), Int(blue * 255)) + } +} + +struct MeetingInfo: Codable { + var state: String + var proposeTime: String + var proposer: String? + var canceler: String? + var mostRecent: Bool + + func toFirebaseMap() -> [String: Any] { + return [ + "state": state, + "proposeTime": proposeTime, + "proposer": proposer ?? "", + "canceler": canceler ?? "", + "mostRecent": mostRecent + ] + } +} diff --git a/Resell/Models/Firebase Models/FCMNotification.swift b/Resell/Models/Firebase Models/FCMNotification.swift new file mode 100644 index 0000000..a10bf01 --- /dev/null +++ b/Resell/Models/Firebase Models/FCMNotification.swift @@ -0,0 +1,27 @@ +// +// FCMNotification.swift +// Resell +// +// Created by Richie Sun on 12/3/24. +// + +import Foundation + +struct FcmBody: Codable { + let message: FcmMessage +} + +struct FcmMessage: Codable { + let token: String + let notification: FcmNotification? + let data: NotificationData +} + +struct FcmNotification: Codable { + let title: String + let body: String +} + +struct NotificationData: Codable { + let navigationId: String +} diff --git a/Resell/Models/Firebase Models/FirebaseDocument.swift b/Resell/Models/Firebase Models/FirebaseDocument.swift new file mode 100644 index 0000000..2f2ba32 --- /dev/null +++ b/Resell/Models/Firebase Models/FirebaseDocument.swift @@ -0,0 +1,27 @@ +// +// FirebaseDocument.swift +// Resell +// +// Created by Richie Sun on 11/30/24. +// + +import Foundation + +struct FirebaseDocument: Codable { + let venmo: String + let onboarded: Bool + let notificationsEnabled: Bool + let fcmToken: String + + init( + venmo: String = "", + onboarded: Bool = false, + notificationsEnabled: Bool = true, + fcmToken: String = "" + ) { + self.venmo = venmo + self.onboarded = onboarded + self.notificationsEnabled = notificationsEnabled + self.fcmToken = fcmToken + } +} 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/TransactionSummary.swift b/Resell/Models/Firebase Models/TransactionSummary.swift new file mode 100644 index 0000000..42a3a90 --- /dev/null +++ b/Resell/Models/Firebase Models/TransactionSummary.swift @@ -0,0 +1,21 @@ +// +// TransactionSummary.swift +// Resell +// +// Created by Richie Sun on 11/30/24. +// + +import Foundation + +struct TransactionSummary: Codable, Identifiable { + var id: String { UUID().uuidString } + let item: Post + let recentMessage: String + let recentMessageTime: String + let recentSender: String + let confirmedTime: String + let confirmedViewed: Bool + let name: String + let image: URL + let viewed: Bool +} diff --git a/Resell/Models/Firebase Models/UserDocument.swift b/Resell/Models/Firebase Models/UserDocument.swift new file mode 100644 index 0000000..e626f3d --- /dev/null +++ b/Resell/Models/Firebase Models/UserDocument.swift @@ -0,0 +1,16 @@ +// +// UserDocument.swift +// Resell +// +// Created by Richie Sun on 11/30/24. +// + +import Foundation + +struct UserDocument: Codable, Identifiable { + var id: String { _id } + var _id: String + let avatar: URL? + let name: String +} + diff --git a/Resell/Models/Message.swift b/Resell/Models/Message.swift deleted file mode 100644 index 13428e6..0000000 --- a/Resell/Models/Message.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// Message.swift -// Resell -// -// Created by Richie Sun on 10/26/24. -// - -import SwiftUI - -struct FirebaseUser: Codable, Identifiable { - var id: String - var avatar: String - var name: String -} - -struct Message: Codable, Identifiable { - var id: String - var text: String - var createdAt: Date - var user: FirebaseUser - var isSentByCurrentUser: Bool -} - -struct MessageBody: Codable { - let id: String -} diff --git a/Resell/Models/Post.swift b/Resell/Models/Post.swift index 88abf13..566d4c0 100644 --- a/Resell/Models/Post.swift +++ b/Resell/Models/Post.swift @@ -7,7 +7,7 @@ import Foundation -struct Post: Codable, Equatable, Identifiable { +struct Post: Codable, Equatable, Identifiable, Hashable { let id: String let title: String let description: String @@ -31,16 +31,20 @@ struct Post: Codable, Equatable, Identifiable { 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 } } diff --git a/Resell/Models/Report.swift b/Resell/Models/Report.swift index cfdfadf..ac8a75c 100644 --- a/Resell/Models/Report.swift +++ b/Resell/Models/Report.swift @@ -23,3 +23,7 @@ struct ReportMessageBody: Codable { let message: MessageBody let reason: String } + +struct MessageBody: Codable { + let id: String +} diff --git a/Resell/Models/User.swift b/Resell/Models/User.swift index c8a8559..7fb421b 100644 --- a/Resell/Models/User.swift +++ b/Resell/Models/User.swift @@ -38,14 +38,22 @@ struct UserResponse: Codable { struct UserSessionData: Codable { let sessions: [UserSession] +} + +struct UserSessionResponse: Codable { + let session: UserSession +} - struct UserSession: Codable { - let userId: String - let accessToken: String - let active: Bool - let expiresAt: Int - let refreshToken: String - } +struct UserSession: Codable { + let userId: String + let accessToken: String + let active: Bool + let expiresAt: Int + let refreshToken: String +} + +struct UserEmailBody: Codable { + let email: String } struct CreateUserBody: Codable { 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/sendButton.imageset/Contents.json b/Resell/Resources/Assets.xcassets/sendButton.imageset/Contents.json new file mode 100644 index 0000000..6666520 --- /dev/null +++ b/Resell/Resources/Assets.xcassets/sendButton.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "sendButton.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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/Utils/Constants.swift b/Resell/Utils/Constants.swift index ec70c4e..5205ddc 100644 --- a/Resell/Utils/Constants.swift +++ b/Resell/Utils/Constants.swift @@ -94,6 +94,13 @@ struct Constants { FilterCategory(id: 8, title: "Other") ] + static let chatMessageOptions: [ChatMessageOption] = [ + .negotiate, + .sendAvailability, + .venmo, + .viewAvailability + ] + static let dummyItemsData: [Item] = [ Item(id: UUID(), title: "Justin", image: "justin", price: "100", category: "School"), Item(id: UUID(), title: "Justin", image: "justin_long", price: "100", category: "School"), @@ -114,3 +121,10 @@ struct FilterCategory: Hashable { let id: Int let title: String } + +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[.. Message? in -// try? document.data(as: Message.self) -// } -// } + func checkEmptyState() -> Bool { + if selectedTab == "Purchases" { + return purchaseChats.isEmpty + } else { + return offerChats.isEmpty + } } - func sendMessage() { - guard !messageText.trimmingCharacters(in: .whitespaces).isEmpty else { return } + func emptyStateTitle() -> String { + return "No messages with \(selectedTab == "Purchases" ? "Sellers" : "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 { + return selectedTab == "Purchases" ? "When you contact a seller, you’ll see your messages here" : "When a buyer contacts you, you’ll see their messages here" + } + + func getAllChats() { + isLoading = true + + Task { + defer { Task { @MainActor in withAnimation { isLoading = false } } } + + do { + if let userID = UserSessionManager.shared.userID { + blockedUsers = try await NetworkManager.shared.getBlockedUsers(id: userID).users.map { $0.email } + + getPurchaceChats() + getOfferChats() + } else { + UserSessionManager.shared.logger.error("Error in BlockedUsersView: userID not found.") + } + } catch { + NetworkManager.shared.logger.error("Error in BlockedUsersView: \(error.localizedDescription)") + } + } + } + + func getPurchaceChats() { + firestoreManager.getPurchaseChats { [weak self] purchaseChats in + guard let self else { return } + + self.purchaseChats = purchaseChats.filter { !self.blockedUsers.contains($0.email) } + purchaseUnread = countUnviewedChats(chats: self.purchaseChats) + } + } + + func getOfferChats() { + firestoreManager.getOfferChats { [weak self] offerChats in + guard let self else { return } + + self.offerChats = offerChats.filter { !self.blockedUsers.contains($0.email) } + offerUnread = countUnviewedChats(chats: self.offerChats) + } + } + + func countUnviewedChats(chats: [ChatPreview]) -> Int { + return chats.filter { !$0.viewed }.count + } + + func updateChatViewed() { + guard let userEmail = UserSessionManager.shared.email, + let chatID = selectedChat?.id else { return } + firestoreManager.updateChatViewedStatus(chatType: selectedTab, userEmail: userEmail, chatId: chatID, isViewed: true) + } + + func getSelectedChatPost(completion: @escaping (Post) -> Void) { + if let itemID = selectedChat?.recentItem["id"] as? String { + isLoading = true + + Task { + defer { Task { @MainActor in withAnimation { isLoading = false } } } + + do { + let postResponse = try await NetworkManager.shared.getPostByID(id: itemID) + selectedPost = postResponse.post + + completion(postResponse.post) + } catch { + NetworkManager.shared.logger.error("Error in ChatsViewModel.getSelectedChatPost: \(error.localizedDescription)") + } + } + } + } + + func getOtherUser(email: String) { + Task { + do { + let userResponse = try await NetworkManager.shared.getUserByEmail(email: email) + otherUser = userResponse.user + + guard let url = otherUser?.photoUrl else { return } + let task = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in + guard let data, let uiImage = UIImage(data: data) else { return } + DispatchQueue.main.async { [weak self] in + self?.otherUserProfileImage = uiImage + } + } + task.resume() + } catch { + NetworkManager.shared.logger.error("Error in ChatsViewModel.getOtherUser: \(error)") + } + } + } + + func parsePayWithVenmoURL(email: String) { + Task { + do { + let venmoHandle = try await firestoreManager.getVenmoHandle(email: email) + let url = URL(string: "https://account.venmo.com/u/\(venmoHandle)") + venmoURL = url + } catch { + firestoreManager.logger.error("Error in ChatsViewModel.parsePayWithVenmoURL: \(error)") + } + } + } + + /// Fetch seller's transaction history + func fetchSellersHistory() { + guard let userEmail = UserSessionManager.shared.email else { + FirestoreManager.shared.logger.error("User email not found in UserSessionManager.") + return + } + + Task { + do { + let sellerData = try await firestoreManager.getSellerHistory(email: userEmail) + self.sellersHistory = sellerData + } catch { + FirestoreManager.shared.logger.error("Error fetching seller history for \(userEmail): \(error.localizedDescription)") + } + } + } + + /// Fetch buyer's transaction history + func fetchBuyersHistory() { + guard let userEmail = UserSessionManager.shared.email else { + FirestoreManager.shared.logger.error("User email not found in UserSessionManager.") + return + } + + Task { + do { + let buyerData = try await firestoreManager.getBuyerHistory(email: userEmail) + self.buyersHistory = buyerData + } catch { + FirestoreManager.shared.logger.error("Error fetching buyer history for \(userEmail): \(error.localizedDescription)") + } + } + } + + /// Subscribe to chat updates + func subscribeToChat(myEmail: String, otherEmail: String, selfIsBuyer: Bool) { + firestoreManager.subscribeToChat( + buyerEmail: selfIsBuyer ? myEmail : otherEmail, + sellerEmail: selfIsBuyer ? otherEmail : myEmail + ) { [weak self] documents in + guard let self = self else { return } + let messageData = documents.map { document -> (ChatMessageData, Bool) in + let messageType: MessageType = { + if !document.image.isEmpty { + return .image + } else if !document.text.isEmpty { + return .message + } else if document.availability != nil { + return .availability + } else if document.meetingInfo != nil { + return .state + } else if document.product != nil { + return .card + } + + return .message + }() + return ( + ChatMessageData( + id: document.id, + timestamp: document.createdAt, + content: document.text, + messageType: messageType, + imageUrl: document.image, + post: document.product, + availability: document.availability + ), + document.user.id == myEmail + ) + } + + // Process the chat data + let messageClusters = self.clusterMessages(messageData) + let dateStateClusters = self.addDateStates(to: messageClusters) + self.subscribedChat = Chat(history: dateStateClusters) + } + } + + /// Send a text message + func sendTextMessage( + senderEmail: String, + recipientEmail: String, + senderName: String, + recipientName: String, + senderImageUrl: URL, + recipientImageUrl: URL, + messageText: String, + isBuyer: Bool, + postId: String + ) async throws { + try await sendGenericMessage( + senderEmail: senderEmail, + recipientEmail: recipientEmail, + senderName: senderName, + recipientName: recipientName, + senderImageUrl: senderImageUrl, + recipientImageUrl: recipientImageUrl, + isBuyer: isBuyer, + postId: postId, + messageText: messageText ) + } - messages.append(newMessage) + /// Send an image message + func sendImageMessage( + senderEmail: String, + recipientEmail: String, + senderName: String, + recipientName: String, + senderImageUrl: URL, + recipientImageUrl: URL, + imageBase64: String, + isBuyer: Bool, + postId: String + ) async throws { + let url = try await uploadImage(imageBase64: imageBase64) - // TODO: - Store to Firebase document + // Send the generic message with the uploaded image URL + try await sendGenericMessage( + senderEmail: senderEmail, + recipientEmail: recipientEmail, + senderName: senderName, + recipientName: recipientName, + senderImageUrl: senderImageUrl, + recipientImageUrl: recipientImageUrl, + isBuyer: isBuyer, + postId: postId, + imageUrl: url + ) + } - messageText = "" + /// Send an availability message + func sendAvailability( + senderEmail: String, + recipientEmail: String, + senderName: String, + recipientName: String, + senderImageUrl: URL, + recipientImageUrl: URL, + isBuyer: Bool, + postId: String, + availability: AvailabilityDocument + ) async throws { + try await sendGenericMessage( + senderEmail: senderEmail, + recipientEmail: recipientEmail, + senderName: senderName, + recipientName: recipientName, + senderImageUrl: senderImageUrl, + recipientImageUrl: recipientImageUrl, + isBuyer: isBuyer, + postId: postId, + availability: availability + ) } + // MARK: - Helper Functions + + /// Cluster messages by sender + private func clusterMessages( + _ messageData: [(ChatMessageData, Bool)] + ) -> [ChatMessageCluster] { + var clusters: [ChatMessageCluster] = [] + var currentMessages: [ChatMessageData] = [] + var currentFromUser: Bool? + + for (message, fromUser) in messageData { + if fromUser != currentFromUser { + if !currentMessages.isEmpty { + clusters.append( + ChatMessageCluster( + senderId: currentFromUser == true ? "self" : "other", + senderImage: nil, + fromUser: currentFromUser ?? false, + messages: currentMessages + ) + ) + } + currentMessages = [message] + currentFromUser = fromUser + } else { + currentMessages.append(message) + } + } + + if !currentMessages.isEmpty { + clusters.append( + ChatMessageCluster( + senderId: currentFromUser == true ? "self" : "other", + senderImage: nil, + fromUser: currentFromUser ?? false, + messages: currentMessages + ) + ) + } + + return clusters + } + + /// Add date states to message clusters + private func addDateStates(to clusters: [ChatMessageCluster]) -> [ChatMessageCluster] { + var lastTimestamp: Date = Date.distantPast + + return clusters.map { cluster in + guard !cluster.messages.isEmpty else { return cluster } + + var newMessages: [ChatMessageData] = [] + + for message in cluster.messages { + let messageDate = message.timestamp.dateValue() + + if !Calendar.current.isDate(messageDate, inSameDayAs: lastTimestamp) { + let dateMessage = ChatMessageData( + id: UUID().uuidString, + timestamp: message.timestamp, + content: messageDate.toFormattedString(), + messageType: .state, + imageUrl: "", + post: nil + ) + newMessages.append(dateMessage) + } + + newMessages.append(message) + + lastTimestamp = messageDate + } + + return ChatMessageCluster( + senderId: cluster.senderId, + senderImage: cluster.senderImage, + fromUser: cluster.fromUser, + messages: newMessages + ) + } + } + + /// 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 + } } +// MARK: - ChatsViewModel: Message Functions + +extension ChatsViewModel { + func sendGenericMessage( + senderEmail: String, + recipientEmail: String, + senderName: String, + recipientName: String, + senderImageUrl: URL, + recipientImageUrl: URL, + isBuyer: Bool, + postId: String, + imageUrl: String? = nil, + messageText: String? = nil, + availability: AvailabilityDocument? = nil, + meetingInfo: MeetingInfo? = nil + ) async throws { + let currentTimeMillis = Int(Date().timeIntervalSince1970 * 1000) + + let buyerEmail = isBuyer ? senderEmail : recipientEmail + let sellerEmail = isBuyer ? recipientEmail : senderEmail + let buyerName = isBuyer ? senderName : recipientName + let sellerName = isBuyer ? recipientName : senderName + let buyerImageUrl = isBuyer ? senderImageUrl : recipientImageUrl + let sellerImageUrl = isBuyer ? recipientImageUrl : senderImageUrl + let timestamp = Timestamp() + + let isoDateFormatter = ISO8601DateFormatter() + isoDateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + isoDateFormatter.timeZone = TimeZone.current + let isoFormattedDate = isoDateFormatter.string(from: timestamp.dateValue()) + + let senderDocument = UserDocument(_id: senderEmail, avatar: senderImageUrl, name: senderName) + + let chatDocumentSendable = ChatDocumentSendable( + _id: UUID().uuidString, + createdAt: timestamp, + user: senderDocument, + availability: availability?.availabilities, + product: [:], + image: imageUrl ?? "", + text: messageText ?? "", + meetingInfo: meetingInfo + ) + + let chatDocument = ChatDocument( + _id: "\(currentTimeMillis)", + createdAt: timestamp, + user: senderDocument, + availability: availability, + product: nil, + image: imageUrl ?? "", + text: messageText ?? "", + meetingInfo: meetingInfo + ) + + guard let post = selectedPost else { return } + + if subscribedChat?.history.isEmpty ?? true { + try await firestoreManager.sendProductMessage( + buyerEmail: buyerEmail, + sellerEmail: sellerEmail, + otherDocument: chatDocument, + post: post + ) + } + + try await firestoreManager.sendChatMessage(buyerEmail: buyerEmail, sellerEmail: sellerEmail, chatDocument: chatDocumentSendable) + + let recentMessage = determineRecentMessage( + text: messageText, + imageUrl: imageUrl, + availability: availability, + meetingInfo: meetingInfo + ) + + let notificationText = determineNotificationText( + text: messageText, + imageUrl: imageUrl, + availability: availability, + meetingInfo: meetingInfo + ) + + let sellerData = TransactionSummary( + item: post, + recentMessage: recentMessage, + recentMessageTime: isoFormattedDate, + recentSender: senderName, + confirmedTime: "", + confirmedViewed: false, + name: sellerName, + image: sellerImageUrl, + viewed: isBuyer + ) + + let buyerData = TransactionSummary( + item: post, + recentMessage: recentMessage, + recentMessageTime: timestamp.dateValue().iso8601String, + recentSender: senderName, + confirmedTime: "", + confirmedViewed: false, + name: buyerName, + image: buyerImageUrl, + viewed: !isBuyer + ) + + try await firestoreManager.updateBuyerHistory(sellerEmail: sellerEmail, buyerEmail: buyerEmail, data: buyerData) + try await firestoreManager.updateSellerHistory(buyerEmail: buyerEmail, sellerEmail: sellerEmail, data: sellerData) + try await firestoreManager.updateItems(email: buyerEmail, postId: postId, post: post) + + if let token = try await firestoreManager.getUserFCMToken(email: recipientEmail) { + + try await FirebaseNotificationService.shared.sendNotification(title: senderName, body: notificationText, recipientToken: token, navigationId: "", authToken: UserSessionManager.shared.oAuthToken ?? "") + } + } + + // MARK: - Helper Functions + + private func determineRecentMessage( + text: String?, + imageUrl: String?, + availability: AvailabilityDocument?, + meetingInfo: MeetingInfo? + ) -> String { + if let text = text { return text } + if let _ = imageUrl { return "[Image]" } + if let _ = availability { return "[Availability]" } + if let meetingInfo = meetingInfo { + switch meetingInfo.state { + case "proposed": return "Proposed a Meeting" + case "confirmed": return "Accepted a Meeting!" + case "declined": return "Declined a Meeting" + case "canceled": return "Canceled a Meeting" + default: return "Updated Meeting Details" + } + } + return "" + } + + private func determineNotificationText( + text: String?, + imageUrl: String?, + availability: AvailabilityDocument?, + meetingInfo: MeetingInfo? + ) -> String { + if let text = text { return text } + if let _ = imageUrl { return "Sent an Image" } + if let _ = availability { return "Sent their Availability" } + if let meetingInfo = meetingInfo { + switch meetingInfo.state { + case "proposed": return "Proposed a Meeting" + case "confirmed": return "Accepted a Meeting!" + case "declined": return "Declined a Meeting" + case "canceled": return "Canceled a Meeting" + default: return "Updated Meeting Details" + } + } + return "" + } + +} diff --git a/Resell/ViewModels/EditProfileViewModel.swift b/Resell/ViewModels/EditProfileViewModel.swift index 75454ac..c981e45 100644 --- a/Resell/ViewModels/EditProfileViewModel.swift +++ b/Resell/ViewModels/EditProfileViewModel.swift @@ -53,16 +53,16 @@ class EditProfileViewModel: ObservableObject { } 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 } catch { NetworkManager.shared.logger.error("Error in EditProfileViewModel.updateProfile: \(error)") - isLoading = false } } } diff --git a/Resell/ViewModels/HomeViewModel.swift b/Resell/ViewModels/HomeViewModel.swift index 8e3122a..578ebcd 100644 --- a/Resell/ViewModels/HomeViewModel.swift +++ b/Resell/ViewModels/HomeViewModel.swift @@ -16,6 +16,8 @@ class HomeViewModel: ObservableObject { // MARK: - Properties + @Published var isLoading: Bool = false + @Published var filteredItems: [Post] = [] @Published var selectedFilter: String = "Recent" { didSet { @@ -38,11 +40,14 @@ class HomeViewModel: ObservableObject { // MARK: - Functions func getAllPosts() { + isLoading = true + Task { + defer { Task { @MainActor in withAnimation { isLoading = false } } } + do { let postsResponse = try await NetworkManager.shared.getAllPosts() allItems = Post.sortPostsByDate(postsResponse.posts) - if selectedFilter == "Recent" { filteredItems = allItems } else { @@ -55,10 +60,14 @@ class HomeViewModel: ObservableObject { } func getSavedPosts() { + 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) } catch { NetworkManager.shared.logger.error("Error in HomeViewModel.getSavedPosts: \(error)") } diff --git a/Resell/ViewModels/LoginViewModel.swift b/Resell/ViewModels/LoginViewModel.swift index ac776f7..1183c04 100644 --- a/Resell/ViewModels/LoginViewModel.swift +++ b/Resell/ViewModels/LoginViewModel.swift @@ -14,47 +14,53 @@ class LoginViewModel: ObservableObject { // MARK: - Properties @Published var didPresentError: Bool = false - @Published var errorText: String = "" + @Published var isLoading: Bool = false + var errorText: String = "Please sign in with a Cornell email" // 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 } + Task { + guard let user = await GoogleAuthManager.shared.signIn(), + let id = user.userID 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 let email = 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" + didPresentError = true return } - guard let id = result?.user.userID else { return } + do { + let user = try await NetworkManager.shared.getUserByGoogleID(googleID: id).user + var userSession = try await NetworkManager.shared.getUserSession(id: user.id).sessions.first - Task { - do { - let user = try await NetworkManager.shared.getUserByGoogleID(googleID: id).user - let userSession = try await NetworkManager.shared.getUserSession(id: user.id).sessions.first + if !(userSession?.active ?? false) { + userSession = try await NetworkManager.shared.refreshToken() + } - UserSessionManager.shared.accessToken = userSession?.accessToken - UserSessionManager.shared.googleID = id - UserSessionManager.shared.userID = user.id + UserSessionManager.shared.accessToken = userSession?.accessToken + UserSessionManager.shared.refreshToken = userSession?.refreshToken + UserSessionManager.shared.googleID = id + UserSessionManager.shared.userID = user.id + UserSessionManager.shared.email = user.email + UserSessionManager.shared.profileURL = user.photoUrl + UserSessionManager.shared.name = "\(user.givenName) \(user.familyName)" - success() - } catch { - NetworkManager.shared.logger.error("Error in LoginViewModel.getUserSession: \(error)") + try? GoogleAuthManager.shared.getOAuthToken { token in + UserSessionManager.shared.oAuthToken = token + } - guard let givenName = result?.user.profile?.givenName, - let familyName = result?.user.profile?.familyName else { return } + success() + } catch { + NetworkManager.shared.logger.error("Error in LoginViewModel.getUserSession: \(error)") - // User id does not exist, take to onboarding - failure(self.getNetID(email: email), givenName, familyName, email, id) - } + guard let givenName = user.profile?.givenName, + let familyName = user.profile?.familyName else { return } + + // User id does not exist, take to onboarding + failure(self.getNetID(email: email), givenName, familyName, email, id) } } } diff --git a/Resell/ViewModels/MainViewModel.swift b/Resell/ViewModels/MainViewModel.swift index 4c71474..1988dfd 100644 --- a/Resell/ViewModels/MainViewModel.swift +++ b/Resell/ViewModels/MainViewModel.swift @@ -5,6 +5,7 @@ // Created by Richie Sun on 9/11/24. // +import FirebaseMessaging import Kingfisher import SwiftUI @@ -18,6 +19,8 @@ class MainViewModel: ObservableObject { @Published var selection = 0 + var hidesSignInButton = true + // MARK: - Persistent Storage @AppStorage("chatNotificationsEnabled") var chatNotificationsEnabled: Bool = true @@ -87,32 +90,82 @@ class MainViewModel: ObservableObject { func restoreSignIn() { Task { do { + hidesSignInButton = true + if let _ = UserSessionManager.shared.accessToken, let _ = UserSessionManager.shared.userID { - // Verify that the accessToken is valid by attempting to prefetch post URLs + // Validate the access token by prefetching saved post URLs let urls = try await NetworkManager.shared.getSavedPosts().posts.compactMap { $0.images.first } let prefetcher = ImagePrefetcher(urls: urls) prefetcher.start() - withAnimation { userDidLogin = true } + try? GoogleAuthManager.shared.getOAuthToken { token in + UserSessionManager.shared.oAuthToken = token + } + + await MainActor.run { + withAnimation { userDidLogin = true } + } } else if let googleID = UserSessionManager.shared.googleID { - // If accessToken is not available, try to re-authenticate using googleID + // Re-authenticate using Google ID let user = try await NetworkManager.shared.getUserByGoogleID(googleID: googleID).user - let userSession = try await NetworkManager.shared.getUserSession(id: user.id).sessions.first + var userSession = try await NetworkManager.shared.getUserSession(id: user.id).sessions.first + + if !(userSession?.active ?? false) { + userSession = try await NetworkManager.shared.refreshToken() + } UserSessionManager.shared.accessToken = userSession?.accessToken + UserSessionManager.shared.refreshToken = userSession?.refreshToken UserSessionManager.shared.googleID = googleID UserSessionManager.shared.userID = user.id + UserSessionManager.shared.email = user.email + UserSessionManager.shared.profileURL = user.photoUrl + UserSessionManager.shared.name = "\(user.givenName) \(user.familyName)" withAnimation { userDidLogin = true } } else { - withAnimation { userDidLogin = false } + // Attempt to restore Google Sign-In + let user = try await GoogleAuthManager.shared.restorePreviousSignIn() + guard let user, + let googleID = user.userID else { + await MainActor.run { + withAnimation { hidesSignInButton = false } + withAnimation { userDidLogin = false } + } + return + } + + // Fetch user data from the server using Google credentials + let serverUser = try await NetworkManager.shared.getUserByGoogleID(googleID: googleID).user + var userSession = try await NetworkManager.shared.getUserSession(id: serverUser.id).sessions.first + + if !(userSession?.active ?? false) { + userSession = try await NetworkManager.shared.refreshToken() + } + + UserSessionManager.shared.accessToken = userSession?.accessToken + UserSessionManager.shared.refreshToken = userSession?.refreshToken + UserSessionManager.shared.googleID = googleID + UserSessionManager.shared.userID = serverUser.id + UserSessionManager.shared.email = serverUser.email + UserSessionManager.shared.profileURL = serverUser.photoUrl + UserSessionManager.shared.name = "\(serverUser.givenName) \(serverUser.familyName)" + + try? GoogleAuthManager.shared.getOAuthToken { token in + UserSessionManager.shared.oAuthToken = token + } + + withAnimation { userDidLogin = true } } } catch { - // Session Token has expired + // Session token has expired or re-authentication failed + withAnimation { hidesSignInButton = false } withAnimation { userDidLogin = false } - NetworkManager.shared.logger.log("User Session Has Expired") + NetworkManager.shared.logger.log("User Session Has Expired or Google Sign-In Failed: \(error)") } } } + + } diff --git a/Resell/ViewModels/NewListingViewModel.swift b/Resell/ViewModels/NewListingViewModel.swift index 95b9596..15776ee 100644 --- a/Resell/ViewModels/NewListingViewModel.swift +++ b/Resell/ViewModels/NewListingViewModel.swift @@ -50,8 +50,10 @@ class NewListingViewModel: ObservableObject { } func createNewListing() { + isLoading = true + Task { - isLoading = true + defer { Task { @MainActor in withAnimation { isLoading = false } } } do { if let userID = UserSessionManager.shared.userID { diff --git a/Resell/ViewModels/NewRequestViewModel.swift b/Resell/ViewModels/NewRequestViewModel.swift index b1eaf4d..6bd797f 100644 --- a/Resell/ViewModels/NewRequestViewModel.swift +++ b/Resell/ViewModels/NewRequestViewModel.swift @@ -29,8 +29,10 @@ 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 { @@ -40,10 +42,8 @@ class NewRequestViewModel: ObservableObject { 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 } } } diff --git a/Resell/ViewModels/ProductDetailsViewModel.swift b/Resell/ViewModels/ProductDetailsViewModel.swift index f407c81..b533bb0 100644 --- a/Resell/ViewModels/ProductDetailsViewModel.swift +++ b/Resell/ViewModels/ProductDetailsViewModel.swift @@ -29,8 +29,10 @@ 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) @@ -39,18 +41,29 @@ class ProductDetailsViewModel: ObservableObject { await calculateMaxImgRatio() getIsSaved() - - isLoading = false } catch { NetworkManager.shared.logger.error("Error in ProductDetailsViewModel.getPost: \(error.localizedDescription)") - isLoading = false } } } + func setPost(post: Post) { + item = post + images = post.images + + Task { + await calculateMaxImgRatio() + } + + getIsSaved() + getSimilarPostsNaive(post: post) + } + + // 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 +72,35 @@ class ProductDetailsViewModel: ObservableObject { } else { similarPosts = postsResponse.posts } + } catch { + NetworkManager.shared.logger.error("Errror in ProductDetailsViewModel.getSimilarPosts: \(error.localizedDescription)") + } + } + } + + func getSimilarPostsNaive(post: Post) { + Task { + do { + guard let category = post.categories.first else { return } + + let postsResponse = try await NetworkManager.shared.getFilteredPosts(by: 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.localizedDescription)") isLoadingImages = false } } } - + func updateItemSaved() { Task { do { diff --git a/Resell/ViewModels/ProfileViewModel.swift b/Resell/ViewModels/ProfileViewModel.swift index 269576a..85b9cdb 100644 --- a/Resell/ViewModels/ProfileViewModel.swift +++ b/Resell/ViewModels/ProfileViewModel.swift @@ -17,6 +17,7 @@ class ProfileViewModel: ObservableObject { @Published var sellerIsBlocked: Bool = false @Published var isLoading: Bool = false + @Published var isLoadingUser: Bool = false @Published var requests: [Request] = [] @Published var selectedPosts: [Post] = [] @@ -46,8 +47,10 @@ class ProfileViewModel: ObservableObject { } func getUser() { + isLoading = true + Task { - isLoading = true + defer { Task { @MainActor in withAnimation { isLoading = false } } } do { if let id = UserSessionManager.shared.userID { @@ -61,8 +64,6 @@ class ProfileViewModel: ObservableObject { 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 @@ -74,30 +75,30 @@ class ProfileViewModel: ObservableObject { 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 } } } catch { NetworkManager.shared.logger.error("Error in ProfileViewModel.getUser: \(error)") - withAnimation { isLoading = false } } } } func getExternalUser(id: String) { Task { + isLoadingUser = true + do { user = try await NetworkManager.shared.getUserByID(id: id).user checkUserIsBlocked() selectedPosts = try await NetworkManager.shared.getPostsByUserID(id: user?.id ?? "").posts + + isLoadingUser = false } catch { NetworkManager.shared.logger.error("Error in ProfileViewModel: \(error.localizedDescription)") + isLoadingUser = false } - } } @@ -117,33 +118,31 @@ class ProfileViewModel: ObservableObject { } func blockUser(id: String) { + isLoading = true + Task { - isLoading = true + defer { Task { @MainActor in withAnimation { isLoading = false } } } 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 unblockUser(id: String) { + isLoading = true + Task { - isLoading = true + defer { Task { @MainActor in withAnimation { isLoading = false } } } 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 } } } diff --git a/Resell/ViewModels/ReportViewModel.swift b/Resell/ViewModels/ReportViewModel.swift index daaa5c0..e3984d4 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,8 +46,10 @@ class ReportViewModel: ObservableObject { } func reportPost() { + isLoading = true + Task { - isLoading = true + defer { Task { @MainActor in withAnimation { isLoading = false } } } do { if let userID = user?.id, @@ -56,47 +57,42 @@ class ReportViewModel: ObservableObject { 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 } } } } func reportUser() { + isLoading = true + Task { - isLoading = true + defer { Task { @MainActor in withAnimation { isLoading = false } } } do { if let userID = user?.id { 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 } } } } func blockUser() { + isLoading = true + Task { - isLoading = true + defer { Task { @MainActor in withAnimation { isLoading = false } } } do { if let id = user?.id { 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 } } } diff --git a/Resell/ViewModels/SendFeedbackViewModel.swift b/Resell/ViewModels/SendFeedbackViewModel.swift index b307a24..6fd928a 100644 --- a/Resell/ViewModels/SendFeedbackViewModel.swift +++ b/Resell/ViewModels/SendFeedbackViewModel.swift @@ -47,8 +47,9 @@ class SendFeedbackViewModel: ObservableObject { } func submitFeedback() { + isLoading = true Task { - isLoading = true + defer { Task { @MainActor in withAnimation { isLoading = false } } } do { if let userID = UserSessionManager.shared.userID { @@ -58,11 +59,8 @@ class SendFeedbackViewModel: ObservableObject { } else { UserSessionManager.shared.logger.error("Error in SendFeedbackViewModel.submitFeedback: userID not found") } - - isLoading = false } catch { NetworkManager.shared.logger.error("Error in SendFeedbackViewModel.submitFeedback: \(error)") - isLoading = false } } } diff --git a/Resell/ViewModels/SettingsViewModel.swift b/Resell/ViewModels/SettingsViewModel.swift index 8d5dbb4..c305a46 100644 --- a/Resell/ViewModels/SettingsViewModel.swift +++ b/Resell/ViewModels/SettingsViewModel.swift @@ -55,6 +55,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)") } diff --git a/Resell/ViewModels/SetupProfileViewModel.swift b/Resell/ViewModels/SetupProfileViewModel.swift index 664fa3d..72f283f 100644 --- a/Resell/ViewModels/SetupProfileViewModel.swift +++ b/Resell/ViewModels/SetupProfileViewModel.swift @@ -50,21 +50,24 @@ class SetupProfileViewModel: ObservableObject { } func createNewUser() { + isLoading = true + 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) + + try await FirestoreManager.shared.saveOnboarded(userEmail: email) + try await FirestoreManager.shared.saveVenmo(userEmail: email, venmo: venmoHandle) + loginUser(id: googleID) } else { // TODO: Present Toast Error } - - isLoading = false } catch { NetworkManager.shared.logger.error("Error in SetupProfileViewModel.createNewUser: \(error.localizedDescription)") - isLoading = false } } } @@ -87,4 +90,24 @@ class SetupProfileViewModel: ObservableObject { googleID = "" } + private func loginUser(id: String) { + 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 + UserSessionManager.shared.email = user.email + UserSessionManager.shared.profileURL = user.photoUrl + UserSessionManager.shared.name = "\(user.givenName) \(user.familyName)" + + FirebaseNotificationService.shared.setupFCMToken() + } catch { + NetworkManager.shared.logger.error("Error in LoginViewModel.getUserSession: \(error)") + } + } + } + } diff --git a/Resell/Views/Chats/ChatsView.swift b/Resell/Views/Chats/ChatsView.swift new file mode 100644 index 0000000..e83e124 --- /dev/null +++ b/Resell/Views/Chats/ChatsView.swift @@ -0,0 +1,156 @@ +// +// 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 + + // MARK: - UI + + var body: some View { + VStack(alignment: .leading) { + headerView + + filtersView + + chatsView + + Spacer() + } + .background(Constants.Colors.white) + .loadingView(isLoading: viewModel.isLoading) + .emptyState(isEmpty: viewModel.checkEmptyState(), title: viewModel.emptyStateTitle(), text: viewModel.emptyStateMessage()) + .refreshable { + viewModel.getAllChats() + } + .onAppear { + viewModel.getAllChats() + } + } + + 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 == "Purchases" ? viewModel.purchaseUnread : viewModel.offerUnread + FilterButton(filter: filter, unreadChats: unreadCount, isSelected: viewModel.selectedTab == filter.title) { + viewModel.selectedTab = filter.title + } + } + } + .padding(.leading, Constants.Spacing.horizontalPadding) + .padding(.vertical, 1) + } + + private var chatsView: some View { + ScrollView(.vertical) { + VStack(alignment: .center, spacing: 24) { + ForEach(viewModel.selectedTab == "Purchases" ? viewModel.purchaseChats : viewModel.offerChats) { chatPreview in + chatPreviewRow(chatPreview: chatPreview) + } + } + .padding(.top, 12) + + Spacer() + + } + .frame(width: UIScreen.width) + } + + private func chatPreviewRow(chatPreview: ChatPreview) -> some View { + HStack(spacing: 12) { + KFImage(chatPreview.image) + .placeholder { + ShimmerView() + .frame(width: 52, height: 52) + .clipShape(Circle()) + } + .resizable() + .scaledToFill() + .frame(width: 52, height: 52) + .clipShape(Circle()) + + VStack(alignment: .leading) { + HStack { + Text(chatPreview.sellerName) + .font(Constants.Fonts.title1) + .foregroundStyle(Constants.Colors.black) + .lineLimit(1) + .truncationMode(.tail) + + Text(chatPreview.recentItem["title"] as? String ?? "") + .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(chatPreview.recentMessage) + .font(Constants.Fonts.title4) + .foregroundStyle(Constants.Colors.secondaryGray) + .lineLimit(1) + .truncationMode(.tail) + + Text(" • ") + + Text(Date.timeAgo(from: chatPreview.recentMessageTime)) + .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 !chatPreview.viewed { + Circle() + .frame(width: 10, height: 10) + .foregroundStyle(Constants.Colors.resellPurple) + .padding(.leading, 8) + } + } + .onTapGesture { + viewModel.selectedChat = chatPreview + viewModel.updateChatViewed() + viewModel.getSelectedChatPost { post in + router.push(.messages(post: post)) + } + } + } + +} + diff --git a/Resell/Views/Chats/MessagesView.swift b/Resell/Views/Chats/MessagesView.swift new file mode 100644 index 0000000..41a6b7f --- /dev/null +++ b/Resell/Views/Chats/MessagesView.swift @@ -0,0 +1,664 @@ +// +// 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 + @EnvironmentObject var viewModel: ChatsViewModel + + @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 = "" + + var post: Post + let maxCharacters: Int = 1000 + + // MARK: - UI + + var body: some View { + ZStack { + VStack { + messageListView + + Spacer() + + Divider() + + messageInputView + } + + if didShowOptionsMenu { + OptionsMenuView(showMenu: $didShowOptionsMenu, options: [.report(type: "User", id: viewModel.otherUser?.id ?? "")]) + .zIndex(100) + } + } + .background(Constants.Colors.white) + .toolbarBackground(Constants.Colors.white, for: .automatic) + .toolbar { + ToolbarItem(placement: .principal) { + Button { + navigateToProductDetails(post: post) + } label: { + VStack(spacing: 0) { + Text(post.title) + .font(Constants.Fonts.title1) + .foregroundStyle(Constants.Colors.black) + .lineLimit(1) + .truncationMode(.tail) + + Text("\(post.user?.givenName ?? "") \(post.user?.familyName ?? "")") + .font(Constants.Fonts.title3) + .foregroundStyle(Constants.Colors.secondaryGray) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + + ToolbarItem(placement: .topBarTrailing) { + Button { + withAnimation { + didShowOptionsMenu.toggle() + } + } label: { + Image(systemName: "ellipsis") + .resizable() + .frame(width: 24, height: 6) + .foregroundStyle(Constants.Colors.black) + } + .padding() + } + } + .sheet(isPresented: $didShowNegotiationView, onDismiss: setNegotiationText) { + VStack(spacing: 24) { + HStack(spacing: 16) { + KFImage(post.images.first) + .placeholder { + ShimmerView() + .frame(width: 128, height: 100) + } + .resizable() + .scaledToFill() + .frame(width: 128, height: 100) + .clipShape(.rect(cornerRadius: 18)) + + VStack(alignment: .leading, spacing: 8) { + Text(post.title) + .font(Constants.Fonts.h2) + .foregroundStyle(Constants.Colors.black) + + Text("$\(post.alteredPrice)") + .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: $didShowNegotiationView, 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() + } + .sheet(isPresented: $didShowAvailabilityView) { + AvailabilitySelectorView(isPresented: $didShowAvailabilityView, selectedDates: $viewModel.availabilityDates, didSubmit: $didSubmitAvailabilities, isEditing: isEditing, proposerName: viewModel.otherUser?.givenName) + .presentationCornerRadius(25) + .presentationDragIndicator(.visible) + } + .sheet(isPresented: $didShowWebView) { + WebView(url: viewModel.venmoURL!) + .edgesIgnoringSafeArea(.all) + } + .onAppear { + guard let myEmail = UserSessionManager.shared.email, + let myID = UserSessionManager.shared.userID else { + UserSessionManager.shared.logger.error("Error in MessagesView: User Email Not Found") + return + } + viewModel.parsePayWithVenmoURL(email: viewModel.selectedChat?.email ?? post.user?.email ?? "") + + viewModel.subscribeToChat( + myEmail: myEmail, + otherEmail: viewModel.selectedChat?.email ?? post.user?.email ?? "", + selfIsBuyer: !(post.user?.id == myID) + ) + + viewModel.getOtherUser(email: viewModel.selectedChat?.email ?? post.user?.email ?? "") + } + .onChange(of: router.path) { newPath in + if !newPath.contains(where: { $0 == .messages(post: post) }) { + viewModel.selectedChat = nil + } + } + .onChange(of: didSubmitAvailabilities) { didSubmit in + if didSubmit { + Task { + await sendAvailabilities(availabilities: viewModel.availabilityDates) + viewModel.availabilityDates = [] + didSubmitAvailabilities = false + } + } + } + .endEditingOnTap() + + } + + private var messageListView: some View { + VStack { + ScrollViewReader { proxy in + ScrollView { + LazyVStack { + ForEach(viewModel.subscribedChat?.history ?? []) { cluster in + VStack(spacing: 12) { + ForEach(cluster.messages) { message in + MessageBubbleView( + didShowAvailabilityView: $didShowAvailabilityView, + isEditing: $isEditing, + otherUserPhoto: $viewModel.otherUserProfileImage, + selectedAvailabilities: $viewModel.availabilityDates, + senderName: cluster.fromUser ? UserSessionManager.shared.name ?? "" : viewModel.otherUser?.givenName ?? "", + message: message, + fromUser: cluster.fromUser + ) + } + } + } + Color.clear.frame(height: 1).id("BOTTOM") + } + } + .padding(.horizontal, 12) + .background(Constants.Colors.white) + .onChange(of: viewModel.subscribedChat?.history.count) { _ in + withAnimation { + proxy.scrollTo("BOTTOM", anchor: .bottom) + } + } + .onChange(of: viewModel.subscribedChat?.history.last?.messages.count) { _ 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 { + 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: + chatOption(title: "View \(post.user?.givenName ?? "")'s Availability") { + isEditing = false + withAnimation { didShowAvailabilityView = true } + // TODO: Pull up other user Recent Avail + } + } + } + } + .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) + } + } + + private var textInputView: some View { + TextInputView(draftMessageText: $viewModel.draftMessageText) { text, image in + if let text { + Task { + await sendMessage(text: text) + } + } + if let image = image { + Task { + await sendImage(image: image) + } + } + } + } + + // MARK: - Functions + + private func navigateToProductDetails(post: Post) { + if let existingIndex = router.path.firstIndex(where: { + if case .productDetails = $0 { + return true + } + return false + }) { + router.path[existingIndex] = .productDetails(post) + router.popTo(router.path[existingIndex]) + } else { + router.push(.productDetails(post)) + } + } + + private func sendMessage(text: String) async { + guard let myEmail = UserSessionManager.shared.email, + let myID = UserSessionManager.shared.userID, + let recipientEmail = viewModel.selectedChat?.email, + let senderName = UserSessionManager.shared.name else { + UserSessionManager.shared.logger.error("Error: Missing user or chat information.") + return + } + + do { + guard let senderImageUrl = UserSessionManager.shared.profileURL, + let recipientImageUrl = viewModel.otherUser?.photoUrl, + let recipientGivenName = viewModel.otherUser?.givenName, + let recipientFamilyName = viewModel.otherUser?.familyName else { return } + + try await viewModel.sendTextMessage( + senderEmail: myEmail, + recipientEmail: recipientEmail, + senderName: senderName, + recipientName: "\(recipientGivenName) \(recipientFamilyName)", + senderImageUrl: senderImageUrl, + recipientImageUrl: recipientImageUrl, + messageText: text, + isBuyer: !(post.user?.id == myID), + postId: post.id + ) + + } catch { + NetworkManager.shared.logger.error("Error in MessagesView.sendMessage: Error sending message: \(error)") + } + } + + private func sendImage(image: UIImage) async { + guard let myEmail = UserSessionManager.shared.email, + let myID = UserSessionManager.shared.userID, + let recipientEmail = viewModel.selectedChat?.email, + let senderName = UserSessionManager.shared.name else { + UserSessionManager.shared.logger.error("Error: Missing user or chat information.") + return + } + + do { + guard let senderImageUrl = UserSessionManager.shared.profileURL, + let recipientImageUrl = viewModel.otherUser?.photoUrl, + let recipientGivenName = viewModel.otherUser?.givenName, + let recipientFamilyName = viewModel.otherUser?.familyName else { return } + + let imageBase64 = image.toBase64() ?? "" + + try await viewModel.sendImageMessage( + senderEmail: myEmail, + recipientEmail: recipientEmail, + senderName: senderName, + recipientName: "\(recipientGivenName) \(recipientFamilyName)", + senderImageUrl: senderImageUrl, + recipientImageUrl: recipientImageUrl, + imageBase64: imageBase64, + isBuyer: !(post.user?.id == myID), + postId: post.id + ) + } catch { + NetworkManager.shared.logger.error("Error in MessagesView.sendImage: Error sending image message: \(error)") + } + } + + private func sendAvailabilities(availabilities: [AvailabilityBlock]) async { + guard let myEmail = UserSessionManager.shared.email, + let myID = UserSessionManager.shared.userID, + let recipientEmail = viewModel.selectedChat?.email, + let senderName = UserSessionManager.shared.name else { + UserSessionManager.shared.logger.error("Error in MessagesView.sendAvailabilities: Missing user or chat information.") + return + } + + do { + guard let senderImageUrl = UserSessionManager.shared.profileURL, + let recipientImageUrl = viewModel.otherUser?.photoUrl, + let recipientGivenName = viewModel.otherUser?.givenName, + let recipientFamilyName = viewModel.otherUser?.familyName else { + return + } + + var sortedAvailabilities = availabilities.sorted { $0.startDate.dateValue() < $1.startDate.dateValue() } + sortedAvailabilities.indices.forEach { index in + sortedAvailabilities[index].id = index + } + + let availabilityDocument = AvailabilityDocument(availabilities: sortedAvailabilities) + + try await viewModel.sendAvailability( + senderEmail: myEmail, + recipientEmail: recipientEmail, + senderName: senderName, + recipientName: "\(recipientGivenName) \(recipientFamilyName)", + senderImageUrl: senderImageUrl, + recipientImageUrl: recipientImageUrl, + isBuyer: !(post.user?.id == myID), + postId: post.id, + availability: availabilityDocument + ) + } catch { + NetworkManager.shared.logger.error("Error in MessagesView.sendAvailability: Error sending availability: \(error)") + } + } + + private func setNegotiationText() { + viewModel.draftMessageText = "Hi! I'm interested in buying your \(post.title), but would you be open to selling it for $\(priceText)?" + priceText = "" + } +} + +// MARK: - MessageBubbleView + +struct MessageBubbleView: View { + + @Binding var didShowAvailabilityView: Bool + @Binding var isEditing: Bool + + @Binding var otherUserPhoto: UIImage + @Binding var selectedAvailabilities: [AvailabilityBlock] + + let senderName: String + let message: ChatMessageData + let fromUser: Bool + + var body: some View { + HStack(spacing: 12) { + if !fromUser && message.messageType != .state { + profileImageView + } + + VStack(alignment: fromUser ? .trailing : .leading) { + messageContentView + } + + if fromUser && message.messageType != .state { + Spacer() + } + } + .padding(fromUser ? .leading : .trailing, message.messageType == .state ? 0 : UIScreen.width / 4) + } + + @ViewBuilder + private var messageContentView: some View { + switch message.messageType { + case .image: + HStack { + if fromUser { + Spacer() + } + + if let url = URL(string: message.imageUrl) { + AsyncImage(url: url) { image in + image.resizable() + .scaledToFill() + .frame(width: 200, height: 200) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } placeholder: { + ProgressView() + } + } + + if !fromUser { + Spacer() + } + } + .padding(.vertical, 6) + case .message: + HStack { + if fromUser { + Spacer() + } + + VStack(alignment: fromUser ? .trailing : .leading, spacing: 8) { + Text(message.content) + .font(Constants.Fonts.body2) + .foregroundStyle(fromUser ? Constants.Colors.white : Constants.Colors.black) + + Text(message.timestampString) + .font(.caption2) + .foregroundStyle(fromUser ? Constants.Colors.white : Constants.Colors.secondaryGray) + } + .padding(12) + .background(fromUser ? Constants.Colors.resellPurple : Constants.Colors.wash) + .foregroundColor(fromUser ? Constants.Colors.white : Constants.Colors.black) + .cornerRadius(10) + + if !fromUser { + Spacer() + } + } + case .availability: + Button { + selectedAvailabilities = message.availability?.availabilities ?? [] + didShowAvailabilityView = true + isEditing = false + } label: { + HStack { + Text("\(senderName)'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) + } + case .state: + Text(message.content) + .font(Constants.Fonts.subtitle1) + .foregroundColor(Constants.Colors.secondaryGray) + default: + Text("Unsupported message type.") + } + } + + private var profileImageView: some View { + Image(uiImage: otherUserPhoto) + .resizable() + .scaledToFill() + .background(Constants.Colors.secondaryGray) + .frame(width: 40, height: 40) + .clipShape(.circle) + } +} + + + +// MARK: - TextInputView + +struct TextInputView: View { + + // MARK: - Properties + + @State private var selectedImage: UIImage? = nil + @State private var showingPhotoPicker = false + @Binding var draftMessageText: String + + let onSend: (String?, UIImage?) -> Void + let maxCharacters: Int = 1000 + + // MARK: - UI + + var body: some View { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 8) { + if let selectedImage = selectedImage { + ZStack(alignment: .topTrailing) { + Image(uiImage: selectedImage) + .resizable() + .scaledToFit() + .frame(height: 80) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + Button(action: { + self.selectedImage = nil + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.white) + .shadow(radius: 2) + .padding(4) + } + } + .padding(.leading, 32) + } + + HStack { + Button { + showingPhotoPicker = true + } label: { + Image(systemName: "photo") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundStyle(Constants.Colors.secondaryGray) + } + .sheet(isPresented: $showingPhotoPicker) { + SingleImagePicker(selectedImage: $selectedImage) + } + + TextEditor(text: $draftMessageText) + .font(Constants.Fonts.body2) + .foregroundColor(Constants.Colors.black) + .padding(12) + .scrollContentBackground(.hidden) + .background(Constants.Colors.wash) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .frame(height: 48) + .onChange(of: draftMessageText) { newText in + if newText.count > maxCharacters { + draftMessageText = String(newText.prefix(maxCharacters)) + } + } + } + + } + + if !draftMessageText.isEmpty || selectedImage != nil { + Button(action: { + onSend(draftMessageText.isEmpty ? nil : draftMessageText, selectedImage) + draftMessageText = "" + selectedImage = nil + }) { + 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/AvailabilitySelectorView.swift b/Resell/Views/Components/AvailabilitySelectorView.swift new file mode 100644 index 0000000..53e14c9 --- /dev/null +++ b/Resell/Views/Components/AvailabilitySelectorView.swift @@ -0,0 +1,317 @@ +// +// 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: [AvailabilityBlock] + @Binding var didSubmit: Bool + + var isEditing: Bool = true + 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 + GeometryReader { geometry in + let cellHeight = geometry.size.height + 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")) + ) + .contentShape(Rectangle()) + .gesture(DragGesture(minimumDistance: 0) + .onChanged { value in + if isEditing { + let isTopHalf = value.location.y < cellHeight / 2 + let identifier = CellIdentifier(date: date, time: isTopHalf ? "\(time) Top" : "\(time) Bottom") + + if toggleSelectionMode == nil { + toggleSelectionMode = selectedCells.contains(identifier) ? false : true + } + + if toggleSelectionMode == true { + draggedCells.insert(identifier) + } else { + draggedCells.insert(identifier) + } + } + } + .onEnded { _ in + if isEditing { + 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) + } + } + } + .frame(width: UIScreen.width / 5 + 10, height: cellHeight) + } + } + } + } + } + .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) + .background(Constants.Colors.white) + .onAppear(perform: initializeSelectedCells) + } + + // MARK: - Functions + + private func initializeSelectedCells() { + for block in selectedDates { + let startDate = block.startDate.dateValue() + + 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) -> AvailabilityBlock? { + 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 AvailabilityBlock(startDate: Timestamp(date: parsedDate)) + } 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/CachedImageView.swift b/Resell/Views/Components/CachedImageView.swift index 336ef3f..2d2a38a 100644 --- a/Resell/Views/Components/CachedImageView.swift +++ b/Resell/Views/Components/CachedImageView.swift @@ -26,8 +26,10 @@ struct CachedImageView: View { .clipShape(RoundedRectangle(cornerRadius: 8)) } .onSuccess { _ in - withAnimation(.easeInOut(duration: 0.3)) { - isImageLoaded = true + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: 0.3)) { + isImageLoaded = true + } } } .fade(duration: 0.3) diff --git a/Resell/Views/Components/ProductsGalleryView.swift b/Resell/Views/Components/ProductsGalleryView.swift index d0566df..df169e8 100644 --- a/Resell/Views/Components/ProductsGalleryView.swift +++ b/Resell/Views/Components/ProductsGalleryView.swift @@ -44,27 +44,26 @@ struct ProductsGalleryView: View { } } .padding(.horizontal, Constants.Spacing.horizontalPadding) - .padding(.top, Constants.Spacing.horizontalPadding) } .onChange(of: selectedItem) { item in if let selectedItem { - navigateToProductDetails(postID: selectedItem.id) + navigateToProductDetails(post: selectedItem) self.selectedItem = nil } } } - 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)) } } @@ -85,8 +84,11 @@ struct ProductGalleryCell: View { var body: some View { VStack(spacing: 0) { - CachedImageView(isImageLoaded: $isImageLoaded, imageURL: post.images.first) - .frame(width: cellWidth, height: cellWidth / 0.75) + ZStack { + CachedImageView(isImageLoaded: $isImageLoaded, imageURL: post.images.first) + .frame(width: cellWidth, height: cellWidth / 0.75) + } + HStack { Text(post.title) .font(Constants.Fonts.title3) @@ -101,14 +103,13 @@ struct ProductGalleryCell: View { .frame(width: cellWidth) .clipped() .clipShape(.rect(cornerRadius: 8)) - .scaleEffect(isImageLoaded ? CGSize(width: 1, height: 1) : CGSize(width: 1, height: 0.9), anchor: .center) + .opacity(isImageLoaded ? 1 : 1) .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/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/Home/ChatsView.swift b/Resell/Views/Home/ChatsView.swift deleted file mode 100644 index 6745254..0000000 --- a/Resell/Views/Home/ChatsView.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// ChatsView.swift -// Resell -// -// Created by Richie Sun on 9/12/24. -// - -import SwiftUI - -struct ChatsView: View { - - // MARK: - Properties - - @EnvironmentObject var router: Router - @StateObject private var viewModel = ChatsViewModel() - - // MARK: - UI - - var body: some View { - NavigationStack(path: $router.path) { - VStack(alignment: .leading) { - headerView - - filtersView - - chatsView - - Spacer() - } - .background(Constants.Colors.white) - } - } - - 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 - FilterButton(filter: filter, unreadChats: viewModel.unreadMessages[filter.title] ?? 0, isSelected: viewModel.selectedTab == filter.title) { - viewModel.selectedTab = filter.title - viewModel.unreadMessages[filter.title] = 0 - } - } - } - .padding(.leading, Constants.Spacing.horizontalPadding) - .padding(.vertical, 1) - } - - private var chatsView: some View { - ScrollView(.vertical) { - VStack(spacing: 24) { - ForEach(viewModel.chats, id: \.self.0) { chat in - HStack(spacing: 12) { - Circle() - .foregroundStyle(chat.5 ? Constants.Colors.resellPurple : Constants.Colors.white) - .frame(width: 10, height: 10) - - Image(chat.2) - .resizable() - .frame(width: 52, height: 52) - .clipShape(.circle) - - VStack(alignment: .leading) { - HStack { - Text(chat.1) - .font(Constants.Fonts.title1) - .foregroundStyle(Constants.Colors.black) - - Text(chat.3) - .font(Constants.Fonts.title4) - .foregroundStyle(Constants.Colors.secondaryGray) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .overlay { - RoundedRectangle(cornerRadius: 12) - .stroke(Constants.Colors.stroke, lineWidth: 0.75) - } - } - - Text(chat.4) - .font(Constants.Fonts.title4) - .foregroundStyle(Constants.Colors.secondaryGray) - } - - Spacer() - - Image(systemName: "chevron.right") - .foregroundStyle(Constants.Colors.inactiveGray) - } - .padding(.horizontal, 15) - .background(Constants.Colors.white) - .onTapGesture { - router.push(.messages) - } - } - } - .padding(.top, 24) - } - } -} diff --git a/Resell/Views/Home/HomeView.swift b/Resell/Views/Home/HomeView.swift index 09aaf29..1c0c3a3 100644 --- a/Resell/Views/Home/HomeView.swift +++ b/Resell/Views/Home/HomeView.swift @@ -6,6 +6,7 @@ // import Kingfisher +import OAuth2 import SwiftUI struct HomeView: View { @@ -15,32 +16,32 @@ struct HomeView: View { @StateObject private var viewModel = HomeViewModel.shared var body: some View { - NavigationStack(path: $router.path) { - VStack(spacing: 0) { - headerView + VStack(spacing: 0) { + headerView - filtersView + filtersView + .padding(.bottom, 12) - ProductsGalleryView(items: viewModel.filteredItems) - } - .background(Constants.Colors.white) - .overlay(alignment: .bottomTrailing) { - ExpandableAddButton() - .padding(.bottom, 40) - } - .onAppear { - viewModel.getAllPosts() - viewModel.getBlockedUsers() + ProductsGalleryView(items: viewModel.filteredItems) + } + .background(Constants.Colors.white) + .overlay(alignment: .bottomTrailing) { + ExpandableAddButton() + .padding(.bottom, 40) + } + .onAppear { + viewModel.getAllPosts() + viewModel.getBlockedUsers() - withAnimation { - mainViewModel.hidesTabBar = false - } - } - .refreshable { - viewModel.getAllPosts() + withAnimation { + mainViewModel.hidesTabBar = false } - .navigationBarBackButtonHidden() } + .refreshable { + viewModel.getAllPosts() + } + .loadingView(isLoading: viewModel.isLoading) + .navigationBarBackButtonHidden() } private var headerView: some View { diff --git a/Resell/Views/Home/MessagesView.swift b/Resell/Views/Home/MessagesView.swift deleted file mode 100644 index 90d1dbf..0000000 --- a/Resell/Views/Home/MessagesView.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// MessagesView.swift -// Resell -// -// Created by Richie Sun on 10/26/24. -// - -import SwiftUI - -// MARK: - MessageView -struct MessagesView: View { - - // MARK: - Properties - - @EnvironmentObject var router: Router - - //TODO: Change back to Env Object - @StateObject var viewModel = ChatsViewModel() - - // MARK: - UI - - var body: some View { - VStack { - messageContentView - - Divider() - - MessageInputView(messageText: $viewModel.messageText) { - viewModel.sendMessage() - } - - } - .background(Constants.Colors.white) - - - - } - - private var messageContentView: some View { - ScrollViewReader { scrollViewProxy in - ScrollView { - VStack { - ForEach(viewModel.messages) { message in - MessageBubbleView(message: message) - } - } - .onChange(of: viewModel.messages.count) { _ in - if let lastMessage = viewModel.messages.last?.id { - scrollViewProxy.scrollTo(lastMessage, anchor: .bottom) - } - } - } - .background(Constants.Colors.white) - .onAppear { - viewModel.fetchMessages() - } - } - } -} - -// MARK: - MessageBubbleView -struct MessageBubbleView: View { - let message: Message - - var body: some View { - HStack(alignment: .bottom, spacing: 10) { - if message.isSentByCurrentUser { - Spacer() - } - - if !message.isSentByCurrentUser { - AsyncImage(url: URL(string: message.user.avatar)) { image in - image.resizable() - } placeholder: { - Circle().fill(Color.gray) - } - .frame(width: 40, height: 40) - .clipShape(Circle()) - } - - VStack(alignment: message.isSentByCurrentUser ? .trailing : .leading) { - Text(message.text) - .padding() - .background(message.isSentByCurrentUser ? Color.blue : Color.gray.opacity(0.2)) - .foregroundColor(message.isSentByCurrentUser ? .white : .black) - .cornerRadius(15) - - Text(message.createdAt, style: .time) - .font(.caption2) - .foregroundColor(.gray) - } - - if !message.isSentByCurrentUser { - Spacer() - } - } - .padding(message.isSentByCurrentUser ? .leading : .trailing, 60) - .padding(.vertical, 5) - } -} - -// MARK: - MessageInputView -struct MessageInputView: View { - @Binding var messageText: String - var onSend: () -> Void - - var body: some View { - HStack { - TextField("Message...", text: $messageText) - .textFieldStyle(RoundedBorderTextFieldStyle()) - - Button(action: onSend) { - Image(systemName: "paperplane.fill") - .font(.system(size: 24)) - .padding(8) - } - } - .padding() - } -} - - diff --git a/Resell/Views/Home/ProfileView.swift b/Resell/Views/Home/ProfileView.swift index 6f02d74..3daf2e2 100644 --- a/Resell/Views/Home/ProfileView.swift +++ b/Resell/Views/Home/ProfileView.swift @@ -18,69 +18,68 @@ struct ProfileView: View { // MARK: - UI var body: some View { - NavigationStack(path: $router.path) { - VStack(spacing: 0) { - profileImageView - .padding(.bottom, 12) - - Text(viewModel.user?.username ?? "") - .font(Constants.Fonts.h3) - .foregroundStyle(Constants.Colors.black) - .padding(.bottom, 4) - - Text(viewModel.user?.givenName ?? "") - .font(Constants.Fonts.body2) - .foregroundStyle(Constants.Colors.secondaryGray) - .padding(.bottom, 16) - - Text(viewModel.user?.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") - } 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) - } + VStack(spacing: 0) { + profileImageView + .padding(.bottom, 12) + + Text(viewModel.user?.username ?? "") + .font(Constants.Fonts.h3) + .foregroundStyle(Constants.Colors.black) + .padding(.bottom, 4) + + Text(viewModel.user?.givenName ?? "") + .font(Constants.Fonts.body2) + .foregroundStyle(Constants.Colors.secondaryGray) + .padding(.bottom, 16) + + Text(viewModel.user?.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") + } 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") + .padding(.top, 24) + .loadingView(isLoading: viewModel.isLoading) } - .background(Constants.Colors.white) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button { - router.push(.settings(false)) - } label: { - Icon(image: "settings") - } + } + .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") - } + ToolbarItem(placement: .topBarTrailing) { + Button { + router.push(.search(viewModel.user?.id)) + } label: { + Icon(image: "search") } } - .overlay(alignment: .bottomTrailing) { - ExpandableAddButton() - .padding(.bottom, 40) - } - .onChange(of: viewModel.selectedTab) { _ in - viewModel.updateItemsGallery() - } - .onAppear { - viewModel.getUser() - } - .refreshable { - viewModel.getUser() - } + } + .overlay(alignment: .bottomTrailing) { + ExpandableAddButton() + .padding(.bottom, 40) + } + .onChange(of: viewModel.selectedTab) { _ in + viewModel.updateItemsGallery() + } + .onAppear { + viewModel.getUser() + } + .refreshable { + viewModel.getUser() } } diff --git a/Resell/Views/Home/SavedView.swift b/Resell/Views/Home/SavedView.swift index ba13cd3..41c1a2f 100644 --- a/Resell/Views/Home/SavedView.swift +++ b/Resell/Views/Home/SavedView.swift @@ -13,18 +13,20 @@ struct SavedView: View { @StateObject private var viewModel = HomeViewModel.shared var body: some View { - NavigationStack(path: $router.path) { - ZStack { - VStack(spacing: 0) { - headerView - ProductsGalleryView(items: viewModel.savedItems) - } + ZStack { + VStack(spacing: 0) { + headerView + ProductsGalleryView(items: viewModel.savedItems) } - .background(Constants.Colors.white) - .onAppear { - viewModel.getSavedPosts() - } - .emptyState(isEmpty: $viewModel.savedItems.isEmpty, title: "No saved posts", text: "Posts you have bookmarked will be displayed here.") + } + .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 { + viewModel.getSavedPosts() + } + .onAppear { + viewModel.getSavedPosts() } } @@ -35,7 +37,7 @@ struct SavedView: View { .foregroundStyle(Constants.Colors.black) Spacer() - + Button(action: { //TODO: Search Endpoint }, label: { diff --git a/Resell/Views/Home/SearchView.swift b/Resell/Views/Home/SearchView.swift index 3076a83..0b4ac01 100644 --- a/Resell/Views/Home/SearchView.swift +++ b/Resell/Views/Home/SearchView.swift @@ -133,6 +133,8 @@ struct SearchView: View { isLoading = true Task { + defer { Task { @MainActor in withAnimation { isLoading = false } } } + do { let postsResponse = try await NetworkManager.shared.getSearchedPosts(with: searchText) @@ -143,10 +145,8 @@ struct SearchView: View { } 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/MainTabView.swift b/Resell/Views/MainTabView.swift index 72e0ff8..e8a756a 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,124 @@ 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() + VStack { + if mainViewModel.userDidLogin { + ZStack(alignment: .bottom) { + mainView + + if !isHidden { + tabBarView + } } + .transition(.opacity) + .environmentObject(router) + } else { + LoginView(userDidLogin: $mainViewModel.userDidLogin) + .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: - 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() - } + } + .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 post): + MessagesView(post: post) + .environmentObject(chatsViewModel) + 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 .notifications: + NotificationsSettingsView() + case .login: + LoginView(userDidLogin: $mainViewModel.userDidLogin) + .environmentObject(onboardingViewModel) + case .profile(let id): + ExternalProfileView(userID: id) + case .setupProfile(let netid, let givenName, let familyName, let email, let googleId): + SetupProfileView(userDidLogin: $mainViewModel.userDidLogin, netid: netid, givenName: givenName, familyName: familyName, email: email, googleID: googleId) + .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 { + SavedView() + } else if selection == 2 { + ChatsView() + .environmentObject(chatsViewModel) + } else if selection == 3 { + 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..<4, 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 != 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) } } diff --git a/Resell/Views/MainView.swift b/Resell/Views/MainView.swift index 21f0f1d..7749889 100644 --- a/Resell/Views/MainView.swift +++ b/Resell/Views/MainView.swift @@ -17,34 +17,31 @@ struct MainView: View { @StateObject 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() // 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(router) + .environmentObject(mainViewModel) + .environmentObject(chatsViewModel) + .environmentObject(newListingViewModel) + .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/Onboarding/LoginView.swift b/Resell/Views/Onboarding/LoginView.swift index 16789fa..e7ede4d 100644 --- a/Resell/Views/Onboarding/LoginView.swift +++ b/Resell/Views/Onboarding/LoginView.swift @@ -10,66 +10,55 @@ 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 + DispatchQueue.main.async { + withAnimation { userDidLogin = true } + } } failure: { netid, givenName, familyName, email, googleId in - userDidLogin = false - router.push(.setupProfile(netid: netid, givenName: givenName, familyName: familyName, email: email, googleId: googleId)) + DispatchQueue.main.async { + userDidLogin = false + router.path.append(.setupProfile(netid: netid, givenName: givenName, familyName: familyName, email: email, googleId: googleId)) + } } - - } - } - .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() + FirebaseNotificationService.shared.setupFCMToken() + } .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() - } - } + .loadingView(isLoading: viewModel.isLoading) } + private var loginSheetView: some View { VStack { Text(viewModel.errorText) diff --git a/Resell/Views/Onboarding/VenmoView.swift b/Resell/Views/Onboarding/VenmoView.swift index 5b080ec..46c868c 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..a3dcb3a 100644 --- a/Resell/Views/ProductDetails/ExternalProfileView.swift +++ b/Resell/Views/ProductDetails/ExternalProfileView.swift @@ -24,26 +24,31 @@ struct ExternalProfileView: View { VStack(spacing: 0) { profileImageView .padding(.bottom, 12) + .padding(.horizontal, 24) Text(viewModel.user?.username ?? "") .font(Constants.Fonts.h3) .foregroundStyle(Constants.Colors.black) .padding(.bottom, 4) + .padding(.horizontal, 24) Text(viewModel.user?.givenName ?? "") .font(Constants.Fonts.body2) .foregroundStyle(Constants.Colors.secondaryGray) .padding(.bottom, 16) + .padding(.horizontal, 24) Text(viewModel.user?.bio ?? "") .font(Constants.Fonts.body2) .foregroundStyle(Constants.Colors.black) .padding(.bottom, 28) + .padding(.horizontal, 24) .lineLimit(3) Divider() ProductsGalleryView(items: viewModel.selectedPosts) + .loadingView(isLoading: viewModel.isLoadingUser) } .background(Constants.Colors.white) .toolbar { diff --git a/Resell/Views/ProductDetails/ProductDetailsView.swift b/Resell/Views/ProductDetails/ProductDetailsView.swift index 4d64720..10a3a65 100644 --- a/Resell/Views/ProductDetails/ProductDetailsView.swift +++ b/Resell/Views/ProductDetails/ProductDetailsView.swift @@ -16,7 +16,8 @@ struct ProductDetailsView: View { @EnvironmentObject var router: Router @StateObject private var viewModel = ProductDetailsViewModel() - @State var id: String + + var post: Post // MARK: - UI @@ -48,7 +49,7 @@ struct ProductDetailsView: View { 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 +92,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 @@ -256,7 +255,7 @@ struct ProductDetailsView: View { .frame(width: imageSize, height: imageSize) .clipShape(.rect(cornerRadius: 10)) .onTapGesture { - changeItem(postID: item.id) + changeItem(post: item) } } } @@ -264,11 +263,9 @@ struct ProductDetailsView: View { } } - 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 +279,18 @@ 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 { + navigateToChats(post: item) + } } } .frame(width: UIScreen.width, height: 50) @@ -354,4 +353,20 @@ struct ProductDetailsView: View { .presentationCornerRadius(25) .presentationBackground(Constants.Colors.white) } + + // MARK: - Functions + + private func navigateToChats(post: Post) { + if let existingIndex = router.path.firstIndex(where: { + if case .messages = $0 { + return true + } + return false + }) { + router.path[existingIndex] = .messages(post: post) + router.popTo(router.path[existingIndex]) + } else { + router.push(.messages(post: post)) + } + } } diff --git a/Resell/Views/Settings/BlockedUsersView.swift b/Resell/Views/Settings/BlockedUsersView.swift index 9d7ff0c..71cfaa7 100644 --- a/Resell/Views/Settings/BlockedUsersView.swift +++ b/Resell/Views/Settings/BlockedUsersView.swift @@ -141,8 +141,10 @@ 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 { @@ -150,11 +152,8 @@ struct BlockedUsersView: View { } else { UserSessionManager.shared.logger.error("Error in BlockedUsersView: userID not found.") } - - isLoading = false } catch { NetworkManager.shared.logger.error("Error in BlockedUsersView: \(error.localizedDescription)") - isLoading = false } } } diff --git a/Resell/Views/Settings/NotificationsSettingsView.swift b/Resell/Views/Settings/NotificationsSettingsView.swift index 9d976be..c8e0917 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,20 @@ struct NotificationsSettingsView: View { .tint(Constants.Colors.resellPurple) } + /// Handles toggling notifications and updates Firestore as needed + private func handleNotificationToggle(chatNotificationsDisabled: Bool) { + guard let userEmail = UserSessionManager.shared.email else { + FirestoreManager.shared.logger.error("User email not found while updating notification settings.") + return + } + + Task { + do { + try await FirestoreManager.shared.saveNotificationsEnabled(userEmail: userEmail, notificationsEnabled: !chatNotificationsDisabled) + FirestoreManager.shared.logger.log("Notifications updated for \(userEmail): \(!chatNotificationsDisabled).") + } catch { + FirestoreManager.shared.logger.error("Failed to update notifications for \(userEmail): \(error.localizedDescription)") + } + } + } } diff --git a/Resell/Views/Settings/SettingsView.swift b/Resell/Views/Settings/SettingsView.swift index 6b59714..c978933 100644 --- a/Resell/Views/Settings/SettingsView.swift +++ b/Resell/Views/Settings/SettingsView.swift @@ -129,6 +129,7 @@ struct SettingsView: View { viewModel.logout() router.popToRoot() mainViewModel.selection = 0 + mainViewModel.hidesSignInButton = false mainViewModel.userDidLogin = false } diff --git a/Resell/Views/ViewModifiers/EmptyState.swift b/Resell/Views/ViewModifiers/EmptyState.swift index 9ea38b9..46f7a51 100644 --- a/Resell/Views/ViewModifiers/EmptyState.swift +++ b/Resell/Views/ViewModifiers/EmptyState.swift @@ -33,6 +33,7 @@ struct EmptyStateModifier: ViewModifier { Text(title) .font(Constants.Fonts.h2) + .multilineTextAlignment(.center) .foregroundStyle(Constants.Colors.black) 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) } }