iOS Integration Guide for quiXzoom

quiXzoom iOS Integration
Teknisk guide för native iOS-appen
SwiftUI · WKWebView · REST API
iOS 17+ SwiftUI REST API v2 JWT Auth Push Notifications
Innehåll
1
Arkitektur & Översikt
2
Autentisering (BankID + JWT)
3
API-endpoints
4
Push Notifications (APNs)
5
Kamera & Bilduppladdning
6
Plats & Kartintegration
7
Deep Links & Universal Links
8
Checklista för App Store-release
01 — Arkitektur
Arkitektur & Översikt
quiXzoom iOS-appen är byggd i SwiftUI med en hybrid-arkitektur: native SwiftUI-vyer för kärnupplevelsen (kamera, karta, notiser) och en WKWebView-fallback för avancerade webbvyer under beta-perioden.
Målarkitektur (Q3 2026): 100% native SwiftUI. Under nuvarande fas (beta → launch) används WKWebView för zoomer-app.html med native overlay för kamera, GPS-spårning och push.
Swift · Projektstruktur
// QuiXzoom iOS — Projektstruktur
QuiXzoom/
 App/
    QuiXzoomApp.swift           // @main
    AppDelegate.swift            // APNs + deep links
 Features/
    Auth/
       BankIDView.swift
       AuthViewModel.swift
    Missions/
       MissionFeedView.swift
       MissionMapView.swift       // MapKit
       ActiveMissionView.swift
    Camera/
       CameraUploadView.swift
    Wallet/
       WalletView.swift
    Profile/
        ProfileView.swift
 Services/
    APIClient.swift              // REST + JWT
    LocationService.swift
    PushService.swift
 DesignSystem/
     Tokens.swift                // iOS 18 native tokens
02 — Auth
Autentisering (BankID + JWT)
Zoomers autentiseras via BankID (personnummer-verifiering) och erhåller ett JWT med 30 dagars livstid. JWT:t lagras i iOS Keychain — aldrig i UserDefaults.
Swift · AuthViewModel
import Foundation
import Security

@MainActor
class AuthViewModel: ObservableObject {
  @Published var isAuthenticated = false
  @Published var isLoading = false

  private let keychainKey = "qz.jwt"
  private let baseURL = "https://amos.wavult.com"

  // 1. Starta BankID-flöde
  func initiateBankID(personnummer: String) async throws {
    isLoading = true
    defer { isLoading = false }

    let response = try await APIClient.post(
      "/api/auth/bankid/init",
      body: ["personnummer": personnummer, "role": "zoomer"]
    )
    // Öppna BankID-appen
    if let url = response["autoStartToken"] as? String {
      UIApplication.shared.open(
        URL(string: "bankid:///?autostarttoken=\(url)")!
      )
    }
  }

  // 2. Polls tills BankID bekräftar (max 30s)
  func collectBankID(orderRef: String) async throws -> String {
    for _ in 0..30 {
      try await Task.sleep(nanoseconds: 1_000_000_000)
      let res = try await APIClient.post(
        "/api/auth/bankid/collect",
        body: ["orderRef": orderRef]
      )
      if res["status"] as? String == "complete" {
        return res["jwt"] as! String
      }
    }
    throw AuthError.timeout
  }

  // 3. Spara JWT i Keychain
  func saveToken(_ jwt: String) {
    let data = jwt.data(using: .utf8)!
    let query: [CFString: Any] = [
      kSecClass: kSecClassGenericPassword,
      kSecAttrAccount: keychainKey,
      kSecValueData: data,
      kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
    ]
    SecItemDelete(query as CFDictionary)
    SecItemAdd(query as CFDictionary, nil)
    isAuthenticated = true
  }
}
03 — API
API-endpoints
Alla endpoints nås på https://amos.wavult.com/api/quixzoom/. JWT skickas som Authorization: Bearer <token>.
GET/missionsAlla öppna uppdrag
POST/missions/:id/acceptAcceptera uppdrag
POST/missions/:id/submitSkicka in (multipart)
GET/earningsPlånbok & historik
GET/ratingsBetyg & recensioner
GET/meProfil
PUT/meUppdatera profil
POST/me/device-tokenRegistrera APNs-token
Swift · APIClient
struct APIClient {
  static let base = "https://amos.wavult.com/api/quixzoom"

  static func get<T: Decodable>(
    _ path: String, type: T.Type
  ) async throws -> T {
    var req = URLRequest(url: URL(string: base + path)!)
    req.setValue("Bearer \(Keychain.get("qz.jwt"))",
                 forHTTPHeaderField: "Authorization")
    let (data, _) = try await URLSession.shared.data(for: req)
    return try JSONDecoder().decode(T.self, from: data)
  }

  // Upload mission with photos
  static func submitMission(
    id: Int, images: [UIImage]
  ) async throws -> MissionResult {
    var req = URLRequest(url: URL(string: "\(base)/missions/\(id)/submit")!)
    req.httpMethod = "POST"
    let boundary = UUID().uuidString
    req.setValue("multipart/form-data; boundary=\(boundary)",
                 forHTTPHeaderField: "Content-Type")
    req.setValue("Bearer \(Keychain.get("qz.jwt"))",
                 forHTTPHeaderField: "Authorization")
    var body = Data()
    images.forEach { img in
      body.append("--\(boundary)\r\n".data(using: .utf8)!)
      body.append("Content-Disposition: form-data; name=\"images\"; filename=\"photo.jpg\"\r\n".data(using: .utf8)!)
      body.append("Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!)
      body.append(img.jpegData(compressionQuality: 0.85)!)
      body.append("\r\n".data(using: .utf8)!)
    }
    body.append("--\(boundary)--\r\n".data(using: .utf8)!)
    req.httpBody = body
    let (data, _) = try await URLSession.shared.data(for: req)
    return try JSONDecoder().decode(MissionResult.self, from: data)
  }
}
04 — Push
Push Notifications (APNs)
Ny uppdrag, uppdragsgodkännanden och utbetalningskvitton levereras via APNs. Device token registreras vid app-start och vid token-rotation.
Swift · AppDelegate
func application(
  _ application: UIApplication,
  didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
  let token = deviceToken.map { String(format: "%02x", $0) }.joined()
  Task {
    try? await APIClient.post("/me/device-token",
      body: ["token": token, "platform": "apns"])
  }
}

// Payload-format från servern:
// {
//   "aps": { "alert": { "title": "Nytt uppdrag!", "body": "350 kr · Storgatan 12" },
//            "badge": 1, "sound": "default" },
//   "type": "new_mission",
//   "missionId": 42
// }

func userNotificationCenter(
  _ center: UNUserNotificationCenter,
  didReceive response: UNNotificationResponse,
  withCompletionHandler: @escaping () -> Void
) {
  let info = response.notification.request.content.userInfo
  if let missionId = info["missionId"] as? Int {
    // Navigate to mission detail
    NavigationRouter.shared.navigate(to: .mission(id: missionId))
  }
  withCompletionHandler()
}
05 — Kamera
Kamera & Bilduppladdning
Native camera med EXIF-stripning (GDPR) och automatisk komprimering till max 2MB per bild. Bilder taggas med uppdrag-ID och GPS-koordinater.
Privacy Manifest krävs: Lägg till NSCameraUsageDescription och NSLocationWhenInUseUsageDescription i Info.plist med motiveringar på svenska (krävs för App Store Review).
Swift · CameraView
import SwiftUI
import PhotosUI

struct CameraUploadView: View {
  let missionId: Int
  @State private var selectedItems: [PhotosPickerItem] = []
  @State private var capturedImages: [UIImage] = []
  @State private var isUploading = false

  var body: some View {
    VStack {
      PhotosPicker(selection: $selectedItems,
                   maxSelectionCount: 10,
                   matching: .images) {
        Label("Ta / välj foton", systemImage: "camera.fill")
          .frame(maxWidth: .infinity)
          .padding()
          .background(Color.blue)
          .foregroundColor(.white)
          .cornerRadius(12)
      }

      if !capturedImages.isEmpty {
        Button("Skicka in \(capturedImages.count) bilder") {
          Task { try await upload() }
        }
        .disabled(isUploading)
      }
    }
    .onChange(of: selectedItems) { _, items in
      Task { await loadImages(items) }
    }
  }

  private func upload() async throws {
    isUploading = true
    let _ = try await APIClient.submitMission(
      id: missionId, images: capturedImages
    )
    isUploading = false
  }
}
06 — Plats
Plats & Kartintegration
MapKit används för uppdragskarta. Kontinuerlig GPS-spårning aktiveras bara under aktiva uppdrag och stoppas omedelbart vid slutförande (batterivänlig design).
Background modes: Aktivera location i Background Modes capability för spårning under aktivt uppdrag. Motivera i Privacy Manifest.
Swift · LocationService
import CoreLocation
import MapKit

@MainActor
class LocationService: NSObject, ObservableObject,
                          CLLocationManagerDelegate {
  @Published var userLocation: CLLocation?
  @Published var distanceToMission: Double = 0
  private let manager = CLLocationManager()

  func startTracking(mission: Mission) {
    manager.delegate = self
    manager.desiredAccuracy = kCLLocationAccuracyBest
    manager.distanceFilter = 10 // update every 10m
    manager.allowsBackgroundLocationUpdates = true
    manager.startUpdatingLocation()
    updateDistance(to: mission)
  }

  func stopTracking() {
    manager.stopUpdatingLocation()
    manager.allowsBackgroundLocationUpdates = false
  }

  nonisolated func locationManager(
    _ manager: CLLocationManager,
    didUpdateLocations locations: [CLLocation]
  ) {
    Task { @MainActor in
      self.userLocation = locations.last
    }
  }
}
07 — Deep Links
Deep Links & Universal Links
Universal Links (HTTPS) och custom URL scheme quixzoom:// används för push-navigation och delning av uppdrag.
URLquixzoom://mission/42Öppna uppdrag
URLquixzoom://walletÖppna plånbok
URLquixzoom://profileÖppna profil
HTTPSamos.wavult.com/quixzoom/mission/42Universal link
apple-app-site-association: Filen /.well-known/apple-app-site-association måste publiceras på amos.wavult.com med Team ID + Bundle ID för Universal Links.
08 — Checklista
Checklista för App Store-release
Privacy Manifest — PrivacyInfo.xcprivacy med alla API-anledningar deklarerade
BankID integration — Testad mot BankID Test-miljö
App Tracking Transparency — ATT-dialog om analytics används
Åldersrating — Minst 17+ (finansiell tjänst, gig economy)
Crashlytics / Sentry — Felrapportering aktiv
GDPR-popup — Dataskyddspolicy acceptansflöde vid första start
Bildkomprimering — Max 2MB per bild, EXIF-stripning aktiverad
Offline-hantering — Graceful degradation utan nätverksanslutning
Dynamic Type — Alla textstorl. skalar med iOS-inställningar
Dark/Light mode — Testad i båda lägena (primärt: dark mode)
Notarization — App notariserad via Xcode Organizer
TestFlight — Minst 10 externa testare godkänt bygget