// // ContentView21.swift // Coba14July2025 // // Created by Luthfi Abdurrahim on 11/09/25. // import Foundation import SwiftUI // MARK: - API Response Models struct TrackingAPIResponse: Decodable { let success: Bool let data: TrackingData let message: String let code: Int } struct TrackingData: Decodable { let shippingName: String let shippingType: String let trackingNumber: String let status: ShipmentStatus let history: [TrackingHistoryItem] enum CodingKeys: String, CodingKey { case shippingName = "shipping_name" case shippingType = "shipping_type" case trackingNumber = "tracking_number" case status case history } } struct TrackingHistoryItem: Decodable, Identifiable { let id = UUID() let note: String let updatedAt: String let status: ShipmentStatus enum CodingKeys: String, CodingKey { case note case updatedAt = "updated_at" case status } } // MARK: - Domain enum ShipmentStatus: String, Decodable, CaseIterable { case delivered case ondelivery case dropping_off case picked case unknown init(from decoder: Decoder) throws { let raw = try decoder.singleValueContainer().decode(String.self) self = ShipmentStatus(rawValue: raw) ?? .unknown } // Display label for the colored badge near "Status Pemesanan" var badgeDisplay: String { switch self { case .ondelivery: return "Pesanan Dikirim" case .delivered: return "Pesanan Tiba" case .picked: return "Pesanan Diproses" case .dropping_off: return "Dalam Perjalanan" case .unknown: return "Status Tidak Dikenal" } } // Whether final (enables button) var isFinalDelivered: Bool { self == .delivered } } // MARK: - View Model Timeline Item struct TimelineStep: Identifiable { let id = UUID() let note: String let dateDisplay: String let isCurrent: Bool let status: ShipmentStatus } import Foundation import Combine // MARK: - Mock / Simple Service final class TrackingService { // In a real app, replace this with URLSession data task func fetchTracking() -> AnyPublisher { let json = """ { "success": true, "data": { "shipping_name": "jne", "shipping_type": "reg", "tracking_number": "016210012931725", "status": "ondelivery", "history": [ { "note": "Item has been delivered.", "updated_at": "4 Sep 2025, 15:55 WIB", "status": "delivered" }, { "note": "WITH DELIVERY COURIER [SUKABUMI] []", "updated_at": "4 Sep 2025, 15:55 WIB", "status": "dropping_off" }, { "note": "RECEIVED AT WAREHOUSE [SUB AGEN SAGARANTEN] [SUB AGEN SAGARANTEN]", "updated_at": "4 Sep 2025, 15:55 WIB", "status": "dropping_off" }, { "note": "DEPART FROM TRANSIT [SUKABUMI] [HUB CIKONDANG]", "updated_at": "4 Sep 2025, 15:55 WIB", "status": "dropping_off" }, { "note": "RECEIVED AT TRANSIT [SUKABUMI] [HUB CIKONDANG]", "updated_at": "4 Sep 2025, 15:55 WIB", "status": "dropping_off" }, { "note": "PROCESSED AT TRANSIT [HUB CIKONDANG] [HUB CIKONDANG]", "updated_at": "4 Sep 2025, 15:55 WIB", "status": "dropping_off" }, { "note": "PROCESSED AT TRANSIT [GATEWAY, MEGAHUB] [GATEWAY, MEGAHUB]", "updated_at": "4 Sep 2025, 15:55 WIB", "status": "dropping_off" }, { "note": "DEPART FROM TRANSIT [GATEWAY JAKARTA] [GATEWAY, MEGAHUB]", "updated_at": "4 Sep 2025, 15:55 WIB", "status": "dropping_off" }, { "note": "PROCESSED AT SORTING CENTER [JAKARTA, MEGAHUB MACHINE-2] [JAKARTA, MEGAHUB MACHINE-2]", "updated_at": "4 Sep 2025, 15:55 WIB", "status": "dropping_off" }, { "note": "RECEIVED AT SORTING CENTER [JAKARTA] [JAKARTA,MEGAHUB MANUALHANDLING]", "updated_at": "4 Sep 2025, 15:55 WIB", "status": "dropping_off" }, { "note": "Item has been picked and ready to be shipped.", "updated_at": "4 Sep 2025, 15:55 WIB", "status": "picked" } ] }, "message": "Permintaan anda berhasil dilakukan", "code": 200 } """ let data = Data(json.utf8) return Just(data) .tryMap { d in let decoded = try JSONDecoder().decode(TrackingAPIResponse.self, from: d) return decoded.data } .delay(for: .milliseconds(350), scheduler: DispatchQueue.main) // small simulated delay .eraseToAnyPublisher() } } import Foundation import Combine import SwiftUI final class TrackingViewModel: ObservableObject { @Published var steps: [TimelineStep] = [] @Published var badgeStatus: ShipmentStatus = .unknown @Published var trackingNumber: String = "" @Published var isDelivered: Bool = false @Published var isLoading: Bool = false private let service: TrackingService private var cancellables = Set() init(service: TrackingService = TrackingService()) { self.service = service load() } func load() { isLoading = true service.fetchTracking() .receive(on: DispatchQueue.main) .sink { [weak self] completion in self?.isLoading = false if case .failure(let error) = completion { print("Error: \(error)") } } receiveValue: { [weak self] data in self?.apply(data: data) } .store(in: &cancellables) } private func apply(data: TrackingData) { badgeStatus = data.status trackingNumber = data.trackingNumber // The JSON history appears newest first; we need latest at top, earliest at bottom like screenshot. // We'll confirm earliest bottom -> so we keep as-is? Provided first item is delivered (newest). // We'll build array already ordered from newest to oldest (top to bottom). // If you want chronological bottom-up, keep current. let ordered = data.history // Already newest first let latestID = ordered.first?.id steps = ordered.enumerated().map { index, item in TimelineStep( note: item.note, dateDisplay: item.updatedAt, isCurrent: item.id == latestID, status: item.status ) } // Determine delivery (either global status or top step) isDelivered = data.status.isFinalDelivered || steps.first?.status.isFinalDelivered == true } func copyTrackingNumber() { UIPasteboard.general.string = trackingNumber // Could add a toast or haptic here } func markOrderReceived() { // Business logic when user acknowledges receipt. // For demonstration we just set isDelivered true. isDelivered = true } } import SwiftUI // MARK: - Badge struct StatusBadge: View { let status: ShipmentStatus var body: some View { HStack(spacing: 6) { Image(systemName: "truck.box") .font(.system(size: 13, weight: .semibold)) Text(status.badgeDisplay) .font(.system(size: 13, weight: .semibold)) } .foregroundColor(Color(red: 0.67, green: 0.40, blue: 0.00)) // A brownish accent similar to screenshot .padding(.vertical, 6) .padding(.horizontal, 10) .background( RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(Color(red: 1.0, green: 0.96, blue: 0.88)) ) } } // MARK: - Timeline Row struct TimelineRow: View { let step: TimelineStep let isLast: Bool private let currentColor = Color(red: 0.09, green: 0.41, blue: 1.0) // Blue bullet private let lineColor = Color(red: 0.80, green: 0.82, blue: 0.86) private let inactiveDot = Color(red: 0.67, green: 0.67, blue: 0.67) var body: some View { HStack(alignment: .top, spacing: 16) { VStack(spacing: 0) { Circle() .fill(step.isCurrent ? currentColor : inactiveDot) .frame(width: 16, height: 16) .overlay( Circle() .stroke(Color.white, lineWidth: step.isCurrent ? 2 : 0) ) if !isLast { Rectangle() .fill(lineColor) .frame(width: 2) .frame(maxHeight: .infinity) } } .padding(.top, 4) VStack(alignment: .leading, spacing: 6) { Text(step.note) .font(.system(size: 15, weight: .medium)) .foregroundColor(.primary) .fixedSize(horizontal: false, vertical: true) Text(step.dateDisplay) .font(.system(size: 13)) .foregroundColor(Color.secondary) } .padding(.bottom, isLast ? 0 : 20) } } } // MARK: - Section Header struct SectionHeaderText: View { let text: String var body: some View { Text(text) .font(.system(size: 20, weight: .semibold)) .foregroundColor(.primary) .frame(maxWidth: .infinity, alignment: .leading) } } import SwiftUI struct ContentView21: View { @StateObject private var vm = TrackingViewModel() var body: some View { ZStack { VStack(spacing: 0) { CustomNavBar(title: "Status Pembelian") { // Back action } Divider() ScrollView { VStack(alignment: .leading, spacing: 28) { topMetaSection SectionHeaderText(text: "Status Pembelian") timelineSection Spacer(minLength: 120) } .padding(.horizontal, 24) .padding(.top, 24) } } bottomButton } .background(Color(.systemBackground)) .ignoresSafeArea(edges: .bottom) } private var topMetaSection: some View { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 4) { Text("Status Pemesanan") .font(.system(size: 15)) .foregroundColor(.primary) } Spacer() StatusBadge(status: vm.badgeStatus) } HStack(alignment: .top) { VStack(alignment: .leading, spacing: 6) { Text("No Resi") .font(.system(size: 15)) .foregroundColor(.secondary) // Additional metadata could go here } Spacer() HStack(spacing: 12) { Text(vm.trackingNumber.isEmpty ? "—" : vm.trackingNumber) .font(.system(size: 15, weight: .semibold)) .foregroundColor(.primary) .lineLimit(1) Button(action: vm.copyTrackingNumber) { Text("SALIN") .font(.system(size: 15, weight: .semibold)) .foregroundColor(Color(red: 0.09, green: 0.41, blue: 1.0)) } } } Rectangle() .fill(Color(red: 0.90, green: 0.91, blue: 0.93)) .frame(height: 1) .padding(.top, 4) } } private var timelineSection: some View { VStack(alignment: .leading, spacing: 0) { if vm.steps.isEmpty { if vm.isLoading { ProgressView() .frame(maxWidth: .infinity) .padding(.vertical, 40) } else { Text("Tidak ada riwayat.") .font(.system(size: 15)) .foregroundColor(.secondary) .padding(.vertical, 40) } } else { ForEach(Array(vm.steps.enumerated()), id: \.element.id) { idx, step in TimelineRow(step: step, isLast: idx == vm.steps.count - 1) } } } } private var bottomButton: some View { VStack { Spacer() ZStack { // Background blur style card Color.clear .background(.ultraThinMaterial) .ignoresSafeArea() Button(action: { vm.markOrderReceived() }) { Text("Pesanan Diterima") .font(.system(size: 17, weight: .semibold)) .frame(maxWidth: .infinity) .padding(.vertical, 20) .foregroundColor(vm.isDelivered ? .white : Color(red: 0.36, green: 0.40, blue: 0.47)) .background( RoundedRectangle(cornerRadius: 44, style: .continuous) .fill(vm.isDelivered ? Color(red: 0.09, green: 0.41, blue: 1.0) : Color(red: 0.86, green: 0.89, blue: 0.94)) ) } .disabled(!vm.isDelivered) .padding(.horizontal, 24) .padding(.top, 12) .padding(.bottom, 16 + safeAreaBottomPadding()) } .frame(height: 120) } .allowsHitTesting(true) } private func safeAreaBottomPadding() -> CGFloat { UIApplication.shared.connectedScenes .compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom } .first ?? 0 } } // MARK: - Custom Nav Bar struct CustomNavBar: View { let title: String var onBack: () -> Void var body: some View { ZStack { HStack(spacing: 12) { Button(action: onBack) { Image(systemName: "chevron.left") .font(.system(size: 19, weight: .semibold)) .foregroundColor(.primary) } Text(title) .font(.system(size: 22, weight: .semibold)) .foregroundColor(.primary) Spacer() } .padding(.horizontal, 16) .frame(height: 56) } .background(Color(.systemBackground)) } }