diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..dfac466 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,127 @@ +name: Build with Firebase CLI +on: + push: + branches: + - 'main' + +jobs: + build: + name: Build iOS app IPA + runs-on: macos-latest + steps: + # Step 1: Checkout the code + - name: Checkout + uses: actions/checkout@v4 + + # Step 2: Set up the Build environment + - name: Setup Build Environment + run: | + # Set BUILD_ENV based on branch name + echo "BUILD_ENV=DEV" >> $GITHUB_ENV + echo "FIREBASE_APP_ID_SECRET_NAME=FIREBASE_APP_ID_DEV" >> $GITHUB_ENV + echo "PROVISIONING_PROFILE_NAME=PROVISIONING_PROFILE" >> $GITHUB_ENV + echo "PROVISIONING_PROFILE_SPECIFIER_NAME=" >> $GITHUB_ENV + echo "SCHEME_NAME=" >> $GITHUB_ENV + echo "PRODUCT_NAME=PETRO-PASS\ DEV" >> $GITHUB_ENV + echo "PLIST_FILE_NAME=GoogleService-Info-dev.plist" >> $GITHUB_ENV + + - name: Verify BUILD_ENV + run: | + echo "Current BUILD_ENV: ${{ env.BUILD_ENV }}" + echo "Firebase App Id: ${{ secrets[env.FIREBASE_APP_ID_SECRET_NAME] }}" + echo "Provisioning Profile: ${{ secrets[env.PROVISIONING_PROFILE_NAME] }}" + echo "Provisioning Profile Specifier: ${{ env.PROVISIONING_PROFILE_SPECIFIER_NAME}}" + echo "Scheme Name: ${{ env.SCHEME_NAME }}" + echo "Product Name: ${{ env.PRODUCT_NAME }}" + echo "Plist File Name: ${{ env.PLIST_FILE_NAME }}" + + #Step 3: Set up the environment (CocoaPods, etc) + - name: Set up Ruby for CocoaPods + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.1.0' + + - name: Get xcodebuild version + run: | + sudo xcode-select -s "/Applications/Xcode_16.1.app" + xcodebuild -version + + - name: Install CocoaPods Dependencies + run: | + gem install cocoapods + pod install + + #Step 4: Set up Provisioning Profile and Certificates + - name: Set up Provisioning Profile and Certificates + env: + CERT_DATA: ${{ secrets.P12_CERTIFICATE }} + CERT_PW: ${{ secrets.P12_PASSWORD }} + run: | + + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + echo "${{ secrets[env.PROVISIONING_PROFILE_NAME] }}" | base64 --decode > ~/Library/MobileDevice/Provisioning\ Profiles/profile.mobileprovision + + # Create and unlock keychain + security create-keychain -p "" build.keychain + security list-keychains -s build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "" build.keychain + security set-keychain-settings + + echo $CERT_DATA | base64 --decode > signingCertificate.p12 + echo "certificate decoded" + security import signingCertificate.p12 -f pkcs12 -k build.keychain -P $CERT_PW -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain + + file signingCertificate.p12 + + rm signingCertificate.p12 + + #Step 5: Clean and Build the iOS App + - name: Clean and Build the iOS App + run: | + xcodebuild clean \ + -workspace .xcworkspace \ + -scheme "${{ env.SCHEME_NAME }}" \ + -configuration "AdHoc" \ + -sdk iphoneos \ + -quiet + + echo "Clean success" + + # Archive the app + xcodebuild -scheme "${{ env.SCHEME_NAME }}" -workspace .xcworkspace -sdk iphoneos -archivePath .xcarchive clean archive PROVISIONING_PROFILE_SPECIFIER="${{ env.PROVISIONING_PROFILE_SPECIFIER_NAME}}" CODE_SIGN_STYLE="Manual" + + echo "Archive success" + + - name: Upload dSYM + run: | + ls Pods/FirebaseCrashlytics/ + ls Pods/FirebaseCrashlytics + Pods/FirebaseCrashlytics/upload-symbols -gsp /Firebase/${{ env.PLIST_FILE_NAME }} -p ios .xcarchive/dSYMs/${{ env.PRODUCT_NAME }}.app.dSYM + + - name: Export the IPA + run: | + xcodebuild -exportArchive \ + -archivePath .xcarchive \ + -exportOptionsPlist plist/ExportOptions.plist \ + -exportPath build + + ls build + + - name: Install Firebase Tools + run: | + curl -sL https://firebase.tools | bash + + - name: Get Firebase Tools Version + run: | + firebase --version + + ## For dev should be update to APK + - name: Publish App to Firebase App Distribution + run: | + firebase appdistribution:distribute build/${{ env.PRODUCT_NAME }}.ipa \ + --app ${{ secrets[env.FIREBASE_APP_ID_SECRET_NAME] }} \ + --groups "${{ env.BUILD_ENV }}" \ + --release-notes "${{ github.event.head_commit.message }}" \ + --token ${{ secrets.FIREBASE_TOKEN }} \ No newline at end of file diff --git a/TorqueNet.xcodeproj/project.pbxproj b/TorqueNet.xcodeproj/project.pbxproj index eb26b42..64550c0 100644 --- a/TorqueNet.xcodeproj/project.pbxproj +++ b/TorqueNet.xcodeproj/project.pbxproj @@ -541,10 +541,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = TorqueNet/TorqueNet.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"TorqueNet/Preview Content\""; - DEVELOPMENT_TEAM = JJ5G6DQS7M; + DEVELOPMENT_TEAM = 89SKCJ295N; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = TorqueNet/Info.plist; @@ -574,10 +575,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = TorqueNet/TorqueNet.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"TorqueNet/Preview Content\""; - DEVELOPMENT_TEAM = JJ5G6DQS7M; + DEVELOPMENT_TEAM = 89SKCJ295N; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = TorqueNet/Info.plist; diff --git a/TorqueNet.xcodeproj/project.xcworkspace/xcuserdata/mac.xcuserdatad/UserInterfaceState.xcuserstate b/TorqueNet.xcodeproj/project.xcworkspace/xcuserdata/mac.xcuserdatad/UserInterfaceState.xcuserstate index e586e5c..c177c6e 100644 Binary files a/TorqueNet.xcodeproj/project.xcworkspace/xcuserdata/mac.xcuserdatad/UserInterfaceState.xcuserstate and b/TorqueNet.xcodeproj/project.xcworkspace/xcuserdata/mac.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/TorqueNet/Core/Firebase/AppDelegate.swift b/TorqueNet/Core/Firebase/AppDelegate.swift index 3839747..8efcf1d 100644 --- a/TorqueNet/Core/Firebase/AppDelegate.swift +++ b/TorqueNet/Core/Firebase/AppDelegate.swift @@ -7,13 +7,66 @@ import SwiftUI import FirebaseCore +import FirebaseMessaging +import UserNotifications +import FirebaseAppCheck +import FirebaseCrashlytics -class AppDelegate: NSObject, UIApplicationDelegate { + +class AppDelegate: NSObject, UIApplicationDelegate, MessagingDelegate, UNUserNotificationCenterDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { - FirebaseApp.configure() - + registerFirebase(application: application) return true } + + private func registerFirebase(application: UIApplication){ + application.registerForRemoteNotifications() + + let providerFactory = AppCheckDebugProviderFactory() // Use App Check with the debug provider on Apple platforms + AppCheck.setAppCheckProviderFactory(providerFactory) + + FirebaseApp.configure() + Messaging.messaging().delegate = self + UNUserNotificationCenter.current().delegate = self + Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(true) + } + + //Firebase + // set APNs token + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + Messaging.messaging().apnsToken = deviceToken + print("DEBUG: APNs token is \(deviceToken)") + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + print("DEBUG: Failed to register for remote notifications: \(error.localizedDescription)") + } + + //Recieve FCM Token + func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { + print("DEBUG: Getting token") + Constants.FCM_TOKEN = fcmToken ?? "" + print("FCM Token: \(fcmToken ?? "")") + + if let fcm = Messaging.messaging().fcmToken { + print("DEBUG: fcm token: ", fcm) + } + } + + // handle notifications. This will enable to also to receive notifications in foreground. + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { + // play sound, show badge/banner, and list(notifications that appears when app is locked) + return [.sound, .badge, .banner, .list] + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { + print("DEBUG: User tapped the notification.") + + // "link" is a key in push notification payload + if let deepLink = response.notification.request.content.userInfo["link"] as? String, let url = URL(string: deepLink) { + print("DEBUG: Received deep link \(deepLink)") + } + } } diff --git a/TorqueNet/Core/Utils/Constant.swift b/TorqueNet/Core/Utils/Constant.swift index 964d5d2..d063fe0 100644 --- a/TorqueNet/Core/Utils/Constant.swift +++ b/TorqueNet/Core/Utils/Constant.swift @@ -7,6 +7,7 @@ class Constants { static let isDashboardBottomNavigationVisible = "isDashboardBottomNavigationVisible" + static var FCM_TOKEN: String = "" } diff --git a/TorqueNet/Features/Auction/Domain/Model/AuctionUploadModel.swift b/TorqueNet/Features/Auction/Domain/Model/AuctionUploadModel.swift index 91d3498..e2b0abf 100644 --- a/TorqueNet/Features/Auction/Domain/Model/AuctionUploadModel.swift +++ b/TorqueNet/Features/Auction/Domain/Model/AuctionUploadModel.swift @@ -21,6 +21,8 @@ struct AuctionUploadModel: Identifiable, Codable { let startingBid: Double let currentBid: Double let auctionEndDate: Timestamp + let startDate: Timestamp + let endDate: Timestamp let auctionStatus: String let createdAt: Timestamp let updatedAt: Timestamp @@ -66,6 +68,8 @@ struct AuctionUploadModel: Identifiable, Codable { case startingBid = "starting_bid" case currentBid = "current_bid" case auctionEndDate = "auction_end_date" + case startDate = "start_date" + case endDate = "end_date" case auctionStatus = "auction_status" case createdAt = "created_at" case updatedAt = "updated_at" diff --git a/TorqueNet/Features/Auction/Domain/UseCase/UploadAuctionUseCase.swift b/TorqueNet/Features/Auction/Domain/UseCase/UploadAuctionUseCase.swift index c07587c..3b87be3 100644 --- a/TorqueNet/Features/Auction/Domain/UseCase/UploadAuctionUseCase.swift +++ b/TorqueNet/Features/Auction/Domain/UseCase/UploadAuctionUseCase.swift @@ -28,6 +28,8 @@ class UploadAuctionUseCase { rating: Double, startingBid: String, auctionEndDate: Date, + startDate: Date, + endDate: Date, auctionStatus: UploadAuctionStatus, mileage: String, year: String, @@ -109,6 +111,8 @@ class UploadAuctionUseCase { startingBid: bidAmount, currentBid: bidAmount, auctionEndDate: Timestamp(date: auctionEndDate), + startDate: Timestamp(date: startDate), + endDate: Timestamp(date: endDate), auctionStatus: auctionStatus.rawValue, createdAt: Timestamp(date: Date()), updatedAt: Timestamp(date: Date()), @@ -161,6 +165,8 @@ class UploadAuctionUseCase { startingBid: auction.startingBid, currentBid: auction.currentBid, auctionEndDate: Timestamp(date: newEndTime ?? Date()), + startDate: auction.startDate, + endDate: auction.endDate, auctionStatus: newStatus, createdAt: auction.createdAt, updatedAt: Timestamp(date: Date()), diff --git a/TorqueNet/Features/Auction/Presentation/AuctionDetails/AuctionCarDetailView.swift b/TorqueNet/Features/Auction/Presentation/AuctionDetails/AuctionCarDetailView.swift index 0f2c068..d6f7b6a 100644 --- a/TorqueNet/Features/Auction/Presentation/AuctionDetails/AuctionCarDetailView.swift +++ b/TorqueNet/Features/Auction/Presentation/AuctionDetails/AuctionCarDetailView.swift @@ -944,7 +944,6 @@ struct BidSheetView: View { } .disabled(auctionDetailsUiState.bidAmount.isEmpty) } - .background(Color.theme.surfaceColor) .padding() .navigationBarTitleDisplayMode(.inline) .navigationBarItems( @@ -952,6 +951,7 @@ struct BidSheetView: View { presentationMode.wrappedValue.dismiss() } ) + .background(Color.theme.surfaceColor) } } diff --git a/TorqueNet/Features/Auction/Presentation/UploadAuction/AuctionFormState.swift b/TorqueNet/Features/Auction/Presentation/UploadAuction/AuctionFormState.swift index d901914..c6776f8 100644 --- a/TorqueNet/Features/Auction/Presentation/UploadAuction/AuctionFormState.swift +++ b/TorqueNet/Features/Auction/Presentation/UploadAuction/AuctionFormState.swift @@ -47,5 +47,10 @@ struct AuctionFormState { var transmissionDetails: String = "" var electronicsRating: Double = 9.0 var electronicsDetails: String = "" + + //Schedule auction + var startDate: Date = Date().addingTimeInterval(3600) + var endDate: Date = Date().addingTimeInterval(86400) + var scheduleError: String = "" } diff --git a/TorqueNet/Features/Auction/Presentation/UploadAuction/AuctionUploadView.swift b/TorqueNet/Features/Auction/Presentation/UploadAuction/AuctionUploadView.swift index 078f019..24400fd 100644 --- a/TorqueNet/Features/Auction/Presentation/UploadAuction/AuctionUploadView.swift +++ b/TorqueNet/Features/Auction/Presentation/UploadAuction/AuctionUploadView.swift @@ -227,6 +227,17 @@ struct AuctionUploadView: View { } } .pickerStyle(.segmented) + + AuctionScheduleComponent( + startDate: $uploadAuctionViewModel.form.startDate, + endDate: $uploadAuctionViewModel.form.endDate, + startingBid: $uploadAuctionViewModel.form.startingBid, + scheduleError: $uploadAuctionViewModel.form.scheduleError, + onValidate: { + uploadAuctionViewModel.validateSchedule() + } + ) + .transition(.opacity.combined(with: .scale)) } .padding() .background( diff --git a/TorqueNet/Features/Auction/Presentation/UploadAuction/UploadAuctionViewModel.swift b/TorqueNet/Features/Auction/Presentation/UploadAuction/UploadAuctionViewModel.swift index 41ff214..58e27d9 100644 --- a/TorqueNet/Features/Auction/Presentation/UploadAuction/UploadAuctionViewModel.swift +++ b/TorqueNet/Features/Auction/Presentation/UploadAuction/UploadAuctionViewModel.swift @@ -205,6 +205,8 @@ class AuctionUploadViewModel: ObservableObject { rating: form.rating, startingBid: form.startingBid, auctionEndDate: form.auctionEndDate, + startDate: form.startDate, + endDate: form.endDate, auctionStatus: form.auctionStatus, mileage: form.mileage, year: form.year, @@ -268,6 +270,37 @@ class AuctionUploadViewModel: ObservableObject { ui = AuctionUploadUIState() form = AuctionFormState() } + + func validateSchedule() { + form.scheduleError = "" + + if form.startDate <= Date() { + form.scheduleError = "Start date must be in the future" + return + } + + let oneHourFromNow = Date().addingTimeInterval(3600) + if form.startDate < oneHourFromNow { + form.scheduleError = "Start date must be at least 1 hour from now" + return + } + + if form.endDate <= form.startDate { + form.scheduleError = "End date must be after start date" + return + } + + if !form.startingBid.isEmpty { + if let bid = Double(form.startingBid), bid <= 0 { + form.scheduleError = "Starting bid must be greater than 0" + return + } else if Double(form.startingBid) == nil { + form.scheduleError = "Please enter a valid starting bid" + return + } + } + } + func saveDraft() { diff --git a/TorqueNet/Features/Schedule/Data/Repository/ScheduleAuctionRepositoryImpl.swift b/TorqueNet/Features/Schedule/Data/Repository/ScheduleAuctionRepositoryImpl.swift new file mode 100644 index 0000000..6613dad --- /dev/null +++ b/TorqueNet/Features/Schedule/Data/Repository/ScheduleAuctionRepositoryImpl.swift @@ -0,0 +1,59 @@ +// +// ScheduleAuctionRepositoryImpl.swift +// TorqueNet +// +// Created by MAC on 17/02/2026. +// + +class ScheduleAuctionRepositoryImpl: ScheduleAuctionRepository { + static let shared = ScheduleAuctionRepositoryImpl() + func fetchAuctions() async -> Result<[AuctionUploadModel], UploadError> { + do { + let snapshot = try await FirestoreConstants.AuctionsCollection + .getDocuments() + + let auctions: [AuctionUploadModel] = snapshot.documents.compactMap { document in + try? document.data(as: AuctionUploadModel.self) + } + + return .success(auctions) + + } catch { + return .failure( + .firestoreFetchFailed(error.localizedDescription, "auctions") + ) + } + } + + func fetchScheduledAuctions() async -> Result<[ScheduledAuction], UploadError> { + let result = await fetchAuctions() + + switch result { + case .success(let auctions): + let scheduled = auctions + .filter { $0.auctionStatus == AuctionStatus.upcoming.rawValue || $0.auctionStatus == AuctionStatus.ongoing.rawValue} + .compactMap { mapToScheduledAuction($0) } + return .success(scheduled) + + case .failure(let error): + return .failure(error) + } + } + + + private func mapToScheduledAuction(_ model: AuctionUploadModel) -> ScheduledAuction? { + guard let status = AuctionStatus(rawValue: model.auctionStatus) else { + return nil + } + + return ScheduledAuction( + id: model.id, + title: model.carTitle, + startDate: model.startDate.dateValue(), + endDate: model.endDate.dateValue(), + startingBid: model.startingBid, + status: status, + imageName: model.imageUrls.first ?? "" + ) + } +} diff --git a/TorqueNet/Features/Schedule/Domain/Model/AuctionStatus.swift b/TorqueNet/Features/Schedule/Domain/Model/AuctionStatus.swift new file mode 100644 index 0000000..648b866 --- /dev/null +++ b/TorqueNet/Features/Schedule/Domain/Model/AuctionStatus.swift @@ -0,0 +1,25 @@ +// +// AuctionStatus.swift +// TorqueNet +// +// Created by MAC on 16/02/2026. +// + +import SwiftUI + +enum AuctionStatus: String { + + case upcoming = "Upcoming" + case ongoing = "Ongoing" + case completed = "Completed" + case featured = "Featured" + + var color: Color { + switch self { + case .upcoming: return .gray + case .ongoing: return .red + case .completed: return .orange + case .featured: return .green + } + } +} diff --git a/TorqueNet/Features/Schedule/Domain/Model/ScheduledAuction.swift b/TorqueNet/Features/Schedule/Domain/Model/ScheduledAuction.swift new file mode 100644 index 0000000..cad685f --- /dev/null +++ b/TorqueNet/Features/Schedule/Domain/Model/ScheduledAuction.swift @@ -0,0 +1,19 @@ +// +// ScheduledAuction.swift +// TorqueNet +// +// Created by MAC on 16/02/2026. +// + +import Foundation + +struct ScheduledAuction: Identifiable { + let id:String + let title: String + let startDate: Date + let endDate: Date? + let startingBid: Double? + let status: AuctionStatus + let imageName: String +} + diff --git a/TorqueNet/Features/Schedule/Domain/Repository/ScheduleAuctionRepository.swift b/TorqueNet/Features/Schedule/Domain/Repository/ScheduleAuctionRepository.swift new file mode 100644 index 0000000..0ac9177 --- /dev/null +++ b/TorqueNet/Features/Schedule/Domain/Repository/ScheduleAuctionRepository.swift @@ -0,0 +1,11 @@ +// +// ScheduleAuctionRepository.swift +// TorqueNet +// +// Created by MAC on 17/02/2026. +// + +protocol ScheduleAuctionRepository { + func fetchAuctions() async -> Result<[AuctionUploadModel], UploadError> + func fetchScheduledAuctions() async -> Result<[ScheduledAuction], UploadError> +} diff --git a/TorqueNet/Features/Schedule/Domain/UseCase/FetchScheduledAuctionsUseCase.swift b/TorqueNet/Features/Schedule/Domain/UseCase/FetchScheduledAuctionsUseCase.swift new file mode 100644 index 0000000..e4fe70c --- /dev/null +++ b/TorqueNet/Features/Schedule/Domain/UseCase/FetchScheduledAuctionsUseCase.swift @@ -0,0 +1,18 @@ +// +// FetchScheduledAuctionsUseCase.swift +// TorqueNet +// +// Created by MAC on 17/02/2026. +// + +class FetchScheduledAuctionsUseCase { + private let repository: ScheduleAuctionRepository + + init(repository: ScheduleAuctionRepository) { + self.repository = repository + } + + func execute() async -> Result<[ScheduledAuction], UploadError> { + return await repository.fetchScheduledAuctions() + } +} diff --git a/TorqueNet/Features/Schedule/Presentation/AuctionScheduleComponent.swift b/TorqueNet/Features/Schedule/Presentation/AuctionScheduleComponent.swift new file mode 100644 index 0000000..c26a6b4 --- /dev/null +++ b/TorqueNet/Features/Schedule/Presentation/AuctionScheduleComponent.swift @@ -0,0 +1,249 @@ +// +// AuctionScheduleComponent.swift +// TorqueNet +// +// Created by MAC on 16/02/2026. +// + +import SwiftUI + +struct AuctionScheduleComponent: View { + @Binding var startDate: Date + @Binding var endDate: Date + @Binding var startingBid: String + @Binding var scheduleError: String + + let onValidate: () -> Void + + @State private var showStartDatePicker = false + @State private var showEndDatePicker = false + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Schedule Auction") + .font(.custom("Exo2-Medium", size: 16)) + .foregroundColor(.theme.onSurfaceColor) + + VStack(alignment: .leading, spacing: 8) { + Text("Start Date & Time") + .font(.custom("Exo2-Regular", size: 14)) + .foregroundColor(.theme.onSurfaceColor.opacity(0.6)) + + Button(action: { + showStartDatePicker.toggle() + }) { + HStack { + Image(systemName: "calendar") + .foregroundColor(.theme.primaryColor) + + Text(formatDate(startDate)) + .font(.custom("Exo2-Regular", size: 14)) + .foregroundColor(.theme.onSurfaceColor) + + Spacer() + + Image(systemName: "chevron.down") + .font(.system(size: 12)) + .foregroundColor(.theme.onSurfaceColor.opacity(0.4)) + .rotationEffect(.degrees(showStartDatePicker ? 180 : 0)) + } + .padding() + .background(Color.theme.surfaceColor) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.theme.onSurfaceColor.opacity(0.2), lineWidth: 1) + ) + } + + if showStartDatePicker { + DatePicker( + "", + selection: $startDate, + in: Date()..., + displayedComponents: [.date, .hourAndMinute] + ) + .datePickerStyle(.graphical) + .padding() + .background(Color.theme.surfaceColor) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.theme.onSurfaceColor.opacity(0.1), lineWidth: 1) + ) + .onChange(of: startDate) { _ in + onValidate() + } + } + } + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("End Date & Time") + .font(.custom("Exo2-Regular", size: 14)) + .foregroundColor(.theme.onSurfaceColor.opacity(0.6)) + + Text("(Optional)") + .font(.custom("Exo2-Light", size: 12)) + .foregroundColor(.theme.onSurfaceColor.opacity(0.4)) + } + + Button(action: { + showEndDatePicker.toggle() + }) { + HStack { + Image(systemName: "calendar.badge.clock") + .foregroundColor(.theme.primaryColor) + + Text(formatDate(endDate)) + .font(.custom("Exo2-Regular", size: 14)) + .foregroundColor(.theme.onSurfaceColor) + + Spacer() + + Image(systemName: "chevron.down") + .font(.system(size: 12)) + .foregroundColor(.theme.onSurfaceColor.opacity(0.4)) + .rotationEffect(.degrees(showEndDatePicker ? 180 : 0)) + } + .padding() + .background(Color.theme.surfaceColor) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.theme.onSurfaceColor.opacity(0.2), lineWidth: 1) + ) + } + + if showEndDatePicker { + DatePicker( + "", + selection: $endDate, + in: startDate..., + displayedComponents: [.date, .hourAndMinute] + ) + .datePickerStyle(.graphical) + .padding() + .background(Color.theme.surfaceColor) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.theme.onSurfaceColor.opacity(0.1), lineWidth: 1) + ) + .onChange(of: endDate) { _ in + onValidate() + } + } + } + + HStack { + Image(systemName: "clock.fill") + .foregroundColor(.theme.primaryColor) + .font(.system(size: 14)) + + Text("Duration: \(calculateDuration())") + .font(.custom("Exo2-Regular", size: 14)) + .foregroundColor(.theme.onSurfaceColor.opacity(0.6)) + } + .padding(.vertical, 8) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Starting Bid") + .font(.custom("Exo2-Regular", size: 14)) + .foregroundColor(.theme.onSurfaceColor.opacity(0.6)) + + Text("(Optional)") + .font(.custom("Exo2-Light", size: 12)) + .foregroundColor(.theme.onSurfaceColor.opacity(0.4)) + } + + HStack { + Text("$") + .font(.custom("Exo2-SemiBold", size: 16)) + .foregroundColor(.theme.primaryColor) + + TextField("0.00", text: $startingBid) + .font(.custom("Exo2-Regular", size: 14)) + .keyboardType(.decimalPad) + .onChange(of: startingBid) { _ in + onValidate() + } + } + .padding() + .background(Color.theme.surfaceColor) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.theme.onSurfaceColor.opacity(0.2), lineWidth: 1) + ) + } + + if !scheduleError.isEmpty { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + .font(.system(size: 14)) + + Text(scheduleError) + .font(.custom("Exo2-Regular", size: 12)) + .foregroundColor(.red) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.red.opacity(0.1)) + .cornerRadius(8) + } + + HStack(spacing: 12) { + Image(systemName: "info.circle.fill") + .foregroundColor(.theme.primaryColor) + .font(.system(size: 20)) + + VStack(alignment: .leading, spacing: 4) { + Text("Scheduling Tips") + .font(.custom("Exo2-SemiBold", size: 13)) + .foregroundColor(.theme.onSurfaceColor) + + Text("Set a start time at least 1 hour from now. Consider timezone differences for your bidders.") + .font(.custom("Exo2-Regular", size: 11)) + .foregroundColor(.theme.onSurfaceColor.opacity(0.6)) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding() + .background(Color.theme.primaryColor.opacity(0.1)) + .cornerRadius(12) + } + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(.ultraThinMaterial) + ) + .animation(.easeInOut(duration: 0.3), value: showStartDatePicker) + .animation(.easeInOut(duration: 0.3), value: showEndDatePicker) + } + + private func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: date) + } + + private func calculateDuration() -> String { + let interval = endDate.timeIntervalSince(startDate) + let hours = Int(interval) / 3600 + let minutes = (Int(interval) % 3600) / 60 + + if hours > 24 { + let days = hours / 24 + let remainingHours = hours % 24 + return "\(days)d \(remainingHours)h" + } else if hours > 0 { + return "\(hours)h \(minutes)m" + } else { + return "\(minutes)m" + } + } +} diff --git a/TorqueNet/Features/Schedule/Presentation/AuctionScheduleView.swift b/TorqueNet/Features/Schedule/Presentation/AuctionScheduleView.swift index 7b7b4c8..9d71f64 100644 --- a/TorqueNet/Features/Schedule/Presentation/AuctionScheduleView.swift +++ b/TorqueNet/Features/Schedule/Presentation/AuctionScheduleView.swift @@ -8,11 +8,7 @@ import SwiftUI struct AuctionScheduleView: View { - @State private var selectedDate = Date() - @State private var currentMonth = Date() - @State private var scheduledAuctions: [ScheduledAuction] = sampleScheduledAuctions - @State private var showingDayDetail = false - @State private var selectedDayAuctions: [ScheduledAuction] = [] + @StateObject var scheduleAuctionViewModel = ScheduledAuctionViewModel() @EnvironmentObject var router: Router private let calendar = Calendar.current @@ -26,41 +22,35 @@ struct AuctionScheduleView: View { ScrollView(.vertical,showsIndicators: false) { VStack(spacing: 0) { - Text(dateFormatter.string(from: currentMonth)) + Text(dateFormatter.string(from: scheduleAuctionViewModel.uiState.currentMonth)) .font(.custom("Exo2-Regular", size: 16)) .foregroundColor(.gray) - // Calendar calendarView - // Today's auctions (if any) todaysAuctionsView } - .background(Color.theme.surfaceColor) .customTopAppBar( title: "Auction Schedule", leadingIcon: "chevron.left", onLeadingTap: { router.pop() }, trailingMenu: { HStack(spacing: 16) { - // Previous month Button(action: previousMonth) { Image(systemName: "chevron.left") .font(.system(size: 18, weight: .medium)) .foregroundColor(.theme.primaryColor) } - // Next month Button(action: nextMonth) { Image(systemName: "chevron.right") .font(.system(size: 18, weight: .medium)) .foregroundColor(.theme.primaryColor) } - // Today button Button("Today") { withAnimation(.easeInOut) { - currentMonth = Date() - selectedDate = Date() + scheduleAuctionViewModel.uiState.currentMonth = Date() + scheduleAuctionViewModel.uiState.selectedDate = Date() } } .font(.custom("Exo2-Medium", size: 14)) @@ -69,21 +59,26 @@ struct AuctionScheduleView: View { } ) } - .sheet(isPresented: $showingDayDetail) { + .sheet(isPresented: $scheduleAuctionViewModel.uiState.showingDayDetail) { DayDetailView( - date: selectedDate, - auctions: selectedDayAuctions + date: scheduleAuctionViewModel.uiState.selectedDate, + auctions: scheduleAuctionViewModel.uiState.selectedDayAuctions ) } + .onAppear(){ + Task{ + await scheduleAuctionViewModel.loadScheduledAuctions() + } + } + .background(Color.theme.surfaceColor) + .fullScreenProgressOverlay(isShowing: scheduleAuctionViewModel.uiState.auctionState == .isLoading ) } private var calendarView: some View { VStack(spacing: 0) { - // Weekday headers weekdayHeaders - // Calendar grid calendarGrid } .padding(.horizontal, 20) @@ -109,14 +104,14 @@ struct AuctionScheduleView: View { ForEach(days, id: \.self) { date in CalendarDayView( date: date, - isSelected: calendar.isDate(date, inSameDayAs: selectedDate), - isCurrentMonth: calendar.isDate(date, equalTo: currentMonth, toGranularity: .month), + isSelected: calendar.isDate(date, inSameDayAs: scheduleAuctionViewModel.uiState.selectedDate), + isCurrentMonth: calendar.isDate(date, equalTo: scheduleAuctionViewModel.uiState.currentMonth, toGranularity: .month), auctions: auctionsForDate(date) ) { - selectedDate = date - selectedDayAuctions = auctionsForDate(date) - if !selectedDayAuctions.isEmpty { - showingDayDetail = true + scheduleAuctionViewModel.uiState.selectedDate = date + scheduleAuctionViewModel.uiState.selectedDayAuctions = auctionsForDate(date) + if !scheduleAuctionViewModel.uiState.selectedDayAuctions.isEmpty { + scheduleAuctionViewModel.uiState.showingDayDetail = true } } } @@ -151,8 +146,8 @@ struct AuctionScheduleView: View { .padding(.top, 20) } - private func generateCalendarDays() -> [Date] { - let startOfMonth = calendar.dateInterval(of: .month, for: currentMonth)?.start ?? currentMonth + func generateCalendarDays() -> [Date] { + let startOfMonth = calendar.dateInterval(of: .month, for: scheduleAuctionViewModel.uiState.currentMonth)?.start ?? scheduleAuctionViewModel.uiState.currentMonth let startOfCalendar = calendar.dateInterval(of: .weekOfYear, for: startOfMonth)?.start ?? startOfMonth var days: [Date] = [] @@ -167,25 +162,24 @@ struct AuctionScheduleView: View { } private func auctionsForDate(_ date: Date) -> [ScheduledAuction] { - return scheduledAuctions.filter { auction in + return scheduleAuctionViewModel.uiState.scheduledAuctions.filter { auction in calendar.isDate(auction.startDate, inSameDayAs: date) }.sorted { $0.startDate < $1.startDate } } private func previousMonth() { withAnimation(.easeInOut) { - currentMonth = calendar.date(byAdding: .month, value: -1, to: currentMonth) ?? currentMonth + scheduleAuctionViewModel.uiState.currentMonth = calendar.date(byAdding: .month, value: -1, to: scheduleAuctionViewModel.uiState.currentMonth) ?? scheduleAuctionViewModel.uiState.currentMonth } } private func nextMonth() { withAnimation(.easeInOut) { - currentMonth = calendar.date(byAdding: .month, value: 1, to: currentMonth) ?? currentMonth + scheduleAuctionViewModel.uiState.currentMonth = calendar.date(byAdding: .month, value: 1, to: scheduleAuctionViewModel.uiState.currentMonth) ?? scheduleAuctionViewModel.uiState.currentMonth } } } -// MARK: - Calendar Day View struct CalendarDayView: View { let date: Date let isSelected: Bool @@ -223,7 +217,7 @@ struct CalendarDayView: View { VStack(spacing: 2) { ForEach(Array(auctions.prefix(3).enumerated()), id: \.offset) { index, auction in RoundedRectangle(cornerRadius: 2) - .fill(auction.category.color) + .fill(Color.theme.primaryColor) .frame(height: 3) .opacity(isCurrentMonth ? 1 : 0.5) } @@ -243,7 +237,6 @@ struct CalendarDayView: View { } } -// MARK: - Today Auction Card struct TodayAuctionCard: View { let auction: ScheduledAuction @@ -269,11 +262,9 @@ struct TodayAuctionCard: View { } .frame(width: 80, alignment: .leading) - // Auction info HStack(spacing: 12) { - // Category color indicator RoundedRectangle(cornerRadius: 2) - .fill(auction.category.color) + .fill(Color.theme.primaryColor) .frame(width: 4, height: 50) VStack(alignment: .leading, spacing: 4) { @@ -282,10 +273,6 @@ struct TodayAuctionCard: View { .foregroundColor(.theme.onSurfaceColor) .lineLimit(2) - Text(auction.category.rawValue.capitalized) - .font(.custom("Exo2-Regular", size: 12)) - .foregroundColor(auction.category.color) - if let startingBid = auction.startingBid { Text("Starting at $\(startingBid, specifier: "%.0f")") .font(.custom("Exo2-Medium", size: 14)) @@ -295,7 +282,6 @@ struct TodayAuctionCard: View { Spacer() - // Status AuctionStatusBadge(status: auction.status) } } @@ -308,7 +294,6 @@ struct TodayAuctionCard: View { } } -// MARK: - Auction Status Badge struct AuctionStatusBadge: View { let status: AuctionStatus @@ -325,7 +310,6 @@ struct AuctionStatusBadge: View { } } -// MARK: - Day Detail View struct DayDetailView: View { let date: Date let auctions: [ScheduledAuction] @@ -359,136 +343,3 @@ struct DayDetailView: View { AuctionScheduleView() } } - -// MARK: - Data Models -struct ScheduledAuction: Identifiable { - let id = UUID() - let title: String - let category: AuctionCategory - let startDate: Date - let endDate: Date? - let startingBid: Double? - let status: AuctionStatus - let imageName: String -} - -enum AuctionCategory: String, CaseIterable { - case art = "art" - case electronics = "electronics" - case collectibles = "collectibles" - case jewelry = "jewelry" - case vehicles = "vehicles" - case furniture = "furniture" - - var color: Color { - switch self { - case .art: return .purple - case .electronics: return .blue - case .collectibles: return .orange - case .jewelry: return .pink - case .vehicles: return .red - case .furniture: return .green - } - } -} - -enum AuctionStatus: String { - case scheduled = "scheduled" - case live = "live" - case ending = "ending soon" - case ended = "ended" - - var color: Color { - switch self { - case .scheduled: return .gray - case .live: return .red - case .ending: return .orange - case .ended: return .green - } - } -} - -// MARK: - Sample Data -let sampleScheduledAuctions: [ScheduledAuction] = [ - // Today - ScheduledAuction( - title: "Vintage Watch Collection", - category: .collectibles, - startDate: Date().addingTimeInterval(3600), // 1 hour from now - endDate: Date().addingTimeInterval(7200), // 2 hours from now - startingBid: 500, - status: .scheduled, - imageName: "vintage_watch" - ), - ScheduledAuction( - title: "Modern Art Paintings", - category: .art, - startDate: Date().addingTimeInterval(7200), // 2 hours from now - endDate: Date().addingTimeInterval(14400), // 4 hours from now - startingBid: 1000, - status: .scheduled, - imageName: "modern_art" - ), - - // Tomorrow - ScheduledAuction( - title: "Classic Car Auction", - category: .vehicles, - startDate: Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date(), - endDate: Calendar.current.date(byAdding: .day, value: 1, to: Date().addingTimeInterval(10800)), - startingBid: 25000, - status: .scheduled, - imageName: "classic_car" - ), - - // Next week - ScheduledAuction( - title: "Diamond Jewelry Collection", - category: .jewelry, - startDate: Calendar.current.date(byAdding: .day, value: 3, to: Date()) ?? Date(), - endDate: Calendar.current.date(byAdding: .day, value: 3, to: Date().addingTimeInterval(7200)), - startingBid: 2000, - status: .scheduled, - imageName: "diamond_ring" - ), - ScheduledAuction( - title: "Antique Furniture", - category: .furniture, - startDate: Calendar.current.date(byAdding: .day, value: 5, to: Date()) ?? Date(), - endDate: Calendar.current.date(byAdding: .day, value: 5, to: Date().addingTimeInterval(10800)), - startingBid: 800, - status: .scheduled, - imageName: "antique_furniture" - ), - - // Electronics spread across different days - ScheduledAuction( - title: "Latest Tech Gadgets", - category: .electronics, - startDate: Calendar.current.date(byAdding: .day, value: 7, to: Date()) ?? Date(), - endDate: Calendar.current.date(byAdding: .day, value: 7, to: Date().addingTimeInterval(5400)), - startingBid: 300, - status: .scheduled, - imageName: "tech_gadgets" - ), - - // More auctions for variety - ScheduledAuction( - title: "Rare Book Collection", - category: .collectibles, - startDate: Calendar.current.date(byAdding: .day, value: 10, to: Date()) ?? Date(), - endDate: Calendar.current.date(byAdding: .day, value: 10, to: Date().addingTimeInterval(9000)), - startingBid: 150, - status: .scheduled, - imageName: "rare_books" - ), - ScheduledAuction( - title: "Contemporary Sculptures", - category: .art, - startDate: Calendar.current.date(byAdding: .day, value: 12, to: Date()) ?? Date(), - endDate: Calendar.current.date(byAdding: .day, value: 12, to: Date().addingTimeInterval(12600)), - startingBid: 5000, - status: .scheduled, - imageName: "sculptures" - ) -] diff --git a/TorqueNet/Features/Schedule/Presentation/ScheduleAuctionViewModel.swift b/TorqueNet/Features/Schedule/Presentation/ScheduleAuctionViewModel.swift new file mode 100644 index 0000000..859055e --- /dev/null +++ b/TorqueNet/Features/Schedule/Presentation/ScheduleAuctionViewModel.swift @@ -0,0 +1,33 @@ +// +// ScheduleAuctionViewModel.swift +// TorqueNet +// +// Created by MAC on 17/02/2026. +// + +import Foundation + + +@MainActor +class ScheduledAuctionViewModel: ObservableObject { + @Published var uiState = ScheduledAuctionUiState() + let fetchScheduledAuctionsUseCase: FetchScheduledAuctionsUseCase = FetchScheduledAuctionsUseCase(repository: ScheduleAuctionRepositoryImpl.shared) + + + func loadScheduledAuctions() async { + uiState.auctionState = .isLoading + let result = await fetchScheduledAuctionsUseCase.execute() + + switch result { + case .success(let auctions): + uiState.scheduledAuctions = auctions + uiState.auctionState = .good + + case .failure(let error): + let message = error.errorDescription?.description ?? "An unexpected error occurred." + uiState.auctionState = .error(message) + uiState.errorMessage = message + //onFailure(message) + } + } +} diff --git a/TorqueNet/Features/Schedule/Presentation/ScheduledAuctionUiState.swift b/TorqueNet/Features/Schedule/Presentation/ScheduledAuctionUiState.swift new file mode 100644 index 0000000..841d9ea --- /dev/null +++ b/TorqueNet/Features/Schedule/Presentation/ScheduledAuctionUiState.swift @@ -0,0 +1,22 @@ +// +// ScheduledAuctionUiState.swift +// TorqueNet +// +// Created by MAC on 17/02/2026. +// + +import Foundation + +struct ScheduledAuctionUiState { + var scheduledAuctions: [ScheduledAuction] = [] + var selectedDate = Date() + var currentMonth = Date() + var showingDayDetail = false + var selectedDayAuctions: [ScheduledAuction] = [] + + var errorMessage: String? = nil + var showError: Bool = false + var uploadSuccess: Bool = false + var auctionState: FetchState = .good + var toast: Toast? = nil +} diff --git a/TorqueNet/Features/Settings/Presentation/ProfileView.swift b/TorqueNet/Features/Settings/Presentation/ProfileView.swift index f96d523..e5fb5dd 100644 --- a/TorqueNet/Features/Settings/Presentation/ProfileView.swift +++ b/TorqueNet/Features/Settings/Presentation/ProfileView.swift @@ -76,6 +76,26 @@ struct ProfileView: View { } ) } + .overlay { + CustomAlertDialogView( + isPresented: $settingsViewModel.isShowAlertDialog, + title: settingsViewModel.dialogEntity.title, + text: settingsViewModel.dialogEntity.message, + confirmButtonText: settingsViewModel.dialogEntity.confirmButtonText, + dismissButtonText: settingsViewModel.dialogEntity.dismissButtonText, + imageName: settingsViewModel.dialogEntity.icon, + onDismiss: { + if let onDismiss = settingsViewModel.dialogEntity.onDismiss { + onDismiss() + } + }, + onConfirmation: { + if let onConfirm = settingsViewModel.dialogEntity.onConfirm { + onConfirm() + } + } + ) + } .fullScreenProgressOverlay(isShowing: settingsViewModel.settingState == .isLoading ) } diff --git a/TorqueNet/Features/Settings/Presentation/SettingsView.swift b/TorqueNet/Features/Settings/Presentation/SettingsView.swift index 41abda2..786e52a 100644 --- a/TorqueNet/Features/Settings/Presentation/SettingsView.swift +++ b/TorqueNet/Features/Settings/Presentation/SettingsView.swift @@ -48,6 +48,16 @@ struct SettingsView: View { } ) + SettingsRowView( + icon: "wallet.bifold.fill", + iconColor: .accentColor, + title: "Auctions", + subtitle: "Manage your auctions", + action: { + //router.push(.changePassword) + } + ) + SettingsRowView( icon: "star.circle.fill", iconColor: .orange, diff --git a/TorqueNet/Features/WishList/Domain/Model/CarCategory.swift b/TorqueNet/Features/WishList/Domain/Model/CarCategory.swift deleted file mode 100644 index dba67cf..0000000 --- a/TorqueNet/Features/WishList/Domain/Model/CarCategory.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// CarCategory.swift -// TorqueNet -// -// Created by Brandy Odhiambo on 22/08/2025. -// - -struct CarCategory: Identifiable, Hashable { - var id: Int - var name: String - var icon: String - var isSelected: Bool -} diff --git a/TorqueNet/Features/WishList/Presentation/WishListView.swift b/TorqueNet/Features/WishList/Presentation/WishListView.swift index e58daee..0cd43a8 100644 --- a/TorqueNet/Features/WishList/Presentation/WishListView.swift +++ b/TorqueNet/Features/WishList/Presentation/WishListView.swift @@ -177,40 +177,6 @@ struct WishListView: View { } } -struct CategoryChip: View { - let category: CarCategory - let isSelected: Bool - let onTap: () -> Void - - var body: some View { - Button(action: onTap) { - HStack(spacing: 8) { - Image(systemName: category.icon) - .font(.system(size: 14, weight: .medium)) - - Text(category.name) - .font(.custom("Exo2-Medium", size: 14)) - } - .foregroundColor(isSelected ? .theme.onPrimaryColor : .theme.onSurfaceColor) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 20) - .fill(isSelected ? Color.theme.primaryColor : Color.gray.opacity(0.1)) - .shadow( - color: isSelected ? .theme.primaryColor.opacity(0.3) : Color.theme.onSurfaceColor.opacity(0.05), - radius: isSelected ? 8 : 4, - x: 0, - y: isSelected ? 4 : 2 - ) - ) - } - .buttonStyle(PlainButtonStyle()) - .scaleEffect(isSelected ? 1.05 : 1.0) - .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isSelected) - } -} - struct WishListCarCard: View { var imageName: String var title: String diff --git a/TorqueNet/TorqueNet.entitlements b/TorqueNet/TorqueNet.entitlements new file mode 100644 index 0000000..903def2 --- /dev/null +++ b/TorqueNet/TorqueNet.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + +