Created
September 11, 2025 02:01
-
-
Save luthviar/67a1d702928518a5093b3bc4eba7be91 to your computer and use it in GitHub Desktop.
OrderTrackingTimelineHistoryScreen.swift SwiftUI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // 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<TrackingData, Error> { | |
| 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<AnyCancellable>() | |
| 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)) | |
| } | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
UI result
