taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

commit 3e7378e4523b6f5550790828915bc971d9bb36da
parent a2f7d3d763be000f46b1467ade8c6151af906982
Author: Bohdan Potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date:   Wed,  1 Jul 2026 23:29:53 +0200

[pos] couple of updates on all aspects v0.1.1-5

Diffstat:
MTalerPOS.xcodeproj/project.pbxproj | 32+++++++++++++++++++-------------
MTalerPOS/App/ContentView.swift | 30++++++++++++++++++++++++++++++
MTalerPOS/App/TalerPOSApp.swift | 1+
MTalerPOS/Config/ConfigManager.swift | 4+++-
MTalerPOS/Config/LanguageManager.swift | 63++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
MTalerPOS/Networking/MerchantApi.swift | 6+-----
MTalerPOS/Order/OrderManager.swift | 18+++++++++++++++++-
MTalerPOS/Resources/Assets.xcassets/AppIcon.appiconset/TalerPOS.png | 0
MTalerPOS/Resources/Assets.xcassets/talerpos-logo.imageset/Contents.json | 10++++++++++
ATalerPOS/Resources/Assets.xcassets/talerpos-logo.imageset/talerpos-logo-dark.svg | 33+++++++++++++++++++++++++++++++++
MTalerPOS/Resources/Info.plist | 4++--
MTalerPOS/Resources/Localizable.xcstrings | 297++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MTalerPOS/Resources/Strings.swift | 8+++++++-
ATalerPOS/TalerPOSRelease.entitlements | 8++++++++
MTalerPOS/Views/AmountEntryView.swift | 175++++++++++++++++++++++++++++++++++++++++---------------------------------------
MTalerPOS/Views/ConfigView.swift | 186++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
MTalerPOS/Views/HistoryView.swift | 26+++++++++++++++++++++++++-
MTalerPOS/Views/OrderView.swift | 153++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
MTalerPOS/Views/RefundView.swift | 157+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
MTalerPOS/Views/SettingsView.swift | 44++++++++++++++++++++++++++++++++++++++++++--
ATalerPOS/Views/ToastView.swift | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
21 files changed, 1017 insertions(+), 292 deletions(-)

diff --git a/TalerPOS.xcodeproj/project.pbxproj b/TalerPOS.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ B1000014 /* LanguageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000014 /* LanguageManager.swift */; }; B1000015 /* PosQRView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000015 /* PosQRView.swift */; }; B1000016 /* QRScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000016 /* QRScannerView.swift */; }; + B1000017 /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000017 /* ToastView.swift */; }; C1000001 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = C2000001 /* Localizable.xcstrings */; }; C1000003 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000003 /* Strings.swift */; }; D1000001 /* ConfigProductTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2000001 /* ConfigProductTests.swift */; }; @@ -58,6 +59,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 605930B92FEE7E6500487A2B /* TalerPOSRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TalerPOSRelease.entitlements; sourceTree = "<group>"; }; A2000001 /* TalerPOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TalerPOSApp.swift; sourceTree = "<group>"; }; A2000002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; A2000003 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; @@ -85,6 +87,7 @@ B2000014 /* LanguageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageManager.swift; sourceTree = "<group>"; }; B2000015 /* PosQRView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosQRView.swift; sourceTree = "<group>"; }; B2000016 /* QRScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScannerView.swift; sourceTree = "<group>"; }; + B2000017 /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = "<group>"; }; C2000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; }; C2000003 /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = "<group>"; }; D2000001 /* ConfigProductTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigProductTests.swift; sourceTree = "<group>"; }; @@ -136,6 +139,7 @@ A4000002 /* TalerPOS */ = { isa = PBXGroup; children = ( + 605930B92FEE7E6500487A2B /* TalerPOSRelease.entitlements */, A4000003 /* App */, A4000004 /* Config */, A4000005 /* Order */, @@ -233,6 +237,7 @@ B2000012 /* SettingsView.swift */, B2000015 /* PosQRView.swift */, B2000016 /* QRScannerView.swift */, + B2000017 /* ToastView.swift */, ); path = Views; sourceTree = "<group>"; @@ -449,6 +454,7 @@ B1000012 /* SettingsView.swift in Sources */, B1000015 /* PosQRView.swift in Sources */, B1000016 /* QRScannerView.swift in Sources */, + B1000017 /* ToastView.swift in Sources */, B1000013 /* ScreenshotController.swift in Sources */, C1000003 /* Strings.swift in Sources */, ); @@ -522,6 +528,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = GUDDQ9428Y; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -585,6 +592,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = GUDDQ9428Y; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -615,7 +623,7 @@ CODE_SIGN_ENTITLEMENTS = TalerPOS/Resources/TalerPOS.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_ASSET_PATHS = "\"TalerPOS/Resources/Preview Content\""; DEVELOPMENT_TEAM = GUDDQ9428Y; ENABLE_PREVIEWS = YES; @@ -623,16 +631,17 @@ INFOPLIST_FILE = TalerPOS/Resources/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Taler POS"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.business"; + INFOPLIST_KEY_NSCameraUsageDescription = "Camera access is required to scan QR codes for merchant backend configuration."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.6.0; + MARKETING_VERSION = 0.1.1; PRODUCT_BUNDLE_IDENTIFIER = "com.taler-systems.talersale-2"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -651,10 +660,10 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; - CODE_SIGN_ENTITLEMENTS = TalerPOS/Resources/TalerPOS.entitlements; + CODE_SIGN_ENTITLEMENTS = TalerPOS/TalerPOSRelease.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_ASSET_PATHS = "\"TalerPOS/Resources/Preview Content\""; DEVELOPMENT_TEAM = GUDDQ9428Y; ENABLE_PREVIEWS = YES; @@ -662,16 +671,17 @@ INFOPLIST_FILE = TalerPOS/Resources/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Taler POS"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.business"; + INFOPLIST_KEY_NSCameraUsageDescription = "Camera access is required to scan QR codes for merchant backend configuration."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.6.0; + MARKETING_VERSION = 0.1.1; PRODUCT_BUNDLE_IDENTIFIER = "com.taler-systems.talersale-2"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -690,7 +700,6 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = GUDDQ9428Y; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; MARKETING_VERSION = 1.0; @@ -711,7 +720,6 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = GUDDQ9428Y; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; MARKETING_VERSION = 1.0; @@ -731,7 +739,6 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = GUDDQ9428Y; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; MARKETING_VERSION = 1.0; @@ -751,7 +758,6 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = GUDDQ9428Y; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; MARKETING_VERSION = 1.0; diff --git a/TalerPOS/App/ContentView.swift b/TalerPOS/App/ContentView.swift @@ -83,6 +83,26 @@ enum PosDestination: Hashable { default: return false } } + + var savedKey: String { + switch self { + case .amountEntry: return "amountEntry" + case .order: return "order" + case .history: return "history" + case .settings: return "settings" + default: return "" + } + } + + static func from(savedKey: String) -> PosDestination? { + switch savedKey { + case "amountEntry": return .amountEntry + case "order": return .order + case "history": return .history + case "settings": return .settings + default: return nil + } + } } struct NavigationDrawer: View { @@ -159,6 +179,7 @@ struct PosTopBar<Actions: View>: View { Image(systemName: "line.3.horizontal") .font(.title2) .frame(width: 44, height: 44) + .contentShape(Rectangle()) .foregroundColor(.posOnSurface) } .buttonStyle(.plain) @@ -167,6 +188,7 @@ struct PosTopBar<Actions: View>: View { .font(.title3) .fontWeight(.medium) .lineLimit(1) + .foregroundColor(.posOnSurface) Spacer(minLength: 0) @@ -198,6 +220,7 @@ struct ContentView: View { @State private var navigationPath = NavigationPath() @State private var currentRoot: PosDestination = .amountEntry @State var drawerOpen = false + @AppStorage("pos_current_root") private var savedRoot: String = "" private var mainDestinations: [PosDestination] { let preferred: PosDestination = configManager.initialOrderScreen.destination @@ -251,10 +274,15 @@ struct ContentView: View { navigationPath.append(dest) } } + } else if let restored = PosDestination.from(savedKey: savedRoot), restored.isMainDestination { + currentRoot = restored } else { currentRoot = configManager.initialOrderScreen.destination } } + .onChange(of: currentRoot) { newRoot in + savedRoot = newRoot.savedKey + } .alert(S.sessionExpiredToast, isPresented: $configManager.sessionExpired) { Button(S.configOk) { configManager.sessionExpired = false @@ -278,6 +306,7 @@ struct ContentView: View { if let order = paymentManager.payment?.order { orderManager.onOrderPaid(order.id) } + historyManager.fetchHistory() navigationPath.removeLast(navigationPath.count) navigateTo(.paymentSuccess) } @@ -325,6 +354,7 @@ struct ContentView: View { ZStack(alignment: .leading) { NavigationStack(path: $navigationPath) { rootView(for: currentRoot) + .id(currentRoot) .navigationDestination(for: PosDestination.self) { destination in destinationView(for: destination) } diff --git a/TalerPOS/App/TalerPOSApp.swift b/TalerPOS/App/TalerPOSApp.swift @@ -44,6 +44,7 @@ struct TalerPOSApp: App { merchantApi.onUnauthorized = { [weak config] in Task { @MainActor in + guard config?.merchantConfig != nil else { return } config?.forgetPassword() config?.sessionExpired = true } diff --git a/TalerPOS/Config/ConfigManager.swift b/TalerPOS/Config/ConfigManager.swift @@ -86,6 +86,7 @@ class ConfigManager: ObservableObject { @Published var merchantConfig: MerchantConfig? @Published var currency: String? @Published var currencySpec: CurrencySpecification? + @Published var backendVersion: String? @Published var configResult: ConfigUpdateResult? @Published var isLoading = false @Published var sessionExpired = false @@ -139,7 +140,7 @@ class ConfigManager: ObservableObject { } func reloadConfig() { - fetchConfigInternal(url: merchantUrl, token: accessToken, save: true, inventoryOnly: false, silent: false) + fetchConfigInternal(url: merchantUrl, token: accessToken, save: true, inventoryOnly: true, silent: false) } func refreshConfigInBackground() { @@ -210,6 +211,7 @@ class ConfigManager: ObservableObject { self.merchantConfig = mc self.currency = backendConfig.currency self.currencySpec = spec + self.backendVersion = backendConfig.version self.saveCachedRuntimeConfig(CachedRuntimeConfig( posConfig: posConfigWithCachedStock, merchantConfig: mc, diff --git a/TalerPOS/Config/LanguageManager.swift b/TalerPOS/Config/LanguageManager.swift @@ -16,6 +16,9 @@ import Foundation import Combine +import os + +private let logger = Logger(subsystem: "net.taler.pos", category: "LanguageManager") /// Manages in-app language override. Changes apply immediately /// without requiring an app restart. @@ -31,37 +34,75 @@ final class LanguageManager: ObservableObject { let saved = UserDefaults.standard.string(forKey: Self.storageKey) ?? "" selectedLanguage = saved bundle = Self.bundle(for: saved) + logger.info("Init: saved=\(saved) appPreferred=\(Locale.preferredLanguages) systemPreferred=\(Self.systemPreferredLanguages) resolved=\(self.bundle.bundlePath)") } + @Published var languageChangedToast: String? + func setLanguage(_ tag: String) { + let previousTag = selectedLanguage selectedLanguage = tag if tag.isEmpty { UserDefaults.standard.removeObject(forKey: Self.storageKey) + UserDefaults.standard.removeObject(forKey: "AppleLanguages") } else { UserDefaults.standard.set(tag, forKey: Self.storageKey) + UserDefaults.standard.set([tag], forKey: "AppleLanguages") } bundle = Self.bundle(for: tag) + logger.info("setLanguage: tag=\(tag) AppleLanguages=\(UserDefaults.standard.object(forKey: "AppleLanguages") as? [String] ?? []) resolved=\(self.bundle.bundlePath)") + if tag != previousTag { + languageChangedToast = S.toastLanguageChanged + } } var locale: Locale { selectedLanguage.isEmpty ? .current : Locale(identifier: selectedLanguage) } - private static func bundle(for languageTag: String) -> Bundle { - guard !languageTag.isEmpty else { return .main } + var resolvedLanguageLabel: String? { + guard selectedLanguage.isEmpty else { return nil } + let resolved = Self.resolveSystemLanguage() + return Locale.current.localizedString(forLanguageCode: resolved) + } + + private static var systemPreferredLanguages: [String] { + let value = CFPreferencesCopyValue( + "AppleLanguages" as CFString, + kCFPreferencesAnyApplication, + kCFPreferencesCurrentUser, + kCFPreferencesAnyHost + ) + return value as? [String] ?? Locale.preferredLanguages + } - if let path = Bundle.main.path(forResource: languageTag, ofType: "lproj"), - let lprojBundle = Bundle(path: path) { - return lprojBundle + private static func resolveSystemLanguage() -> String { + let systemLangs = systemPreferredLanguages + let available = Set(Bundle.main.localizations) + for lang in systemLangs { + if available.contains(lang) { return lang } + let base = lang.components(separatedBy: "-").first ?? lang + if available.contains(base) { return base } } + return "en" + } + + private static func bundle(for languageTag: String) -> Bundle { + let tag = languageTag.isEmpty ? resolveSystemLanguage() : languageTag + return lprojBundle(for: tag) ?? .main + } - let base = languageTag.components(separatedBy: "-").first ?? languageTag - if base != languageTag, + private static func lprojBundle(for tag: String) -> Bundle? { + if let path = Bundle.main.path(forResource: tag, ofType: "lproj"), + let b = Bundle(path: path) { + return b + } + let base = tag.components(separatedBy: "-").first ?? tag + if base != tag, let path = Bundle.main.path(forResource: base, ofType: "lproj"), - let lprojBundle = Bundle(path: path) { - return lprojBundle + let b = Bundle(path: path) { + return b } - - return .main + return nil } } diff --git a/TalerPOS/Networking/MerchantApi.swift b/TalerPOS/Networking/MerchantApi.swift @@ -371,11 +371,7 @@ class MerchantApi { } let tokenResponse = try decoder.decode(LimitedTokenResponse.self, from: data) - let token = tokenResponse.token - if token.hasPrefix("secret-token:") { - return String(token.dropFirst("secret-token:".count)) - } - return token + return tokenResponse.token } /// POST /instances/{username}/challenge/{challengeId} diff --git a/TalerPOS/Order/OrderManager.swift b/TalerPOS/Order/OrderManager.swift @@ -81,9 +81,23 @@ class LiveOrder: ObservableObject, Identifiable { } func removeProduct(_ product: ConfigProduct) { + let removedIndex = order.products.firstIndex(where: { $0.id == product.id }) + lastAddedProduct = nil order = order.removing(product) restartState = order.products.isEmpty ? .disabled : .enabled - repairSelection() + if let idx = order.products.firstIndex(where: { $0.id == product.id }) { + selectedOrderLine = order.products[idx] + } else { + let products = order.products + if products.isEmpty { + selectedOrderLine = nil + } else if let ri = removedIndex { + let newIdx = min(ri, products.count - 1) + selectedOrderLine = products[newIdx] + } else { + selectedOrderLine = products.last + } + } onChanged() } @@ -106,7 +120,9 @@ class LiveOrder: ObservableObject, Identifiable { } func selectOrderLine(_ product: ConfigProduct?) { + lastAddedProduct = nil selectedOrderLine = product + onChanged() } func repairSelection() { diff --git a/TalerPOS/Resources/Assets.xcassets/AppIcon.appiconset/TalerPOS.png b/TalerPOS/Resources/Assets.xcassets/AppIcon.appiconset/TalerPOS.png Binary files differ. diff --git a/TalerPOS/Resources/Assets.xcassets/talerpos-logo.imageset/Contents.json b/TalerPOS/Resources/Assets.xcassets/talerpos-logo.imageset/Contents.json @@ -3,6 +3,16 @@ { "filename" : "talerpos-logo.svg", "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "talerpos-logo-dark.svg", + "idiom" : "universal" } ], "info" : { diff --git a/TalerPOS/Resources/Assets.xcassets/talerpos-logo.imageset/talerpos-logo-dark.svg b/TalerPOS/Resources/Assets.xcassets/talerpos-logo.imageset/talerpos-logo-dark.svg @@ -0,0 +1,33 @@ +<svg width="1706" height="521" viewBox="0 0 1706 521" fill="none" xmlns="http://www.w3.org/2000/svg"> +<!-- + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +--> +<g clip-path="url(#clip0_410_228)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M503.795 6.49023C594.153 6.49023 672.628 60.9124 712.226 140.803H678.508C641.882 78.8635 577.329 37.7322 503.795 37.7322C389.668 37.7322 297.15 136.792 297.15 258.987C297.15 318.796 319.324 373.055 355.34 412.873C347.56 419.361 339.276 425.191 330.559 430.278C291.715 385.27 267.971 325.103 267.971 258.987C267.971 119.537 373.553 6.49023 503.795 6.49023ZM711.594 378.454C671.796 457.646 593.67 511.484 503.795 511.484C497.694 511.484 491.646 511.235 485.661 510.748C503.352 501.711 519.937 490.601 535.149 477.709C594.97 467.958 646.352 430.74 677.742 378.454H711.594Z" fill="#0042B3"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M373.676 6.49023C379.777 6.49023 385.824 6.73853 391.81 7.22583C374.12 16.2632 357.534 27.373 342.321 40.2646C243.075 56.4407 167.031 148.204 167.031 258.987C167.031 341.563 209.288 413.562 271.904 451.577C262.667 453.124 253.2 453.933 243.557 453.933C236.372 453.933 229.29 453.472 222.323 452.605C170.692 406.287 137.852 336.749 137.852 258.987C137.852 119.537 243.434 6.49023 373.676 6.49023ZM405.031 477.709C464.853 467.958 516.234 430.739 547.625 378.451H581.477C541.679 457.645 463.553 511.484 373.676 511.484C367.575 511.484 361.527 511.235 355.542 510.748C373.231 501.71 389.819 490.602 405.031 477.709ZM548.389 140.803C530.192 110.037 505.106 84.4031 475.448 66.3967C484.686 64.8499 494.151 64.0408 503.796 64.0408C510.979 64.0408 518.062 64.5012 525.029 65.3687C548.307 86.2507 567.765 111.854 582.115 140.803H548.389Z" fill="#0042B3"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M243.556 6.49023C249.725 6.49023 255.837 6.74707 261.887 7.2439C244.229 16.2686 227.672 27.3577 212.484 40.2222C113.1 56.2712 36.9116 148.1 36.9116 258.987C36.9116 381.182 129.43 480.241 243.556 480.241C316.584 480.241 380.762 439.678 417.517 378.451H451.357C411.559 457.645 333.433 511.484 243.556 511.484C113.314 511.484 7.73242 398.437 7.73242 258.987C7.73242 119.537 113.314 6.49023 243.556 6.49023ZM418.264 140.803C410.661 127.948 401.856 115.986 392.015 105.106C399.796 98.6167 408.078 92.7847 416.794 87.697C430.547 103.634 442.406 121.473 451.99 140.803H418.264Z" fill="#0042B3"/> +<path d="M442.782 199.19H495.88V169.995H360.027V199.19H413.126V349.256H442.782V199.19Z" fill="#E5E2E1"/> +<path d="M538.487 305.98H617.655L634.638 349.258H665.731L592.063 168.717H564.799L491.131 349.258H521.268L538.487 305.98ZM606.893 278.064H549.252L577.953 206.36L606.893 278.064Z" fill="#E5E2E1"/> +<path d="M719.083 169.997H692.535V349.258H811.628V320.833C780.78 320.833 749.932 320.833 719.083 320.833V169.997Z" fill="#E5E2E1"/> +<path d="M966.376 169.997H842.482V349.258H967.572V320.833H871.662V272.943H955.613V244.518H871.662V198.422H966.376V169.997Z" fill="#E5E2E1"/> +<path d="M1109.64 228.514C1109.64 237.773 1106.53 245.159 1100.28 250.621C1094.06 256.128 1085.65 258.858 1075.08 258.858H1031.91V198.422H1074.84C1085.88 198.422 1094.45 200.941 1100.52 206.019C1106.61 211.056 1109.64 218.567 1109.64 228.514ZM1144.8 349.258L1099.6 281.393C1105.5 279.687 1110.88 277.255 1115.74 274.096C1120.6 270.938 1124.79 267.096 1128.3 262.573C1131.81 258.047 1134.56 252.841 1136.55 246.952C1138.54 241.062 1139.54 234.36 1139.54 226.848C1139.54 218.141 1138.1 210.246 1135.23 203.16C1132.36 196.075 1128.26 190.099 1122.92 185.233C1117.58 180.368 1111.04 176.612 1103.31 173.965C1095.57 171.318 1086.92 169.997 1077.35 169.997H1002.73V349.258H1031.91V286.773H1068.86L1110.12 349.258H1144.8Z" fill="#E5E2E1"/> +<path d="M1215.18 352V167.4H1291.1C1298.9 167.4 1306.01 169.047 1312.42 172.34C1319.01 175.633 1324.73 180.053 1329.58 185.6C1334.43 190.973 1338.16 197.04 1340.76 203.8C1343.53 210.56 1344.92 217.493 1344.92 224.6C1344.92 234.48 1342.67 243.84 1338.16 252.68C1333.83 261.347 1327.76 268.453 1319.96 274C1312.33 279.547 1303.23 282.32 1292.66 282.32H1228.44V352H1215.18ZM1228.44 270.1H1292.14C1300.29 270.1 1307.31 267.933 1313.2 263.6C1319.09 259.267 1323.6 253.633 1326.72 246.7C1329.84 239.767 1331.4 232.4 1331.4 224.6C1331.4 216.453 1329.49 209 1325.68 202.24C1322.04 195.307 1317.1 189.76 1310.86 185.6C1304.79 181.44 1298.03 179.36 1290.58 179.36H1228.44V270.1ZM1450.24 353.3C1437.42 353.3 1425.72 350.7 1415.14 345.5C1404.57 340.127 1395.47 333.02 1387.84 324.18C1380.22 315.34 1374.32 305.373 1370.16 294.28C1366 283.013 1363.92 271.487 1363.92 259.7C1363.92 247.393 1366.09 235.693 1370.42 224.6C1374.76 213.333 1380.82 203.367 1388.62 194.7C1396.6 185.86 1405.78 178.927 1416.18 173.9C1426.76 168.7 1438.2 166.1 1450.5 166.1C1463.5 166.1 1475.2 168.787 1485.6 174.16C1496.18 179.533 1505.28 186.727 1512.9 195.74C1520.53 204.753 1526.42 214.807 1530.58 225.9C1534.74 236.993 1536.82 248.26 1536.82 259.7C1536.82 272.18 1534.66 284.053 1530.32 295.32C1525.99 306.413 1519.84 316.38 1511.86 325.22C1504.06 333.887 1494.88 340.733 1484.3 345.76C1473.9 350.787 1462.55 353.3 1450.24 353.3ZM1377.18 259.7C1377.18 270.273 1379 280.413 1382.64 290.12C1386.28 299.827 1391.31 308.493 1397.72 316.12C1404.31 323.747 1412.02 329.813 1420.86 334.32C1429.88 338.827 1439.76 341.08 1450.5 341.08C1461.42 341.08 1471.39 338.74 1480.4 334.06C1489.42 329.38 1497.13 323.14 1503.54 315.34C1509.96 307.54 1514.81 298.873 1518.1 289.34C1521.57 279.633 1523.3 269.753 1523.3 259.7C1523.3 249.127 1521.48 238.987 1517.84 229.28C1514.38 219.573 1509.35 210.907 1502.76 203.28C1496.18 195.653 1488.38 189.587 1479.36 185.08C1470.52 180.573 1460.9 178.32 1450.5 178.32C1439.41 178.32 1429.36 180.66 1420.34 185.34C1411.33 190.02 1403.62 196.26 1397.2 204.06C1390.96 211.86 1386.02 220.613 1382.38 230.32C1378.92 239.853 1377.18 249.647 1377.18 259.7ZM1679.81 198.08C1676.86 194.787 1673.57 191.927 1669.93 189.5C1666.29 186.9 1662.39 184.82 1658.23 183.26C1654.07 181.527 1649.56 180.227 1644.71 179.36C1639.85 178.493 1634.65 178.06 1629.11 178.06C1611.08 178.06 1597.91 181.527 1589.59 188.46C1581.44 195.22 1577.37 204.407 1577.37 216.02C1577.37 223.82 1579.19 230.06 1582.83 234.74C1586.64 239.247 1592.53 242.887 1600.51 245.66C1608.48 248.433 1618.79 251.12 1631.45 253.72C1644.79 256.493 1656.32 259.787 1666.03 263.6C1675.73 267.24 1683.19 272.267 1688.39 278.68C1693.76 285.093 1696.45 293.76 1696.45 304.68C1696.45 312.827 1694.89 320.02 1691.77 326.26C1688.65 332.327 1684.14 337.353 1678.25 341.34C1672.35 345.327 1665.33 348.36 1657.19 350.44C1649.21 352.52 1640.29 353.56 1630.41 353.56C1620.87 353.56 1611.77 352.52 1603.11 350.44C1594.44 348.36 1586.29 345.327 1578.67 341.34C1571.04 337.353 1563.93 332.24 1557.35 326L1564.37 315.34C1568.18 319.5 1572.43 323.227 1577.11 326.52C1581.96 329.64 1587.16 332.327 1592.71 334.58C1598.43 336.833 1604.49 338.653 1610.91 340.04C1617.32 341.253 1623.99 341.86 1630.93 341.86C1647.05 341.86 1659.61 338.913 1668.63 333.02C1677.81 326.953 1682.41 318.027 1682.41 306.24C1682.41 298.093 1680.33 291.507 1676.17 286.48C1672.01 281.453 1665.59 277.38 1656.93 274.26C1648.26 270.967 1637.43 267.933 1624.43 265.16C1611.43 262.387 1600.33 259.267 1591.15 255.8C1582.13 252.333 1575.29 247.653 1570.61 241.76C1566.1 235.867 1563.85 227.893 1563.85 217.84C1563.85 206.747 1566.53 197.387 1571.91 189.76C1577.45 181.96 1585.17 176.067 1595.05 172.08C1604.93 168.093 1616.28 166.1 1629.11 166.1C1637.08 166.1 1644.45 166.88 1651.21 168.44C1658.14 170 1664.47 172.427 1670.19 175.72C1676.08 178.84 1681.54 182.827 1686.57 187.68L1679.81 198.08Z" fill="#E5E2E1"/> +</g> +<defs> +<clipPath id="clip0_410_228"> +<rect width="1706" height="521" fill="white"/> +</clipPath> +</defs> +</svg> diff --git a/TalerPOS/Resources/Info.plist b/TalerPOS/Resources/Info.plist @@ -2,8 +2,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>NSCameraUsageDescription</key> - <string>Camera access is required to scan QR codes for merchant backend configuration.</string> + <key>ITSAppUsesNonExemptEncryption</key> + <false/> <key>CFBundleURLTypes</key> <array> <dict> diff --git a/TalerPOS/Resources/Localizable.xcstrings b/TalerPOS/Resources/Localizable.xcstrings @@ -17,6 +17,16 @@ } } }, + "%@ (%@)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ (%2$@)" + } + } + } + }, "%@: %@" : { "localizations" : { "en" : { @@ -30,9 +40,6 @@ "%lld" : { }, - "0.00" : { - - }, "amount_entry_backspace" : { "extractionState" : "stale", "localizations" : { @@ -11350,6 +11357,290 @@ } } }, + "toast_history_refreshed" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bestellungen aktualisiert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Orders refreshed" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pedidos actualizados" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilaukset päivitetty" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Commandes actualisées" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ההזמנות רועננו" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ordini aggiornati" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заказы обновлены" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ordrar uppdaterade" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Siparişler yenilendi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Замовлення оновлено" + } + } + } + }, + "toast_history_refreshing" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bestellungen werden aktualisiert…" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Refreshing orders…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualizando pedidos…" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Päivitetään tilauksia…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualisation des commandes…" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מרענן הזמנות…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiornamento ordini…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновление заказов…" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uppdaterar ordrar…" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Siparişler yenileniyor…" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оновлення замовлень…" + } + } + } + }, + "toast_language_changed" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sprache geändert. App neu starten für volle Wirkung." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Language changed. Restart the app for full effect." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Idioma cambiado. Reinicia la app para efecto completo." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kieli vaihdettu. Käynnistä sovellus uudelleen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Langue modifiée. Redémarrez l'app pour un effet complet." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "השפה שונתה. הפעל מחדש את האפליקציה." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lingua cambiata. Riavvia l'app per l'effetto completo." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Язык изменён. Перезапустите приложение для полного эффекта." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Språk ändrat. Starta om appen för full effekt." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dil değiştirildi. Tam etki için uygulamayı yeniden başlatın." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Мову змінено. Перезапустіть додаток для повного ефекту." + } + } + } + }, + "toast_reloaded" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inventar neu geladen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inventory reloaded" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inventario recargado" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varasto ladattu uudelleen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inventaire rechargé" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "המלאי נטען מחדש" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inventario ricaricato" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инвентарь перезагружен" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lager omladdat" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envanter yeniden yüklendi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Інвентар перезавантажено" + } + } + } + }, "toast_reloading" : { "extractionState" : "stale", "localizations" : { diff --git a/TalerPOS/Resources/Strings.swift b/TalerPOS/Resources/Strings.swift @@ -137,6 +137,7 @@ enum S { static var settingsInstanceTitle: String { s("settings_instance_title") } static var settingsInstanceButton: String { s("settings_instance_button") } static var settingsLogoutButton: String { s("settings_logout_button") } + static var settingsAboutTitle: String { s("settings_about_title") } static var settingsInstanceDescription: String { s("settings_instance_description") } // MARK: - Errors @@ -182,6 +183,11 @@ enum S { static var appNameShort: String { s("app_name_short") } static var projectName: String { s("project_name") } - // MARK: - Session + // MARK: - Toasts static var sessionExpiredToast: String { s("session_expired_toast") } + static var toastReloading: String { s("toast_reloading") } + static var toastReloaded: String { s("toast_reloaded") } + static var toastHistoryRefreshing: String { s("toast_history_refreshing") } + static var toastHistoryRefreshed: String { s("toast_history_refreshed") } + static var toastLanguageChanged: String { s("toast_language_changed") } } diff --git a/TalerPOS/TalerPOSRelease.entitlements b/TalerPOS/TalerPOSRelease.entitlements @@ -0,0 +1,8 @@ +<?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>com.apple.security.application-groups</key> + <array/> +</dict> +</plist> diff --git a/TalerPOS/Views/AmountEntryView.swift b/TalerPOS/Views/AmountEntryView.swift @@ -84,7 +84,7 @@ struct AmountEntryView: View { GeometryReader { geo in VStack(spacing: 12) { tabletAmountPane - .frame(height: geo.size.height * 0.35) + .frame(maxHeight: geo.size.height * 0.35) HStack { Spacer() @@ -92,9 +92,9 @@ struct AmountEntryView: View { .frame(width: geo.size.width * 0.6) Spacer() } - .frame(height: geo.size.height * 0.65 - 12) } .padding(16) + .padding(.bottom, geo.safeAreaInsets.bottom > 0 ? 0 : 16) } } @@ -200,39 +200,100 @@ struct AmountEntryView: View { // MARK: - Keypad Pane private func keypadPane(isCompact: Bool) -> some View { + VStack(spacing: isCompact ? 6 : 8) { + AmountNumpad( + amount: $amount, + currency: selectedCurrency.isEmpty ? (configManager.currency ?? "") : selectedCurrency, + numInputDigits: numInputDigits, + isCompact: isCompact + ) + chargeButton + } + } + + private var chargeButton: some View { + let canCharge = !amount.isZero && !selectedCurrency.isEmpty + return Button(action: createPayment) { + Text(S.amountEntryCreateOrder) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .frame(height: 56) + .background(canCharge ? Color.posPrimary : Color.posSurfaceVariant) + .foregroundColor(canCharge ? .posOnPrimary : .posOnSurfaceVariant) + .cornerRadius(12) + } + .disabled(!canCharge) + } + + // MARK: - Create Payment + + private func createPayment() { + guard !amount.isZero else { + errorMessage = S.amountEntryErrorZero + return + } + guard let configuredCurrency = configManager.currency else { + onNavigate(.config) + return + } + if selectedCurrency != configuredCurrency { + errorMessage = S.amountEntryErrorWrongCurrency + return + } + + let product = ConfigProduct( + productId: quickAmountProductId, + description: S.amountEntryProductDescription, + price: amount, + categories: [Int.min], + quantity: 1 + ) + let order = Order( + id: -1, + currency: configuredCurrency, + availableCategories: [:], + products: [product] + ) + paymentManager.createPayment(order: order, includeProducts: false, origin: .amountEntry) + onNavigate(.processPayment) + } +} + +// MARK: - Reusable Numpad + +struct AmountNumpad: View { + @Binding var amount: Amount + let currency: String + let numInputDigits: Int + var isCompact: Bool = false + var onKeyPressed: (() -> Void)? = nil + + private var digitRows: [[String]] { + [["1", "2", "3"], ["4", "5", "6"], ["7", "8", "9"]] + } + + var body: some View { let rowSpacing: CGFloat = isCompact ? 6 : 8 let digitFontSize: CGFloat = isCompact ? 24 : 28 - return VStack(spacing: rowSpacing) { + VStack(spacing: rowSpacing) { ForEach(digitRows, id: \.self) { row in HStack(spacing: rowSpacing) { ForEach(row, id: \.self) { digit in - AmountKeyButton( - text: digit, - fontSize: digitFontSize, - action: { handleKey(digit) } - ) + AmountKeyButton(text: digit, fontSize: digitFontSize) { + handleKey(digit) + } } } .frame(maxHeight: .infinity) } - bottomRow(rowSpacing: rowSpacing, digitFontSize: digitFontSize, isCompact: isCompact) + bottomRow(rowSpacing: rowSpacing, digitFontSize: digitFontSize) .frame(maxHeight: .infinity) - - chargeButton } } - private var digitRows: [[String]] { - [ - ["1", "2", "3"], - ["4", "5", "6"], - ["7", "8", "9"], - ] - } - - private func bottomRow(rowSpacing: CGFloat, digitFontSize: CGFloat, isCompact: Bool) -> some View { + private func bottomRow(rowSpacing: CGFloat, digitFontSize: CGFloat) -> some View { let clearLabel = S.amountEntryClear let clearFontSize: CGFloat = { let len = clearLabel.count @@ -242,18 +303,12 @@ struct AmountEntryView: View { }() return HStack(spacing: rowSpacing) { - AmountKeyButton( - text: clearLabel, - fontSize: clearFontSize, - action: { handleKey("clear") } - ) - - AmountKeyButton( - text: "0", - fontSize: digitFontSize, - action: { handleKey("0") } - ) - + AmountKeyButton(text: clearLabel, fontSize: clearFontSize) { + handleKey("clear") + } + AmountKeyButton(text: "0", fontSize: digitFontSize) { + handleKey("0") + } Button(action: { handleKey("backspace") }) { Image(systemName: "delete.backward") .font(.system(size: digitFontSize)) @@ -266,25 +321,8 @@ struct AmountEntryView: View { } } - private var chargeButton: some View { - let canCharge = !amount.isZero && !selectedCurrency.isEmpty - return Button(action: createPayment) { - Text(S.amountEntryCreateOrder) - .fontWeight(.semibold) - .frame(maxWidth: .infinity) - .frame(height: 56) - .background(canCharge ? Color.posPrimary : Color.posSecondary.opacity(0.12)) - .foregroundColor(canCharge ? .posOnPrimary : .posOnSurface.opacity(0.38)) - .cornerRadius(12) - } - .disabled(!canCharge) - } - - // MARK: - Input - private func handleKey(_ key: String) { - errorMessage = nil - let currency = selectedCurrency.isEmpty ? (configManager.currency ?? "") : selectedCurrency + onKeyPressed?() if amount.currency != currency { amount = .zero(currency) } @@ -301,44 +339,9 @@ struct AmountEntryView: View { } } } - - // MARK: - Create Payment - - private func createPayment() { - guard !amount.isZero else { - errorMessage = S.amountEntryErrorZero - return - } - guard let configuredCurrency = configManager.currency else { - onNavigate(.config) - return - } - if selectedCurrency != configuredCurrency { - errorMessage = S.amountEntryErrorWrongCurrency - return - } - - let product = ConfigProduct( - productId: quickAmountProductId, - description: S.amountEntryProductDescription, - price: amount, - categories: [Int.min], - quantity: 1 - ) - let order = Order( - id: -1, - currency: configuredCurrency, - availableCategories: [:], - products: [product] - ) - paymentManager.createPayment(order: order, includeProducts: false, origin: .amountEntry) - onNavigate(.processPayment) - } } -// MARK: - Key Button - -private struct AmountKeyButton: View { +struct AmountKeyButton: View { let text: String let fontSize: CGFloat let action: () -> Void diff --git a/TalerPOS/Views/ConfigView.swift b/TalerPOS/Views/ConfigView.swift @@ -267,6 +267,8 @@ struct ConfigView: View { } } .buttonStyle(.borderedProminent) + .tint(.posPrimary) + .foregroundColor(.posOnPrimary) .disabled(isSubmitting) } @@ -397,10 +399,15 @@ struct ConfigView: View { await MainActor.run { handleChallengeResponse(error.challengeResponse) } + } catch let error as ApiError where error.isUnauthorized { + await MainActor.run { + isSubmitting = false + errorMessage = S.configAuthError + } } catch { await MainActor.run { isSubmitting = false - errorMessage = error.localizedDescription + errorMessage = S.configErrorNetwork } } } @@ -572,7 +579,6 @@ private struct TanInputSheet: View { let onResend: () -> Void @State private var codeText: String = "" - @FocusState private var isCodeFieldFocused: Bool private var channelLabel: String { switch challenge.tanChannel { @@ -592,43 +598,44 @@ private struct TanInputSheet: View { var body: some View { NavigationStack { VStack(spacing: 20) { - Text(S.mfaEnterCode) - .font(.headline) - .padding(.top, 20) - Text(S.mfaChallengeMessage(channelLabel, channelInfo)) .font(.subheadline) .foregroundColor(.posOnSurfaceVariant) .multilineTextAlignment(.center) .padding(.horizontal) - ZStack { - TextField("", text: $codeText) - .keyboardType(.numberPad) - .textContentType(.none) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - .focused($isCodeFieldFocused) - .foregroundColor(.clear) - .tint(.clear) - .accentColor(.clear) - .onChange(of: codeText) { newValue in - let digitsOnly = newValue.filter(\.isNumber) - let clamped = String(digitsOnly.prefix(MFA_CODE_DIGITS)) - if clamped != newValue { - codeText = clamped + if isPhone { + HStack(spacing: 8) { + digitBoxesView + .contentShape(Rectangle()) + .onLongPressGesture { + if let pasted = UIPasteboard.general.string { + let digitsOnly = pasted.filter(\.isNumber) + let clamped = String(digitsOnly.prefix(MFA_CODE_DIGITS)) + codeText = clamped + syncDigits() + } } - let chars = Array(clamped) - for i in 0..<MFA_CODE_DIGITS { - digits[i] = i < chars.count ? String(chars[i]) : "" + Button(S.mfaResendCode) { onResend() } + .font(.caption) + .foregroundColor(.posPrimary) + } + } else { + digitBoxesView + .contentShape(Rectangle()) + .onLongPressGesture { + if let pasted = UIPasteboard.general.string { + let digitsOnly = pasted.filter(\.isNumber) + let clamped = String(digitsOnly.prefix(MFA_CODE_DIGITS)) + codeText = clamped + syncDigits() } } - digitBoxesView - .allowsHitTesting(false) + Button(S.mfaResendCode) { onResend() } + .font(.subheadline) + .foregroundColor(.posPrimary) } - .contentShape(Rectangle()) - .onTapGesture { isCodeFieldFocused = true } if let errorMessage = errorMessage { Text(errorMessage) @@ -636,11 +643,8 @@ private struct TanInputSheet: View { .foregroundColor(.posError) } - Button(S.mfaResendCode) { onResend() } - .font(.subheadline) - .foregroundColor(.posPrimary) - - Spacer() + digitNumpad + .padding(.horizontal, 20) Button(action: onConfirm) { Text(S.mfaVerify) @@ -649,6 +653,8 @@ private struct TanInputSheet: View { .padding(.vertical, 12) } .buttonStyle(.borderedProminent) + .tint(.posPrimary) + .foregroundColor(.posOnPrimary) .disabled(!isComplete) .padding(.horizontal) .padding(.bottom, 20) @@ -663,18 +669,118 @@ private struct TanInputSheet: View { } .onAppear { codeText = digits.joined() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - isCodeFieldFocused = true + } + } + .presentationDetents([.large]) + } + + private func syncDigits() { + let chars = Array(codeText) + for i in 0..<MFA_CODE_DIGITS { + digits[i] = i < chars.count ? String(chars[i]) : "" + } + } + + private func appendDigit(_ d: String) { + guard codeText.count < MFA_CODE_DIGITS else { return } + codeText.append(d) + syncDigits() + } + + private func deleteLastDigit() { + guard !codeText.isEmpty else { return } + codeText.removeLast() + syncDigits() + } + + private var isPhone: Bool { UIDevice.current.userInterfaceIdiom == .phone } + + private var digitNumpad: some View { + Group { + if isPhone { + phoneNumpad + } else { + tabletNumpad + } + } + } + + private func numpadDigitButton(_ digit: String) -> some View { + Button { appendDigit(digit) } label: { + Text(digit) + .font(isPhone ? .body : .title2) + .fontWeight(.medium) + .frame(maxWidth: .infinity) + .frame(height: isPhone ? 44 : 52) + .background(Color.posSurface) + .foregroundColor(.posOnSurface) + .cornerRadius(isPhone ? 10 : 12) + .overlay( + RoundedRectangle(cornerRadius: isPhone ? 10 : 12) + .stroke(Color.posOutlineVariant, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + private func numpadIconButton(icon: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: icon) + .font(isPhone ? .body : .title3) + .frame(maxWidth: .infinity) + .frame(height: isPhone ? 44 : 52) + .background(Color.posSurface) + .foregroundColor(.posOnSurface) + .cornerRadius(isPhone ? 10 : 12) + .overlay( + RoundedRectangle(cornerRadius: isPhone ? 10 : 12) + .stroke(Color.posOutlineVariant, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + private var phoneNumpad: some View { + VStack(spacing: 6) { + HStack(spacing: 6) { + ForEach(1...5, id: \.self) { n in numpadDigitButton("\(n)") } + numpadIconButton(icon: "doc.on.clipboard") { + if let pasted = UIPasteboard.general.string { + let digitsOnly = pasted.filter(\.isNumber) + codeText = String(digitsOnly.prefix(MFA_CODE_DIGITS)) + syncDigits() + } + } + } + HStack(spacing: 6) { + ForEach(6...9, id: \.self) { n in numpadDigitButton("\(n)") } + numpadDigitButton("0") + numpadIconButton(icon: "delete.left") { deleteLastDigit() } + } + } + } + + private var tabletNumpad: some View { + VStack(spacing: 8) { + ForEach(0..<3, id: \.self) { row in + HStack(spacing: 8) { + ForEach(1...3, id: \.self) { col in + numpadDigitButton("\(row * 3 + col)") + } } } - .onChange(of: digits) { newDigits in - let joined = newDigits.joined() - if joined != codeText { - codeText = joined + HStack(spacing: 8) { + numpadIconButton(icon: "doc.on.clipboard") { + if let pasted = UIPasteboard.general.string { + let digitsOnly = pasted.filter(\.isNumber) + codeText = String(digitsOnly.prefix(MFA_CODE_DIGITS)) + syncDigits() + } } + numpadDigitButton("0") + numpadIconButton(icon: "delete.left") { deleteLastDigit() } } } - .presentationDetents([.large]) } private var digitBoxesView: some View { diff --git a/TalerPOS/Views/HistoryView.swift b/TalerPOS/Views/HistoryView.swift @@ -67,11 +67,17 @@ struct HistoryView: View { let onNavigate: (PosDestination) -> Void var openDrawer: (() -> Void)? @State private var showRefundStateMissing = false + @State private var toastMessage: String? + @State private var manualRefreshTriggered = false var body: some View { VStack(spacing: 0) { PosTopBar(title: S.menuHistory, onMenuTap: { openDrawer?() }) { - Button(action: { historyManager.fetchHistory() }) { + Button(action: { + manualRefreshTriggered = true + toastMessage = S.toastHistoryRefreshing + historyManager.fetchHistory() + }) { Text(S.historyRefresh) .font(.subheadline) .fontWeight(.medium) @@ -183,6 +189,13 @@ struct HistoryView: View { } message: { Text(S.refundStateMissing) } + .toast(message: $toastMessage) + .onChange(of: historyManager.isLoading) { loading in + if !loading && manualRefreshTriggered { + manualRefreshTriggered = false + toastMessage = S.toastHistoryRefreshed + } + } } } @@ -243,17 +256,24 @@ struct HistoryItemCard: View { if item.hasPendingRefund || hasPendingRefund { Button(S.historyShowRefund, action: onShowRefundClicked) .buttonStyle(.borderedProminent) + .tint(.posPrimary) + .foregroundColor(.posOnPrimary) .controlSize(.small) } else if item.refundable { Button(S.historyRefund, action: onRefundClicked) .buttonStyle(.borderedProminent) + .tint(.posPrimary) + .foregroundColor(.posOnPrimary) .controlSize(.small) } else if !item.paid { Button(S.historyShowPayment, action: onShowPaymentClicked) .buttonStyle(.borderedProminent) + .tint(.posPrimary) + .foregroundColor(.posOnPrimary) .controlSize(.small) Button(S.orderDelete, action: onDeleteClicked) .buttonStyle(.bordered) + .tint(.posPrimary) .controlSize(.small) } } @@ -261,6 +281,10 @@ struct HistoryItemCard: View { .padding(16) .background(Color.posSurface) .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.posOutlineVariant, lineWidth: 1) + ) } } diff --git a/TalerPOS/Views/OrderView.swift b/TalerPOS/Views/OrderView.swift @@ -25,6 +25,8 @@ struct OrderView: View { @State private var showCustomProduct = false @State private var refreshTimer: Timer? + @State private var toastMessage: String? + @State private var manualReloadTriggered = false private var liveOrder: LiveOrder? { orderManager.currentLiveOrder } private var isTablet: Bool { UIDevice.current.userInterfaceIdiom == .pad } @@ -38,12 +40,19 @@ struct OrderView: View { .onAppear { startInventoryRefresh() } .onDisappear { stopInventoryRefresh() } .sheet(isPresented: $showCustomProduct) { - CustomProductDialog(currency: orderManager.currentLiveOrder?.order.currency ?? configManager.currency ?? "") { product in + CustomProductDialog(currency: orderManager.currentLiveOrder?.order.currency ?? configManager.currency ?? "", currencySpec: configManager.currencySpec) { product in if let orderId = liveOrder?.id { orderManager.addProduct(orderId: orderId, product: product) } } } + .toast(message: $toastMessage) + .onChange(of: configManager.isLoading) { loading in + if !loading && manualReloadTriggered { + manualReloadTriggered = false + toastMessage = S.toastReloaded + } + } } private var orderTopBar: some View { @@ -77,6 +86,8 @@ struct OrderView: View { label: S.menuReload, enabled: true ) { + manualReloadTriggered = true + toastMessage = S.toastReloading configManager.reloadConfig() } } @@ -91,8 +102,8 @@ struct OrderView: View { .padding(.vertical, 8) .padding(.horizontal, 14) .frame(minHeight: 40) - .background(enabled ? Color.posSecondaryContainer : Color.posSecondaryContainer.opacity(0.4)) - .foregroundColor(enabled ? .posOnSecondaryContainer : .posOnSecondaryContainer.opacity(0.4)) + .background(enabled ? Color.posSecondaryContainer : Color.posSurfaceVariant) + .foregroundColor(enabled ? .posOnSecondaryContainer : .posOnSurfaceVariant) .clipShape(RoundedRectangle(cornerRadius: 20)) } .buttonStyle(.plain) @@ -133,7 +144,7 @@ struct OrderView: View { liveOrder: liveOrder, currencySpec: configManager.currencySpec, compact: compact, - onSelectLine: { liveOrder?.selectOrderLine($0) }, + onSelectLine: { product in liveOrder?.selectOrderLine(product) }, onIncrease: { liveOrder?.increaseSelectedOrderLine() }, onDecrease: { liveOrder?.decreaseSelectedOrderLine() }, onCustom: { showCustomProduct = true }, @@ -336,7 +347,7 @@ struct OrderColumnPane: View { isSelected: lo.selectedProductKey == product.id, compact: compact ) { - onSelectLine(lo.selectedProductKey == product.id ? nil : product) + onSelectLine(product) } Color.posOutlineVariant.frame(height: 1) } @@ -407,6 +418,7 @@ struct OrderRow: View { } .padding(.horizontal, compact ? 6 : 10) .padding(.vertical, compact ? 4 : 8) + .contentShape(Rectangle()) .background(isSelected ? Color.posSecondaryContainer : Color.clear) } .buttonStyle(.plain) @@ -485,83 +497,98 @@ private extension Array { struct CustomProductDialog: View { let currency: String + let currencySpec: CurrencySpecification? let onAdd: (ConfigProduct) -> Void @Environment(\.dismiss) private var dismiss @State private var name = S.orderCustomProductDefault - @State private var priceString = "" - @State private var priceError: String? + @State private var amount: Amount = .zero("") + @FocusState private var nameFieldFocused: Bool - private var parsedPrice: Amount? { - Amount.fromString(currency, priceString) + private var numInputDigits: Int { + currencySpec?.numFractionalInputDigits ?? 2 } var body: some View { NavigationStack { - VStack(spacing: 16) { - VStack(alignment: .leading, spacing: 4) { - Text(S.orderCustomProduct) - .font(.caption) - .foregroundColor(.posOnSurfaceVariant) - TextField(S.orderCustomProduct, text: $name) - .padding(10) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.posOutline, lineWidth: 1) - ) - } - - VStack(alignment: .leading, spacing: 4) { - Text(currency) - .font(.caption) - .foregroundColor(.posOnSurfaceVariant) - TextField("0.00", text: $priceString) - .keyboardType(.decimalPad) - .padding(10) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(priceError != nil ? Color.posError : Color.posOutline, lineWidth: 1) + GeometryReader { geo in + ScrollView { + VStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(S.orderCustomProduct) + .font(.caption) + .foregroundColor(.posOnSurfaceVariant) + TextField(S.orderCustomProduct, text: $name) + .focused($nameFieldFocused) + .submitLabel(.done) + .onSubmit { nameFieldFocused = false } + .padding(10) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.posOutline, lineWidth: 1) + ) + } + .padding(.horizontal, 20) + .padding(.top, 8) + + HStack { + Spacer() + Text(amount.amountStr(numDigits: numInputDigits)) + .font(.system(size: 40, weight: .bold)) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + Text(currency) + .font(.title3) + .foregroundColor(.posOnSurfaceVariant) + Spacer() + } + .padding(.vertical, 8) + .onTapGesture { nameFieldFocused = false } + + AmountNumpad( + amount: $amount, + currency: currency, + numInputDigits: numInputDigits, + isCompact: true, + onKeyPressed: { nameFieldFocused = false } ) - .onChange(of: priceString) { _ in - if priceString.isEmpty { - priceError = nil - } else if parsedPrice == nil { - priceError = S.refundErrorInvalidAmount - } else { - priceError = nil - } + .frame(height: geo.size.height * 0.55) + .padding(.horizontal, 20) + + Button { + guard !name.isEmpty, !amount.isZero else { return } + let product = ConfigProduct( + description: name, + price: amount, + categories: [Int.min], + quantity: 1 + ) + onAdd(product) + dismiss() + } label: { + Text(S.orderCustomAdd) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(!name.isEmpty && !amount.isZero ? Color.posPrimary : Color.posSurfaceVariant) + .foregroundColor(!name.isEmpty && !amount.isZero ? .posOnPrimary : .posOnSurfaceVariant) + .cornerRadius(12) } - if let priceError { - Text(priceError) - .font(.caption) - .foregroundColor(.posError) + .disabled(name.isEmpty || amount.isZero) + .padding(.horizontal, 20) + .padding(.bottom, 12) } } - - Spacer() + .scrollDismissesKeyboard(.interactively) } - .padding(20) .navigationTitle(S.orderCustom) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button(S.refundAbort) { dismiss() } } - ToolbarItem(placement: .confirmationAction) { - Button(S.orderCustomAdd) { - guard !name.isEmpty, - let price = parsedPrice, - !price.isZero else { return } - let product = ConfigProduct( - description: name, - price: price, - categories: [Int.min], - quantity: 1 - ) - onAdd(product) - dismiss() - } - .disabled(name.isEmpty || priceString.isEmpty || parsedPrice == nil || parsedPrice?.isZero == true) - } + } + .onAppear { + amount = .zero(currency) } } } diff --git a/TalerPOS/Views/RefundView.swift b/TalerPOS/Views/RefundView.swift @@ -24,64 +24,109 @@ struct RefundFormView: View { @Environment(\.dismiss) private var dismiss let item: OrderHistoryEntry - @State private var amountText: String = "" + @State private var amount: Amount = .zero("") @State private var reason: String = "" @State private var errorText: String? = nil @State private var showErrorAlert: (String, String)? = nil - @FocusState private var amountFocused: Bool @FocusState private var reasonFocused: Bool + private var numInputDigits: Int { + configManager.currencySpec?.numFractionalInputDigits ?? 2 + } + var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text(item.summary) - .font(.body) - - TextField(S.refundAmount, text: $amountText) - .keyboardType(.decimalPad) - .textFieldStyle(.roundedBorder) - .focused($amountFocused) - .onSubmit { reasonFocused = true } - .onChange(of: amountText) { _ in errorText = nil } - - if let errorText = errorText { - Text(errorText) - .font(.caption) - .foregroundColor(.posError) - } + GeometryReader { geo in + ScrollView { + VStack(spacing: 12) { + Text(item.summary) + .font(.body) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.top, 8) + + VStack(alignment: .leading, spacing: 4) { + Text(S.refundReason) + .font(.caption) + .foregroundColor(.posOnSurfaceVariant) + TextField(S.refundReason, text: $reason) + .focused($reasonFocused) + .submitLabel(.done) + .onSubmit { reasonFocused = false } + .padding(10) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.posOutline, lineWidth: 1) + ) + } + .padding(.horizontal, 20) + + HStack { + Spacer() + Text(amount.amountStr(numDigits: numInputDigits)) + .font(.system(size: 40, weight: .bold)) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + Text(item.amount.currency) + .font(.title3) + .foregroundColor(.posOnSurfaceVariant) + Spacer() + } + .padding(.vertical, 4) + .onTapGesture { reasonFocused = false } - TextField(S.refundReason, text: $reason) - .textFieldStyle(.roundedBorder) - .focused($reasonFocused) - .onSubmit { submitRefund() } + if let errorText { + Text(errorText) + .font(.caption) + .foregroundColor(.posError) + } - Button(action: submitRefund) { - Text(S.refundConfirm) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - - Button(action: { - refundManager.abortRefund() - dismiss() - }) { - Text(S.paymentCancel) - .frame(maxWidth: .infinity) + AmountNumpad( + amount: $amount, + currency: item.amount.currency, + numInputDigits: numInputDigits, + isCompact: true, + onKeyPressed: { + reasonFocused = false + errorText = nil + } + ) + .frame(height: geo.size.height * 0.45) + .padding(.horizontal, 20) + + Button(action: submitRefund) { + Text(S.refundConfirm) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(!amount.isZero ? Color.posPrimary : Color.posSurfaceVariant) + .foregroundColor(!amount.isZero ? .posOnPrimary : .posOnSurfaceVariant) + .cornerRadius(12) + } + .disabled(amount.isZero) + .padding(.horizontal, 20) + + Button(action: { + refundManager.abortRefund() + dismiss() + }) { + Text(S.paymentCancel) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(Color.posErrorContainer) + .foregroundColor(.posOnErrorContainer) + .cornerRadius(12) + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } } - .buttonStyle(.bordered) - - Spacer() - } - .padding(20) - .contentShape(Rectangle()) - .onTapGesture { - amountFocused = false - reasonFocused = false + .scrollDismissesKeyboard(.interactively) } .navigationTitle(S.historyRefund) .navigationBarTitleDisplayMode(.inline) .onAppear { - amountText = item.amount.amountStr(numDigits: configManager.currencySpec?.inputDigits ?? 2) - amountFocused = true + amount = item.amount } .onChange(of: refundManager.refundResult) { result in guard let result = result else { return } @@ -109,33 +154,19 @@ struct RefundFormView: View { } private func submitRefund() { - amountFocused = false reasonFocused = false - let maxAmount = item.amount - let normalizedText = amountText.trimmingCharacters(in: .whitespaces) - - let inputAmount: Amount - if normalizedText.isEmpty { - inputAmount = maxAmount - } else { - guard let parsed = Amount.fromString(item.amount.currency, normalizedText) else { - errorText = S.refundErrorInvalidAmount - return - } - inputAmount = parsed - } - if inputAmount > maxAmount { - errorText = S.refundErrorMaxAmount(maxAmount.amountStr(numDigits: configManager.currencySpec?.inputDigits ?? 2)) + if amount > maxAmount { + errorText = S.refundErrorMaxAmount(maxAmount.amountStr(numDigits: numInputDigits)) return } - if inputAmount.isZero { + if amount.isZero { errorText = S.refundErrorZero return } - refundManager.refund(item: item, amount: inputAmount, reason: reason) + refundManager.refund(item: item, amount: amount, reason: reason) } } diff --git a/TalerPOS/Views/SettingsView.swift b/TalerPOS/Views/SettingsView.swift @@ -44,6 +44,12 @@ struct SettingsView: View { let onNavigate: (PosDestination) -> Void var openDrawer: (() -> Void)? + private var username: String? { + guard !configManager.merchantUrl.isEmpty, + let url = URL(string: configManager.merchantUrl) else { return nil } + return url.pathComponents.last(where: { !$0.isEmpty && $0 != "/" }) + } + var body: some View { VStack(spacing: 0) { PosTopBar(title: S.menuSettings, onMenuTap: { openDrawer?() }) @@ -62,18 +68,23 @@ struct SettingsView: View { set: { languageManager.setLanguage($0) } )) { ForEach(supportedLanguages) { lang in - Text(lang.label).tag(lang.tag) + if lang.tag.isEmpty, let resolved = languageManager.resolvedLanguageLabel { + Text("\(lang.label) (\(resolved))").tag(lang.tag) + } else { + Text(lang.label).tag(lang.tag) + } } } } header: { Text(S.settingsAppTitle) + .foregroundColor(.posOnSurface) } Section { VStack(alignment: .leading, spacing: 12) { Text(S.settingsInstanceDescription) .font(.subheadline) - .foregroundColor(.secondary) + .foregroundColor(.posOnSurface) Button { onNavigate(.config) @@ -104,6 +115,21 @@ struct SettingsView: View { } } header: { Text(S.settingsInstanceTitle) + .foregroundColor(.posOnSurface) + } + + Section { + aboutRow(label: S.appName, value: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "") + if let username { + aboutRow(label: S.configUsername, value: username) + } + if !configManager.merchantUrl.isEmpty { + let suffix = configManager.backendVersion.map { " (\($0))" } ?? "" + aboutRow(label: S.configMerchantUrl, value: "\(configManager.merchantUrl)\(suffix)") + } + } header: { + Text(S.settingsAboutTitle) + .foregroundColor(.posOnSurface) } } @@ -112,5 +138,19 @@ struct SettingsView: View { } .background(Color.posSurface) .navigationBarHidden(true) + .toast(message: Binding( + get: { languageManager.languageChangedToast }, + set: { languageManager.languageChangedToast = $0 } + ), duration: 4) + } + + private func aboutRow(label: String, value: String) -> some View { + HStack(spacing: 4) { + Text(label + ":") + .fontWeight(.bold) + Text(value) + } + .font(.subheadline) + .foregroundColor(.posOnSurfaceVariant) } } diff --git a/TalerPOS/Views/ToastView.swift b/TalerPOS/Views/ToastView.swift @@ -0,0 +1,54 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Bohdan, Volodymyr Potuzhnyi + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import SwiftUI + +struct ToastModifier: ViewModifier { + @Binding var message: String? + let duration: TimeInterval + + func body(content: Content) -> some View { + content.overlay(alignment: .bottom) { + if let message { + Text(message) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.posOnPrimaryContainer) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Color.posPrimaryContainer) + .clipShape(Capsule()) + .shadow(color: .black.opacity(0.15), radius: 4, y: 2) + .padding(.bottom, 24) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { + withAnimation(.easeOut(duration: 0.25)) { + self.message = nil + } + } + } + } + } + .animation(.easeInOut(duration: 0.25), value: message) + } +} + +extension View { + func toast(message: Binding<String?>, duration: TimeInterval = 2.5) -> some View { + modifier(ToastModifier(message: message, duration: duration)) + } +}