πŸ“±
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