commit 2f7bf57e6b79aca65fb1fa2994db76d897424429 Author: Rafal Kolarz Date: Fri Oct 16 23:37:49 2026 +0100 Prepare iOS app for Xcode Cloud build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c3cb01 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +Config/Secrets.xcconfig +*.xcuserstate +xcuserdata/ +DerivedData/ +.DS_Store + diff --git a/Config/AppConfig.xcconfig b/Config/AppConfig.xcconfig new file mode 100644 index 0000000..41033f8 --- /dev/null +++ b/Config/AppConfig.xcconfig @@ -0,0 +1,4 @@ +// Wartość domyślnie jest pusta. Utwórz Secrets.xcconfig na podstawie przykładu. +KASZTELAN_APP_KEY = +#include? "Secrets.xcconfig" + diff --git a/Config/Secrets.xcconfig.example b/Config/Secrets.xcconfig.example new file mode 100644 index 0000000..c97d3e0 --- /dev/null +++ b/Config/Secrets.xcconfig.example @@ -0,0 +1,3 @@ +// Skopiuj ten plik jako Secrets.xcconfig i wklej klucz z ustawień wtyczki WordPress. +KASZTELAN_APP_KEY = WPISZ_TUTAJ_KLUCZ + diff --git a/Kasztelan.xcodeproj/project.pbxproj b/Kasztelan.xcodeproj/project.pbxproj new file mode 100644 index 0000000..a21ea20 --- /dev/null +++ b/Kasztelan.xcodeproj/project.pbxproj @@ -0,0 +1,361 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + A10000000000000000000001 /* KasztelanApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000001 /* KasztelanApp.swift */; }; + A10000000000000000000002 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000002 /* Models.swift */; }; + A10000000000000000000003 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000003 /* Theme.swift */; }; + A10000000000000000000004 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000004 /* APIClient.swift */; }; + A10000000000000000000005 /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000005 /* KeychainStore.swift */; }; + A10000000000000000000006 /* MessageStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000006 /* MessageStore.swift */; }; + A10000000000000000000007 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000007 /* AppModel.swift */; }; + A10000000000000000000008 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000008 /* NotificationService.swift */; }; + A10000000000000000000009 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000009 /* AuthView.swift */; }; + A1000000000000000000000A /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000A /* MainTabView.swift */; }; + A1000000000000000000000B /* CardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000B /* CardView.swift */; }; + A1000000000000000000000C /* EAN13BarcodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000C /* EAN13BarcodeView.swift */; }; + A1000000000000000000000D /* MessagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000D /* MessagesView.swift */; }; + A1000000000000000000000E /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000E /* ProfileView.swift */; }; + A1000000000000000000000F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000F /* Assets.xcassets */; }; + A10000000000000000000010 /* WalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000014 /* WalletView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + B10000000000000000000001 /* KasztelanApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KasztelanApp.swift; sourceTree = ""; }; + B10000000000000000000002 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + B10000000000000000000003 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; + B10000000000000000000004 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; + B10000000000000000000005 /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; }; + B10000000000000000000006 /* MessageStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageStore.swift; sourceTree = ""; }; + B10000000000000000000007 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; + B10000000000000000000008 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + B10000000000000000000009 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; + B1000000000000000000000A /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; + B1000000000000000000000B /* CardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardView.swift; sourceTree = ""; }; + B1000000000000000000000C /* EAN13BarcodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EAN13BarcodeView.swift; sourceTree = ""; }; + B1000000000000000000000D /* MessagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesView.swift; sourceTree = ""; }; + B1000000000000000000000E /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; + B1000000000000000000000F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + B10000000000000000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B10000000000000000000011 /* Kasztelan.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Kasztelan.app; sourceTree = BUILT_PRODUCTS_DIR; }; + B10000000000000000000012 /* AppConfig.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppConfig.xcconfig; sourceTree = ""; }; + B10000000000000000000013 /* Secrets.xcconfig.example */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig.example; sourceTree = ""; }; + B10000000000000000000014 /* WalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletView.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + C10000000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + D10000000000000000000001 = { + isa = PBXGroup; + children = ( + D10000000000000000000002 /* Kasztelan */, + D10000000000000000000003 /* Config */, + D10000000000000000000004 /* Products */, + ); + sourceTree = ""; + }; + D10000000000000000000002 /* Kasztelan */ = { + isa = PBXGroup; + children = ( + B10000000000000000000001 /* KasztelanApp.swift */, + B10000000000000000000002 /* Models.swift */, + B10000000000000000000003 /* Theme.swift */, + B10000000000000000000004 /* APIClient.swift */, + B10000000000000000000005 /* KeychainStore.swift */, + B10000000000000000000006 /* MessageStore.swift */, + B10000000000000000000007 /* AppModel.swift */, + B10000000000000000000008 /* NotificationService.swift */, + B10000000000000000000009 /* AuthView.swift */, + B1000000000000000000000A /* MainTabView.swift */, + B10000000000000000000014 /* WalletView.swift */, + B1000000000000000000000B /* CardView.swift */, + B1000000000000000000000C /* EAN13BarcodeView.swift */, + B1000000000000000000000D /* MessagesView.swift */, + B1000000000000000000000E /* ProfileView.swift */, + B1000000000000000000000F /* Assets.xcassets */, + B10000000000000000000010 /* Info.plist */, + ); + path = Kasztelan; + sourceTree = ""; + }; + D10000000000000000000003 /* Config */ = { + isa = PBXGroup; + children = ( + B10000000000000000000012 /* AppConfig.xcconfig */, + B10000000000000000000013 /* Secrets.xcconfig.example */, + ); + path = Config; + sourceTree = ""; + }; + D10000000000000000000004 /* Products */ = { + isa = PBXGroup; + children = ( + B10000000000000000000011 /* Kasztelan.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + E10000000000000000000001 /* Kasztelan */ = { + isa = PBXNativeTarget; + buildConfigurationList = F10000000000000000000002 /* Build configuration list for PBXNativeTarget "Kasztelan" */; + buildPhases = ( + C10000000000000000000002 /* Sources */, + C10000000000000000000001 /* Frameworks */, + C10000000000000000000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Kasztelan; + productName = Kasztelan; + productReference = B10000000000000000000011 /* Kasztelan.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + E10000000000000000000002 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1640; + TargetAttributes = { + E10000000000000000000001 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = F10000000000000000000001 /* Build configuration list for PBXProject "Kasztelan" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = D10000000000000000000001; + productRefGroup = D10000000000000000000004 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + E10000000000000000000001 /* Kasztelan */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + C10000000000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A1000000000000000000000F /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + C10000000000000000000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000000000000000000001 /* KasztelanApp.swift in Sources */, + A10000000000000000000002 /* Models.swift in Sources */, + A10000000000000000000003 /* Theme.swift in Sources */, + A10000000000000000000004 /* APIClient.swift in Sources */, + A10000000000000000000005 /* KeychainStore.swift in Sources */, + A10000000000000000000006 /* MessageStore.swift in Sources */, + A10000000000000000000007 /* AppModel.swift in Sources */, + A10000000000000000000008 /* NotificationService.swift in Sources */, + A10000000000000000000009 /* AuthView.swift in Sources */, + A1000000000000000000000A /* MainTabView.swift in Sources */, + A10000000000000000000010 /* WalletView.swift in Sources */, + A1000000000000000000000B /* CardView.swift in Sources */, + A1000000000000000000000C /* EAN13BarcodeView.swift in Sources */, + A1000000000000000000000D /* MessagesView.swift in Sources */, + A1000000000000000000000E /* ProfileView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + F20000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = TP6MXP54V8; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + ONLY_ACTIVE_ARCH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + F20000000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = TP6MXP54V8; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + F20000000000000000000003 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B10000000000000000000012 /* AppConfig.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 10; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Kasztelan/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MARKETING_VERSION = 1.0.8; + PRODUCT_BUNDLE_IDENTIFIER = uk.co.kasztelan.app.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + F20000000000000000000004 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B10000000000000000000012 /* AppConfig.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 10; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Kasztelan/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MARKETING_VERSION = 1.0.8; + PRODUCT_BUNDLE_IDENTIFIER = uk.co.kasztelan.app.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + F10000000000000000000001 /* Build configuration list for PBXProject "Kasztelan" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F20000000000000000000001 /* Debug */, + F20000000000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F10000000000000000000002 /* Build configuration list for PBXNativeTarget "Kasztelan" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F20000000000000000000003 /* Debug */, + F20000000000000000000004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = E10000000000000000000002 /* Project object */; +} diff --git a/Kasztelan.xcodeproj/xcshareddata/xcschemes/Kasztelan.xcscheme b/Kasztelan.xcodeproj/xcshareddata/xcschemes/Kasztelan.xcscheme new file mode 100644 index 0000000..5fb6e63 --- /dev/null +++ b/Kasztelan.xcodeproj/xcshareddata/xcschemes/Kasztelan.xcscheme @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Kasztelan/APIClient.swift b/Kasztelan/APIClient.swift new file mode 100644 index 0000000..1475607 --- /dev/null +++ b/Kasztelan/APIClient.swift @@ -0,0 +1,151 @@ +import Foundation + +enum APIError: LocalizedError { + case missingAppKey + case invalidResponse + case server(status: Int, code: String?, message: String) + + var errorDescription: String? { + switch self { + case .missingAppKey: + return "Brak klucza aplikacji. Uzupełnij Config/Secrets.xcconfig." + case .invalidResponse: + return "Serwer zwrócił nieprawidłową odpowiedź." + case let .server(_, _, message): + return message + } + } + + var code: String? { + guard case let .server(_, code, _) = self else { return nil } + return code + } +} + +final class APIClient { + private let baseURL = URL(string: "https://kasztelan.co.uk/wp-json/kasztelan/v1")! + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + private var appKey: String { + Bundle.main.object(forInfoDictionaryKey: "KASZTELANAppKey") as? String ?? "" + } + + func login(login: String, password: String) async throws -> APIEnvelope { + try await request("POST", path: "/login", body: ["login": login, "password": password]) + } + + func register(_ form: RegistrationForm) async throws -> APIEnvelope { + try await request("POST", path: "/register", body: [ + "first_name": form.firstName, + "last_name": form.lastName, + "email": form.email, + "password": form.password, + "mobile": form.mobile, + "kod_pocztowy": form.postcode, + "dob": form.dob, + "numer_karty": form.cardNumber, + "register_tos": form.acceptsTerms + ]) + } + + func me(token: String) async throws -> APIEnvelope { + try await request("GET", path: "/me", token: token) + } + + func logout(token: String) async throws { + let _: APIEnvelope = try await request("POST", path: "/logout", token: token, body: [:]) + } + + func assignCard(_ number: String, token: String) async throws -> APIEnvelope { + try await request("POST", path: "/card", token: token, body: ["numer_karty": number]) + } + + func generateCard(token: String) async throws -> APIEnvelope { + try await request("POST", path: "/card/generate", token: token, body: [:]) + } + + func updateProfile(_ form: ProfileForm, token: String) async throws -> APIEnvelope { + try await request("POST", path: "/profile", token: token, body: [ + "first_name": form.firstName, + "last_name": form.lastName, + "email": form.email, + "mobile": form.mobile, + "kod_pocztowy": form.postcode, + "dob": form.dob + ]) + } + + func changePassword(current: String, new: String, token: String) async throws { + let _: APIEnvelope = try await request("POST", path: "/password", token: token, body: [ + "current_password": current, + "new_password": new + ]) + } + + func deleteAccount(password: String, token: String) async throws { + let _: APIEnvelope = try await request("DELETE", path: "/account", token: token, body: ["password": password]) + } + + func notifications(after: Int, token: String) async throws -> APIEnvelope { + try await request("GET", path: "/notifications?after=\(max(0, after))", token: token) + } + + func latestNotification(token: String) async throws -> APIEnvelope { + try await request("GET", path: "/notifications/latest", token: token) + } + + func wallet(token: String) async throws -> APIEnvelope { + try await request("GET", path: "/wallet", token: token) + } + + func saveWallet(payload: String, token: String) async throws -> APIEnvelope { + try await request("POST", path: "/wallet", token: token, body: ["payload": payload]) + } + + private func request( + _ method: String, + path: String, + token: String? = nil, + body: [String: Any]? = nil + ) async throws -> APIEnvelope { + guard !appKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw APIError.missingAppKey + } + guard let url = URL(string: baseURL.absoluteString + path) else { throw APIError.invalidResponse } + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = 20 + request.cachePolicy = .reloadIgnoringLocalCacheData + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("no-cache, no-store", forHTTPHeaderField: "Cache-Control") + request.setValue(appKey, forHTTPHeaderField: "X-Kasztelan-App-Key") + request.setValue("iOS", forHTTPHeaderField: "X-Kasztelan-Platform") + request.setValue( + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "", + forHTTPHeaderField: "X-Kasztelan-App-Version" + ) + if let token, !token.isEmpty { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + if let body { + request.httpBody = try JSONSerialization.data(withJSONObject: body) + request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") + } + + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } + let envelope = (try? JSONDecoder().decode(APIEnvelope.self, from: data)) ?? APIEnvelope() + guard (200..<300).contains(http.statusCode), envelope.success != false else { + throw APIError.server( + status: http.statusCode, + code: envelope.code, + message: envelope.message ?? "Nie udało się połączyć z kontem." + ) + } + return envelope + } +} diff --git a/Kasztelan/AppModel.swift b/Kasztelan/AppModel.swift new file mode 100644 index 0000000..5cef220 --- /dev/null +++ b/Kasztelan/AppModel.swift @@ -0,0 +1,438 @@ +import Foundation +import UIKit +import UserNotifications +import Combine +import Network + +@MainActor +final class AppModel: ObservableObject { + @Published var language = AppLanguage.initial { + didSet { UserDefaults.standard.set(language.rawValue, forKey: "language") } + } + @Published var user: UserProfile? + @Published var loyalty: Loyalty? + @Published var messages: [KasztelanMessage] = [] + @Published var isLoading = true + @Published var isBusy = false + @Published var errorMessage: String? + @Published var selectedTab = 0 + @Published var presentedMessage: KasztelanMessage? + + private let api = APIClient() + private let messageStore = MessageStore() + private let networkMonitor = NWPathMonitor() + private var token = KeychainStore.load() + private var refreshTask: Task? + private var bannerTask: Task? + private var accountRefreshInFlight = false + private var messageRefreshInFlight = false + + var isAuthenticated: Bool { !token.isEmpty && user != nil } + + var cardNumber: String { + let loyaltyNumber = digits(loyalty?.cardNumber?.value ?? "") + if loyaltyNumber.count == 13 { return loyaltyNumber } + let userNumber = digits(user?.cardNumber ?? "") + return userNumber.count == 13 ? userNumber : "" + } + + init() { + messages = messageStore.messages + if !token.isEmpty, let cached = OfflineAccountStore.load() { + user = cached.user + loyalty = cached.loyalty + isLoading = false + } else if token.isEmpty { + isLoading = false + } + startNetworkMonitoring() + } + + func text(_ polish: String, _ english: String) -> String { language == .pl ? polish : english } + + func launch() async { + defer { isLoading = false } + guard !token.isEmpty else { return } + do { + if let response = try await fetchAccount() { apply(response) } + beginLiveRefresh() + Task { await refreshMessages(showNotifications: true) } + } catch { + if case let APIError.server(status, code, _) = error, + status == 401 || code == "invalid_token" { + clearSession() + } else if isAuthenticated { + beginLiveRefresh() + } + } + } + + func login(login: String, password: String) async { + await perform { + let response = try await self.api.login(login: login.trimmingCharacters(in: .whitespaces), password: password) + try self.accept(response) + } + } + + func register(_ form: RegistrationForm) async { + guard form.acceptsTerms else { + errorMessage = text("Zaakceptuj regulamin.", "Please accept the terms.") + return + } + guard validDate(form.dob) else { + errorMessage = text("Nieprawidłowa data. Użyj formatu RRRR-MM-DD.", "Invalid date. Use YYYY-MM-DD.") + return + } + await perform { + try self.accept(await self.api.register(form)) + } + } + + func assignCard(_ number: String) async -> Bool { + let number = digits(number) + guard number.count == 13 else { + errorMessage = text("Wpisz prawidłowy numer karty.", "Enter a valid card number.") + return false + } + var succeeded = false + await perform { + self.apply(try await self.api.assignCard(number, token: self.token)) + succeeded = true + } + return succeeded + } + + func generateCard() async -> Bool { + var succeeded = false + await perform { + self.apply(try await self.api.generateCard(token: self.token)) + succeeded = true + } + return succeeded + } + + func saveProfile(_ form: ProfileForm) async -> Bool { + guard validDate(form.dob) else { + errorMessage = text("Nieprawidłowa data. Użyj formatu RRRR-MM-DD.", "Invalid date. Use YYYY-MM-DD.") + return false + } + var succeeded = false + await perform { + self.apply(try await self.api.updateProfile(form, token: self.token)) + succeeded = true + } + return succeeded + } + + func changePassword(current: String, new: String) async -> Bool { + var succeeded = false + await perform { + try await self.api.changePassword(current: current, new: new, token: self.token) + succeeded = true + } + return succeeded + } + + func deleteAccount(password: String) async -> Bool { + var succeeded = false + await perform { + try await self.api.deleteAccount(password: password, token: self.token) + self.clearSession() + succeeded = true + } + return succeeded + } + + func logout() async { + let currentToken = token + if user?.id != nil { + let wallet = DiscountCardStore() + await wallet.sync(using: self) + } + clearSession() + try? await api.logout(token: currentToken) + } + + func beginLiveRefresh() { + UIApplication.shared.isIdleTimerDisabled = true + guard isAuthenticated else { return } + guard refreshTask == nil else { return } + refreshTask = Task { [weak self] in + await self?.refresh() + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 15_000_000_000) + guard !Task.isCancelled, let self else { return } + await self.refresh() + } + } + } + + func stopLiveRefresh() { + refreshTask?.cancel() + refreshTask = nil + UIApplication.shared.isIdleTimerDisabled = false + } + + func refresh() async { + guard isAuthenticated else { return } + do { + if let response = try await fetchAccount() { apply(response) } + } catch { + if case let APIError.server(status, code, _) = error, + status == 401 || code == "invalid_token" { + clearSession() + return + } + } + await refreshMessages(showNotifications: true) + } + + func cloudWallet() async throws -> APIEnvelope { + guard !token.isEmpty else { throw APIError.invalidResponse } + return try await api.wallet(token: token) + } + + func saveCloudWallet(_ payload: String) async throws { + guard !token.isEmpty else { throw APIError.invalidResponse } + _ = try await api.saveWallet(payload: payload, token: token) + } + + func deleteMessage(id: Int) { + messageStore.delete(id: id) + messages = messageStore.messages + } + + func openPresentedMessage() { + selectedTab = 1 + dismissPresentedMessage() + } + + func dismissPresentedMessage() { + bannerTask?.cancel() + bannerTask = nil + presentedMessage = nil + } + + func profileForm() -> ProfileForm { + ProfileForm( + firstName: user?.firstName ?? "", + lastName: user?.lastName ?? "", + email: user?.email ?? "", + mobile: user?.mobile ?? "", + postcode: user?.postcode ?? "", + dob: displayDOB(user?.dob) + ) + } + + func money(_ raw: String?) -> String { + guard let number = decimal(raw) else { return "—" } + let formatted = String(format: "%.2f", number) + return (language == .pl ? formatted.replacingOccurrences(of: ".", with: ",") : formatted) + " £" + } + + func pointsWithValue(_ raw: String?) -> String { + guard let points = decimal(raw) else { return "—" } + let count = points.rounded() == points ? String(Int(points)) : String(points) + let localizedCount = language == .pl ? count.replacingOccurrences(of: ".", with: ",") : count + return "\(localizedCount) = \(money(String(points / 100)))" + } + + func friendlyError(_ error: Error) -> String { + guard let apiError = error as? APIError else { + return text("Brak połączenia z serwerem Kasztelan.", "Cannot connect to the Kasztelan server.") + } + switch apiError.code { + case "invalid_credentials": return text("Nieprawidłowy login lub hasło.", "Incorrect username or password.") + case "invalid_current_password": return text("Obecne hasło jest nieprawidłowe.", "The current password is incorrect.") + case "invalid_card_number": return text("Wpisz prawidłowy numer karty.", "Enter a valid card number.") + case "card_in_use": return text("Ta karta jest już przypisana do innego konta.", "This card is already assigned to another account.") + case "card_already_assigned": return text("Do konta jest już przypisana karta i nie można jej zmienić.", "A card is already assigned and cannot be changed.") + case "weak_password": return text("Hasło musi mieć co najmniej 8 znaków, wielką i małą literę, cyfrę oraz znak specjalny.", "Password must be at least 8 characters and include upper and lower case letters, a number and a special character.") + case "email_exists": return text("Konto z tym adresem e-mail już istnieje.", "An account with this email already exists.") + case "invalid_name": return text("Wpisz imię i nazwisko.", "Enter your first and last name.") + case "invalid_email": return text("Wpisz prawidłowy adres e-mail.", "Enter a valid email address.") + case "invalid_mobile": return text("Wpisz prawidłowy numer telefonu UK lub PL.", "Enter a valid UK or Polish phone number.") + case "invalid_postcode": return text("Wpisz prawidłowy kod pocztowy UK.", "Enter a valid UK postcode.") + case "invalid_dob": return text("Wpisz datę w formacie RRRR-MM-DD.", "Enter the date as YYYY-MM-DD.") + case "too_many_attempts": return text("Zbyt wiele prób. Spróbuj ponownie za 15 minut.", "Too many attempts. Try again in 15 minutes.") + default: return apiError.localizedDescription + } + } + + private func perform(_ action: () async throws -> Void) async { + guard !isBusy else { return } + isBusy = true + defer { isBusy = false } + do { try await action() } + catch { errorMessage = friendlyError(error) } + } + + private func accept(_ response: APIEnvelope) throws { + guard let token = response.token, !token.isEmpty else { throw APIError.invalidResponse } + try KeychainStore.save(token: token) + self.token = token + apply(response) + beginLiveRefresh() + Task { await refreshMessages(showNotifications: true) } + } + + private func apply(_ response: APIEnvelope) { + if let user = response.user { self.user = user } + if let loyalty = response.loyalty { + self.loyalty = merged(loyalty, keeping: self.loyalty) + } + OfflineAccountStore.save(user: self.user, loyalty: self.loyalty) + } + + private func fetchAccount() async throws -> APIEnvelope? { + guard !accountRefreshInFlight else { return nil } + accountRefreshInFlight = true + defer { accountRefreshInFlight = false } + return try await api.me(token: token) + } + + private func merged(_ fresh: Loyalty, keeping cached: Loyalty?) -> Loyalty { + Loyalty( + points: nonEmpty(fresh.points) ?? cached?.points, + transactionsValue: nonEmpty(fresh.transactionsValue) ?? cached?.transactionsValue, + discountValue: nonEmpty(fresh.discountValue) ?? cached?.discountValue, + cardNumber: nonEmpty(fresh.cardNumber) ?? cached?.cardNumber, + savings: nonEmpty(fresh.savings) ?? cached?.savings, + firstName: nonEmpty(fresh.firstName) ?? cached?.firstName + ) + } + + private func nonEmpty(_ value: FlexibleText?) -> FlexibleText? { + guard let value, !value.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } + return value + } + + private func refreshMessages(showNotifications: Bool) async { + guard !token.isEmpty, !messageRefreshInFlight else { return } + messageRefreshInFlight = true + defer { messageRefreshInFlight = false } + + var incoming: [KasztelanMessage] = [] + if let response = try? await api.notifications(after: messageStore.lastServerID, token: token) { + incoming = response.notifications ?? [] + } + if incoming.isEmpty, + let response = try? await api.latestNotification(token: token), + let latest = response.notification, + latest.id > messageStore.lastServerID { + incoming = [latest] + } + + let added = messageStore.merge(incoming) + messages = messageStore.messages + guard showNotifications, !added.isEmpty else { return } + + for message in added { await NotificationService.show(message) } + } + + private func showInAppBanner(_ message: KasztelanMessage) { + bannerTask?.cancel() + presentedMessage = message + bannerTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 6_000_000_000) + guard !Task.isCancelled else { return } + self?.presentedMessage = nil + self?.bannerTask = nil + } + } + + private func clearSession() { + DiscountCardStore.clearLocal(for: user?.id) + KeychainStore.clear() + OfflineAccountStore.clear() + messageStore.clear() + token = "" + user = nil + loyalty = nil + messages = [] + stopLiveRefresh() + } + + private func startNetworkMonitoring() { + networkMonitor.pathUpdateHandler = { [weak self] path in + guard path.status == .satisfied else { return } + Task { @MainActor [weak self] in + guard let self, self.isAuthenticated else { return } + await self.refresh() + } + } + networkMonitor.start(queue: DispatchQueue(label: "uk.co.kasztelan.network-monitor")) + } + + private func digits(_ value: String) -> String { value.filter(\.isNumber) } + + private func decimal(_ raw: String?) -> Double? { + guard let raw else { return nil } + let clean = raw.replacingOccurrences(of: "£", with: "") + .replacingOccurrences(of: " ", with: "") + .replacingOccurrences(of: ",", with: ".") + .filter { $0.isNumber || $0 == "." || $0 == "-" } + return Double(clean) + } + + private func validDate(_ value: String) -> Bool { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + formatter.isLenient = false + guard let date = formatter.date(from: value), + let year = formatter.calendar.dateComponents([.year], from: date).year else { return false } + return year >= 1900 && date <= Date() + } + + private func displayDOB(_ value: String?) -> String { + guard let value, !value.isEmpty else { return "" } + if value.range(of: #"^\d{4}-\d{2}-\d{2}$"#, options: .regularExpression) != nil { return value } + if let seconds = TimeInterval(value), value.count >= 9 { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: Date(timeIntervalSince1970: seconds)) + } + return "" + } +} + +private struct CachedAccount: Codable { + let user: UserProfile? + let loyalty: Loyalty? + let savedAt: Date +} + +private enum OfflineAccountStore { + private static var fileURL: URL? { + guard let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + return directory.appendingPathComponent("kasztelan-account-cache.json") + } + + static func load() -> CachedAccount? { + guard let fileURL, + let data = try? Data(contentsOf: fileURL) else { return nil } + return try? JSONDecoder().decode(CachedAccount.self, from: data) + } + + static func save(user: UserProfile?, loyalty: Loyalty?) { + guard user != nil || loyalty != nil, + let fileURL, + let data = try? JSONEncoder().encode(CachedAccount(user: user, loyalty: loyalty, savedAt: Date())) else { return } + let directory = fileURL.deletingLastPathComponent() + try? FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication] + ) + try? data.write(to: fileURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } + + static func clear() { + guard let fileURL else { return } + try? FileManager.default.removeItem(at: fileURL) + } +} diff --git a/Kasztelan/Assets.xcassets/AccentColor.colorset/Contents.json b/Kasztelan/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..d74190e --- /dev/null +++ b/Kasztelan/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,18 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.416", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { "author" : "xcode", "version" : 1 } +} + diff --git a/Kasztelan/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/Kasztelan/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..d6e7a67 Binary files /dev/null and b/Kasztelan/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/Kasztelan/Assets.xcassets/AppIcon.appiconset/Contents.json b/Kasztelan/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..491e613 --- /dev/null +++ b/Kasztelan/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { "author" : "xcode", "version" : 1 } +} + diff --git a/Kasztelan/Assets.xcassets/Contents.json b/Kasztelan/Assets.xcassets/Contents.json new file mode 100644 index 0000000..6cc1226 --- /dev/null +++ b/Kasztelan/Assets.xcassets/Contents.json @@ -0,0 +1,7 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} + diff --git a/Kasztelan/Assets.xcassets/Logo.imageset/Contents.json b/Kasztelan/Assets.xcassets/Logo.imageset/Contents.json new file mode 100644 index 0000000..e9d6ecb --- /dev/null +++ b/Kasztelan/Assets.xcassets/Logo.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "logo_kasztelan_white.png", "idiom" : "universal", "scale" : "1x" }, + { "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "preserves-vector-representation" : true } +} + diff --git a/Kasztelan/Assets.xcassets/Logo.imageset/logo_kasztelan_white.png b/Kasztelan/Assets.xcassets/Logo.imageset/logo_kasztelan_white.png new file mode 100644 index 0000000..32225ed Binary files /dev/null and b/Kasztelan/Assets.xcassets/Logo.imageset/logo_kasztelan_white.png differ diff --git a/Kasztelan/AuthView.swift b/Kasztelan/AuthView.swift new file mode 100644 index 0000000..0850925 --- /dev/null +++ b/Kasztelan/AuthView.swift @@ -0,0 +1,129 @@ +import SwiftUI + +struct AuthView: View { + @EnvironmentObject private var model: AppModel + @State private var registering = false + @State private var login = "" + @State private var password = "" + @State private var form = RegistrationForm() + + var body: some View { + ScrollView { + VStack(spacing: 14) { + HStack { + Image("Logo") + .resizable() + .scaledToFit() + .frame(height: 58) + Spacer() + LanguageButton() + } + .padding(.bottom, 18) + + if registering { registrationForm } + else { loginForm } + } + .padding(22) + } + .scrollDismissesKeyboard(.interactively) + .background(KTheme.background) + } + + private var loginForm: some View { + VStack(alignment: .leading, spacing: 14) { + Text(model.text("Zaloguj się", "Sign in")).font(.largeTitle.bold()) + Text(model.text("Twoja karta i punkty będą dostępne po zalogowaniu.", "Your card and points will be available after signing in.")) + .foregroundStyle(.secondary) + KasztelanField(title: model.text("E-mail lub login", "Email or username"), text: $login, keyboard: .emailAddress, contentType: .username) + KasztelanField(title: model.text("Hasło", "Password"), text: $password, secure: true, contentType: .password) + Button(model.text("Zaloguj", "Sign in")) { + guard !login.trimmingCharacters(in: .whitespaces).isEmpty, !password.isEmpty else { + model.errorMessage = model.text("Wpisz login i hasło.", "Enter your username and password.") + return + } + Task { await model.login(login: login, password: password) } + } + .buttonStyle(PrimaryButtonStyle()) + Link(destination: URL(string: "https://kasztelan.co.uk/forgot/")!) { + HStack(spacing: 8) { + Image(systemName: "key.fill") + Text(model.text("Nie pamiętasz hasła?", "Forgot your password?")) + } + .font(.subheadline.bold()) + .foregroundStyle(KTheme.orange) + .frame(maxWidth: .infinity, minHeight: 46) + } + .accessibilityLabel(model.text("Otwórz resetowanie hasła", "Open password reset")) + Button(model.text("Załóż konto", "Create account")) { registering = true } + .frame(maxWidth: .infinity, minHeight: 50) + .background(KTheme.panel) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + } + + private var registrationForm: some View { + VStack(alignment: .leading, spacing: 12) { + Text(model.text("Załóż konto", "Create account")).font(.largeTitle.bold()) + KasztelanField(title: model.text("Imię", "First name"), text: $form.firstName, contentType: .givenName) + KasztelanField(title: model.text("Nazwisko", "Last name"), text: $form.lastName, contentType: .familyName) + KasztelanField(title: "E-mail", text: $form.email, keyboard: .emailAddress, contentType: .emailAddress) + KasztelanField(title: model.text("Hasło", "Password"), text: $form.password, secure: true, contentType: .newPassword) + KasztelanField(title: model.text("Telefon UK lub PL", "UK or Polish phone number"), text: $form.mobile, keyboard: .phonePad, contentType: .telephoneNumber) + KasztelanField(title: model.text("Kod pocztowy UK", "UK postcode"), text: $form.postcode, contentType: .postalCode) + KasztelanField(title: model.text("Data urodzenia: RRRR-MM-DD", "Date of birth: YYYY-MM-DD"), text: dateBinding, keyboard: .numberPad) + KasztelanField(title: model.text("Numer karty (opcjonalnie)", "Card number (optional)"), text: $form.cardNumber, keyboard: .numberPad) + Toggle(model.text("Akceptuję regulamin", "I accept the terms"), isOn: $form.acceptsTerms) + .tint(KTheme.orange) + HStack(spacing: 16) { + Link(model.text("Regulamin", "Terms"), destination: URL(string: "https://kasztelan.co.uk/regulamin/")!) + Link(model.text("Polityka prywatności", "Privacy policy"), destination: URL(string: "https://kasztelan.co.uk/privacy-policy/")!) + } + .font(.subheadline.bold()) + .foregroundStyle(KTheme.orange) + Button(model.text("Utwórz konto", "Create account")) { + Task { await model.register(form) } + } + .buttonStyle(PrimaryButtonStyle()) + Button(model.text("Mam już konto", "I already have an account")) { registering = false } + .frame(maxWidth: .infinity, minHeight: 50) + .background(KTheme.panel) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + } + + private var dateBinding: Binding { + Binding( + get: { form.dob }, + set: { form.dob = Self.formatDateInput($0) } + ) + } + + static func formatDateInput(_ input: String) -> String { + let digits = String(input.filter(\.isNumber).prefix(8)) + var result = "" + for (index, character) in digits.enumerated() { + if index == 4 || index == 6 { result.append("-") } + result.append(character) + } + return result + } +} + +struct LanguageButton: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + Button { + model.language = model.language == .pl ? .en : .pl + } label: { + Text(model.language == .pl ? "EN" : "PL") + .font(.subheadline.bold()) + .foregroundStyle(KTheme.orange) + .frame(width: 44, height: 44, alignment: .center) + .background(KTheme.panel, in: Circle()) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(model.text("Zmień język na angielski", "Change language to Polish")) + } +} diff --git a/Kasztelan/CardView.swift b/Kasztelan/CardView.swift new file mode 100644 index 0000000..47b9b24 --- /dev/null +++ b/Kasztelan/CardView.swift @@ -0,0 +1,225 @@ +import SwiftUI + +private enum CardAlert: Identifiable { + case generate + case scannerError(String) + + var id: String { + switch self { + case .generate: return "generate" + case .scannerError: return "scanner-error" + } + } +} + +struct CardView: View { + @EnvironmentObject private var model: AppModel + @State private var cardNumber = "" + @State private var cardScannerPresented = false + @State private var activeAlert: CardAlert? + @FocusState private var cardNumberFocused: Bool + + var body: some View { + GeometryReader { geometry in + let compact = geometry.size.height < 680 + + VStack(alignment: .leading, spacing: compact ? 10 : 18) { + Text(model.text("Witaj, ", "Hello, ") + firstName) + .font(compact ? .title.bold() : .largeTitle.bold()) + .lineLimit(1) + .minimumScaleFactor(0.72) + Text(model.text("Pokaż kod karty przy kasie.", "Show your card code at the checkout.")) + .foregroundStyle(.secondary) + .font(compact ? .subheadline : .body) + + loyaltyCard(compact: compact) + summary(compact: compact) + } + .padding(.horizontal, 20) + .padding(.vertical, compact ? 8 : 14) + .frame(width: geometry.size.width, height: geometry.size.height, alignment: .top) + } + .background(KTheme.background) + .fullScreenCover(isPresented: $cardScannerPresented) { + CardScannerScreen( + onScan: acceptScannedCard, + onCancel: { cardScannerPresented = false }, + onError: { error in + cardScannerPresented = false + activeAlert = .scannerError(error) + } + ) + .ignoresSafeArea() + } + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(model.text("Gotowe", "Done")) { cardNumberFocused = false } + .fontWeight(.semibold) + } + } + .alert(item: $activeAlert) { alert in + switch alert { + case .generate: + return Alert( + title: Text(model.text("Wygenerować nową kartę?", "Create a new card?")), + message: Text(model.text( + "Do konta zostanie na stałe przypisana nowa karta cyfrowa.", + "A new digital card will be permanently assigned to this account." + )), + primaryButton: .default(Text(model.text("Wygeneruj", "Create"))) { + Task { _ = await model.generateCard() } + }, + secondaryButton: .cancel(Text(model.text("Anuluj", "Cancel"))) + ) + case let .scannerError(message): + return Alert( + title: Text(model.text("Nie udało się odczytać karty", "Could not read the card")), + message: Text(message), + dismissButton: .cancel(Text("OK")) + ) + } + } + } + + private var firstName: String { + model.loyalty?.firstName?.value.nonEmpty + ?? model.user?.firstName?.nonEmpty + ?? model.text("Kliencie", "Customer") + } + + private func loyaltyCard(compact: Bool) -> some View { + return VStack(spacing: compact ? 10 : 20) { + Image("Logo") + .resizable() + .scaledToFit() + .frame(maxHeight: compact ? 54 : 76) + if model.cardNumber.count == 13 { + VStack(spacing: compact ? 4 : 8) { + CardBarcodeView(digits: model.cardNumber) + .frame(height: compact ? 108 : 126) + Text(model.cardNumber) + .font(.system(.headline, design: .monospaced).weight(.bold)) + .tracking(2) + .foregroundStyle(.black) + } + .padding(compact ? 8 : 12) + .background(.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } else { + VStack(alignment: .leading, spacing: 10) { + Text(model.text("Przypisz kartę", "Assign a card")).font(.headline) + Text(model.text("Wpisz 13-cyfrowy numer znajdujący się na karcie.", "Enter the 13-digit number shown on the card.")) + .font(.subheadline) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + TextField(model.text("Numer karty", "Card number"), text: $cardNumber) + .keyboardType(.numberPad) + .focused($cardNumberFocused) + .foregroundStyle(.white) + .tint(.white) + .padding(.horizontal, 14) + .frame(minHeight: 50) + .background(KTheme.panel.opacity(0.92)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.35), lineWidth: 1) + } + .onChange(of: cardNumber) { value in + cardNumber = String(value.filter(\.isNumber).prefix(13)) + } + Button { + Task { if await model.assignCard(cardNumber) { cardNumber = "" } } + } label: { + Text(model.text("Przypisz", "Assign")) + .frame(maxWidth: .infinity, minHeight: 22) + } + .buttonStyle(.borderedProminent) + .tint(KTheme.pink) + Button { + cardNumberFocused = false + cardScannerPresented = true + } label: { + Label(model.text("Skanuj kartę", "Scan card"), systemImage: "camera.viewfinder") + .frame(maxWidth: .infinity, minHeight: 22) + } + .buttonStyle(.bordered) + .tint(.white) + .font(.subheadline.weight(.semibold)) + Button { + cardNumberFocused = false + activeAlert = .generate + } label: { + Text(model.text("Nie mam karty — wygeneruj cyfrową", "I don't have a card — create a digital one")) + .frame(maxWidth: .infinity, minHeight: 22) + } + .buttonStyle(.bordered) + .tint(.white) + .font(.subheadline.weight(.semibold)) + } + } + } + .frame(maxWidth: .infinity) + .padding(compact ? 14 : 22) + .background(LinearGradient(colors: [KTheme.orange, KTheme.pink, KTheme.purple], startPoint: .topLeading, endPoint: .bottomTrailing)) + .clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous)) + .shadow(color: KTheme.pink.opacity(0.2), radius: 18, y: 8) + } + + private func acceptScannedCard(_ scan: ScannedCard) { + cardScannerPresented = false + let digits = scan.value.filter(\.isNumber) + guard digits.count == 13 else { + activeAlert = .scannerError(model.text( + "Odczytany kod nie zawiera 13 cyfr. Ustaw całą kartę w ramce i spróbuj ponownie.", + "The scanned code does not contain 13 digits. Place the whole card inside the frame and try again." + )) + return + } + cardNumber = digits + } + + private func summary(compact: Bool) -> some View { + VStack(spacing: 0) { + Text(model.text("PODSUMOWANIE", "SUMMARY")) + .font(.caption.bold()) + .foregroundStyle(KTheme.orange) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 8) + StatRow(label: model.text("Punkty", "Points"), value: model.pointsWithValue(model.loyalty?.points?.value), highlighted: true, compact: compact) + Divider().overlay(Color.white.opacity(0.15)) + StatRow(label: model.text("Wartość zakupów", "Purchase value"), value: model.money(model.loyalty?.transactionsValue?.value), compact: compact) + Divider().overlay(Color.white.opacity(0.15)) + StatRow(label: model.text("Wartość upustu", "Discount value"), value: model.money(model.loyalty?.discountValue?.value), compact: compact) + if let savings = model.loyalty?.savings?.value.nonEmpty { + Divider().overlay(Color.white.opacity(0.15)) + StatRow(label: model.text("Oszczędności", "Savings"), value: model.money(savings), compact: compact) + } + } + .kasztelanPanel() + } +} + +private struct StatRow: View { + let label: String + let value: String + var highlighted = false + var compact = false + + var body: some View { + HStack { + Text(label).foregroundStyle(.secondary) + Spacer() + Text(value) + .font(highlighted ? .title3.bold() : .headline) + .foregroundStyle(highlighted ? KTheme.orange : .white) + .multilineTextAlignment(.trailing) + } + .padding(.vertical, compact ? 8 : 13) + } +} + +extension String { + var nonEmpty: String? { isEmpty ? nil : self } +} diff --git a/Kasztelan/EAN13BarcodeView 2.swift b/Kasztelan/EAN13BarcodeView 2.swift new file mode 100644 index 0000000..7e5f750 --- /dev/null +++ b/Kasztelan/EAN13BarcodeView 2.swift @@ -0,0 +1,47 @@ +import CoreImage +import CoreImage.CIFilterBuiltins +import SwiftUI + +/// Printed Kasztelan cards contain an arbitrary 13-digit account number. +/// Code 128 preserves all 13 digits exactly. EAN-13 cannot, because its final +/// digit is always a mandatory checksum. +struct CardBarcodeView: View { + let digits: String + @Environment(\.displayScale) private var displayScale + + var body: some View { + GeometryReader { geometry in + if let image = barcodeImage( + targetWidth: geometry.size.width, + targetHeight: geometry.size.height, + displayScale: displayScale + ) { + Image(decorative: image, scale: displayScale) + .interpolation(.none) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + .background(.white) + .accessibilityElement() + .accessibilityLabel("Barcode \(digits)") + } + + private func barcodeImage(targetWidth: CGFloat, targetHeight: CGFloat, displayScale: CGFloat) -> CGImage? { + let clean = String(digits.filter(\.isNumber)) + guard clean.count == 13, let data = clean.data(using: .ascii) else { return nil } + + let generator = CIFilter.code128BarcodeGenerator() + generator.message = data + generator.quietSpace = 12 + guard let output = generator.outputImage else { return nil } + + let pixelWidth = floor(targetWidth * displayScale) + let pixelHeight = floor(targetHeight * displayScale) + let horizontalScale = max(1, floor(pixelWidth / output.extent.width)) + let verticalScale = max(1, floor(pixelHeight / output.extent.height)) + let scaled = output.transformed( + by: CGAffineTransform(scaleX: horizontalScale, y: verticalScale) + ) + return CIContext(options: [.useSoftwareRenderer: false]).createCGImage(scaled, from: scaled.extent) + } +} diff --git a/Kasztelan/EAN13BarcodeView.swift b/Kasztelan/EAN13BarcodeView.swift new file mode 100644 index 0000000..fe5564e --- /dev/null +++ b/Kasztelan/EAN13BarcodeView.swift @@ -0,0 +1,226 @@ +import AVFoundation +import CoreImage +import CoreImage.CIFilterBuiltins +import SwiftUI + +/// Kasztelan card numbers are arbitrary 13-digit account identifiers, so they +/// remain Code 128. EAN-13 would silently replace the last digit with a checksum. +struct CardBarcodeView: View { + let digits: String + @Environment(\.displayScale) private var displayScale + + var body: some View { + GeometryReader { geometry in + if let image = barcodeImage( + targetWidth: geometry.size.width, + targetHeight: geometry.size.height, + displayScale: displayScale + ) { + Image(decorative: image, scale: displayScale) + .interpolation(.none) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + .background(.white) + .accessibilityElement() + .accessibilityLabel("Barcode \(digits)") + } + + private func barcodeImage(targetWidth: CGFloat, targetHeight: CGFloat, displayScale: CGFloat) -> CGImage? { + let clean = String(digits.filter(\.isNumber)) + guard clean.count == 13, let data = clean.data(using: .ascii) else { return nil } + + let generator = CIFilter.code128BarcodeGenerator() + generator.message = data + generator.quietSpace = 12 + guard let output = generator.outputImage else { return nil } + + let pixelWidth = floor(targetWidth * displayScale) + let pixelHeight = floor(targetHeight * displayScale) + let horizontalScale = max(1, floor(pixelWidth / output.extent.width)) + let verticalScale = max(1, floor(pixelHeight / output.extent.height)) + let scaled = output.transformed( + by: CGAffineTransform(scaleX: horizontalScale, y: verticalScale) + ) + return CIContext(options: [.useSoftwareRenderer: false]).createCGImage(scaled, from: scaled.extent) + } +} + +/// Recreates an imported loyalty-card code using the symbology reported by the +/// camera. The payload itself is never normalised or reformatted. +struct StoredBarcodeView: View { + let value: String + let symbology: String + + var body: some View { + let type = AVMetadataObject.ObjectType(rawValue: symbology) + Group { + switch type { + case .ean13: + EANBarcodeView(value: value, kind: .ean13) + case .ean8: + EANBarcodeView(value: value, kind: .ean8) + case .qr: + MatrixBarcodeView(value: value, filterName: "CIQRCodeGenerator") + case .aztec: + MatrixBarcodeView(value: value, filterName: "CIAztecCodeGenerator") + case .pdf417: + MatrixBarcodeView(value: value, filterName: "CIPDF417BarcodeGenerator") + default: + Code128BarcodeView(value: value) + } + } + .accessibilityElement() + .accessibilityLabel("Barcode \(value)") + } +} + +private struct Code128BarcodeView: View { + let value: String + @Environment(\.displayScale) private var displayScale + + var body: some View { + GeometryReader { geometry in + if let image = barcodeImage( + targetWidth: geometry.size.width, + targetHeight: geometry.size.height + ) { + Image(decorative: image, scale: displayScale) + .interpolation(.none) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .background(.white) + } + + private func barcodeImage(targetWidth: CGFloat, targetHeight: CGFloat) -> CGImage? { + guard !value.isEmpty, let data = value.data(using: .isoLatin1) ?? value.data(using: .utf8) else { + return nil + } + let generator = CIFilter.code128BarcodeGenerator() + generator.message = data + generator.quietSpace = 12 + guard let output = generator.outputImage else { return nil } + return pixelPerfectImage(output, targetWidth: targetWidth, targetHeight: targetHeight, displayScale: displayScale) + } +} + +private struct MatrixBarcodeView: View { + let value: String + let filterName: String + @Environment(\.displayScale) private var displayScale + + var body: some View { + GeometryReader { geometry in + if let image = barcodeImage(size: geometry.size) { + Image(decorative: image, scale: displayScale) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .background(.white) + } + + private func barcodeImage(size: CGSize) -> CGImage? { + guard let data = value.data(using: .isoLatin1) ?? value.data(using: .utf8), + let filter = CIFilter(name: filterName) else { return nil } + filter.setValue(data, forKey: "inputMessage") + if filterName == "CIQRCodeGenerator" { filter.setValue("M", forKey: "inputCorrectionLevel") } + guard let output = filter.outputImage else { return nil } + return pixelPerfectImage(output, targetWidth: size.width, targetHeight: size.height, displayScale: displayScale) + } +} + +private func pixelPerfectImage( + _ image: CIImage, + targetWidth: CGFloat, + targetHeight: CGFloat, + displayScale: CGFloat +) -> CGImage? { + let pixelWidth = floor(targetWidth * displayScale) + let pixelHeight = floor(targetHeight * displayScale) + let scale = max(1, floor(min(pixelWidth / image.extent.width, pixelHeight / image.extent.height))) + let scaled = image.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) + return CIContext(options: [.useSoftwareRenderer: false]).createCGImage(scaled, from: scaled.extent) +} + +private struct EANBarcodeView: View { + enum Kind { case ean13, ean8 } + let value: String + let kind: Kind + + private var modules: [Bool]? { + switch kind { + case .ean13: return Self.ean13(value) + case .ean8: return Self.ean8(value) + } + } + + var body: some View { + GeometryReader { geometry in + if let modules { + Canvas(rendersAsynchronously: false) { context, size in + let quietZone = 12 + let totalModules = modules.count + quietZone * 2 + let moduleWidth = floor(size.width / CGFloat(totalModules)) + let usedWidth = moduleWidth * CGFloat(totalModules) + let originX = floor((size.width - usedWidth) / 2) + CGFloat(quietZone) * moduleWidth + for (index, black) in modules.enumerated() where black { + context.fill( + Path(CGRect(x: originX + CGFloat(index) * moduleWidth, y: 0, width: moduleWidth, height: size.height)), + with: .color(.black) + ) + } + } + } + } + .background(.white) + } + + private static let leftOdd = [ + "0001101", "0011001", "0010011", "0111101", "0100011", + "0110001", "0101111", "0111011", "0110111", "0001011" + ] + private static let leftEven = [ + "0100111", "0110011", "0011011", "0100001", "0011101", + "0111001", "0000101", "0010001", "0001001", "0010111" + ] + private static let right = [ + "1110010", "1100110", "1101100", "1000010", "1011100", + "1001110", "1010000", "1000100", "1001000", "1110100" + ] + private static let parity = [ + "OOOOOO", "OOEOEE", "OOEEOE", "OOEEEO", "OEOOEE", + "OEEOOE", "OEEEOO", "OEOEOE", "OEOEEO", "OEEOEO" + ] + + private static func bits(_ string: String) -> [Bool] { string.map { $0 == "1" } } + + private static func ean13(_ value: String) -> [Bool]? { + guard value.count == 13, value.allSatisfy(\.isNumber), + let first = value.first?.wholeNumberValue else { return nil } + let digits = value.compactMap(\.wholeNumberValue) + var result = bits("101") + for index in 1...6 { + let encoding = Array(parity[first])[index - 1] == "O" ? leftOdd[digits[index]] : leftEven[digits[index]] + result += bits(encoding) + } + result += bits("01010") + for index in 7...12 { result += bits(right[digits[index]]) } + result += bits("101") + return result + } + + private static func ean8(_ value: String) -> [Bool]? { + guard value.count == 8, value.allSatisfy(\.isNumber) else { return nil } + let digits = value.compactMap(\.wholeNumberValue) + var result = bits("101") + for index in 0...3 { result += bits(leftOdd[digits[index]]) } + result += bits("01010") + for index in 4...7 { result += bits(right[digits[index]]) } + result += bits("101") + return result + } +} diff --git a/Kasztelan/Info.plist b/Kasztelan/Info.plist new file mode 100644 index 0000000..aaf95d2 --- /dev/null +++ b/Kasztelan/Info.plist @@ -0,0 +1,57 @@ + + + + + CFBundleDisplayName + Kasztelan + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + KASZTELANAppKey + $(KASZTELAN_APP_KEY) + LSRequiresIPhoneOS + + NSCameraUsageDescription + Aparat służy do skanowania kodów z kart rabatowych. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIBackgroundModes + + fetch + + BGTaskSchedulerPermittedIdentifiers + + uk.co.kasztelan.app.ios.refresh + + UILaunchScreen + + UIColorName + + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UIRequiresFullScreen + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + + diff --git a/Kasztelan/KasztelanApp.swift b/Kasztelan/KasztelanApp.swift new file mode 100644 index 0000000..49b4bac --- /dev/null +++ b/Kasztelan/KasztelanApp.swift @@ -0,0 +1,227 @@ +import SwiftUI +import UserNotifications +import BackgroundTasks +import CoreSpotlight +import UniformTypeIdentifiers + +final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { + private static let backgroundRefreshID = "uk.co.kasztelan.app.ios.refresh" + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + UNUserNotificationCenter.current().delegate = self + let openAction = UNNotificationAction( + identifier: "KASZTELAN_OPEN", + title: "Otwórz wiadomość", + options: [.foreground] + ) + let messageCategory = UNNotificationCategory( + identifier: "KASZTELAN_MESSAGE", + actions: [openAction], + intentIdentifiers: [], + options: [] + ) + UNUserNotificationCenter.current().setNotificationCategories([messageCategory]) + BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.backgroundRefreshID, using: nil) { [weak self] task in + guard let refreshTask = task as? BGAppRefreshTask else { + task.setTaskCompleted(success: false) + return + } + self?.handle(refreshTask) + } + indexInSpotlight() + scheduleBackgroundRefresh() + return true + } + + private func indexInSpotlight() { + let attributes = CSSearchableItemAttributeSet(contentType: .application) + attributes.title = "Kasztelan" + attributes.displayName = "Kasztelan" + attributes.contentDescription = "Karta klienta Kasztelan, punkty i wiadomości" + attributes.keywords = ["Kasztelan", "KASZTELAN", "karta Kasztelan", "punkty", "loyalty card"] + let item = CSSearchableItem( + uniqueIdentifier: "uk.co.kasztelan.app.ios.open", + domainIdentifier: "uk.co.kasztelan.app.ios", + attributeSet: attributes + ) + item.expirationDate = .distantFuture + CSSearchableIndex.default().indexSearchableItems([item]) + } + + func applicationDidEnterBackground(_ application: UIApplication) { + scheduleBackgroundRefresh() + } + + private func scheduleBackgroundRefresh() { + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.backgroundRefreshID) + let request = BGAppRefreshTaskRequest(identifier: Self.backgroundRefreshID) + request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) + try? BGTaskScheduler.shared.submit(request) + } + + private func handle(_ backgroundTask: BGAppRefreshTask) { + scheduleBackgroundRefresh() + let operation = Task { + let token = KeychainStore.load() + guard !token.isEmpty else { + backgroundTask.setTaskCompleted(success: true) + return + } + do { + let store = MessageStore() + let lastServerID = store.lastServerID + let response = try await APIClient().notifications(after: store.lastServerID, token: token) + var incoming = response.notifications ?? [] + if incoming.isEmpty, + let latestResponse = try? await APIClient().latestNotification(token: token), + let latest = latestResponse.notification, + latest.id > lastServerID { + incoming = [latest] + } + let added = store.merge(incoming) + for message in added { await NotificationService.show(message) } + backgroundTask.setTaskCompleted(success: true) + } catch { + backgroundTask.setTaskCompleted(success: false) + } + } + backgroundTask.expirationHandler = { operation.cancel() } + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .list, .sound]) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + NotificationCenter.default.post(name: .kasztelanOpenMessages, object: nil) + completionHandler() + } +} + +extension Notification.Name { + static let kasztelanOpenMessages = Notification.Name("kasztelan.openMessages") +} + +@main +struct KasztelanApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @StateObject private var model = AppModel() + @Environment(\.scenePhase) private var scenePhase + + var body: some Scene { + WindowGroup { + RootView() + .environmentObject(model) + .preferredColorScheme(.dark) + .task { await model.launch() } + .onOpenURL { url in + if url.host == "message" { model.selectedTab = 1 } + } + .onReceive(NotificationCenter.default.publisher(for: .kasztelanOpenMessages)) { _ in + model.selectedTab = 1 + } + } + .onChange(of: scenePhase) { phase in + if phase == .active { model.beginLiveRefresh() } + else { model.stopLiveRefresh() } + } + } +} + +struct RootView: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + ZStack { + KTheme.background.ignoresSafeArea() + if model.isLoading { + ProgressView(model.text("Sprawdzam konto…", "Checking your account…")) + .tint(KTheme.orange) + .foregroundStyle(.white) + } else if model.isAuthenticated { + MainTabView() + } else { + AuthView() + } + } + .alert(model.text("Kasztelan", "Kasztelan"), isPresented: Binding( + get: { model.errorMessage != nil }, + set: { if !$0 { model.errorMessage = nil } } + )) { + Button("OK", role: .cancel) { model.errorMessage = nil } + } message: { + Text(model.errorMessage ?? "") + } + .overlay { + if model.isBusy { + ZStack { + Color.black.opacity(0.3).ignoresSafeArea() + ProgressView().controlSize(.large).tint(KTheme.orange) + } + } + } + .overlay(alignment: .top) { + if let message = model.presentedMessage { + InAppMessageBanner(message: message) + .environmentObject(model) + .padding(.horizontal, 14) + .padding(.top, 8) + .transition(.move(edge: .top).combined(with: .opacity)) + .zIndex(10) + } + } + .animation(.spring(response: 0.35, dampingFraction: 0.86), value: model.presentedMessage?.id) + } +} + +private struct InAppMessageBanner: View { + @EnvironmentObject private var model: AppModel + let message: KasztelanMessage + + var body: some View { + HStack(spacing: 12) { + Image("Logo") + .resizable() + .scaledToFit() + .frame(width: 48, height: 36) + VStack(alignment: .leading, spacing: 2) { + Text("KASZTELAN") + .font(.caption.bold()) + .foregroundStyle(KTheme.orange) + Text(message.title.isEmpty ? "Kasztelan" : message.title) + .font(.headline) + .foregroundStyle(.white) + .lineLimit(2) + } + Spacer(minLength: 4) + Button { model.dismissPresentedMessage() } label: { + Image(systemName: "xmark.circle.fill") + .font(.title3) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(model.text("Zamknij", "Close")) + } + .padding(14) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(KTheme.orange.opacity(0.45), lineWidth: 1) + } + .shadow(color: .black.opacity(0.3), radius: 14, y: 8) + .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + .onTapGesture { model.openPresentedMessage() } + .accessibilityLabel(message.title) + } +} diff --git a/Kasztelan/KeychainStore.swift b/Kasztelan/KeychainStore.swift new file mode 100644 index 0000000..8c16fe1 --- /dev/null +++ b/Kasztelan/KeychainStore.swift @@ -0,0 +1,45 @@ +import Foundation +import Security + +enum KeychainStore { + private static let service = "uk.co.kasztelan.app.ios" + private static let account = "api-token" + + static func save(token: String) throws { + let data = Data(token.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + SecItemDelete(query as CFDictionary) + var item = query + item[kSecValueData as String] = data + item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let status = SecItemAdd(item as CFDictionary, nil) + guard status == errSecSuccess else { throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) } + } + + static func load() -> String { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { return "" } + return String(data: data, encoding: .utf8) ?? "" + } + + static func clear() { + SecItemDelete([ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] as CFDictionary) + } +} + diff --git a/Kasztelan/MainTabView.swift b/Kasztelan/MainTabView.swift new file mode 100644 index 0000000..1943043 --- /dev/null +++ b/Kasztelan/MainTabView.swift @@ -0,0 +1,36 @@ +import SwiftUI + +struct MainTabView: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + TabView(selection: $model.selectedTab) { + NavigationStack { WalletView() } + .tabItem { Label(model.text("Karta", "Card"), systemImage: "creditcard.fill") } + .tag(0) + NavigationStack { MessagesView() } + .tabItem { Label(model.text("Wiadomości", "Messages"), systemImage: "envelope.fill") } + .badge(model.messages.count) + .tag(1) + NavigationStack { ProfileView() } + .tabItem { Label(model.text("Profil", "Profile"), systemImage: "person.crop.circle.fill") } + .tag(2) + } + .tint(KTheme.orange) + .task { await NotificationService.requestPermission() } + } +} + +struct AppToolbar: ToolbarContent { + @EnvironmentObject private var model: AppModel + + var body: some ToolbarContent { + ToolbarItem(placement: .principal) { + Image("Logo") + .resizable() + .scaledToFit() + .frame(width: 170, height: 44) + } + ToolbarItem(placement: .topBarTrailing) { LanguageButton() } + } +} diff --git a/Kasztelan/MessageStore.swift b/Kasztelan/MessageStore.swift new file mode 100644 index 0000000..2d734c4 --- /dev/null +++ b/Kasztelan/MessageStore.swift @@ -0,0 +1,63 @@ +import Foundation + +final class MessageStore { + private let messagesKey = "kasztelan.messages" + private let lastIDKey = "kasztelan.messages.lastID" + private let installedAtKey = "kasztelan.installation.timestamp" + + var messages: [KasztelanMessage] { + guard let data = UserDefaults.standard.data(forKey: messagesKey), + let values = try? JSONDecoder().decode([KasztelanMessage].self, from: data) else { return [] } + return values.filter(isFromThisInstallation).sorted { $0.id > $1.id } + } + + var lastServerID: Int { UserDefaults.standard.integer(forKey: lastIDKey) } + + @discardableResult + func merge(_ incoming: [KasztelanMessage]) -> [KasztelanMessage] { + var saved = messages + var ids = Set(saved.map(\.id)) + let previousLast = lastServerID + let added = incoming.filter { message in + guard message.id > 0, + isFromThisInstallation(message), + !message.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } + return ids.insert(message.id).inserted + } + saved.append(contentsOf: added) + saved.sort { $0.id > $1.id } + saved = Array(saved.prefix(100)) + persist(saved, lastID: max(previousLast, incoming.map(\.id).max() ?? 0)) + return added.filter { $0.id > previousLast }.sorted { $0.id > $1.id } + } + + func delete(id: Int) { + persist(messages.filter { $0.id != id }, lastID: lastServerID) + } + + func clear() { + UserDefaults.standard.removeObject(forKey: messagesKey) + UserDefaults.standard.removeObject(forKey: lastIDKey) + } + + private func persist(_ messages: [KasztelanMessage], lastID: Int) { + if let data = try? JSONEncoder().encode(messages) { + UserDefaults.standard.set(data, forKey: messagesKey) + } + UserDefaults.standard.set(lastID, forKey: lastIDKey) + } + + private var installedAt: Int { + let saved = UserDefaults.standard.integer(forKey: installedAtKey) + if saved > 0 { return saved } + let created = (try? FileManager.default.attributesOfItem(atPath: NSHomeDirectory())[.creationDate] as? Date) + ?? Date() + let timestamp = Int(created.timeIntervalSince1970) + UserDefaults.standard.set(timestamp, forKey: installedAtKey) + return timestamp + } + + private func isFromThisInstallation(_ message: KasztelanMessage) -> Bool { + message.sentAt >= installedAt + } +} diff --git a/Kasztelan/MessagesView.swift b/Kasztelan/MessagesView.swift new file mode 100644 index 0000000..9efe3d4 --- /dev/null +++ b/Kasztelan/MessagesView.swift @@ -0,0 +1,79 @@ +import Foundation +import SwiftUI + +struct MessagesView: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + Text(model.text("Wiadomości", "Messages")).font(.largeTitle.bold()) + Text(model.text("Najnowsze wiadomości są wyświetlane na górze.", "The newest messages appear first.")) + .foregroundStyle(.secondary) + .padding(.bottom, 4) + if model.messages.isEmpty { + VStack(spacing: 12) { + Image(systemName: "sparkles").font(.largeTitle).foregroundStyle(KTheme.orange) + Text(model.text("Nie masz jeszcze żadnych wiadomości.", "You do not have any messages yet.")) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .kasztelanPanel() + } else { + ForEach(model.messages) { message in + messageCard(message) + } + } + } + .padding(20) + } + .background(KTheme.background) + .toolbar { AppToolbar() } + .refreshable { await model.refresh() } + .task { await model.refresh() } + } + + private func messageCard(_ message: KasztelanMessage) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top) { + Circle().fill(KTheme.orange).frame(width: 9, height: 9).padding(.top, 6) + VStack(alignment: .leading, spacing: 3) { + Text(message.title.isEmpty ? "Kasztelan" : message.title).font(.headline) + Text(messageDate(message.sentAt)).font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button(role: .destructive) { model.deleteMessage(id: message.id) } label: { + Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) + } + .accessibilityLabel(model.text("Usuń wiadomość", "Delete message")) + } + Text(linkedMessage(message.text)) + .tint(KTheme.orange) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + .kasztelanPanel() + } + + private func messageDate(_ seconds: Int) -> String { + guard seconds > 0 else { return model.text("Teraz", "Now") } + let formatter = DateFormatter() + formatter.dateFormat = model.language == .pl ? "dd.MM.yyyy HH:mm" : "dd/MM/yyyy HH:mm" + return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(seconds))) + } + + private func linkedMessage(_ value: String) -> AttributedString { + let result = NSMutableAttributedString(string: value) + guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else { + return AttributedString(value) + } + let fullRange = NSRange(value.startIndex.. { + Binding(get: { form.dob }, set: { form.dob = AuthView.formatDateInput($0) }) + } +} + +struct ChangePasswordView: View { + @EnvironmentObject private var model: AppModel + @Environment(\.dismiss) private var dismiss + @State private var current = "" + @State private var newPassword = "" + @State private var confirmation = "" + + var body: some View { + NavigationStack { + VStack(spacing: 14) { + KasztelanField(title: model.text("Obecne hasło", "Current password"), text: $current, secure: true, contentType: .password) + KasztelanField(title: model.text("Nowe hasło", "New password"), text: $newPassword, secure: true, contentType: .newPassword) + KasztelanField(title: model.text("Powtórz nowe hasło", "Repeat new password"), text: $confirmation, secure: true, contentType: .newPassword) + Button(model.text("Zmień hasło", "Change password")) { + guard newPassword == confirmation else { + model.errorMessage = model.text("Nowe hasła nie są takie same.", "The new passwords do not match.") + return + } + Task { if await model.changePassword(current: current, new: newPassword) { dismiss() } } + } + .buttonStyle(PrimaryButtonStyle()) + Spacer() + } + .padding(20) + .background(KTheme.background) + .navigationTitle(model.text("Zmień hasło", "Change password")) + .toolbar { ToolbarItem(placement: .cancellationAction) { Button(model.text("Anuluj", "Cancel")) { dismiss() } } } + } + .presentationDetents([.medium]) + } +} diff --git a/Kasztelan/Theme.swift b/Kasztelan/Theme.swift new file mode 100644 index 0000000..7f6dfa6 --- /dev/null +++ b/Kasztelan/Theme.swift @@ -0,0 +1,70 @@ +import SwiftUI + +enum KTheme { + static let background = Color(red: 11/255, green: 11/255, blue: 14/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 pink = Color(red: 242/255, green: 29/255, blue: 167/255) + static let purple = Color(red: 111/255, green: 60/255, blue: 1) +} + +struct PanelModifier: ViewModifier { + func body(content: Content) -> some View { + content + .padding(18) + .background(KTheme.panel) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + } +} + +extension View { + func kasztelanPanel() -> some View { modifier(PanelModifier()) } +} + +struct PrimaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.headline) + .foregroundStyle(.white) + .frame(maxWidth: .infinity, minHeight: 54) + .background(KTheme.pink.opacity(configuration.isPressed ? 0.72 : 1)) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } +} + +struct KasztelanField: View { + @EnvironmentObject private var model: AppModel + let title: String + @Binding var text: String + var secure = false + var keyboard: UIKeyboardType = .default + var contentType: UITextContentType? + @FocusState private var isFocused: Bool + + var body: some View { + Group { + if secure { + SecureField(title, text: $text) + } else { + TextField(title, text: $text) + .keyboardType(keyboard) + } + } + .focused($isFocused) + .textContentType(contentType) + .textInputAutocapitalization(keyboard == .emailAddress ? .never : .sentences) + .autocorrectionDisabled(keyboard == .emailAddress) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, minHeight: 54) + .background(KTheme.panel) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .foregroundStyle(.white) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(model.text("Gotowe", "Done")) { isFocused = false } + .fontWeight(.semibold) + } + } + } +} diff --git a/Kasztelan/WalletView.swift b/Kasztelan/WalletView.swift new file mode 100644 index 0000000..d3bd74a --- /dev/null +++ b/Kasztelan/WalletView.swift @@ -0,0 +1,689 @@ +import AVFoundation +import CryptoKit +import SwiftUI +import UIKit + +struct WalletView: View { + @EnvironmentObject private var model: AppModel + @StateObject private var store = DiscountCardStore() + @State private var selectedPage = 0 + @State private var scannerPresented = false + @State private var pendingScan: ScannedCard? + @State private var scannerError: String? + @State private var dragOffset: CGFloat = 0 + @State private var changingPage = false + + var body: some View { + walletCarousel + .background(KTheme.background) + .toolbar { walletToolbar } + .onAppear { selectedPage = 0 } + .task(id: model.user?.id) { await store.sync(using: model) } + .fullScreenCover(isPresented: $scannerPresented) { scannerView } + .sheet(item: $pendingScan, content: nameSheet) + .alert( + model.text("Nie można uruchomić aparatu", "Cannot open the camera"), + isPresented: scannerErrorBinding, + actions: scannerAlertActions, + message: { Text(scannerError ?? "") } + ) + } + + private var walletCarousel: some View { + GeometryReader { geometry in + AnyView(currentPage) + .id(pageIdentity) + .offset(x: dragOffset) + .offset(y: carouselArc(width: geometry.size.width)) + .scaleEffect(carouselScale(width: geometry.size.width)) + .rotation3DEffect(carouselAngle(width: geometry.size.width), axis: (x: 0, y: 1, z: 0), perspective: 0.9) + .opacity(carouselOpacity(width: geometry.size.width)) + .shadow(color: .black.opacity(carouselShadow), radius: 24, x: dragOffset < 0 ? 12 : -12) + .contentShape(Rectangle()) + .simultaneousGesture(carouselGesture(width: geometry.size.width)) + .overlay(alignment: .bottom) { pageIndicator } + .clipped() + } + } + + @ToolbarContentBuilder private var walletToolbar: some ToolbarContent { + AppToolbar() + } + + private var scannerView: some View { + CardScannerScreen( + onScan: { scan in + scannerPresented = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { pendingScan = scan } + }, + onCancel: { scannerPresented = false }, + onError: { error in + scannerPresented = false + scannerError = error + } + ) + .ignoresSafeArea() + } + + private func nameSheet(for scan: ScannedCard) -> some View { + CardNameSheet(scan: scan) { name in + store.add(name: name, scan: scan) + pendingScan = nil + selectedPage = store.cards.count + Task { await store.upload(using: model) } + } + .environmentObject(model) + .presentationDetents([.medium]) + } + + private var scannerErrorBinding: Binding { + Binding( + get: { scannerError != nil }, + set: { if !$0 { scannerError = nil } } + ) + } + + @ViewBuilder private func scannerAlertActions() -> some View { + Button("OK", role: .cancel) { scannerError = nil } + Button(model.text("Ustawienia", "Settings")) { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + } + + private func carouselScale(width: CGFloat) -> CGFloat { + 1 - min(abs(dragOffset) / max(width, 1), 1) * 0.14 + } + + private func carouselAngle(width: CGFloat) -> Angle { + .degrees(Double(dragOffset / max(width, 1)) * 68) + } + + private func carouselArc(width: CGFloat) -> CGFloat { + let progress = min(abs(dragOffset) / max(width, 1), 1) + return progress * progress * 34 + } + + private func carouselOpacity(width: CGFloat) -> Double { + 1 - min(Double(abs(dragOffset) / max(width, 1)), 1) * 0.28 + } + + private var carouselShadow: Double { min(Double(abs(dragOffset) / 520), 0.42) } + + private func carouselGesture(width: CGFloat) -> some Gesture { + DragGesture(minimumDistance: 18) + .onChanged { value in + guard !changingPage, + abs(value.translation.width) > abs(value.translation.height) else { return } + dragOffset = value.translation.width + } + .onEnded { value in finishDrag(value, width: width) } + } + + @ViewBuilder private var currentPage: some View { + if selectedPage == 0 { + CardView() + } else if selectedPage <= store.cards.count { + let card = store.cards[selectedPage - 1] + DiscountCardView(card: card) { + store.delete(card) + selectedPage = 0 + Task { await store.upload(using: model) } + } + } else { + AddDiscountCardView { scannerPresented = true } + } + } + + private var pageIdentity: String { + if selectedPage == 0 { return "kasztelan" } + if selectedPage <= store.cards.count { return store.cards[selectedPage - 1].id.uuidString } + return "add-card" + } + + private var pageIndicator: some View { + HStack(spacing: 7) { + ForEach(0..<(store.cards.count + 2), id: \.self) { index in + Circle() + .fill(index == selectedPage ? KTheme.orange : Color.secondary.opacity(0.5)) + .frame(width: index == selectedPage ? 8 : 6, height: index == selectedPage ? 8 : 6) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.black.opacity(0.35), in: Capsule()) + .padding(.bottom, 2) + .allowsHitTesting(false) + } + + private func finishDrag(_ value: DragGesture.Value, width: CGFloat) { + guard !changingPage else { return } + let horizontal = abs(value.translation.width) > abs(value.translation.height) + let predicted = value.predictedEndTranslation.width + guard horizontal, max(abs(value.translation.width), abs(predicted)) > max(52, width * 0.15) else { + changingPage = true + withAnimation(.easeOut(duration: 0.14)) { dragOffset = 0 } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { changingPage = false } + return + } + changingPage = true + let direction = predicted < 0 ? 1 : -1 + let pageCount = store.cards.count + 2 + var instant = Transaction() + instant.disablesAnimations = true + withTransaction(instant) { + selectedPage = (selectedPage + direction + pageCount) % pageCount + dragOffset = direction > 0 ? width * 0.72 : -width * 0.72 + } + DispatchQueue.main.async { + withAnimation(.easeOut(duration: 0.26)) { dragOffset = 0 } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.27) { changingPage = false } + } + } +} + +private struct AddDiscountCardView: View { + @EnvironmentObject private var model: AppModel + let scan: () -> Void + + var body: some View { + VStack(spacing: 22) { + Spacer() + Image(systemName: "barcode.viewfinder") + .font(.system(size: 72, weight: .semibold)) + .foregroundStyle(KTheme.orange) + Text(model.text("Dodaj kartę rabatową", "Add a loyalty card")) + .font(.title2.bold()) + Text(model.text( + "Zeskanuj kod z plastikowej karty. Zaszyfrujemy go i przypiszemy do Twojego konta.", + "Scan the code from a plastic card. It will be encrypted and linked to your account." + )) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal, 30) + Button(model.text("Skanuj kartę", "Scan card"), action: scan) + .buttonStyle(PrimaryButtonStyle()) + .padding(.horizontal, 24) + Spacer() + } + .padding(.bottom, 34) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(KTheme.background) + } +} + +private struct DiscountCardView: View { + @EnvironmentObject private var model: AppModel + let card: DiscountCard + let delete: () -> Void + @State private var confirmingDelete = false + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(card.name).font(.largeTitle.bold()).lineLimit(2) + Text(model.text("Karta rabatowa", "Loyalty card")).foregroundStyle(.secondary) + } + Spacer() + Button(role: .destructive) { confirmingDelete = true } label: { + Image(systemName: "trash.circle.fill").font(.title) + } + .accessibilityLabel(model.text("Usuń kartę", "Delete card")) + } + + VStack(spacing: 18) { + StoredBarcodeView(value: card.payload, symbology: card.symbology) + .frame(maxWidth: .infinity) + .frame(height: card.isTwoDimensional ? 220 : 132) + .padding(14) + .background(.white) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + Text(card.payload) + .font(.system(.headline, design: .monospaced).weight(.bold)) + .multilineTextAlignment(.center) + .textSelection(.enabled) + .lineLimit(3) + .minimumScaleFactor(0.65) + } + .frame(maxWidth: .infinity) + .padding(22) + .background(LinearGradient( + colors: [KTheme.purple, KTheme.pink, KTheme.orange], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + .clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous)) + + Text(model.text("Dostępna offline • zaszyfrowana synchronizacja", "Available offline • encrypted sync")) + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + Spacer() + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + .background(KTheme.background) + .alert(model.text("Usunąć kartę?", "Delete this card?"), isPresented: $confirmingDelete) { + Button(model.text("Anuluj", "Cancel"), role: .cancel) {} + Button(model.text("Usuń", "Delete"), role: .destructive, action: delete) + } + } +} + +private struct CardNameSheet: View { + @EnvironmentObject private var model: AppModel + @Environment(\.dismiss) private var dismiss + let scan: ScannedCard + let save: (String) -> Void + @State private var name = "" + + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 16) { + Text(model.text("Jak nazywa się ta karta?", "What card is this?")) + .font(.title2.bold()) + KasztelanField(title: model.text("Np. Tesco Clubcard", "e.g. Tesco Clubcard"), text: $name) + Button(model.text("Zapisz kartę", "Save card")) { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + save(trimmed.isEmpty ? model.text("Karta rabatowa", "Loyalty card") : trimmed) + dismiss() + } + .buttonStyle(PrimaryButtonStyle()) + Spacer() + } + .padding(20) + .background(KTheme.background) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(model.text("Anuluj", "Cancel")) { dismiss() } + } + } + } + } +} + +struct DiscountCard: Codable, Identifiable, Equatable { + let id: UUID + let name: String + let payload: String + let symbology: String + let createdAt: Date + + var isTwoDimensional: Bool { + let type = AVMetadataObject.ObjectType(rawValue: symbology) + return type == .qr || type == .aztec || type == .pdf417 + } +} + +@MainActor +final class DiscountCardStore: ObservableObject { + @Published private(set) var cards: [DiscountCard] = [] + private var userID: Int? + private var syncing = false + + init() {} + + func add(name: String, scan: ScannedCard) { + cards.append(DiscountCard( + id: UUID(), + name: name, + payload: scan.value, + symbology: scan.symbology, + createdAt: Date() + )) + save() + setDirty(true) + } + + func delete(_ card: DiscountCard) { + cards.removeAll { $0.id == card.id } + save() + setDirty(true) + } + + func sync(using model: AppModel) async { + guard let id = model.user?.id else { return } + activate(userID: id) + guard !syncing else { return } + syncing = true + defer { syncing = false } + do { + let cloud = try await model.cloudWallet() + guard let key = cloud.key, !key.isEmpty else { return } + if isDirty { + try await saveToCloud(key: key, using: model) + } else if let payload = cloud.payload, !payload.isEmpty { + cards = try Self.decrypt(payload: payload, key: key) + save() + setDirty(false) + } else if !cards.isEmpty { + try await saveToCloud(key: key, using: model) + } + } catch { + // Local cards remain available offline and will sync next time. + } + } + + func upload(using model: AppModel) async { + guard let id = model.user?.id else { return } + activate(userID: id) + guard !syncing else { return } + syncing = true + defer { syncing = false } + do { + let cloud = try await model.cloudWallet() + guard let key = cloud.key, !key.isEmpty else { return } + try await saveToCloud(key: key, using: model) + } catch { + setDirty(true) + } + } + + private func activate(userID: Int) { + guard self.userID != userID else { return } + self.userID = userID + let accountCards = Self.load(from: fileURL) + if accountCards.isEmpty, let legacyURL = Self.legacyFileURL { + let legacyCards = Self.load(from: legacyURL) + cards = legacyCards + if !legacyCards.isEmpty { + save() + setDirty(true) + } + } else { + cards = accountCards + } + } + + static func clearLocal(for userID: Int?) { + guard let userID, let supportDirectory else { return } + let accountURL = supportDirectory.appendingPathComponent("kasztelan-discount-cards-\(userID).json") + try? FileManager.default.removeItem(at: accountURL) + if let legacyFileURL { try? FileManager.default.removeItem(at: legacyFileURL) } + UserDefaults.standard.removeObject(forKey: "kasztelan-wallet-dirty-\(userID)") + } + + private static var supportDirectory: URL? { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + } + + private static var legacyFileURL: URL? { + supportDirectory?.appendingPathComponent("kasztelan-discount-cards.json") + } + + private var fileURL: URL? { + guard let userID else { return nil } + return Self.supportDirectory?.appendingPathComponent("kasztelan-discount-cards-\(userID).json") + } + + private var dirtyKey: String? { userID.map { "kasztelan-wallet-dirty-\($0)" } } + private var isDirty: Bool { dirtyKey.map { UserDefaults.standard.bool(forKey: $0) } ?? false } + + private func setDirty(_ dirty: Bool) { + guard let dirtyKey else { return } + UserDefaults.standard.set(dirty, forKey: dirtyKey) + } + + private static func load(from fileURL: URL?) -> [DiscountCard] { + guard let fileURL, let data = try? Data(contentsOf: fileURL) else { return [] } + return (try? JSONDecoder().decode([DiscountCard].self, from: data)) ?? [] + } + + private func save() { + guard let fileURL, + let data = try? JSONEncoder().encode(cards) else { return } + try? FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication] + ) + try? data.write(to: fileURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } + + private func saveToCloud(key: String, using model: AppModel) async throws { + let payload = try Self.encrypt(cards: cards, key: key) + try await model.saveCloudWallet(payload) + setDirty(false) + if let legacyURL = Self.legacyFileURL { try? FileManager.default.removeItem(at: legacyURL) } + } + + private struct CloudCard: Codable { + let id: String + let name: String + let payload: String + let format: String + let createdAt: Int64 + + enum CodingKeys: String, CodingKey { + case id, name, payload, format + case createdAt = "created_at" + } + } + + private static func encrypt(cards: [DiscountCard], key: String) throws -> String { + guard let keyData = Data(base64Encoded: key), keyData.count == 32 else { throw APIError.invalidResponse } + let cloudCards = cards.map { + CloudCard( + id: $0.id.uuidString, + name: $0.name, + payload: $0.payload, + format: canonicalFormat($0.symbology), + createdAt: Int64($0.createdAt.timeIntervalSince1970 * 1000) + ) + } + let plain = try JSONEncoder().encode(cloudCards) + let sealed = try AES.GCM.seal(plain, using: SymmetricKey(data: keyData)) + guard let combined = sealed.combined else { throw APIError.invalidResponse } + return combined.base64EncodedString() + } + + private static func decrypt(payload: String, key: String) throws -> [DiscountCard] { + guard let keyData = Data(base64Encoded: key), keyData.count == 32, + let encrypted = Data(base64Encoded: payload) else { throw APIError.invalidResponse } + let plain = try AES.GCM.open( + AES.GCM.SealedBox(combined: encrypted), + using: SymmetricKey(data: keyData) + ) + return try JSONDecoder().decode([CloudCard].self, from: plain).compactMap { item in + guard let id = UUID(uuidString: item.id), !item.payload.isEmpty else { return nil } + return DiscountCard( + id: id, + name: item.name, + payload: item.payload, + symbology: nativeFormat(item.format), + createdAt: Date(timeIntervalSince1970: Double(item.createdAt) / 1000) + ) + } + } + + private static func canonicalFormat(_ format: String) -> String { + let type = AVMetadataObject.ObjectType(rawValue: format) + switch type { + case .ean13: return "EAN_13" + case .ean8: return "EAN_8" + case .code128: return "CODE_128" + case .code39: return "CODE_39" + case .code93: return "CODE_93" + case .upce: return "UPC_E" + case .qr: return "QR_CODE" + case .aztec: return "AZTEC" + case .pdf417: return "PDF_417" + case .dataMatrix: return "DATA_MATRIX" + case .itf14: return "ITF" + default: return format + } + } + + private static func nativeFormat(_ format: String) -> String { + switch format { + case "EAN_13": return AVMetadataObject.ObjectType.ean13.rawValue + case "EAN_8": return AVMetadataObject.ObjectType.ean8.rawValue + case "CODE_128": return AVMetadataObject.ObjectType.code128.rawValue + case "CODE_39": return AVMetadataObject.ObjectType.code39.rawValue + case "CODE_93": return AVMetadataObject.ObjectType.code93.rawValue + case "UPC_E": return AVMetadataObject.ObjectType.upce.rawValue + case "QR_CODE": return AVMetadataObject.ObjectType.qr.rawValue + case "AZTEC": return AVMetadataObject.ObjectType.aztec.rawValue + case "PDF_417": return AVMetadataObject.ObjectType.pdf417.rawValue + case "DATA_MATRIX": return AVMetadataObject.ObjectType.dataMatrix.rawValue + case "ITF": return AVMetadataObject.ObjectType.itf14.rawValue + default: return format + } + } +} + +struct ScannedCard: Identifiable { + let id = UUID() + let value: String + let symbology: String +} + +struct CardScannerScreen: View { + @EnvironmentObject private var model: AppModel + let onScan: (ScannedCard) -> Void + let onCancel: () -> Void + let onError: (String) -> Void + + var body: some View { + ZStack { + CardScannerCamera(onScan: onScan, onError: onError) + VStack { + HStack { + Button(action: onCancel) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 34)) + .foregroundStyle(.white) + .shadow(radius: 4) + } + Spacer() + } + .padding(20) + Spacer() + Text(model.text("Umieść cały kod w ramce", "Place the whole code inside the frame")) + .font(.headline) + .foregroundStyle(.white) + .padding(.horizontal, 18) + .padding(.vertical, 12) + .background(.black.opacity(0.65), in: Capsule()) + Button(action: onCancel) { + Text(model.text("Anuluj", "Cancel")) + .font(.headline) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(KTheme.orange, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .contentShape(Rectangle()) + .padding(.horizontal, 44) + .padding(.top, 14) + .padding(.bottom, 32) + } + RoundedRectangle(cornerRadius: 24) + .stroke(KTheme.orange, lineWidth: 4) + .frame(width: 310, height: 230) + .shadow(color: .black.opacity(0.5), radius: 8) + .allowsHitTesting(false) + } + .background(.black) + } +} + +private struct CardScannerCamera: UIViewControllerRepresentable { + let onScan: (ScannedCard) -> Void + let onError: (String) -> Void + + func makeUIViewController(context: Context) -> ScannerViewController { + let controller = ScannerViewController() + controller.onScan = onScan + controller.onError = onError + return controller + } + + func updateUIViewController(_ uiViewController: ScannerViewController, context: Context) {} +} + +private final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + var onScan: ((ScannedCard) -> Void)? + var onError: ((String) -> Void)? + private let session = AVCaptureSession() + private let sessionQueue = DispatchQueue(label: "uk.co.kasztelan.card-scanner") + private var previewLayer: AVCaptureVideoPreviewLayer? + private var completed = false + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + prepareCamera() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.bounds + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + sessionQueue.async { [session] in if session.isRunning { session.stopRunning() } } + } + + private func prepareCamera() { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + configureSession() + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + DispatchQueue.main.async { + if granted { self?.configureSession() } + else { self?.onError?("Aplikacja potrzebuje dostępu do aparatu, aby skanować karty.") } + } + } + default: + onError?("Włącz dostęp do aparatu dla Kasztelan w Ustawieniach iPhone’a.") + } + } + + private func configureSession() { + guard let camera = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: camera), + session.canAddInput(input) else { + onError?("Nie znaleziono dostępnego aparatu.") + return + } + session.addInput(input) + let output = AVCaptureMetadataOutput() + guard session.canAddOutput(output) else { + onError?("Nie można uruchomić skanera kodów.") + return + } + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: .main) + let supported: [AVMetadataObject.ObjectType] = [ + .code128, .ean13, .ean8, .qr, .aztec, .pdf417 + ] + output.metadataObjectTypes = supported.filter { output.availableMetadataObjectTypes.contains($0) } + + let preview = AVCaptureVideoPreviewLayer(session: session) + preview.videoGravity = .resizeAspectFill + view.layer.insertSublayer(preview, at: 0) + previewLayer = preview + preview.frame = view.bounds + sessionQueue.async { [session] in session.startRunning() } + } + + func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + guard !completed, + let code = metadataObjects.compactMap({ $0 as? AVMetadataMachineReadableCodeObject }).first, + let value = code.stringValue, + !value.isEmpty else { return } + completed = true + UINotificationFeedbackGenerator().notificationOccurred(.success) + sessionQueue.async { [session] in if session.isRunning { session.stopRunning() } } + onScan?(ScannedCard(value: value, symbology: code.type.rawValue)) + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..8489f42 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# Kasztelan iOS + +Natywna aplikacja SwiftUI na iPhone'a, odtworzona na podstawie wersji Android 0.9.5 i Kasztelan App API 1.8.0. + +## Pierwsze uruchomienie + +1. Użyj Xcode 16.4 lub nowszego. Projekt został sprawdzony kompilatorem Xcode 16.4 (build 16F6). +2. Skopiuj `Config/Secrets.xcconfig.example` jako `Config/Secrets.xcconfig`. +3. W `Secrets.xcconfig` wpisz klucz z **WordPress → Ustawienia → Kasztelan App API**. +4. Otwórz `Kasztelan.xcodeproj` w Xcode. +5. W target **Kasztelan → Signing & Capabilities** wybierz swoje Apple ID / Personal Team. +6. Podłącz iPhone'a albo wybierz symulator i naciśnij Run. + +Klucz aplikacji nie jest przechowywany w repozytorium. Token logowania trafia do iOS Keychain. + +## Funkcje + +- logowanie i rejestracja po polsku i angielsku, +- karta klienta i natywnie renderowany EAN-13, +- punkty, zakupy, upust i oszczędności, +- przypisanie karty tylko raz, +- wiadomości przechowywane lokalnie i powiadomienia podczas działania aplikacji, +- edycja profilu, zmiana hasła i usunięcie konta, +- automatyczne odświeżanie co 15 sekund przy otwartej aplikacji, +- bezpieczny zapis sesji w Keychain i wyłącznie HTTPS. + +Produkcjne powiadomienia push oraz Apple Wallet wymagają osobnej konfiguracji Apple Developer i serwera; nie są aktywne w tej wersji. diff --git a/ci_scripts/ci_post_clone.sh b/ci_scripts/ci_post_clone.sh new file mode 100755 index 0000000..67e0351 --- /dev/null +++ b/ci_scripts/ci_post_clone.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +set -eu + +PROJECT_ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +SECRETS_FILE="$PROJECT_ROOT/Config/Secrets.xcconfig" + +if [ -z "${KASZTELAN_APP_KEY:-}" ]; then + echo "error: Add KASZTELAN_APP_KEY as a Secret environment variable in the Xcode Cloud workflow." + exit 1 +fi + +umask 077 +printf 'KASZTELAN_APP_KEY = %s\n' "$KASZTELAN_APP_KEY" > "$SECRETS_FILE" +