Prepare iOS app for Xcode Cloud build

This commit is contained in:
Rafal Kolarz 2026-10-16 23:37:49 +01:00
commit 2f7bf57e6b
30 changed files with 3341 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
Config/Secrets.xcconfig
*.xcuserstate
xcuserdata/
DerivedData/
.DS_Store

View File

@ -0,0 +1,4 @@
// Wartość domyślnie jest pusta. Utwórz Secrets.xcconfig na podstawie przykładu.
KASZTELAN_APP_KEY =
#include? "Secrets.xcconfig"

View File

@ -0,0 +1,3 @@
// Skopiuj ten plik jako Secrets.xcconfig i wklej klucz z ustawień wtyczki WordPress.
KASZTELAN_APP_KEY = WPISZ_TUTAJ_KLUCZ

View File

@ -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 = "<group>"; };
B10000000000000000000002 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
B10000000000000000000003 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
B10000000000000000000004 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
B10000000000000000000005 /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = "<group>"; };
B10000000000000000000006 /* MessageStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageStore.swift; sourceTree = "<group>"; };
B10000000000000000000007 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
B10000000000000000000008 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
B10000000000000000000009 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
B1000000000000000000000A /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
B1000000000000000000000B /* CardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardView.swift; sourceTree = "<group>"; };
B1000000000000000000000C /* EAN13BarcodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EAN13BarcodeView.swift; sourceTree = "<group>"; };
B1000000000000000000000D /* MessagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesView.swift; sourceTree = "<group>"; };
B1000000000000000000000E /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
B1000000000000000000000F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
B10000000000000000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
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 = "<group>"; };
B10000000000000000000013 /* Secrets.xcconfig.example */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig.example; sourceTree = "<group>"; };
B10000000000000000000014 /* WalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletView.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
};
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 = "<group>";
};
D10000000000000000000003 /* Config */ = {
isa = PBXGroup;
children = (
B10000000000000000000012 /* AppConfig.xcconfig */,
B10000000000000000000013 /* Secrets.xcconfig.example */,
);
path = Config;
sourceTree = "<group>";
};
D10000000000000000000004 /* Products */ = {
isa = PBXGroup;
children = (
B10000000000000000000011 /* Kasztelan.app */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}

View File

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1640"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E10000000000000000000001"
BuildableName = "Kasztelan.app"
BlueprintName = "Kasztelan"
ReferencedContainer = "container:Kasztelan.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E10000000000000000000001"
BuildableName = "Kasztelan.app"
BlueprintName = "Kasztelan"
ReferencedContainer = "container:Kasztelan.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E10000000000000000000001"
BuildableName = "Kasztelan.app"
BlueprintName = "Kasztelan"
ReferencedContainer = "container:Kasztelan.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

151
Kasztelan/APIClient.swift Normal file
View File

@ -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
}
}

438
Kasztelan/AppModel.swift Normal file
View File

@ -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<Void, Never>?
private var bannerTask: Task<Void, Never>?
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)
}
}

View File

@ -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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 KiB

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : { "author" : "xcode", "version" : 1 }
}

View File

@ -0,0 +1,7 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

129
Kasztelan/AuthView.swift Normal file
View File

@ -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<String> {
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"))
}
}

225
Kasztelan/CardView.swift Normal file
View File

@ -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 }
}

View File

@ -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)
}
}

View File

@ -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
}
}

57
Kasztelan/Info.plist Normal file
View File

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>Kasztelan</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>KASZTELANAppKey</key>
<string>$(KASZTELAN_APP_KEY)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Aparat służy do skanowania kodów z kart rabatowych.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>uk.co.kasztelan.app.ios.refresh</string>
</array>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string></string>
</dict>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UIRequiresFullScreen</key>
<true/>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
</dict>
</plist>

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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() }
}
}

View File

@ -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
}
}

View File

@ -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..<value.endIndex, in: value)
detector.enumerateMatches(in: value, options: [], range: fullRange) { match, _, _ in
guard let match, let url = match.url,
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https" else { return }
result.addAttribute(.link, value: url, range: match.range)
}
return AttributedString(result)
}
}

117
Kasztelan/Models.swift Normal file
View File

@ -0,0 +1,117 @@
import Foundation
enum AppLanguage: String, CaseIterable {
case pl, en
static var initial: AppLanguage {
if let saved = UserDefaults.standard.string(forKey: "language"), let language = AppLanguage(rawValue: saved) {
return language
}
return Locale.current.language.languageCode?.identifier == "pl" ? .pl : .en
}
}
struct UserProfile: Codable, Equatable {
var id: Int?
var username: String?
var email: String?
var firstName: String?
var lastName: String?
var displayName: String?
var mobile: String?
var postcode: String?
var dob: String?
var cardNumber: String?
var cardAssigned: Bool?
enum CodingKeys: String, CodingKey {
case id, username, email, mobile, dob
case firstName = "first_name"
case lastName = "last_name"
case displayName = "display_name"
case postcode = "kod_pocztowy"
case cardNumber = "numer_karty"
case cardAssigned = "card_assigned"
}
}
struct Loyalty: Codable, Equatable {
var points: FlexibleText?
var transactionsValue: FlexibleText?
var discountValue: FlexibleText?
var cardNumber: FlexibleText?
var savings: FlexibleText?
var firstName: FlexibleText?
enum CodingKeys: String, CodingKey {
case points, savings
case transactionsValue = "transactions_value"
case discountValue = "discount_value"
case cardNumber = "card_number"
case firstName = "first_name"
}
}
struct FlexibleText: Codable, Equatable {
let value: String
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let string = try? container.decode(String.self) { value = string }
else if let integer = try? container.decode(Int.self) { value = String(integer) }
else if let decimal = try? container.decode(Double.self) { value = String(decimal) }
else { value = "" }
}
}
struct KasztelanMessage: Codable, Identifiable, Equatable {
let id: Int
let title: String
let text: String
let sentAt: Int
enum CodingKeys: String, CodingKey {
case id, title, text
case sentAt = "sent_at"
}
}
struct APIEnvelope: Codable {
var success: Bool? = nil
var token: String? = nil
var user: UserProfile? = nil
var loyalty: Loyalty? = nil
var notifications: [KasztelanMessage]? = nil
var notification: KasztelanMessage? = nil
var key: String? = nil
var payload: String? = nil
var updatedAt: Int? = nil
var code: String? = nil
var message: String? = nil
enum CodingKeys: String, CodingKey {
case success, token, user, loyalty, notifications, notification, key, payload, code, message
case updatedAt = "updated_at"
}
}
struct RegistrationForm {
var firstName = ""
var lastName = ""
var email = ""
var password = ""
var mobile = ""
var postcode = ""
var dob = ""
var cardNumber = ""
var acceptsTerms = false
}
struct ProfileForm {
var firstName = ""
var lastName = ""
var email = ""
var mobile = ""
var postcode = ""
var dob = ""
}

View File

@ -0,0 +1,22 @@
import Foundation
import UserNotifications
enum NotificationService {
static func requestPermission() async {
_ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge])
}
static func show(_ message: KasztelanMessage) async {
let settings = await UNUserNotificationCenter.current().notificationSettings()
guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else { return }
let content = UNMutableNotificationContent()
content.title = "KASZTELAN"
content.subtitle = message.title
content.sound = .default
content.threadIdentifier = "kasztelan-messages"
content.categoryIdentifier = "KASZTELAN_MESSAGE"
content.userInfo = ["message_id": message.id]
let request = UNNotificationRequest(identifier: "kasztelan-\(message.id)", content: content, trigger: nil)
try? await UNUserNotificationCenter.current().add(request)
}
}

183
Kasztelan/ProfileView.swift Normal file
View File

@ -0,0 +1,183 @@
import SwiftUI
struct ProfileView: View {
@EnvironmentObject private var model: AppModel
@State private var editingProfile = false
@State private var changingPassword = false
@State private var deletingAccount = false
@State private var deletePassword = ""
@State private var cardNumber = ""
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text(model.text("Twój profil", "Your profile")).font(.largeTitle.bold())
Spacer()
Button { editingProfile = true } label: {
Image(systemName: "pencil.circle.fill").font(.title).foregroundStyle(KTheme.orange)
}
.accessibilityLabel(model.text("Edytuj profil", "Edit profile"))
}
VStack(alignment: .leading, spacing: 16) {
ProfileValue(label: model.text("Imię i nazwisko", "Name"), value: model.user?.displayName ?? name)
ProfileValue(label: "E-mail", value: model.user?.email ?? "—")
ProfileValue(label: model.text("Telefon", "Phone"), value: model.user?.mobile ?? "—")
ProfileValue(label: model.text("Kod pocztowy", "Postcode"), value: model.user?.postcode ?? "—")
ProfileValue(label: model.text("Data urodzenia", "Date of birth"), value: model.profileForm().dob.nonEmpty ?? "—")
ProfileValue(label: model.text("Numer karty", "Card number"), value: model.cardNumber.nonEmpty ?? "—")
}
.frame(maxWidth: .infinity, alignment: .leading)
.kasztelanPanel()
if model.cardNumber.isEmpty { cardAssignment }
Button(model.text("Zmień hasło", "Change password")) { changingPassword = true }
.frame(maxWidth: .infinity, minHeight: 52)
.background(KTheme.panel)
.clipShape(RoundedRectangle(cornerRadius: 16))
Button(model.text("Wyloguj", "Sign out")) { Task { await model.logout() } }
.buttonStyle(PrimaryButtonStyle())
Button(model.text("Usuń konto", "Delete account"), role: .destructive) { deletingAccount = true }
.frame(maxWidth: .infinity, minHeight: 48)
VStack(spacing: 12) {
Link(model.text("Polityka prywatności", "Privacy policy"), destination: URL(string: "https://kasztelan.co.uk/privacy-policy/")!)
Link(model.text("Informacje o usuwaniu konta", "Account deletion information"), destination: URL(string: "https://kasztelan.co.uk/delete-account/")!)
Link(model.text("Regulamin", "Terms"), destination: URL(string: "https://kasztelan.co.uk/regulamin/")!)
}
.font(.subheadline.bold())
.foregroundStyle(KTheme.orange)
.frame(maxWidth: .infinity)
.padding(.top, 4)
}
.padding(20)
}
.background(KTheme.background)
.toolbar { AppToolbar() }
.fullScreenCover(isPresented: $editingProfile) { EditProfileView() }
.sheet(isPresented: $changingPassword) { ChangePasswordView() }
.sheet(isPresented: $deletingAccount) {
NavigationStack {
VStack(spacing: 16) {
Text(model.text("Usunięcie konta jest nieodwracalne. Potwierdź swoim hasłem.", "Deleting your account cannot be undone. Confirm with your password."))
KasztelanField(title: model.text("Hasło", "Password"), text: $deletePassword, secure: true, contentType: .password)
Button(model.text("Usuń konto", "Delete account"), role: .destructive) {
Task { if await model.deleteAccount(password: deletePassword) { deletingAccount = false } }
}
.buttonStyle(PrimaryButtonStyle())
Spacer()
}
.padding(20)
.background(KTheme.background)
.navigationTitle(model.text("Usuń konto", "Delete account"))
.toolbar { ToolbarItem(placement: .cancellationAction) { Button(model.text("Anuluj", "Cancel")) { deletingAccount = false } } }
}
.presentationDetents([.medium])
}
}
private var name: String {
let value = "\(model.user?.firstName ?? "") \(model.user?.lastName ?? "")".trimmingCharacters(in: .whitespaces)
return value.nonEmpty ?? "—"
}
private var cardAssignment: some View {
VStack(alignment: .leading, spacing: 10) {
Text(model.text("Przypisz kartę", "Assign a card")).font(.headline)
KasztelanField(title: model.text("13-cyfrowy numer karty", "13-digit card number"), text: $cardNumber, keyboard: .numberPad)
Text(model.text("Po przypisaniu numeru nie można go zmienić.", "The number cannot be changed after assignment."))
.font(.caption).foregroundStyle(.secondary)
Button(model.text("Przypisz", "Assign")) {
Task { if await model.assignCard(cardNumber) { cardNumber = "" } }
}
.buttonStyle(PrimaryButtonStyle())
Button(model.text("Nie mam karty — wygeneruj cyfrową", "I don't have a card — create a digital one")) {
Task { _ = await model.generateCard() }
}
.frame(maxWidth: .infinity, minHeight: 50)
.background(KTheme.panel)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
.kasztelanPanel()
}
}
private struct ProfileValue: View {
let label: String
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 3) {
Text(label).font(.caption).foregroundStyle(.secondary)
Text(value.nonEmpty ?? "—").font(.title3.bold())
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct EditProfileView: View {
@EnvironmentObject private var model: AppModel
@Environment(\.dismiss) private var dismiss
@State private var form = ProfileForm()
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 12) {
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("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)
Button(model.text("Zapisz", "Save")) {
Task { if await model.saveProfile(form) { dismiss() } }
}
.buttonStyle(PrimaryButtonStyle())
}
.frame(maxWidth: .infinity)
.padding(20)
}
.background(KTheme.background)
.navigationTitle(model.text("Edytuj profil", "Edit profile"))
.toolbar { ToolbarItem(placement: .cancellationAction) { Button(model.text("Anuluj", "Cancel")) { dismiss() } } }
.onAppear { form = model.profileForm() }
}
}
private var dateBinding: Binding<String> {
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])
}
}

70
Kasztelan/Theme.swift Normal file
View File

@ -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)
}
}
}
}

689
Kasztelan/WalletView.swift Normal file
View File

@ -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<Bool> {
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))
}
}

27
README.md Normal file
View File

@ -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.

15
ci_scripts/ci_post_clone.sh Executable file
View File

@ -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"