Files
boc/quixzoom-landing-fixed/ios-integration-guide.html
T
Bernt 6989a98d75 feat: Passwordless cross-device authentication
- Arkitektur: docs/auth/passwordless-architecture.md
- Backend: iom/quixzoom-auth-service/ (FastAPI + Redis)
- Webb: quixzoom-market-pages/se/login/ (QR-kod + polling)
- App: iom/quixzoom-app/src/features/auth/ (push + deep links)

Flöde: QR-kod → app-godkännande → webb-inloggad
2026-07-07 07:11:50 +00:00

707 lines
38 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="sv" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>iOS-integrationsguide — quiXzoom</title>
<link rel="stylesheet" >
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-text);
background: var(--background-primary);
color: var(--label-primary);
min-height: 100dvh;
padding-top: env(safe-area-inset-top, 44px);
padding-bottom: 48px;
-webkit-font-smoothing: antialiased;
}
.nav-bar {
position: sticky; top: 0; z-index: 200;
display: flex; align-items: center; gap: 12px;
height: 44px; padding: 0 16px;
background: var(--navbar-bg);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-bottom: 0.5px solid var(--separator);
}
.nav-back {
background: none; border: none; color: var(--ios-blue);
font-size: 17px; cursor: pointer; padding: 4px 0;
display: flex; align-items: center; gap: 4px;
}
.nav-title {
flex: 1; font-size: 17px; font-weight: 600; letter-spacing: -0.41px;
}
/* Hero */
.guide-hero {
background: linear-gradient(160deg, #0d2137 0%, #0a1520 60%, #000 100%);
padding: 40px 24px 36px; text-align: center; position: relative; overflow: hidden;
}
.guide-hero::before {
content: '';
position: absolute; inset: 0;
background: radial-gradient(ellipse at 50% 30%, rgba(0,122,255,0.25) 0%, transparent 60%);
}
.hero-icon { font-size: 64px; margin-bottom: 16px; position: relative; z-index: 1; }
.hero-title {
font-family: var(--font-display); font-size: 28px; font-weight: 800;
letter-spacing: -0.8px; color: #fff; position: relative; z-index: 1;
}
.hero-sub {
font-size: 15px; color: rgba(255,255,255,0.75); margin-top: 8px;
position: relative; z-index: 1; line-height: 1.5;
}
.hero-badges {
display: flex; justify-content: center; gap: 8px; margin-top: 16px; flex-wrap: wrap;
position: relative; z-index: 1;
}
.hero-badge {
background: rgba(255,255,255,0.1); border-radius: var(--radius-xl); padding: 4px 14px;
font-size: 12px; font-weight: 600; color: rgba(255,255,255,0.8);
border: 0.5px solid rgba(255,255,255,0.2);
}
/* TOC */
.toc { margin: 20px 16px; background: var(--background-secondary); border-radius: var(--radius-lg); overflow: hidden; border: 0.5px solid var(--separator); }
.toc-title { padding: 14px 16px; font-size: 13px; font-weight: 600; color: var(--label-secondary); text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 0.5px solid var(--separator); }
.toc-item { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 0.5px solid var(--separator); cursor: pointer; text-decoration: none; }
.toc-item:last-child { border-bottom: none; }
.toc-item:active { background: var(--fill-quaternary); }
.toc-num { width: 26px; height: 26px; border-radius: var(--radius-full); background: var(--ios-blue); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; flex-shrink: 0; }
.toc-label { font-size: 15px; font-weight: 500; flex: 1; }
.toc-chevron { color: var(--label-tertiary); font-size: 13px; }
/* Section */
.guide-section { margin: 24px 0; }
.section-anchor { padding: 0 16px; }
.section-num {
font-size: 12px; font-weight: 700; color: var(--ios-blue);
text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px;
}
.section-title {
font-family: var(--font-display); font-size: 22px; font-weight: 800;
letter-spacing: -0.5px; margin-bottom: 12px;
}
.section-body { font-size: 15px; line-height: 1.65; color: var(--label-secondary); }
/* Code block */
.code-block {
margin: 16px 16px 0;
background: #F2F2F7; border-radius: var(--radius-lg); overflow: hidden;
border: 0.5px solid var(--separator);
}
.code-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 16px;
background: rgba(255,255,255,0.05);
border-bottom: 0.5px solid var(--separator);
}
.code-lang { font-size: 12px; color: var(--label-tertiary); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.code-copy { background: none; border: none; color: var(--ios-blue); font-size: 12px; font-weight: 600; cursor: pointer; padding: 2px 6px; border-radius: var(--radius-sm); }
.code-copy:active { background: var(--fill-secondary); }
pre {
padding: 16px; font-family: 'SF Mono', 'Menlo', 'Monaco', monospace;
font-size: 13px; line-height: 1.7; color: #2a2a2a;
overflow-x: auto; white-space: pre;
}
.kw { color: #fc7c7c; } /* keyword */
.st { color: #79d4a1; } /* string */
.cm { color: #aaa; } /* comment */
.fn { color: #82baff; } /* function */
.tp { color: #f0c080; } /* type */
.nm { color: #2a2a2a; } /* number */
/* Info card */
.info-card {
margin: 16px 16px 0;
padding: 14px 16px; border-radius: var(--radius-lg);
border-left: 3px solid var(--ios-blue);
background: rgba(0,122,255,0.1);
font-size: 14px; line-height: 1.6; color: var(--label-secondary);
}
.info-card.warning { border-color: var(--ios-orange); background: rgba(255,149,0,0.1); }
.info-card.success { border-color: var(--ios-green); background: rgba(52,199,89,0.1); }
.info-card strong { color: var(--label-primary); }
/* Step list */
.step-list { margin: 16px 16px 0; display: flex; flex-direction: column; gap: 12px; }
.step-item { display: flex; gap: 14px; align-items: flex-start; }
.step-dot { width: 28px; height: 28px; border-radius: var(--radius-full); background: var(--ios-blue); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; flex-shrink: 0; margin-top: 2px; }
.step-content { flex: 1; }
.step-title { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
.step-desc { font-size: 14px; color: var(--label-secondary); line-height: 1.5; }
/* Endpoint table */
.endpoint-card {
margin: 16px 16px 0;
background: var(--background-secondary); border-radius: var(--radius-lg);
overflow: hidden; border: 0.5px solid var(--separator);
}
.endpoint-row {
display: flex; align-items: baseline; gap: 10px;
padding: 12px 16px; border-bottom: 0.5px solid var(--separator);
}
.endpoint-row:last-child { border-bottom: none; }
.method {
font-size: 11px; font-weight: 800; padding: 3px 8px; border-radius: var(--radius-sm);
text-transform: uppercase; letter-spacing: 0.3px; flex-shrink: 0;
}
.method.get { background: rgba(52,199,89,0.15); color: var(--ios-green); }
.method.post { background: rgba(0,122,255,0.15); color: var(--ios-blue); }
.method.put { background: rgba(255,149,0,0.15); color: var(--ios-orange); }
.method.del { background: rgba(255,69,58,0.15); color: var(--ios-red); }
.endpoint-path { font-family: 'SF Mono', monospace; font-size: 13px; flex: 1; }
.endpoint-desc { font-size: 12px; color: var(--label-secondary); min-width: 120px; text-align: right; }
/* Checklist */
.checklist { margin: 16px 16px 0; display: flex; flex-direction: column; gap: 10px; }
.check-item { display: flex; align-items: flex-start; gap: 10px; }
.check-box { width: 22px; height: 22px; border-radius: var(--radius-full); background: rgba(52,199,89,0.15); color: var(--ios-green); display: flex; align-items: center; justify-content: center; font-size: 13px; flex-shrink: 0; margin-top: 1px; }
.check-text { font-size: 14px; color: var(--label-secondary); line-height: 1.5; }
.check-text strong { color: var(--label-primary); }
.divider { height: 0.5px; background: var(--separator); margin: 8px 0; }
@media (max-width: 640px) {
.hero h1 { font-size: 2rem !important; }
.hero p { font-size: 1rem !important; }
.card { padding: 1.5rem !important; }
.grid { grid-template-columns: 1fr !important; }
.btn { padding: 0.75rem 1.5rem !important; font-size: 1rem !important; }
}
</style>
<style id="wcag-contrast-enforcement">
/* AAMOS GLOBAL CONTRAST ENFORCEMENT — WCAG AA (4.5:1 minimum) */
/* Regel: Ingen text under 70% opacity. Aldrig. */
body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
/* Fix common low-contrast patterns */
[style*="rgba(60,60,67,0.4)"] { color: rgba(60,60,67,0.65) !important; }
[style*="rgba(60,60,67,0.3)"] { color: rgba(60,60,67,0.65) !important; }
[style*="rgba(255,255,255,0.4)"] { color: rgba(255,255,255,0.72) !important; }
[style*="rgba(255,255,255,0.3)"] { color: rgba(255,255,255,0.72) !important; }
[style*="opacity: 0.4"] { opacity: 0.72 !important; }
[style*="opacity: 0.3"] { opacity: 0.72 !important; }
@media (max-width: 640px) {
.hero h1 { font-size: 2rem !important; }
.hero p { font-size: 1rem !important; }
.card { padding: 1.5rem !important; }
.grid { grid-template-columns: 1fr !important; }
.btn { padding: 0.75rem 1.5rem !important; font-size: 1rem !important; }
}
</style>
</head>
<body>
<h1 style="position:absolute;left:-9999px;">iOS Integration Guide for quiXzoom</h1>
<nav class="nav-bar">
<button class="nav-back" onclick="history.back()"> Tillbaka</button>
<div class="nav-title">iOS-integrationsguide</div>
</nav>
<div class="guide-hero">
<div class="hero-icon"></div>
<div class="hero-title">quiXzoom iOS Integration</div>
<div class="hero-sub">Teknisk guide för native iOS-appen<br>SwiftUI · WKWebView · REST API</div>
<div class="hero-badges">
<span class="hero-badge">iOS 17+</span>
<span class="hero-badge">SwiftUI</span>
<span class="hero-badge">REST API v2</span>
<span class="hero-badge">JWT Auth</span>
<span class="hero-badge">Push Notifications</span>
</div>
</div>
<!-- Table of contents -->
<div class="toc">
<div class="toc-title">Innehåll</div>
<a class="toc-item" href="#s1">
<div class="toc-num">1</div>
<span class="toc-label">Arkitektur & Översikt</span>
<span class="toc-chevron"></span>
</a>
<a class="toc-item" href="#s2">
<div class="toc-num">2</div>
<span class="toc-label">Autentisering (BankID + JWT)</span>
<span class="toc-chevron"></span>
</a>
<a class="toc-item" href="#s3">
<div class="toc-num">3</div>
<span class="toc-label">API-endpoints</span>
<span class="toc-chevron"></span>
</a>
<a class="toc-item" href="#s4">
<div class="toc-num">4</div>
<span class="toc-label">Push Notifications (APNs)</span>
<span class="toc-chevron"></span>
</a>
<a class="toc-item" href="#s5">
<div class="toc-num">5</div>
<span class="toc-label">Kamera & Bilduppladdning</span>
<span class="toc-chevron"></span>
</a>
<a class="toc-item" href="#s6">
<div class="toc-num">6</div>
<span class="toc-label">Plats & Kartintegration</span>
<span class="toc-chevron"></span>
</a>
<a class="toc-item" href="#s7">
<div class="toc-num">7</div>
<span class="toc-label">Deep Links & Universal Links</span>
<span class="toc-chevron"></span>
</a>
<a class="toc-item" href="#s8">
<div class="toc-num">8</div>
<span class="toc-label">Checklista för App Store-release</span>
<span class="toc-chevron"></span>
</a>
</div>
<!-- SECTION 1 -->
<div class="guide-section" id="s1">
<div class="section-anchor">
<div class="section-num">01 — Arkitektur</div>
<div class="section-title">Arkitektur & Översikt</div>
<div class="section-body">
quiXzoom iOS-appen är byggd i <strong style="color:var(--label-primary)">SwiftUI</strong> med en hybrid-arkitektur:
native SwiftUI-vyer för kärnupplevelsen (kamera, karta, notiser) och
en <code style="font-family:monospace;color:var(--ios-blue)">WKWebView</code>-fallback för
avancerade webbvyer under beta-perioden.
</div>
</div>
<div class="info-card" style="margin-top:16px;">
<strong>Målarkitektur (Q3 2026):</strong> 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.
</div>
<div class="code-block">
<div class="code-header">
<span class="code-lang">Swift · Projektstruktur</span>
<button class="code-copy" onclick="copyCode(this)">Kopiera</button>
</div>
<pre>
<span class="cm">// QuiXzoom iOS — Projektstruktur</span>
QuiXzoom/
App/
<span class="fn">QuiXzoomApp</span>.swift <span class="cm">// @main</span>
<span class="fn">AppDelegate</span>.swift <span class="cm">// APNs + deep links</span>
Features/
Auth/
<span class="fn">BankIDView</span>.swift
<span class="fn">AuthViewModel</span>.swift
Missions/
<span class="fn">MissionFeedView</span>.swift
<span class="fn">MissionMapView</span>.swift <span class="cm">// MapKit</span>
<span class="fn">ActiveMissionView</span>.swift
Camera/
<span class="fn">CameraUploadView</span>.swift
Wallet/
<span class="fn">WalletView</span>.swift
Profile/
<span class="fn">ProfileView</span>.swift
Services/
<span class="fn">APIClient</span>.swift <span class="cm">// REST + JWT</span>
<span class="fn">LocationService</span>.swift
<span class="fn">PushService</span>.swift
DesignSystem/
<span class="fn">Tokens</span>.swift <span class="cm">// iOS 18 native tokens</span>
</pre>
</div>
</div>
<!-- SECTION 2 -->
<div class="guide-section" id="s2">
<div class="section-anchor">
<div class="section-num">02 — Auth</div>
<div class="section-title">Autentisering (BankID + JWT)</div>
<div class="section-body">
Zoomers autentiseras via <strong style="color:var(--label-primary)">BankID</strong> (personnummer-verifiering)
och erhåller ett <strong style="color:var(--label-primary)">JWT</strong> med 30 dagars livstid.
JWT:t lagras i iOS Keychain — aldrig i UserDefaults.
</div>
</div>
<div class="code-block">
<div class="code-header">
<span class="code-lang">Swift · AuthViewModel</span>
<button class="code-copy" onclick="copyCode(this)">Kopiera</button>
</div>
<pre>
<span class="kw">import</span> Foundation
<span class="kw">import</span> Security
<span class="kw">@MainActor
class</span> <span class="tp">AuthViewModel</span>: <span class="tp">ObservableObject</span> {
<span class="kw">@Published var</span> isAuthenticated = <span class="kw">false</span>
<span class="kw">@Published var</span> isLoading = <span class="kw">false</span>
<span class="kw">private let</span> keychainKey = <span class="st">"qz.jwt"</span>
<span class="kw">private let</span> baseURL = <span class="st">"https://amos.wavult.com"</span>
<span class="cm">// 1. Starta BankID-flöde</span>
<span class="kw">func</span> <span class="fn">initiateBankID</span>(personnummer: <span class="tp">String</span>) <span class="kw">async throws</span> {
isLoading = <span class="kw">true</span>
<span class="kw">defer</span> { isLoading = <span class="kw">false</span> }
<span class="kw">let</span> response = <span class="kw">try await</span> <span class="fn">APIClient</span>.post(
<span class="st">"/api/auth/bankid/init"</span>,
body: [<span class="st">"personnummer"</span>: personnummer, <span class="st">"role"</span>: <span class="st">"zoomer"</span>]
)
<span class="cm">// Öppna BankID-appen</span>
<span class="kw">if let</span> url = response[<span class="st">"autoStartToken"</span>] <span class="kw">as?</span> <span class="tp">String</span> {
<span class="tp">UIApplication</span>.shared.<span class="fn">open</span>(
<span class="tp">URL</span>(string: <span class="st">"bankid:///?autostarttoken=\(url)"</span>)!
)
}
}
<span class="cm">// 2. Polls tills BankID bekräftar (max 30s)</span>
<span class="kw">func</span> <span class="fn">collectBankID</span>(orderRef: <span class="tp">String</span>) <span class="kw">async throws</span> -> <span class="tp">String</span> {
<span class="kw">for</span> _ <span class="kw">in</span> <span class="nm">0</span>..<span class="nm">30</span> {
<span class="kw">try await</span> <span class="tp">Task</span>.sleep(nanoseconds: <span class="nm">1_000_000_000</span>)
<span class="kw">let</span> res = <span class="kw">try await</span> <span class="fn">APIClient</span>.post(
<span class="st">"/api/auth/bankid/collect"</span>,
body: [<span class="st">"orderRef"</span>: orderRef]
)
<span class="kw">if</span> res[<span class="st">"status"</span>] <span class="kw">as?</span> <span class="tp">String</span> == <span class="st">"complete"</span> {
<span class="kw">return</span> res[<span class="st">"jwt"</span>] <span class="kw">as!</span> <span class="tp">String</span>
}
}
<span class="kw">throw</span> <span class="tp">AuthError</span>.timeout
}
<span class="cm">// 3. Spara JWT i Keychain</span>
<span class="kw">func</span> <span class="fn">saveToken</span>(_ jwt: <span class="tp">String</span>) {
<span class="kw">let</span> data = jwt.data(using: .utf8)!
<span class="kw">let</span> query: [<span class="tp">CFString</span>: <span class="tp">Any</span>] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: keychainKey,
kSecValueData: data,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
<span class="tp">SecItemDelete</span>(query <span class="kw">as</span> <span class="tp">CFDictionary</span>)
<span class="tp">SecItemAdd</span>(query <span class="kw">as</span> <span class="tp">CFDictionary</span>, <span class="kw">nil</span>)
isAuthenticated = <span class="kw">true</span>
}
}
</pre>
</div>
</div>
<!-- SECTION 3 -->
<div class="guide-section" id="s3">
<div class="section-anchor">
<div class="section-num">03 — API</div>
<div class="section-title">API-endpoints</div>
<div class="section-body">
Alla endpoints nås på <code style="font-family:monospace;color:var(--ios-blue)">https://amos.wavult.com/api/quixzoom/</code>.
JWT skickas som <code style="font-family:monospace">Authorization: Bearer &lt;token&gt;</code>.
</div>
</div>
<div class="endpoint-card">
<div class="endpoint-row"><span class="method get">GET</span><span class="endpoint-path">/missions</span><span class="endpoint-desc">Alla öppna uppdrag</span></div>
<div class="endpoint-row"><span class="method post">POST</span><span class="endpoint-path">/missions/:id/accept</span><span class="endpoint-desc">Acceptera uppdrag</span></div>
<div class="endpoint-row"><span class="method post">POST</span><span class="endpoint-path">/missions/:id/submit</span><span class="endpoint-desc">Skicka in (multipart)</span></div>
<div class="endpoint-row"><span class="method get">GET</span><span class="endpoint-path">/earnings</span><span class="endpoint-desc">Plånbok & historik</span></div>
<div class="endpoint-row"><span class="method get">GET</span><span class="endpoint-path">/ratings</span><span class="endpoint-desc">Betyg & recensioner</span></div>
<div class="endpoint-row"><span class="method get">GET</span><span class="endpoint-path">/me</span><span class="endpoint-desc">Profil</span></div>
<div class="endpoint-row"><span class="method put">PUT</span><span class="endpoint-path">/me</span><span class="endpoint-desc">Uppdatera profil</span></div>
<div class="endpoint-row"><span class="method post">POST</span><span class="endpoint-path">/me/device-token</span><span class="endpoint-desc">Registrera APNs-token</span></div>
</div>
<div class="code-block">
<div class="code-header">
<span class="code-lang">Swift · APIClient</span>
<button class="code-copy" onclick="copyCode(this)">Kopiera</button>
</div>
<pre>
<span class="kw">struct</span> <span class="tp">APIClient</span> {
<span class="kw">static let</span> base = <span class="st">"https://amos.wavult.com/api/quixzoom"</span>
<span class="kw">static func</span> <span class="fn">get</span>&lt;T: <span class="tp">Decodable</span>&gt;(
_ path: <span class="tp">String</span>, type: T.Type
) <span class="kw">async throws</span> -> T {
<span class="kw">var</span> req = <span class="tp">URLRequest</span>(url: <span class="tp">URL</span>(string: base + path)!)
req.setValue(<span class="st">"Bearer \(</span><span class="fn">Keychain</span>.get(<span class="st">"qz.jwt"</span>)<span class="st">)"</span>,
forHTTPHeaderField: <span class="st">"Authorization"</span>)
<span class="kw">let</span> (data, _) = <span class="kw">try await</span> <span class="tp">URLSession</span>.shared.data(for: req)
<span class="kw">return try</span> <span class="tp">JSONDecoder</span>().<span class="fn">decode</span>(T.self, from: data)
}
<span class="cm">// Upload mission with photos</span>
<span class="kw">static func</span> <span class="fn">submitMission</span>(
id: <span class="tp">Int</span>, images: [<span class="tp">UIImage</span>]
) <span class="kw">async throws</span> -> <span class="tp">MissionResult</span> {
<span class="kw">var</span> req = <span class="tp">URLRequest</span>(url: <span class="tp">URL</span>(string: <span class="st">"\(</span>base<span class="st">)/missions/\(</span>id<span class="st">)/submit"</span>)!)
req.httpMethod = <span class="st">"POST"</span>
<span class="kw">let</span> boundary = <span class="tp">UUID</span>().uuidString
req.setValue(<span class="st">"multipart/form-data; boundary=\(</span>boundary<span class="st">)"</span>,
forHTTPHeaderField: <span class="st">"Content-Type"</span>)
req.setValue(<span class="st">"Bearer \(</span><span class="fn">Keychain</span>.get(<span class="st">"qz.jwt"</span>)<span class="st">)"</span>,
forHTTPHeaderField: <span class="st">"Authorization"</span>)
<span class="kw">var</span> body = <span class="tp">Data</span>()
images.<span class="fn">forEach</span> { img <span class="kw">in</span>
body.append(<span class="st">"--\(boundary)\r\n"</span>.data(using: .utf8)!)
body.append(<span class="st">"Content-Disposition: form-data; name=\"images\"; filename=\"photo.jpg\"\r\n"</span>.data(using: .utf8)!)
body.append(<span class="st">"Content-Type: image/jpeg\r\n\r\n"</span>.data(using: .utf8)!)
body.append(img.<span class="fn">jpegData</span>(compressionQuality: <span class="nm">0.85</span>)!)
body.append(<span class="st">"\r\n"</span>.data(using: .utf8)!)
}
body.append(<span class="st">"--\(boundary)--\r\n"</span>.data(using: .utf8)!)
req.httpBody = body
<span class="kw">let</span> (data, _) = <span class="kw">try await</span> <span class="tp">URLSession</span>.shared.data(for: req)
<span class="kw">return try</span> <span class="tp">JSONDecoder</span>().<span class="fn">decode</span>(<span class="tp">MissionResult</span>.self, from: data)
}
}
</pre>
</div>
</div>
<!-- SECTION 4 -->
<div class="guide-section" id="s4">
<div class="section-anchor">
<div class="section-num">04 — Push</div>
<div class="section-title">Push Notifications (APNs)</div>
<div class="section-body">
Ny uppdrag, uppdragsgodkännanden och utbetalningskvitton levereras via APNs.
Device token registreras vid app-start och vid token-rotation.
</div>
</div>
<div class="code-block">
<div class="code-header">
<span class="code-lang">Swift · AppDelegate</span>
<button class="code-copy" onclick="copyCode(this)">Kopiera</button>
</div>
<pre>
<span class="kw">func</span> <span class="fn">application</span>(
_ application: <span class="tp">UIApplication</span>,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: <span class="tp">Data</span>
) {
<span class="kw">let</span> token = deviceToken.<span class="fn">map</span> { <span class="tp">String</span>(format: <span class="st">"%02x"</span>, $0) }.<span class="fn">joined</span>()
<span class="tp">Task</span> {
<span class="kw">try?</span> <span class="kw">await</span> <span class="tp">APIClient</span>.post(<span class="st">"/me/device-token"</span>,
body: [<span class="st">"token"</span>: token, <span class="st">"platform"</span>: <span class="st">"apns"</span>])
}
}
<span class="cm">// Payload-format från servern:</span>
<span class="cm">// {</span>
<span class="cm">// "aps": { "alert": { "title": "Nytt uppdrag!", "body": "350 kr · Storgatan 12" },</span>
<span class="cm">// "badge": 1, "sound": "default" },</span>
<span class="cm">// "type": "new_mission",</span>
<span class="cm">// "missionId": 42</span>
<span class="cm">// }</span>
<span class="kw">func</span> <span class="fn">userNotificationCenter</span>(
_ center: <span class="tp">UNUserNotificationCenter</span>,
didReceive response: <span class="tp">UNNotificationResponse</span>,
withCompletionHandler: @<span class="kw">escaping</span> () -> <span class="tp">Void</span>
) {
<span class="kw">let</span> info = response.notification.request.content.userInfo
<span class="kw">if let</span> missionId = info[<span class="st">"missionId"</span>] <span class="kw">as?</span> <span class="tp">Int</span> {
<span class="cm">// Navigate to mission detail</span>
<span class="tp">NavigationRouter</span>.shared.<span class="fn">navigate</span>(to: .mission(id: missionId))
}
withCompletionHandler()
}
</pre>
</div>
</div>
<!-- SECTION 5 -->
<div class="guide-section" id="s5">
<div class="section-anchor">
<div class="section-num">05 — Kamera</div>
<div class="section-title">Kamera & Bilduppladdning</div>
<div class="section-body">
Native camera med EXIF-stripning (GDPR) och automatisk komprimering till max 2MB per bild.
Bilder taggas med uppdrag-ID och GPS-koordinater.
</div>
</div>
<div class="info-card warning">
<strong><svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10 3L17 17H3L10 3Z" stroke="#f59e0b" stroke-width="2"/><path d="M10 8V12M10 14V14.5" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/></svg> Privacy Manifest krävs:</strong> Lägg till <code style="font-family:monospace">NSCameraUsageDescription</code>
och <code style="font-family:monospace">NSLocationWhenInUseUsageDescription</code> i Info.plist med
motiveringar på svenska (krävs för App Store Review).
</div>
<div class="code-block">
<div class="code-header">
<span class="code-lang">Swift · CameraView</span>
<button class="code-copy" onclick="copyCode(this)">Kopiera</button>
</div>
<pre>
<span class="kw">import</span> SwiftUI
<span class="kw">import</span> PhotosUI
<span class="kw">struct</span> <span class="tp">CameraUploadView</span>: <span class="tp">View</span> {
<span class="kw">let</span> missionId: <span class="tp">Int</span>
<span class="kw">@State private var</span> selectedItems: [<span class="tp">PhotosPickerItem</span>] = []
<span class="kw">@State private var</span> capturedImages: [<span class="tp">UIImage</span>] = []
<span class="kw">@State private var</span> isUploading = <span class="kw">false</span>
<span class="kw">var</span> body: <span class="kw">some</span> <span class="tp">View</span> {
<span class="tp">VStack</span> {
<span class="tp">PhotosPicker</span>(selection: $selectedItems,
maxSelectionCount: <span class="nm">10</span>,
matching: .images) {
<span class="tp">Label</span>(<span class="st">"Ta / välj foton"</span>, systemImage: <span class="st">"camera.fill"</span>)
.<span class="fn">frame</span>(maxWidth: .<span class="kw">infinity</span>)
.<span class="fn">padding</span>()
.<span class="fn">background</span>(<span class="tp">Color</span>.blue)
.<span class="fn">foregroundColor</span>(.<span class="kw">white</span>)
.<span class="fn">cornerRadius</span>(<span class="nm">12</span>)
}
<span class="kw">if</span> !capturedImages.<span class="fn">isEmpty</span> {
<span class="tp">Button</span>(<span class="st">"Skicka in \(capturedImages.count) bilder"</span>) {
<span class="tp">Task</span> { <span class="kw">try await</span> <span class="fn">upload</span>() }
}
.<span class="fn">disabled</span>(isUploading)
}
}
.<span class="fn">onChange</span>(of: selectedItems) { _, items <span class="kw">in</span>
<span class="tp">Task</span> { <span class="kw">await</span> <span class="fn">loadImages</span>(items) }
}
}
<span class="kw">private func</span> <span class="fn">upload</span>() <span class="kw">async throws</span> {
isUploading = <span class="kw">true</span>
<span class="kw">let</span> _ = <span class="kw">try await</span> <span class="tp">APIClient</span>.<span class="fn">submitMission</span>(
id: missionId, images: capturedImages
)
isUploading = <span class="kw">false</span>
}
}
</pre>
</div>
</div>
<!-- SECTION 6 -->
<div class="guide-section" id="s6">
<div class="section-anchor">
<div class="section-num">06 — Plats</div>
<div class="section-title">Plats & Kartintegration</div>
<div class="section-body">
MapKit används för uppdragskarta. Kontinuerlig GPS-spårning aktiveras bara under
aktiva uppdrag och stoppas omedelbart vid slutförande (batterivänlig design).
</div>
</div>
<div class="info-card success">
<strong><svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="9" stroke="#22c55e" stroke-width="2"/><path d="M6 10L9 13L14 7" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> Background modes:</strong> Aktivera <code style="font-family:monospace">location</code>
i Background Modes capability för spårning under aktivt uppdrag. Motivera i Privacy Manifest.
</div>
<div class="code-block">
<div class="code-header">
<span class="code-lang">Swift · LocationService</span>
<button class="code-copy" onclick="copyCode(this)">Kopiera</button>
</div>
<pre>
<span class="kw">import</span> CoreLocation
<span class="kw">import</span> MapKit
<span class="kw">@MainActor
class</span> <span class="tp">LocationService</span>: <span class="tp">NSObject</span>, <span class="tp">ObservableObject</span>,
<span class="tp">CLLocationManagerDelegate</span> {
<span class="kw">@Published var</span> userLocation: <span class="tp">CLLocation</span>?
<span class="kw">@Published var</span> distanceToMission: <span class="tp">Double</span> = <span class="nm">0</span>
<span class="kw">private let</span> manager = <span class="tp">CLLocationManager</span>()
<span class="kw">func</span> <span class="fn">startTracking</span>(mission: <span class="tp">Mission</span>) {
manager.delegate = <span class="kw">self</span>
manager.desiredAccuracy = kCLLocationAccuracyBest
manager.distanceFilter = <span class="nm">10</span> <span class="cm">// update every 10m</span>
manager.allowsBackgroundLocationUpdates = <span class="kw">true</span>
manager.<span class="fn">startUpdatingLocation</span>()
<span class="fn">updateDistance</span>(to: mission)
}
<span class="kw">func</span> <span class="fn">stopTracking</span>() {
manager.<span class="fn">stopUpdatingLocation</span>()
manager.allowsBackgroundLocationUpdates = <span class="kw">false</span>
}
<span class="kw">nonisolated func</span> <span class="fn">locationManager</span>(
_ manager: <span class="tp">CLLocationManager</span>,
didUpdateLocations locations: [<span class="tp">CLLocation</span>]
) {
<span class="tp">Task</span> { <span class="kw">@MainActor in</span>
<span class="kw">self</span>.userLocation = locations.last
}
}
}
</pre>
</div>
</div>
<!-- SECTION 7 -->
<div class="guide-section" id="s7">
<div class="section-anchor">
<div class="section-num">07 — Deep Links</div>
<div class="section-title">Deep Links & Universal Links</div>
<div class="section-body">
Universal Links (HTTPS) och custom URL scheme <code style="font-family:monospace;color:var(--ios-blue)">quixzoom://</code>
används för push-navigation och delning av uppdrag.
</div>
</div>
<div class="endpoint-card">
<div class="endpoint-row"><span class="method get" style="background:rgba(255,255,255,0.1);color:var(--label-secondary)">URL</span><span class="endpoint-path">quixzoom://mission/42</span><span class="endpoint-desc">Öppna uppdrag</span></div>
<div class="endpoint-row"><span class="method get" style="background:rgba(255,255,255,0.1);color:var(--label-secondary)">URL</span><span class="endpoint-path">quixzoom://wallet</span><span class="endpoint-desc">Öppna plånbok</span></div>
<div class="endpoint-row"><span class="method get" style="background:rgba(255,255,255,0.1);color:var(--label-secondary)">URL</span><span class="endpoint-path">quixzoom://profile</span><span class="endpoint-desc">Öppna profil</span></div>
<div class="endpoint-row"><span class="method get" style="background:rgba(255,255,255,0.1);color:var(--label-secondary)">HTTPS</span><span class="endpoint-path">amos.wavult.com/quixzoom/mission/42</span><span class="endpoint-desc">Universal link</span></div>
</div>
<div class="info-card">
<strong>apple-app-site-association:</strong> Filen <code style="font-family:monospace">/.well-known/apple-app-site-association</code>
måste publiceras på <code style="font-family:monospace">amos.wavult.com</code> med Team ID + Bundle ID för Universal Links.
</div>
</div>
<!-- SECTION 8 -->
<div class="guide-section" id="s8">
<div class="section-anchor">
<div class="section-num">08 — Checklista</div>
<div class="section-title">Checklista för App Store-release</div>
</div>
<div class="checklist">
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>Privacy Manifest</strong> — PrivacyInfo.xcprivacy med alla API-anledningar deklarerade</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>BankID integration</strong> — Testad mot BankID Test-miljö</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>App Tracking Transparency</strong> — ATT-dialog om analytics används</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>Åldersrating</strong> — Minst 17+ (finansiell tjänst, gig economy)</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>Crashlytics / Sentry</strong> — Felrapportering aktiv</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>GDPR-popup</strong> — Dataskyddspolicy acceptansflöde vid första start</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>Bildkomprimering</strong> — Max 2MB per bild, EXIF-stripning aktiverad</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>Offline-hantering</strong> — Graceful degradation utan nätverksanslutning</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>Dynamic Type</strong> — Alla textstorl. skalar med iOS-inställningar</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>Dark/Light mode</strong> — Testad i båda lägena (primärt: dark mode)</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>Notarization</strong> — App notariserad via Xcode Organizer</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>TestFlight</strong> — Minst 10 externa testare godkänt bygget</div></div>
</div>
<div style="height:32px;"></div>
</div>
<script>
function copyCode(btn) {
const pre = btn.closest('.code-block').querySelector('pre');
navigator.clipboard.writeText(pre.innerText || pre.textContent).then(() => {
btn.textContent = '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> Kopierat';
setTimeout(() => btn.textContent = 'Kopiera', 2000);
});
}
</script>
</body>
</html>