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.
OrderTrackingTimelineHistoryScreen.swift SwiftUI
//
// 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))
}
}
@luthviar
Copy link
Author

UI result
IMG_851855C86C1E-1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment