Prepare iOS app for Xcode Cloud build
This commit is contained in:
commit
2f7bf57e6b
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
Config/Secrets.xcconfig
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.DS_Store
|
||||
|
||||
4
Config/AppConfig.xcconfig
Normal file
4
Config/AppConfig.xcconfig
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Wartość domyślnie jest pusta. Utwórz Secrets.xcconfig na podstawie przykładu.
|
||||
KASZTELAN_APP_KEY =
|
||||
#include? "Secrets.xcconfig"
|
||||
|
||||
3
Config/Secrets.xcconfig.example
Normal file
3
Config/Secrets.xcconfig.example
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Skopiuj ten plik jako Secrets.xcconfig i wklej klucz z ustawień wtyczki WordPress.
|
||||
KASZTELAN_APP_KEY = WPISZ_TUTAJ_KLUCZ
|
||||
|
||||
361
Kasztelan.xcodeproj/project.pbxproj
Normal file
361
Kasztelan.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
|
|
@ -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
151
Kasztelan/APIClient.swift
Normal 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
438
Kasztelan/AppModel.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
18
Kasztelan/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
18
Kasztelan/Assets.xcassets/AccentColor.colorset/Contents.json
Normal 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 }
|
||||
}
|
||||
|
||||
BIN
Kasztelan/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
BIN
Kasztelan/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1020 KiB |
12
Kasztelan/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
12
Kasztelan/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
|
||||
7
Kasztelan/Assets.xcassets/Contents.json
Normal file
7
Kasztelan/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
||||
10
Kasztelan/Assets.xcassets/Logo.imageset/Contents.json
vendored
Normal file
10
Kasztelan/Assets.xcassets/Logo.imageset/Contents.json
vendored
Normal 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 }
|
||||
}
|
||||
|
||||
BIN
Kasztelan/Assets.xcassets/Logo.imageset/logo_kasztelan_white.png
vendored
Normal file
BIN
Kasztelan/Assets.xcassets/Logo.imageset/logo_kasztelan_white.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
129
Kasztelan/AuthView.swift
Normal file
129
Kasztelan/AuthView.swift
Normal 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
225
Kasztelan/CardView.swift
Normal 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 }
|
||||
}
|
||||
47
Kasztelan/EAN13BarcodeView 2.swift
Normal file
47
Kasztelan/EAN13BarcodeView 2.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
226
Kasztelan/EAN13BarcodeView.swift
Normal file
226
Kasztelan/EAN13BarcodeView.swift
Normal 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
57
Kasztelan/Info.plist
Normal 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>
|
||||
227
Kasztelan/KasztelanApp.swift
Normal file
227
Kasztelan/KasztelanApp.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
45
Kasztelan/KeychainStore.swift
Normal file
45
Kasztelan/KeychainStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
36
Kasztelan/MainTabView.swift
Normal file
36
Kasztelan/MainTabView.swift
Normal 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() }
|
||||
}
|
||||
}
|
||||
63
Kasztelan/MessageStore.swift
Normal file
63
Kasztelan/MessageStore.swift
Normal 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
|
||||
}
|
||||
}
|
||||
79
Kasztelan/MessagesView.swift
Normal file
79
Kasztelan/MessagesView.swift
Normal 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
117
Kasztelan/Models.swift
Normal 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 = ""
|
||||
}
|
||||
22
Kasztelan/NotificationService.swift
Normal file
22
Kasztelan/NotificationService.swift
Normal 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
183
Kasztelan/ProfileView.swift
Normal 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
70
Kasztelan/Theme.swift
Normal 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
689
Kasztelan/WalletView.swift
Normal 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
27
README.md
Normal 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
15
ci_scripts/ci_post_clone.sh
Executable 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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user