Compare commits
No commits in common. "push-testflight" and "main" have entirely different histories.
push-testf
...
main
|
|
@ -292,14 +292,12 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CODE_SIGN_ENTITLEMENTS = Kasztelan/Kasztelan.entitlements;
|
CURRENT_PROJECT_VERSION = 10;
|
||||||
APS_ENVIRONMENT = development;
|
|
||||||
CURRENT_PROJECT_VERSION = 18;
|
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Kasztelan/Info.plist;
|
INFOPLIST_FILE = Kasztelan/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
MARKETING_VERSION = 1.0.10;
|
MARKETING_VERSION = 1.0.8;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = uk.co.kasztelan.app.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = uk.co.kasztelan.app.ios;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
|
@ -318,14 +316,12 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CODE_SIGN_ENTITLEMENTS = Kasztelan/Kasztelan.entitlements;
|
CURRENT_PROJECT_VERSION = 10;
|
||||||
APS_ENVIRONMENT = production;
|
|
||||||
CURRENT_PROJECT_VERSION = 18;
|
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Kasztelan/Info.plist;
|
INFOPLIST_FILE = Kasztelan/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
MARKETING_VERSION = 1.0.10;
|
MARKETING_VERSION = 1.0.8;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = uk.co.kasztelan.app.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = uk.co.kasztelan.app.ios;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
|
|
||||||
|
|
@ -98,16 +98,6 @@ final class APIClient {
|
||||||
try await request("GET", path: "/notifications/latest", token: token)
|
try await request("GET", path: "/notifications/latest", token: token)
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerPush(deviceToken: String, environment: String, token: String) async throws {
|
|
||||||
let _: APIEnvelope = try await request("POST", path: "/push/device", token: token, body: [
|
|
||||||
"device_token": deviceToken, "environment": environment
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
func unregisterPush(deviceToken: String, token: String) async throws {
|
|
||||||
let _: APIEnvelope = try await request("DELETE", path: "/push/device", token: token, body: ["device_token": deviceToken])
|
|
||||||
}
|
|
||||||
|
|
||||||
func wallet(token: String) async throws -> APIEnvelope {
|
func wallet(token: String) async throws -> APIEnvelope {
|
||||||
try await request("GET", path: "/wallet", token: token)
|
try await request("GET", path: "/wallet", token: token)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,6 @@ final class AppModel: ObservableObject {
|
||||||
let wallet = DiscountCardStore()
|
let wallet = DiscountCardStore()
|
||||||
await wallet.sync(using: self)
|
await wallet.sync(using: self)
|
||||||
}
|
}
|
||||||
await NotificationService.unregisterPush(session: currentToken)
|
|
||||||
clearSession()
|
clearSession()
|
||||||
try? await api.logout(token: currentToken)
|
try? await api.logout(token: currentToken)
|
||||||
}
|
}
|
||||||
|
|
@ -176,7 +175,6 @@ final class AppModel: ObservableObject {
|
||||||
|
|
||||||
func refresh() async {
|
func refresh() async {
|
||||||
guard isAuthenticated else { return }
|
guard isAuthenticated else { return }
|
||||||
await NotificationService.syncPushRegistration()
|
|
||||||
do {
|
do {
|
||||||
if let response = try await fetchAccount() { apply(response) }
|
if let response = try await fetchAccount() { apply(response) }
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -273,7 +271,6 @@ final class AppModel: ObservableObject {
|
||||||
guard let token = response.token, !token.isEmpty else { throw APIError.invalidResponse }
|
guard let token = response.token, !token.isEmpty else { throw APIError.invalidResponse }
|
||||||
try KeychainStore.save(token: token)
|
try KeychainStore.save(token: token)
|
||||||
self.token = token
|
self.token = token
|
||||||
Task { await NotificationService.requestPermission() }
|
|
||||||
apply(response)
|
apply(response)
|
||||||
beginLiveRefresh()
|
beginLiveRefresh()
|
||||||
Task { await refreshMessages(showNotifications: true) }
|
Task { await refreshMessages(showNotifications: true) }
|
||||||
|
|
|
||||||
|
|
@ -20,19 +20,13 @@ struct AuthView: View {
|
||||||
}
|
}
|
||||||
.padding(.bottom, 18)
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
if registering {
|
if registering { registrationForm }
|
||||||
registrationForm
|
else { loginForm }
|
||||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
|
||||||
} else {
|
|
||||||
loginForm
|
|
||||||
.transition(.move(edge: .leading).combined(with: .opacity))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(22)
|
.padding(22)
|
||||||
.animation(.spring(response: 0.42, dampingFraction: 0.86), value: registering)
|
|
||||||
}
|
}
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.background(AmbientBackground())
|
.background(KTheme.background)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var loginForm: some View {
|
private var loginForm: some View {
|
||||||
|
|
@ -61,7 +55,9 @@ struct AuthView: View {
|
||||||
}
|
}
|
||||||
.accessibilityLabel(model.text("Otwórz resetowanie hasła", "Open password reset"))
|
.accessibilityLabel(model.text("Otwórz resetowanie hasła", "Open password reset"))
|
||||||
Button(model.text("Załóż konto", "Create account")) { registering = true }
|
Button(model.text("Załóż konto", "Create account")) { registering = true }
|
||||||
.buttonStyle(GlassButtonStyle())
|
.frame(maxWidth: .infinity, minHeight: 50)
|
||||||
|
.background(KTheme.panel)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,7 +85,9 @@ struct AuthView: View {
|
||||||
}
|
}
|
||||||
.buttonStyle(PrimaryButtonStyle())
|
.buttonStyle(PrimaryButtonStyle())
|
||||||
Button(model.text("Mam już konto", "I already have an account")) { registering = false }
|
Button(model.text("Mam już konto", "I already have an account")) { registering = false }
|
||||||
.buttonStyle(GlassButtonStyle())
|
.frame(maxWidth: .infinity, minHeight: 50)
|
||||||
|
.background(KTheme.panel)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,9 +120,7 @@ struct LanguageButton: View {
|
||||||
.font(.subheadline.bold())
|
.font(.subheadline.bold())
|
||||||
.foregroundStyle(KTheme.orange)
|
.foregroundStyle(KTheme.orange)
|
||||||
.frame(width: 44, height: 44, alignment: .center)
|
.frame(width: 44, height: 44, alignment: .center)
|
||||||
.background(.ultraThinMaterial, in: Circle())
|
.background(KTheme.panel, in: Circle())
|
||||||
.overlay { Circle().stroke(.white.opacity(0.20), lineWidth: 1) }
|
|
||||||
.shadow(color: KTheme.orange.opacity(0.18), radius: 12)
|
|
||||||
.contentShape(Circle())
|
.contentShape(Circle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ struct CardView: View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
let compact = geometry.size.height < 680
|
let compact = geometry.size.height < 680
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: compact ? 8 : 12) {
|
VStack(alignment: .leading, spacing: compact ? 10 : 18) {
|
||||||
Text(model.text("Witaj, ", "Hello, ") + firstName)
|
Text(model.text("Witaj, ", "Hello, ") + firstName)
|
||||||
.font(compact ? .title.bold() : .largeTitle.bold())
|
.font(compact ? .title.bold() : .largeTitle.bold())
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
@ -36,10 +36,10 @@ struct CardView: View {
|
||||||
summary(compact: compact)
|
summary(compact: compact)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.top, compact ? 0 : 2)
|
.padding(.vertical, compact ? 8 : 14)
|
||||||
.frame(width: geometry.size.width, height: geometry.size.height, alignment: .top)
|
.frame(width: geometry.size.width, height: geometry.size.height, alignment: .top)
|
||||||
}
|
}
|
||||||
.background(.clear)
|
.background(KTheme.background)
|
||||||
.fullScreenCover(isPresented: $cardScannerPresented) {
|
.fullScreenCover(isPresented: $cardScannerPresented) {
|
||||||
CardScannerScreen(
|
CardScannerScreen(
|
||||||
onScan: acceptScannedCard,
|
onScan: acceptScannedCard,
|
||||||
|
|
@ -89,17 +89,11 @@ struct CardView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loyaltyCard(compact: Bool) -> some View {
|
private func loyaltyCard(compact: Bool) -> some View {
|
||||||
GlassDepthDeck {
|
return VStack(spacing: compact ? 10 : 20) {
|
||||||
loyaltyCardFace(compact: compact)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loyaltyCardFace(compact: Bool) -> some View {
|
|
||||||
VStack(spacing: compact ? 10 : 20) {
|
|
||||||
Image("Logo")
|
Image("Logo")
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(maxHeight: compact ? 66 : 88)
|
.frame(maxHeight: compact ? 54 : 76)
|
||||||
if model.cardNumber.count == 13 {
|
if model.cardNumber.count == 13 {
|
||||||
VStack(spacing: compact ? 4 : 8) {
|
VStack(spacing: compact ? 4 : 8) {
|
||||||
CardBarcodeView(digits: model.cardNumber)
|
CardBarcodeView(digits: model.cardNumber)
|
||||||
|
|
@ -126,7 +120,8 @@ struct CardView: View {
|
||||||
.tint(.white)
|
.tint(.white)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.frame(minHeight: 50)
|
.frame(minHeight: 50)
|
||||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
.background(KTheme.panel.opacity(0.92))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
.stroke(Color.white.opacity(0.35), lineWidth: 1)
|
.stroke(Color.white.opacity(0.35), lineWidth: 1)
|
||||||
|
|
@ -149,7 +144,8 @@ struct CardView: View {
|
||||||
Label(model.text("Skanuj kartę", "Scan card"), systemImage: "camera.viewfinder")
|
Label(model.text("Skanuj kartę", "Scan card"), systemImage: "camera.viewfinder")
|
||||||
.frame(maxWidth: .infinity, minHeight: 22)
|
.frame(maxWidth: .infinity, minHeight: 22)
|
||||||
}
|
}
|
||||||
.buttonStyle(GlassButtonStyle())
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.white)
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
Button {
|
Button {
|
||||||
cardNumberFocused = false
|
cardNumberFocused = false
|
||||||
|
|
@ -158,50 +154,17 @@ struct CardView: View {
|
||||||
Text(model.text("Nie mam karty — wygeneruj cyfrową", "I don't have a card — create a digital one"))
|
Text(model.text("Nie mam karty — wygeneruj cyfrową", "I don't have a card — create a digital one"))
|
||||||
.frame(maxWidth: .infinity, minHeight: 22)
|
.frame(maxWidth: .infinity, minHeight: 22)
|
||||||
}
|
}
|
||||||
.buttonStyle(GlassButtonStyle())
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.white)
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(compact ? 14 : 22)
|
.padding(compact ? 14 : 22)
|
||||||
.background {
|
.background(LinearGradient(colors: [KTheme.orange, KTheme.pink, KTheme.purple], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
ZStack {
|
|
||||||
LinearGradient(
|
|
||||||
colors: [KTheme.orange.opacity(0.94), KTheme.pink.opacity(0.90), KTheme.purple.opacity(0.94)],
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
Circle()
|
|
||||||
.fill(.white.opacity(0.16))
|
|
||||||
.frame(width: 210, height: 210)
|
|
||||||
.blur(radius: 10)
|
|
||||||
.offset(x: 150, y: -130)
|
|
||||||
Circle()
|
|
||||||
.fill(KTheme.cyan.opacity(0.14))
|
|
||||||
.frame(width: 140, height: 140)
|
|
||||||
.blur(radius: 16)
|
|
||||||
.offset(x: -145, y: 150)
|
|
||||||
LinearGradient(
|
|
||||||
colors: [.white.opacity(0.22), .clear, .black.opacity(0.12)],
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
|
||||||
.overlay {
|
.shadow(color: KTheme.pink.opacity(0.2), radius: 18, y: 8)
|
||||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
|
||||||
.stroke(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [.white.opacity(0.72), .white.opacity(0.18), KTheme.pink.opacity(0.38)],
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
),
|
|
||||||
lineWidth: 1.5
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.shadow(color: KTheme.pink.opacity(0.28), radius: 28, y: 16)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func acceptScannedCard(_ scan: ScannedCard) {
|
private func acceptScannedCard(_ scan: ScannedCard) {
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,10 @@
|
||||||
<string>$(MARKETING_VERSION)</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>KASZTELANPushEnvironment</key>
|
|
||||||
<string>$(APS_ENVIRONMENT)</string>
|
|
||||||
<key>KASZTELANAppKey</key>
|
<key>KASZTELANAppKey</key>
|
||||||
<string>$(KASZTELAN_APP_KEY)</string>
|
<string>$(KASZTELAN_APP_KEY)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
|
||||||
<false/>
|
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Aparat służy do skanowania kodów z kart rabatowych.</string>
|
<string>Aparat służy do skanowania kodów z kart rabatowych.</string>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0"><dict><key>aps-environment</key><string>$(APS_ENVIRONMENT)</string></dict></plist>
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
import BackgroundTasks
|
import BackgroundTasks
|
||||||
import CoreSpotlight
|
import CoreSpotlight
|
||||||
|
|
@ -37,14 +36,6 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
|
||||||
Task { await NotificationService.receivedDeviceToken(deviceToken) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
|
||||||
// APNs registration is attempted again on the next activation.
|
|
||||||
}
|
|
||||||
|
|
||||||
private func indexInSpotlight() {
|
private func indexInSpotlight() {
|
||||||
let attributes = CSSearchableItemAttributeSet(contentType: .application)
|
let attributes = CSSearchableItemAttributeSet(contentType: .application)
|
||||||
attributes.title = "Kasztelan"
|
attributes.title = "Kasztelan"
|
||||||
|
|
@ -105,7 +96,6 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||||
willPresent notification: UNNotification,
|
willPresent notification: UNNotification,
|
||||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||||
) {
|
) {
|
||||||
NotificationService.recordReceivedPush(notification.request.content)
|
|
||||||
completionHandler([.banner, .list, .sound])
|
completionHandler([.banner, .list, .sound])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,7 +104,6 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||||
didReceive response: UNNotificationResponse,
|
didReceive response: UNNotificationResponse,
|
||||||
withCompletionHandler completionHandler: @escaping () -> Void
|
withCompletionHandler completionHandler: @escaping () -> Void
|
||||||
) {
|
) {
|
||||||
NotificationService.recordReceivedPush(response.notification.request.content)
|
|
||||||
NotificationCenter.default.post(name: .kasztelanOpenMessages, object: nil)
|
NotificationCenter.default.post(name: .kasztelanOpenMessages, object: nil)
|
||||||
completionHandler()
|
completionHandler()
|
||||||
}
|
}
|
||||||
|
|
@ -130,21 +119,6 @@ struct KasztelanApp: App {
|
||||||
@StateObject private var model = AppModel()
|
@StateObject private var model = AppModel()
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
init() {
|
|
||||||
let navigationAppearance = UINavigationBarAppearance()
|
|
||||||
navigationAppearance.configureWithTransparentBackground()
|
|
||||||
navigationAppearance.backgroundColor = .clear
|
|
||||||
navigationAppearance.backgroundEffect = nil
|
|
||||||
navigationAppearance.shadowColor = .clear
|
|
||||||
|
|
||||||
let navigationBar = UINavigationBar.appearance()
|
|
||||||
navigationBar.standardAppearance = navigationAppearance
|
|
||||||
navigationBar.scrollEdgeAppearance = navigationAppearance
|
|
||||||
navigationBar.compactAppearance = navigationAppearance
|
|
||||||
navigationBar.isTranslucent = true
|
|
||||||
navigationBar.backgroundColor = .clear
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
RootView()
|
RootView()
|
||||||
|
|
@ -159,10 +133,7 @@ struct KasztelanApp: App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { phase in
|
.onChange(of: scenePhase) { phase in
|
||||||
if phase == .active {
|
if phase == .active { model.beginLiveRefresh() }
|
||||||
model.beginLiveRefresh()
|
|
||||||
Task { await NotificationService.requestPermission() }
|
|
||||||
}
|
|
||||||
else { model.stopLiveRefresh() }
|
else { model.stopLiveRefresh() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -173,7 +144,7 @@ struct RootView: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
AmbientBackground()
|
KTheme.background.ignoresSafeArea()
|
||||||
if model.isLoading {
|
if model.isLoading {
|
||||||
ProgressView(model.text("Sprawdzam konto…", "Checking your account…"))
|
ProgressView(model.text("Sprawdzam konto…", "Checking your account…"))
|
||||||
.tint(KTheme.orange)
|
.tint(KTheme.orange)
|
||||||
|
|
|
||||||
|
|
@ -4,44 +4,19 @@ struct MainTabView: View {
|
||||||
@EnvironmentObject private var model: AppModel
|
@EnvironmentObject private var model: AppModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
|
||||||
AmbientBackground()
|
|
||||||
|
|
||||||
TabView(selection: $model.selectedTab) {
|
TabView(selection: $model.selectedTab) {
|
||||||
NavigationStack {
|
NavigationStack { WalletView() }
|
||||||
ZStack {
|
|
||||||
AmbientBackground()
|
|
||||||
WalletView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.toolbarBackground(.hidden, for: .navigationBar)
|
|
||||||
.tabItem { Label(model.text("Karta", "Card"), systemImage: "creditcard.fill") }
|
.tabItem { Label(model.text("Karta", "Card"), systemImage: "creditcard.fill") }
|
||||||
.tag(0)
|
.tag(0)
|
||||||
NavigationStack {
|
NavigationStack { MessagesView() }
|
||||||
ZStack {
|
|
||||||
AmbientBackground()
|
|
||||||
MessagesView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.toolbarBackground(.hidden, for: .navigationBar)
|
|
||||||
.tabItem { Label(model.text("Wiadomości", "Messages"), systemImage: "envelope.fill") }
|
.tabItem { Label(model.text("Wiadomości", "Messages"), systemImage: "envelope.fill") }
|
||||||
.badge(model.messages.count)
|
.badge(model.messages.count)
|
||||||
.tag(1)
|
.tag(1)
|
||||||
NavigationStack {
|
NavigationStack { ProfileView() }
|
||||||
ZStack {
|
|
||||||
AmbientBackground()
|
|
||||||
ProfileView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.toolbarBackground(.hidden, for: .navigationBar)
|
|
||||||
.tabItem { Label(model.text("Profil", "Profile"), systemImage: "person.crop.circle.fill") }
|
.tabItem { Label(model.text("Profil", "Profile"), systemImage: "person.crop.circle.fill") }
|
||||||
.tag(2)
|
.tag(2)
|
||||||
}
|
}
|
||||||
.background(.clear)
|
|
||||||
}
|
|
||||||
.tint(KTheme.orange)
|
.tint(KTheme.orange)
|
||||||
.toolbarBackground(.ultraThinMaterial, for: .tabBar)
|
|
||||||
.toolbarBackground(.visible, for: .tabBar)
|
|
||||||
.task { await NotificationService.requestPermission() }
|
.task { await NotificationService.requestPermission() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -54,8 +29,7 @@ struct AppToolbar: ToolbarContent {
|
||||||
Image("Logo")
|
Image("Logo")
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: 190, height: 46)
|
.frame(width: 170, height: 44)
|
||||||
.shadow(color: KTheme.pink.opacity(0.28), radius: 12)
|
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .topBarTrailing) { LanguageButton() }
|
ToolbarItem(placement: .topBarTrailing) { LanguageButton() }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,8 @@ struct MessagesView: View {
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
}
|
}
|
||||||
.background(.clear)
|
.background(KTheme.background)
|
||||||
.toolbar { AppToolbar() }
|
.toolbar { AppToolbar() }
|
||||||
.toolbarBackground(.hidden, for: .navigationBar)
|
|
||||||
.refreshable { await model.refresh() }
|
.refreshable { await model.refresh() }
|
||||||
.task { await model.refresh() }
|
.task { await model.refresh() }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,17 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
import UIKit
|
|
||||||
|
|
||||||
enum NotificationService {
|
enum NotificationService {
|
||||||
@MainActor
|
|
||||||
static func requestPermission() async {
|
static func requestPermission() async {
|
||||||
_ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge])
|
_ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge])
|
||||||
UIApplication.shared.registerForRemoteNotifications()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor private static var deviceToken: String?
|
|
||||||
@MainActor private static var registeredSession: String?
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
static func receivedDeviceToken(_ data: Data) async {
|
|
||||||
deviceToken = data.map { String(format: "%02x", $0) }.joined()
|
|
||||||
registeredSession = nil
|
|
||||||
await syncPushRegistration()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
static func syncPushRegistration() async {
|
|
||||||
let session = KeychainStore.load()
|
|
||||||
guard !session.isEmpty, let deviceToken, registeredSession != session else { return }
|
|
||||||
let environment = Bundle.main.object(forInfoDictionaryKey: "KASZTELANPushEnvironment") as? String ?? "production"
|
|
||||||
do {
|
|
||||||
try await APIClient().registerPush(deviceToken: deviceToken, environment: environment, token: session)
|
|
||||||
// The account may have changed while the request was in flight.
|
|
||||||
if KeychainStore.load() == session { registeredSession = session }
|
|
||||||
} catch {
|
|
||||||
// Retry when the app next refreshes; keep account access working offline.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
static func unregisterPush(session: String) async {
|
|
||||||
registeredSession = nil
|
|
||||||
guard let deviceToken else { return }
|
|
||||||
try? await APIClient().unregisterPush(deviceToken: deviceToken, token: session)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func recordReceivedPush(_ content: UNNotificationContent) {
|
|
||||||
guard let id = content.userInfo["message_id"] as? Int, id > 0 else { return }
|
|
||||||
var received = UserDefaults.standard.array(forKey: "kasztelan.push.receivedIDs") as? [Int] ?? []
|
|
||||||
received.removeAll { $0 == id }
|
|
||||||
received.append(id)
|
|
||||||
UserDefaults.standard.set(Array(received.suffix(100)), forKey: "kasztelan.push.receivedIDs")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func show(_ message: KasztelanMessage) async {
|
static func show(_ message: KasztelanMessage) async {
|
||||||
let received = UserDefaults.standard.array(forKey: "kasztelan.push.receivedIDs") as? [Int] ?? []
|
|
||||||
guard !received.contains(message.id) else { return }
|
|
||||||
let settings = await UNUserNotificationCenter.current().notificationSettings()
|
let settings = await UNUserNotificationCenter.current().notificationSettings()
|
||||||
guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else { return }
|
guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else { return }
|
||||||
let delivered = await UNUserNotificationCenter.current().deliveredNotifications()
|
|
||||||
guard !delivered.contains(where: {
|
|
||||||
($0.request.content.userInfo["message_id"] as? Int) == message.id
|
|
||||||
}) else { return }
|
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = "KASZTELAN"
|
content.title = "KASZTELAN"
|
||||||
content.subtitle = message.title
|
content.subtitle = message.title
|
||||||
content.body = message.text
|
|
||||||
content.sound = .default
|
content.sound = .default
|
||||||
content.threadIdentifier = "kasztelan-messages"
|
content.threadIdentifier = "kasztelan-messages"
|
||||||
content.categoryIdentifier = "KASZTELAN_MESSAGE"
|
content.categoryIdentifier = "KASZTELAN_MESSAGE"
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,9 @@ struct ProfileView: View {
|
||||||
if model.cardNumber.isEmpty { cardAssignment }
|
if model.cardNumber.isEmpty { cardAssignment }
|
||||||
|
|
||||||
Button(model.text("Zmień hasło", "Change password")) { changingPassword = true }
|
Button(model.text("Zmień hasło", "Change password")) { changingPassword = true }
|
||||||
.buttonStyle(GlassButtonStyle())
|
.frame(maxWidth: .infinity, minHeight: 52)
|
||||||
|
.background(KTheme.panel)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
Button(model.text("Wyloguj", "Sign out")) { Task { await model.logout() } }
|
Button(model.text("Wyloguj", "Sign out")) { Task { await model.logout() } }
|
||||||
.buttonStyle(PrimaryButtonStyle())
|
.buttonStyle(PrimaryButtonStyle())
|
||||||
Button(model.text("Usuń konto", "Delete account"), role: .destructive) { deletingAccount = true }
|
Button(model.text("Usuń konto", "Delete account"), role: .destructive) { deletingAccount = true }
|
||||||
|
|
@ -51,9 +53,8 @@ struct ProfileView: View {
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
}
|
}
|
||||||
.background(.clear)
|
.background(KTheme.background)
|
||||||
.toolbar { AppToolbar() }
|
.toolbar { AppToolbar() }
|
||||||
.toolbarBackground(.hidden, for: .navigationBar)
|
|
||||||
.fullScreenCover(isPresented: $editingProfile) { EditProfileView() }
|
.fullScreenCover(isPresented: $editingProfile) { EditProfileView() }
|
||||||
.sheet(isPresented: $changingPassword) { ChangePasswordView() }
|
.sheet(isPresented: $changingPassword) { ChangePasswordView() }
|
||||||
.sheet(isPresented: $deletingAccount) {
|
.sheet(isPresented: $deletingAccount) {
|
||||||
|
|
@ -68,7 +69,7 @@ struct ProfileView: View {
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
.background(AmbientBackground())
|
.background(KTheme.background)
|
||||||
.navigationTitle(model.text("Usuń konto", "Delete account"))
|
.navigationTitle(model.text("Usuń konto", "Delete account"))
|
||||||
.toolbar { ToolbarItem(placement: .cancellationAction) { Button(model.text("Anuluj", "Cancel")) { deletingAccount = false } } }
|
.toolbar { ToolbarItem(placement: .cancellationAction) { Button(model.text("Anuluj", "Cancel")) { deletingAccount = false } } }
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +95,9 @@ struct ProfileView: View {
|
||||||
Button(model.text("Nie mam karty — wygeneruj cyfrową", "I don't have a card — create a digital one")) {
|
Button(model.text("Nie mam karty — wygeneruj cyfrową", "I don't have a card — create a digital one")) {
|
||||||
Task { _ = await model.generateCard() }
|
Task { _ = await model.generateCard() }
|
||||||
}
|
}
|
||||||
.buttonStyle(GlassButtonStyle())
|
.frame(maxWidth: .infinity, minHeight: 50)
|
||||||
|
.background(KTheme.panel)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
}
|
}
|
||||||
.kasztelanPanel()
|
.kasztelanPanel()
|
||||||
}
|
}
|
||||||
|
|
@ -135,7 +138,7 @@ struct EditProfileView: View {
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(20)
|
.padding(20)
|
||||||
}
|
}
|
||||||
.background(AmbientBackground())
|
.background(KTheme.background)
|
||||||
.navigationTitle(model.text("Edytuj profil", "Edit profile"))
|
.navigationTitle(model.text("Edytuj profil", "Edit profile"))
|
||||||
.toolbar { ToolbarItem(placement: .cancellationAction) { Button(model.text("Anuluj", "Cancel")) { dismiss() } } }
|
.toolbar { ToolbarItem(placement: .cancellationAction) { Button(model.text("Anuluj", "Cancel")) { dismiss() } } }
|
||||||
.onAppear { form = model.profileForm() }
|
.onAppear { form = model.profileForm() }
|
||||||
|
|
@ -171,7 +174,7 @@ struct ChangePasswordView: View {
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
.background(AmbientBackground())
|
.background(KTheme.background)
|
||||||
.navigationTitle(model.text("Zmień hasło", "Change password"))
|
.navigationTitle(model.text("Zmień hasło", "Change password"))
|
||||||
.toolbar { ToolbarItem(placement: .cancellationAction) { Button(model.text("Anuluj", "Cancel")) { dismiss() } } }
|
.toolbar { ToolbarItem(placement: .cancellationAction) { Button(model.text("Anuluj", "Cancel")) { dismiss() } } }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,168 +1,24 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum KTheme {
|
enum KTheme {
|
||||||
static let background = Color(red: 5/255, green: 6/255, blue: 12/255)
|
static let background = Color(red: 11/255, green: 11/255, blue: 14/255)
|
||||||
static let panel = Color(red: 28/255, green: 28/255, blue: 38/255)
|
static let panel = Color(red: 28/255, green: 28/255, blue: 34/255)
|
||||||
static let orange = Color(red: 1, green: 106/255, blue: 0)
|
static let orange = Color(red: 1, green: 106/255, blue: 0)
|
||||||
static let pink = Color(red: 242/255, green: 29/255, blue: 167/255)
|
static let pink = Color(red: 242/255, green: 29/255, blue: 167/255)
|
||||||
static let purple = Color(red: 111/255, green: 60/255, blue: 1)
|
static let purple = Color(red: 111/255, green: 60/255, blue: 1)
|
||||||
static let cyan = Color(red: 41/255, green: 209/255, blue: 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AmbientBackground: View {
|
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
||||||
@State private var isMoving = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
KTheme.background
|
|
||||||
RadialGradient(
|
|
||||||
colors: [KTheme.purple.opacity(0.32), .clear],
|
|
||||||
center: .topLeading,
|
|
||||||
startRadius: 10,
|
|
||||||
endRadius: 420
|
|
||||||
)
|
|
||||||
.scaleEffect(isMoving ? 1.18 : 0.92)
|
|
||||||
.offset(x: isMoving ? 36 : -28, y: isMoving ? 70 : -50)
|
|
||||||
|
|
||||||
Circle()
|
|
||||||
.fill(KTheme.pink.opacity(0.22))
|
|
||||||
.frame(width: 260, height: 260)
|
|
||||||
.blur(radius: 42)
|
|
||||||
.offset(x: isMoving ? 150 : 95, y: isMoving ? -250 : -190)
|
|
||||||
|
|
||||||
Circle()
|
|
||||||
.fill(KTheme.orange.opacity(0.20))
|
|
||||||
.frame(width: 220, height: 220)
|
|
||||||
.blur(radius: 48)
|
|
||||||
.offset(x: isMoving ? -150 : -90, y: isMoving ? 320 : 250)
|
|
||||||
|
|
||||||
Circle()
|
|
||||||
.fill(KTheme.cyan.opacity(0.12))
|
|
||||||
.frame(width: 180, height: 180)
|
|
||||||
.blur(radius: 40)
|
|
||||||
.offset(x: isMoving ? -120 : -165, y: isMoving ? -40 : 80)
|
|
||||||
|
|
||||||
LinearGradient(
|
|
||||||
colors: [.white.opacity(0.045), .clear, .black.opacity(0.25)],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.ignoresSafeArea()
|
|
||||||
.onAppear {
|
|
||||||
guard !reduceMotion else { return }
|
|
||||||
withAnimation(.easeInOut(duration: 9).repeatForever(autoreverses: true)) {
|
|
||||||
isMoving = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GlassDepthDeck<Content: View>: View {
|
|
||||||
private let cornerRadius: CGFloat
|
|
||||||
private let content: Content
|
|
||||||
|
|
||||||
init(cornerRadius: CGFloat = 26, @ViewBuilder content: () -> Content) {
|
|
||||||
self.cornerRadius = cornerRadius
|
|
||||||
self.content = content()
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
|
||||||
.fill(.black.opacity(0.50))
|
|
||||||
.scaleEffect(x: 0.94, y: 0.97)
|
|
||||||
.offset(y: 18)
|
|
||||||
.blur(radius: 18)
|
|
||||||
|
|
||||||
content
|
|
||||||
.shadow(color: .black.opacity(0.66), radius: 30, y: 20)
|
|
||||||
.shadow(color: KTheme.purple.opacity(0.30), radius: 24, x: -8, y: 8)
|
|
||||||
.shadow(color: KTheme.pink.opacity(0.30), radius: 20, x: 10, y: 4)
|
|
||||||
|
|
||||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
|
||||||
.fill(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [.white.opacity(0.20), .clear, .clear, .black.opacity(0.12)],
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
|
|
||||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
|
||||||
.stroke(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [.white.opacity(0.92), .white.opacity(0.18), KTheme.purple.opacity(0.52)],
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
),
|
|
||||||
lineWidth: 2.2
|
|
||||||
)
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
|
|
||||||
RoundedRectangle(cornerRadius: cornerRadius - 3, style: .continuous)
|
|
||||||
.inset(by: 3)
|
|
||||||
.stroke(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [.white.opacity(0.34), .clear, .black.opacity(0.28)],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
),
|
|
||||||
lineWidth: 1
|
|
||||||
)
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
|
|
||||||
VStack {
|
|
||||||
HStack {
|
|
||||||
Capsule()
|
|
||||||
.fill(.white.opacity(0.58))
|
|
||||||
.frame(width: 112, height: 4)
|
|
||||||
.blur(radius: 0.6)
|
|
||||||
.padding(.top, 8)
|
|
||||||
.padding(.leading, 22)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
}
|
|
||||||
.compositingGroup()
|
|
||||||
.padding(.bottom, 20)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PanelModifier: ViewModifier {
|
struct PanelModifier: ViewModifier {
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content
|
content
|
||||||
.padding(18)
|
.padding(18)
|
||||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
|
.background(KTheme.panel)
|
||||||
.overlay {
|
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
|
||||||
.stroke(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [.white.opacity(0.30), .white.opacity(0.06), KTheme.pink.opacity(0.16)],
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
),
|
|
||||||
lineWidth: 1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.shadow(color: .black.opacity(0.22), radius: 24, y: 14)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
func kasztelanPanel() -> some View { modifier(PanelModifier()) }
|
func kasztelanPanel() -> some View { modifier(PanelModifier()) }
|
||||||
|
|
||||||
func glassSurface(cornerRadius: CGFloat = 18) -> some View {
|
|
||||||
background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
|
||||||
.overlay {
|
|
||||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
|
||||||
.stroke(.white.opacity(0.20), lineWidth: 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PrimaryButtonStyle: ButtonStyle {
|
struct PrimaryButtonStyle: ButtonStyle {
|
||||||
|
|
@ -171,37 +27,8 @@ struct PrimaryButtonStyle: ButtonStyle {
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.frame(maxWidth: .infinity, minHeight: 54)
|
.frame(maxWidth: .infinity, minHeight: 54)
|
||||||
.background(
|
.background(KTheme.pink.opacity(configuration.isPressed ? 0.72 : 1))
|
||||||
LinearGradient(
|
|
||||||
colors: [KTheme.pink, KTheme.purple, KTheme.orange],
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
.opacity(configuration.isPressed ? 0.72 : 1)
|
|
||||||
)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.overlay {
|
|
||||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
||||||
.stroke(.white.opacity(configuration.isPressed ? 0.12 : 0.30), lineWidth: 1)
|
|
||||||
}
|
|
||||||
.shadow(color: KTheme.pink.opacity(configuration.isPressed ? 0.10 : 0.30), radius: 18, y: 9)
|
|
||||||
.scaleEffect(configuration.isPressed ? 0.975 : 1)
|
|
||||||
.animation(.spring(response: 0.24, dampingFraction: 0.72), value: configuration.isPressed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GlassButtonStyle: ButtonStyle {
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
|
||||||
configuration.label
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.frame(maxWidth: .infinity, minHeight: 52)
|
|
||||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
||||||
.overlay {
|
|
||||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
||||||
.stroke(.white.opacity(configuration.isPressed ? 0.10 : 0.24), lineWidth: 1)
|
|
||||||
}
|
|
||||||
.scaleEffect(configuration.isPressed ? 0.975 : 1)
|
|
||||||
.animation(.spring(response: 0.24, dampingFraction: 0.72), value: configuration.isPressed)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,12 +56,8 @@ struct KasztelanField: View {
|
||||||
.autocorrectionDisabled(keyboard == .emailAddress)
|
.autocorrectionDisabled(keyboard == .emailAddress)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.frame(maxWidth: .infinity, minHeight: 54)
|
.frame(maxWidth: .infinity, minHeight: 54)
|
||||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
.background(KTheme.panel)
|
||||||
.overlay {
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
||||||
.stroke(isFocused ? KTheme.orange.opacity(0.75) : .white.opacity(0.18), lineWidth: 1)
|
|
||||||
}
|
|
||||||
.shadow(color: isFocused ? KTheme.orange.opacity(0.14) : .clear, radius: 12)
|
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .keyboard) {
|
ToolbarItemGroup(placement: .keyboard) {
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,8 @@ struct WalletView: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
walletCarousel
|
walletCarousel
|
||||||
.background(.clear)
|
.background(KTheme.background)
|
||||||
.toolbar { walletToolbar }
|
.toolbar { walletToolbar }
|
||||||
.toolbarBackground(.hidden, for: .navigationBar)
|
|
||||||
.onAppear { selectedPage = 0 }
|
.onAppear { selectedPage = 0 }
|
||||||
.task(id: model.user?.id) { await store.sync(using: model) }
|
.task(id: model.user?.id) { await store.sync(using: model) }
|
||||||
.fullScreenCover(isPresented: $scannerPresented) { scannerView }
|
.fullScreenCover(isPresented: $scannerPresented) { scannerView }
|
||||||
|
|
@ -32,26 +31,17 @@ struct WalletView: View {
|
||||||
|
|
||||||
private var walletCarousel: some View {
|
private var walletCarousel: some View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
ZStack {
|
|
||||||
WalletMotionBubbles(progress: carouselProgress(width: geometry.size.width))
|
|
||||||
|
|
||||||
AnyView(currentPage)
|
AnyView(currentPage)
|
||||||
.id(pageIdentity)
|
.id(pageIdentity)
|
||||||
.offset(x: dragOffset)
|
.offset(x: dragOffset)
|
||||||
.offset(y: carouselArc(width: geometry.size.width))
|
.offset(y: carouselArc(width: geometry.size.width))
|
||||||
.scaleEffect(carouselScale(width: geometry.size.width))
|
.scaleEffect(carouselScale(width: geometry.size.width))
|
||||||
.rotation3DEffect(
|
.rotation3DEffect(carouselAngle(width: geometry.size.width), axis: (x: 0, y: 1, z: 0), perspective: 0.9)
|
||||||
carouselAngle(width: geometry.size.width),
|
|
||||||
axis: (x: 0.08, y: 1, z: 0),
|
|
||||||
perspective: 0.62
|
|
||||||
)
|
|
||||||
.rotationEffect(.degrees(Double(carouselProgress(width: geometry.size.width)) * -2.4))
|
|
||||||
.opacity(carouselOpacity(width: geometry.size.width))
|
.opacity(carouselOpacity(width: geometry.size.width))
|
||||||
.shadow(color: KTheme.purple.opacity(carouselShadow), radius: 32, x: dragOffset < 0 ? 16 : -16, y: 12)
|
.shadow(color: .black.opacity(carouselShadow), radius: 24, x: dragOffset < 0 ? 12 : -12)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.simultaneousGesture(carouselGesture(width: geometry.size.width))
|
.simultaneousGesture(carouselGesture(width: geometry.size.width))
|
||||||
}
|
.overlay(alignment: .bottom) { pageIndicator }
|
||||||
.safeAreaInset(edge: .bottom, spacing: 0) { pageIndicator }
|
|
||||||
.clipped()
|
.clipped()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -103,27 +93,23 @@ struct WalletView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func carouselScale(width: CGFloat) -> CGFloat {
|
private func carouselScale(width: CGFloat) -> CGFloat {
|
||||||
1 - min(abs(carouselProgress(width: width)), 1) * 0.10
|
1 - min(abs(dragOffset) / max(width, 1), 1) * 0.14
|
||||||
}
|
}
|
||||||
|
|
||||||
private func carouselAngle(width: CGFloat) -> Angle {
|
private func carouselAngle(width: CGFloat) -> Angle {
|
||||||
.degrees(Double(carouselProgress(width: width)) * 54)
|
.degrees(Double(dragOffset / max(width, 1)) * 68)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func carouselArc(width: CGFloat) -> CGFloat {
|
private func carouselArc(width: CGFloat) -> CGFloat {
|
||||||
let progress = min(abs(carouselProgress(width: width)), 1)
|
let progress = min(abs(dragOffset) / max(width, 1), 1)
|
||||||
return progress * progress * 24
|
return progress * progress * 34
|
||||||
}
|
}
|
||||||
|
|
||||||
private func carouselOpacity(width: CGFloat) -> Double {
|
private func carouselOpacity(width: CGFloat) -> Double {
|
||||||
1 - min(Double(abs(carouselProgress(width: width))), 1) * 0.22
|
1 - min(Double(abs(dragOffset) / max(width, 1)), 1) * 0.28
|
||||||
}
|
}
|
||||||
|
|
||||||
private func carouselProgress(width: CGFloat) -> CGFloat {
|
private var carouselShadow: Double { min(Double(abs(dragOffset) / 520), 0.42) }
|
||||||
dragOffset / max(width, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var carouselShadow: Double { 0.12 + min(Double(abs(dragOffset) / 520), 0.34) }
|
|
||||||
|
|
||||||
private func carouselGesture(width: CGFloat) -> some Gesture {
|
private func carouselGesture(width: CGFloat) -> some Gesture {
|
||||||
DragGesture(minimumDistance: 18)
|
DragGesture(minimumDistance: 18)
|
||||||
|
|
@ -166,11 +152,7 @@ struct WalletView: View {
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.background(.ultraThinMaterial, in: Capsule())
|
.background(.black.opacity(0.35), in: Capsule())
|
||||||
.overlay {
|
|
||||||
Capsule()
|
|
||||||
.stroke(.white.opacity(0.18), lineWidth: 1)
|
|
||||||
}
|
|
||||||
.padding(.bottom, 2)
|
.padding(.bottom, 2)
|
||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
|
|
@ -181,8 +163,8 @@ struct WalletView: View {
|
||||||
let predicted = value.predictedEndTranslation.width
|
let predicted = value.predictedEndTranslation.width
|
||||||
guard horizontal, max(abs(value.translation.width), abs(predicted)) > max(52, width * 0.15) else {
|
guard horizontal, max(abs(value.translation.width), abs(predicted)) > max(52, width * 0.15) else {
|
||||||
changingPage = true
|
changingPage = true
|
||||||
withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) { dragOffset = 0 }
|
withAnimation(.easeOut(duration: 0.14)) { dragOffset = 0 }
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { changingPage = false }
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { changingPage = false }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
changingPage = true
|
changingPage = true
|
||||||
|
|
@ -192,53 +174,15 @@ struct WalletView: View {
|
||||||
instant.disablesAnimations = true
|
instant.disablesAnimations = true
|
||||||
withTransaction(instant) {
|
withTransaction(instant) {
|
||||||
selectedPage = (selectedPage + direction + pageCount) % pageCount
|
selectedPage = (selectedPage + direction + pageCount) % pageCount
|
||||||
dragOffset = direction > 0 ? width * 0.82 : -width * 0.82
|
dragOffset = direction > 0 ? width * 0.72 : -width * 0.72
|
||||||
}
|
}
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
withAnimation(.spring(response: 0.48, dampingFraction: 0.84, blendDuration: 0.08)) { dragOffset = 0 }
|
withAnimation(.easeOut(duration: 0.26)) { dragOffset = 0 }
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.50) { changingPage = false }
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.27) { changingPage = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct WalletMotionBubbles: View {
|
|
||||||
let progress: CGFloat
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
bubble(size: 112, tint: KTheme.pink.opacity(0.20))
|
|
||||||
.offset(x: -145 + progress * 90, y: -230 - abs(progress) * 24)
|
|
||||||
bubble(size: 74, tint: KTheme.orange.opacity(0.22))
|
|
||||||
.offset(x: 145 + progress * 55, y: -80 + progress * 42)
|
|
||||||
bubble(size: 148, tint: KTheme.purple.opacity(0.17))
|
|
||||||
.offset(x: 125 - progress * 100, y: 255)
|
|
||||||
bubble(size: 48, tint: KTheme.cyan.opacity(0.18))
|
|
||||||
.offset(x: -120 - progress * 50, y: 150 + progress * 30)
|
|
||||||
}
|
|
||||||
.blur(radius: abs(progress) * 1.5)
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func bubble(size: CGFloat, tint: Color) -> some View {
|
|
||||||
Circle()
|
|
||||||
.fill(.ultraThinMaterial)
|
|
||||||
.frame(width: size, height: size)
|
|
||||||
.overlay {
|
|
||||||
Circle()
|
|
||||||
.fill(
|
|
||||||
RadialGradient(
|
|
||||||
colors: [.white.opacity(0.28), tint, .clear],
|
|
||||||
center: .topLeading,
|
|
||||||
startRadius: 1,
|
|
||||||
endRadius: size
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.overlay { Circle().stroke(.white.opacity(0.14), lineWidth: 1) }
|
|
||||||
.shadow(color: tint, radius: 24)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AddDiscountCardView: View {
|
private struct AddDiscountCardView: View {
|
||||||
@EnvironmentObject private var model: AppModel
|
@EnvironmentObject private var model: AppModel
|
||||||
let scan: () -> Void
|
let scan: () -> Void
|
||||||
|
|
@ -265,7 +209,7 @@ private struct AddDiscountCardView: View {
|
||||||
}
|
}
|
||||||
.padding(.bottom, 34)
|
.padding(.bottom, 34)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(.clear)
|
.background(KTheme.background)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,7 +233,6 @@ private struct DiscountCardView: View {
|
||||||
.accessibilityLabel(model.text("Usuń kartę", "Delete card"))
|
.accessibilityLabel(model.text("Usuń kartę", "Delete card"))
|
||||||
}
|
}
|
||||||
|
|
||||||
GlassDepthDeck {
|
|
||||||
VStack(spacing: 18) {
|
VStack(spacing: 18) {
|
||||||
StoredBarcodeView(value: card.payload, symbology: card.symbology)
|
StoredBarcodeView(value: card.payload, symbology: card.symbology)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
@ -306,26 +249,12 @@ private struct DiscountCardView: View {
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(22)
|
.padding(22)
|
||||||
.background(
|
.background(LinearGradient(
|
||||||
ZStack {
|
colors: [KTheme.purple, KTheme.pink, KTheme.orange],
|
||||||
LinearGradient(
|
|
||||||
colors: [KTheme.purple.opacity(0.92), KTheme.pink.opacity(0.88), KTheme.orange.opacity(0.86)],
|
|
||||||
startPoint: .topLeading,
|
startPoint: .topLeading,
|
||||||
endPoint: .bottomTrailing
|
endPoint: .bottomTrailing
|
||||||
)
|
))
|
||||||
Circle()
|
|
||||||
.fill(.white.opacity(0.15))
|
|
||||||
.frame(width: 170, height: 170)
|
|
||||||
.blur(radius: 8)
|
|
||||||
.offset(x: 145, y: -100)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
|
||||||
.overlay {
|
|
||||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
|
||||||
.stroke(.white.opacity(0.28), lineWidth: 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(model.text("Dostępna offline • zaszyfrowana synchronizacja", "Available offline • encrypted sync"))
|
Text(model.text("Dostępna offline • zaszyfrowana synchronizacja", "Available offline • encrypted sync"))
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
|
|
@ -335,7 +264,7 @@ private struct DiscountCardView: View {
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.vertical, 14)
|
.padding(.vertical, 14)
|
||||||
.background(.clear)
|
.background(KTheme.background)
|
||||||
.alert(model.text("Usunąć kartę?", "Delete this card?"), isPresented: $confirmingDelete) {
|
.alert(model.text("Usunąć kartę?", "Delete this card?"), isPresented: $confirmingDelete) {
|
||||||
Button(model.text("Anuluj", "Cancel"), role: .cancel) {}
|
Button(model.text("Anuluj", "Cancel"), role: .cancel) {}
|
||||||
Button(model.text("Usuń", "Delete"), role: .destructive, action: delete)
|
Button(model.text("Usuń", "Delete"), role: .destructive, action: delete)
|
||||||
|
|
@ -365,7 +294,7 @@ private struct CardNameSheet: View {
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
.background(AmbientBackground())
|
.background(KTheme.background)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button(model.text("Anuluj", "Cancel")) { dismiss() }
|
Button(model.text("Anuluj", "Cancel")) { dismiss() }
|
||||||
|
|
|
||||||
|
|
@ -41,5 +41,5 @@ workflows:
|
||||||
publishing:
|
publishing:
|
||||||
app_store_connect:
|
app_store_connect:
|
||||||
auth: integration
|
auth: integration
|
||||||
submit_to_testflight: true
|
submit_to_testflight: false
|
||||||
submit_to_app_store: false
|
submit_to_app_store: false
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user