Created
September 11, 2025 02:01
-
-
Save luthviar/67a1d702928518a5093b3bc4eba7be91 to your computer and use it in GitHub Desktop.
Revisions
-
luthviar created this gist
Sep 11, 2025 .There are no files selected for viewing
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 charactersOriginal 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)) } }