2026-07-05 06:41:32 +00:00
<!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 : 100 dvh ;
padding-top : env ( safe - area - inset - top , 44 px ) ;
padding-bottom : 48 px ;
-webkit- font-smoothing : antialiased ;
}
. nav-bar {
position : sticky ; top : 0 ; z-index : 200 ;
display : flex ; align-items : center ; gap : 12 px ;
height : 44 px ; padding : 0 16 px ;
background : var ( - - navbar - bg ) ;
backdrop-filter : saturate ( 180 % ) blur ( 20 px ) ;
-webkit- backdrop-filter : saturate ( 180 % ) blur ( 20 px ) ;
border-bottom : 0.5 px solid var ( - - separator ) ;
}
. nav-back {
background : none ; border : none ; color : var ( - - ios - blue ) ;
font-size : 17 px ; cursor : pointer ; padding : 4 px 0 ;
display : flex ; align-items : center ; gap : 4 px ;
}
. nav-title {
flex : 1 ; font-size : 17 px ; font-weight : 600 ; letter-spacing : -0.41 px ;
}
/* Hero */
. guide-hero {
background : linear-gradient ( 160 deg , #0d2137 0 % , #0a1520 60 % , #000 100 % ) ;
padding : 40 px 24 px 36 px ; 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 : 64 px ; margin-bottom : 16 px ; position : relative ; z-index : 1 ; }
. hero-title {
font-family : var ( - - font - display ) ; font-size : 28 px ; font-weight : 800 ;
letter-spacing : -0.8 px ; color : #fff ; position : relative ; z-index : 1 ;
}
. hero-sub {
font-size : 15 px ; color : rgba ( 255 , 255 , 255 , 0.75 ) ; margin-top : 8 px ;
position : relative ; z-index : 1 ; line-height : 1.5 ;
}
. hero-badges {
display : flex ; justify-content : center ; gap : 8 px ; margin-top : 16 px ; flex-wrap : wrap ;
position : relative ; z-index : 1 ;
}
. hero-badge {
2026-07-07 07:11:50 +00:00
background : rgba ( 255 , 255 , 255 , 0.1 ) ; border-radius : var ( - - radius - xl ) ; padding : 4 px 14 px ;
2026-07-05 06:41:32 +00:00
font-size : 12 px ; font-weight : 600 ; color : rgba ( 255 , 255 , 255 , 0.8 ) ;
border : 0.5 px solid rgba ( 255 , 255 , 255 , 0.2 ) ;
}
/* TOC */
2026-07-07 07:11:50 +00:00
. toc { margin : 20 px 16 px ; background : var ( - - background - secondary ) ; border-radius : var ( - - radius - lg ) ; overflow : hidden ; border : 0.5 px solid var ( - - separator ) ; }
2026-07-05 06:41:32 +00:00
. toc-title { padding : 14 px 16 px ; font-size : 13 px ; font-weight : 600 ; color : var ( - - label - secondary ) ; text-transform : uppercase ; letter-spacing : 0.5 px ; border-bottom : 0.5 px solid var ( - - separator ) ; }
. toc-item { display : flex ; align-items : center ; gap : 12 px ; padding : 12 px 16 px ; border-bottom : 0.5 px solid var ( - - separator ) ; cursor : pointer ; text-decoration : none ; }
. toc-item : last-child { border-bottom : none ; }
. toc-item : active { background : var ( - - fill - quaternary ) ; }
2026-07-07 07:11:50 +00:00
. toc-num { width : 26 px ; height : 26 px ; border-radius : var ( - - radius - full ) ; background : var ( - - ios - blue ) ; color : #fff ; display : flex ; align-items : center ; justify-content : center ; font-size : 12 px ; font-weight : 700 ; flex-shrink : 0 ; }
2026-07-05 06:41:32 +00:00
. toc-label { font-size : 15 px ; font-weight : 500 ; flex : 1 ; }
. toc-chevron { color : var ( - - label - tertiary ) ; font-size : 13 px ; }
/* Section */
. guide-section { margin : 24 px 0 ; }
. section-anchor { padding : 0 16 px ; }
. section-num {
font-size : 12 px ; font-weight : 700 ; color : var ( - - ios - blue ) ;
text-transform : uppercase ; letter-spacing : 1 px ; margin-bottom : 4 px ;
}
. section-title {
font-family : var ( - - font - display ) ; font-size : 22 px ; font-weight : 800 ;
letter-spacing : -0.5 px ; margin-bottom : 12 px ;
}
. section-body { font-size : 15 px ; line-height : 1.65 ; color : var ( - - label - secondary ) ; }
/* Code block */
. code-block {
margin : 16 px 16 px 0 ;
2026-07-07 07:11:50 +00:00
background : #F2F2F7 ; border-radius : var ( - - radius - lg ) ; overflow : hidden ;
2026-07-05 06:41:32 +00:00
border : 0.5 px solid var ( - - separator ) ;
}
. code-header {
display : flex ; align-items : center ; justify-content : space-between ;
padding : 10 px 16 px ;
background : rgba ( 255 , 255 , 255 , 0.05 ) ;
border-bottom : 0.5 px solid var ( - - separator ) ;
}
. code-lang { font-size : 12 px ; color : var ( - - label - tertiary ) ; font-weight : 600 ; text-transform : uppercase ; letter-spacing : 0.5 px ; }
2026-07-07 07:11:50 +00:00
. code-copy { background : none ; border : none ; color : var ( - - ios - blue ) ; font-size : 12 px ; font-weight : 600 ; cursor : pointer ; padding : 2 px 6 px ; border-radius : var ( - - radius - sm ) ; }
2026-07-05 06:41:32 +00:00
. code-copy : active { background : var ( - - fill - secondary ) ; }
pre {
padding : 16 px ; font-family : 'SF Mono' , 'Menlo' , 'Monaco' , monospace ;
font-size : 13 px ; 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 : 16 px 16 px 0 ;
2026-07-07 07:11:50 +00:00
padding : 14 px 16 px ; border-radius : var ( - - radius - lg ) ;
2026-07-05 06:41:32 +00:00
border-left : 3 px solid var ( - - ios - blue ) ;
background : rgba ( 0 , 122 , 255 , 0.1 ) ;
font-size : 14 px ; 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 : 16 px 16 px 0 ; display : flex ; flex-direction : column ; gap : 12 px ; }
. step-item { display : flex ; gap : 14 px ; align-items : flex-start ; }
2026-07-07 07:11:50 +00:00
. step-dot { width : 28 px ; height : 28 px ; border-radius : var ( - - radius - full ) ; background : var ( - - ios - blue ) ; color : #fff ; display : flex ; align-items : center ; justify-content : center ; font-size : 13 px ; font-weight : 700 ; flex-shrink : 0 ; margin-top : 2 px ; }
2026-07-05 06:41:32 +00:00
. step-content { flex : 1 ; }
. step-title { font-size : 15 px ; font-weight : 600 ; margin-bottom : 4 px ; }
. step-desc { font-size : 14 px ; color : var ( - - label - secondary ) ; line-height : 1.5 ; }
/* Endpoint table */
. endpoint-card {
margin : 16 px 16 px 0 ;
2026-07-07 07:11:50 +00:00
background : var ( - - background - secondary ) ; border-radius : var ( - - radius - lg ) ;
2026-07-05 06:41:32 +00:00
overflow : hidden ; border : 0.5 px solid var ( - - separator ) ;
}
. endpoint-row {
display : flex ; align-items : baseline ; gap : 10 px ;
padding : 12 px 16 px ; border-bottom : 0.5 px solid var ( - - separator ) ;
}
. endpoint-row : last-child { border-bottom : none ; }
. method {
2026-07-07 07:11:50 +00:00
font-size : 11 px ; font-weight : 800 ; padding : 3 px 8 px ; border-radius : var ( - - radius - sm ) ;
2026-07-05 06:41:32 +00:00
text-transform : uppercase ; letter-spacing : 0.3 px ; 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 : 13 px ; flex : 1 ; }
. endpoint-desc { font-size : 12 px ; color : var ( - - label - secondary ) ; min-width : 120 px ; text-align : right ; }
/* Checklist */
. checklist { margin : 16 px 16 px 0 ; display : flex ; flex-direction : column ; gap : 10 px ; }
. check-item { display : flex ; align-items : flex-start ; gap : 10 px ; }
2026-07-07 07:11:50 +00:00
. check-box { width : 22 px ; height : 22 px ; 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 : 13 px ; flex-shrink : 0 ; margin-top : 1 px ; }
2026-07-05 06:41:32 +00:00
. check-text { font-size : 14 px ; color : var ( - - label - secondary ) ; line-height : 1.5 ; }
. check-text strong { color : var ( - - label - primary ) ; }
. divider { height : 0.5 px ; background : var ( - - separator ) ; margin : 8 px 0 ; }
@ media ( max-width : 640px ) {
. hero h1 { font-size : 2 rem !important ; }
. hero p { font-size : 1 rem !important ; }
. card { padding : 1.5 rem !important ; }
. grid { grid-template-columns : 1 fr !important ; }
. btn { padding : 0.75 rem 1.5 rem !important ; font-size : 1 rem !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 : 2 rem !important ; }
. hero p { font-size : 1 rem !important ; }
. card { padding : 1.5 rem !important ; }
. grid { grid-template-columns : 1 fr !important ; }
. btn { padding : 0.75 rem 1.5 rem !important ; font-size : 1 rem !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" >
2026-07-07 07:11:50 +00:00
< div class = "hero-icon" > < / div >
2026-07-05 06:41:32 +00:00
< 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 >
2026-07-07 07:11:50 +00:00
<!-- SECTION 1 -->
2026-07-05 06:41:32 +00:00
< 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/
2026-07-07 07:11:50 +00:00
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 >
2026-07-05 06:41:32 +00:00
< / pre >
< / div >
< / div >
2026-07-07 07:11:50 +00:00
<!-- SECTION 2 -->
2026-07-05 06:41:32 +00:00
< 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 >
2026-07-07 07:11:50 +00:00
<!-- SECTION 3 -->
2026-07-05 06:41:32 +00:00
< 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 < token> < / 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 > < T: < span class = "tp" > Decodable< / span > > (
_ 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 >
2026-07-07 07:11:50 +00:00
<!-- SECTION 4 -->
2026-07-05 06:41:32 +00:00
< 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 >
2026-07-07 07:11:50 +00:00
<!-- SECTION 5 -->
2026-07-05 06:41:32 +00:00
< 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" >
2026-07-07 07:11:50 +00:00
< 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 >
2026-07-05 06:41:32 +00:00
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 >
2026-07-07 07:11:50 +00:00
<!-- SECTION 6 -->
2026-07-05 06:41:32 +00:00
< 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" >
2026-07-07 07:11:50 +00:00
< 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 >
2026-07-05 06:41:32 +00:00
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 >
2026-07-07 07:11:50 +00:00
<!-- SECTION 7 -->
2026-07-05 06:41:32 +00:00
< 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 >
2026-07-07 07:11:50 +00:00
<!-- SECTION 8 -->
2026-07-05 06:41:32 +00:00
< 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" >
2026-07-07 07:11:50 +00:00
< 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 >
2026-07-05 06:41:32 +00:00
< / 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 ( ( ) => {
2026-07-07 07:11:50 +00:00
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' ;
2026-07-05 06:41:32 +00:00
setTimeout ( ( ) => btn . textContent = 'Kopiera' , 2000 ) ;
} ) ;
}
< / script >
< / body >
< / html >