Skip to content

Instantly share code, notes, and snippets.

@luthviar
Created November 11, 2025 09:26
Show Gist options
  • Select an option

  • Save luthviar/099c86cba2c16ad1da20854ba174f5c4 to your computer and use it in GitHub Desktop.

Select an option

Save luthviar/099c86cba2c16ad1da20854ba174f5c4 to your computer and use it in GitHub Desktop.
//
// ContentView3.swift
// Mon10nov
//
// Created by Luthfi Abdurrahim on 10/11/25.
// sourece: https://github.com/copilot/c/32f66827-e081-447d-9f57-a9b61d82177a
import Foundation
// MARK: - API Models (based on provided JSON)
struct CameraAPIResponse: Decodable {
let success: Bool
let data: [APICamera]
let message: String?
let code: Int?
}
struct APICamera: Decodable, Hashable {
let cameraName: String
let cameraSerial: String
let cameraBrand: String?
let deviceVersion: String?
let deviceLocalIp: String?
let cloudStatus: APICameraCloudStatus?
let videoAnalyticStatus: String?
let ssid: String?
let createdAt: Date?
let detailTuya: APIDetailTuya?
let statusCamera: String?
let deviceGroupId: Int?
let deviceGroupOrderPriority: Int?
let statusRent: Bool?
}
struct APICameraCloudStatus: Decodable, Hashable {
let status: String?
let expireTime: Int64?
let serviceCode: String?
let invoiceNumber: String?
let activateDate: Int64?
let productName: String?
}
struct APIDetailTuya: Decodable, Hashable {
let id: String
let userId: String?
let deviceSerial: String?
let deviceBrand: String?
let deviceCategory: String?
let deviceName: String?
let deviceVersion: String?
let deviceLocalIp: String?
let deviceProduct: String?
let deviceUuid: String?
let disableNotification: String?
let cloudStatus: APICameraCloudStatus?
let cloudLocation: String?
let ssid: String?
let online: Bool?
let createdAt: Date?
let updatedAt: Date?
let iconOn: String?
let iconOff: String?
}
// MARK: - View Models used by UI
struct CameraUI: Identifiable, Hashable {
let id: String // use camera_serial as stable id
let name: String
let snapshotURL: URL?
let timestamp: Date
let status: String?
let brand: String?
}
enum CameraLayoutMode: String, CaseIterable {
case list
case grid
}
extension Date {
func formattedCameraStamp() -> String {
let df = DateFormatter()
df.locale = Locale(identifier: "id_ID")
df.dateFormat = "dd/MM/yyyy - HH:mm"
return df.string(from: self)
}
}
// MARK: - Service
enum CameraService {
// Hardcoded per request: same as the provided cURL (URL and headers)
private static let urlString = ""
private static let acceptHeader = "Application/json"
private static let contentTypeHeader = "application/json"
private static let appVersionHeader = "ios-V1.29.0"
private static let authorizationHeader = ""
// Robust date decoder supporting ISO8601 (with/without fractional seconds) and unix seconds/milliseconds
private static func decodeDate(_ decoder: Decoder) throws -> Date {
let container = try decoder.singleValueContainer()
if let str = try? container.decode(String.self) {
// Try ISO8601 with fractional seconds
let isoWithFS = ISO8601DateFormatter()
isoWithFS.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let d = isoWithFS.date(from: str) { return d }
// Try ISO8601 without fractional seconds
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime]
if let d = iso.date(from: str) { return d }
}
if let ms = try? container.decode(Int64.self) {
// Heuristic: 13 digits = milliseconds, 10 digits = seconds
let seconds: TimeInterval
if ms > 2_000_000_000_000 { // way too big; safeguard
seconds = TimeInterval(ms) / 1000.0
} else if ms > 100_000_000_000 { // very likely milliseconds
seconds = TimeInterval(ms) / 1000.0
} else {
seconds = TimeInterval(ms)
}
return Date(timeIntervalSince1970: seconds)
}
if let dbl = try? container.decode(Double.self) {
// If value is in milliseconds (e.g., 1.769e12), normalize
let seconds = dbl > 100_000_000_000 ? dbl / 1000.0 : dbl
return Date(timeIntervalSince1970: seconds)
}
// Fallback
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported date format")
}
static func fetchCameras() async throws -> [CameraUI] {
guard let url = URL(string: urlString) else {
return []
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue(acceptHeader, forHTTPHeaderField: "Accept")
request.setValue(contentTypeHeader, forHTTPHeaderField: "Content-Type")
request.setValue(appVersionHeader, forHTTPHeaderField: "X-App-Version")
request.setValue(authorizationHeader, forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
throw NSError(domain: "CameraService", code: http.statusCode, userInfo: [NSLocalizedDescriptionKey: "HTTP \(http.statusCode)"])
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .custom(Self.decodeDate)
let apiResponse = try decoder.decode(CameraAPIResponse.self, from: data)
let mapped: [CameraUI] = apiResponse.data.map { cam in
let iconUrlStr = cam.detailTuya?.iconOn ?? cam.detailTuya?.iconOff
let url = iconUrlStr.flatMap { URL(string: $0) }
let ts = cam.detailTuya?.updatedAt ?? cam.createdAt ?? Date()
return CameraUI(
id: cam.cameraSerial,
name: cam.cameraName,
snapshotURL: url,
timestamp: ts,
status: cam.statusCamera,
brand: cam.cameraBrand
)
}
return mapped
}
}
// MARK: - Observable ViewModel
@MainActor
final class CameraListViewModel: ObservableObject {
@Published var items: [CameraUI] = []
@Published var isLoading: Bool = false
@Published var errorMessage: String?
func load() async {
isLoading = true
errorMessage = nil
do {
let cams = try await CameraService.fetchCameras()
self.items = cams
} catch {
self.errorMessage = (error as NSError).localizedDescription
}
isLoading = false
}
}
import SwiftUI
struct ContentView: View {
@State private var layout: CameraLayoutMode = .list
@State private var showBanner: Bool = true
@StateObject private var viewModel = CameraListViewModel()
// Show at most 10 items on iOS side (no API pagination)
private var limitedItems: [CameraUI] {
Array(viewModel.items.prefix(10))
}
var body: some View {
NavigationStack {
ZStack(alignment: .bottom) {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if viewModel.isLoading {
HStack { Spacer(); ProgressView(); Spacer() }
.padding(.vertical, 24)
}
if let err = viewModel.errorMessage {
Text("Gagal memuat: \(err)")
.foregroundColor(.red)
.padding(.horizontal)
}
// Info banner
if showBanner {
InfoBannerView(
text: "Nikmati pengalaman monitoring terbaik melalui desktop di www.eazy.co.id/webmonitoring",
linkTitle: "www.eazy.co.id/webmonitoring",
linkURL: URL(string: "https://www.eazy.co.id/webmonitoring")!,
onClose: { withAnimation(.spring) { showBanner = false } }
)
.transition(.move(edge: .top).combined(with: .opacity))
.padding(.horizontal)
}
// Content
Group {
switch layout {
case .list:
LazyVStack(spacing: 16) {
ForEach(limitedItems) { cam in
CameraCardView(camera: cam, style: .list)
.padding(.horizontal)
}
}
case .grid:
LazyVGrid(
columns: [GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12)],
spacing: 16
) {
ForEach(limitedItems) { cam in
CameraCardView(camera: cam, style: .grid)
}
}
.padding(.horizontal)
}
}
.animation(.spring(duration: 0.35), value: layout)
}
.padding(.top, 8)
}
}
.navigationTitle("Multi Tampilan")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(action: {}) {
Image(systemName: "chevron.left")
}
.accessibilityLabel("Kembali")
}
ToolbarItem(placement: .topBarTrailing) {
LayoutToggle(layout: $layout)
}
}
.background(Color(.systemGroupedBackground))
.task {
// Load only once
if viewModel.items.isEmpty {
await viewModel.load()
}
}
}
}
}
import SwiftUI
struct CameraCardView: View {
enum Style { case list, grid }
let camera: CameraUI
let style: Style
@State private var showOverlay: Bool = true
@State private var isHighlighted: Bool = false
private var cornerRadius: CGFloat { 16 }
private var cardHeight: CGFloat { style == .list ? 170 : 140 }
var body: some View {
ZStack(alignment: .bottomLeading) {
// Snapshot
AsyncImage(url: camera.snapshotURL) { phase in
switch phase {
case .empty:
ZStack {
Rectangle().fill(.secondary.opacity(0.08))
ProgressView()
}
case .success(let image):
image
.resizable()
.scaledToFill()
case .failure:
ZStack {
Rectangle().fill(.secondary.opacity(0.1))
Image(systemName: "photo")
.font(.system(size: 28, weight: .semibold))
.foregroundColor(.secondary)
}
@unknown default:
Color.secondary.opacity(0.1)
}
}
.frame(height: cardHeight)
.frame(maxWidth: .infinity)
.clipped()
// Top-left timestamp overlay
if showOverlay {
VStack(alignment: .leading, spacing: 6) {
Text(camera.timestamp.formattedCameraStamp())
.font(.caption2.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(.ultraThinMaterial, in: Capsule())
.overlay(
Capsule()
.strokeBorder(Color.white.opacity(0.3), lineWidth: 0.5)
)
.padding(.top, 10)
.padding(.leading, 10)
Spacer()
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.transition(.opacity)
}
// Bottom gradient + title
LinearGradient(
colors: [Color.black.opacity(0.02), Color.black.opacity(0.55)],
startPoint: .top, endPoint: .bottom
)
.frame(height: 84)
.frame(maxWidth: .infinity, alignment: .bottom)
.offset(y: 0)
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 6) {
Text(camera.name)
.font(.title3.weight(.semibold))
.foregroundColor(.white)
.shadow(radius: 2)
}
Spacer()
// // "More" menu
// Menu {
// Button("Ganti Nama", action: {})
// Button("Atur Notifikasi", action: {})
// Divider()
// Button(role: .destructive, action: {}) { Text("Hapus dari Daftar") }
// } label: {
// Image(systemName: "ellipsis")
// .font(.headline)
// .foregroundColor(.white)
// .padding(10)
// .background(Color.black.opacity(0.35), in: Circle())
// }
// .menuActionDismissBehavior(.automatic)
}
.padding(.horizontal, 12)
.padding(.bottom, 10)
}
.background(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(Color(.secondarySystemBackground))
)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.shadow(color: Color.black.opacity(0.08), radius: 8, x: 0, y: 4)
.contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.onTapGesture {
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
showOverlay.toggle()
pulse()
}
}
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.stroke(isHighlighted ? Color.blue.opacity(0.4) : Color.clear, lineWidth: 2)
)
.accessibilityElement(children: .combine)
.accessibilityLabel(camera.name)
.accessibilityHint("Ketuk untuk menampilkan atau menyembunyikan informasi")
}
private func pulse() {
isHighlighted = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
withAnimation(.easeOut(duration: 0.25)) { isHighlighted = false }
}
}
}
import SwiftUI
struct LayoutToggle: View {
@Binding var layout: CameraLayoutMode
var body: some View {
HStack(spacing: 2) {
ToggleButton(
systemName: "square.grid.2x2",
isOn: layout == .grid
) { layout = .grid }
ToggleButton(
systemName: "list.bullet",
isOn: layout == .list
) { layout = .list }
}
.padding(4)
.background(.ultraThinMaterial, in: Capsule())
.overlay(
Capsule().strokeBorder(Color.black.opacity(0.08), lineWidth: 0.5)
)
}
private struct ToggleButton: View {
let systemName: String
let isOn: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: systemName)
.font(.subheadline.weight(.semibold))
.foregroundColor(isOn ? .white : .primary)
.frame(width: 32, height: 28)
.background(isOn ? Color.blue : Color.clear, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel(systemName == "list.bullet" ? "Tampilan daftar" : "Tampilan grid")
.accessibilityAddTraits(isOn ? .isSelected : [])
}
}
}
import SwiftUI
struct InfoBannerView: View {
var text: String
var linkTitle: String
var linkURL: URL
var onClose: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "sparkles.rectangle.stack")
.font(.title3)
.foregroundColor(Color.blue)
VStack(alignment: .leading, spacing: 6) {
Text(text)
.font(.subheadline)
.foregroundColor(.primary)
Link(linkTitle, destination: linkURL)
.font(.subheadline.weight(.semibold))
.foregroundColor(.blue)
.underline()
.accessibilityHint("Buka tautan di Safari")
}
Spacer()
Button(action: onClose) {
Image(systemName: "xmark")
.font(.footnote.weight(.bold))
.foregroundColor(.secondary)
.padding(6)
.background(.thinMaterial, in: Circle())
}
.buttonStyle(.plain)
.accessibilityLabel("Tutup banner")
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.blue.opacity(0.08))
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(Color.blue.opacity(0.2), lineWidth: 1)
)
}
}
import SwiftUI
struct PaginationControl: View {
var title: String
@Binding var currentPage: Int
var totalPages: Int
private let chipSize: CGFloat = 34
var body: some View {
HStack(spacing: 10) {
Button {
withAnimation(.spring) { currentPage = max(1, currentPage - 1) }
} label: {
Image(systemName: "chevron.left")
.font(.headline)
.frame(width: chipSize, height: chipSize)
.background(Color(.systemBackground), in: Circle())
.shadow(color: Color.black.opacity(0.12), radius: 6, y: 2)
}
.disabled(currentPage == 1)
Text(title)
.font(.subheadline)
.foregroundColor(.secondary)
.padding(.horizontal, 4)
// Numbered chips
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(1...totalPages, id: \.self) { p in
Button {
withAnimation(.spring) { currentPage = p }
} label: {
Text("\(p)")
.font(.subheadline.weight(.semibold))
.foregroundColor(currentPage == p ? .white : .primary)
.frame(width: chipSize, height: chipSize)
.background(currentPage == p ? Color.blue : Color(.systemBackground), in: Capsule())
.overlay(
Capsule()
.strokeBorder(Color.black.opacity(0.08), lineWidth: currentPage == p ? 0 : 1)
)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 6)
.padding(.horizontal, 2)
}
.frame(height: chipSize + 12)
Button {
withAnimation(.spring) { currentPage = min(totalPages, currentPage + 1) }
} label: {
Image(systemName: "chevron.right")
.font(.headline)
.frame(width: chipSize, height: chipSize)
.background(Color(.systemBackground), in: Circle())
.shadow(color: Color.black.opacity(0.12), radius: 6, y: 2)
}
.disabled(currentPage == totalPages)
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(.ultraThinMaterial, in: Capsule())
.overlay(
Capsule().strokeBorder(Color.black.opacity(0.08), lineWidth: 0.5)
)
.padding(.horizontal)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment