Created
November 11, 2025 09:26
-
-
Save luthviar/099c86cba2c16ad1da20854ba174f5c4 to your computer and use it in GitHub Desktop.
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
| // | |
| // 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