Skip to content

Instantly share code, notes, and snippets.

@luthviar
Created September 11, 2025 02:01
Show Gist options
  • Save luthviar/67a1d702928518a5093b3bc4eba7be91 to your computer and use it in GitHub Desktop.
Save luthviar/67a1d702928518a5093b3bc4eba7be91 to your computer and use it in GitHub Desktop.

Revisions

  1. luthviar created this gist Sep 11, 2025.
    480 changes: 480 additions & 0 deletions OrderTrackingTimelineHistoryScreen.swift
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,480 @@
    //
    // 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))
    }
    }