From c3b00c34d8eebec49ab007e50efd5a70a5b940b2 Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Wed, 22 Jul 2026 04:56:32 +0200 Subject: [PATCH 01/10] Adopt Liquid Glass design and add member-creation error handling - Add Liquid Glass styles for iOS 26 (glass action button, prominent button style, tab bar minimize) with pre-iOS 26 fallbacks - Replace the floating add button modifier with a reusable GlassActionButton - Label tab bar items (Bills / Members) - Report HTTP status when adding a member and show a specific error alert (insufficient rights / network / generic); keep the sheet open on failure - Redesign AddProjectManualView as a Form with clipboard paste button, QR scan link, server-address hints and a Cancel toolbar item - Add localization keys for the above in all supported languages --- PayForMe.xcodeproj/project.pbxproj | 12 +- PayForMe/Services/NetworkService.swift | 10 ++ PayForMe/Services/ProjectManager.swift | 17 ++- PayForMe/Strings/cs.lproj/Localizable.strings | 11 ++ PayForMe/Strings/de.lproj/Localizable.strings | 11 ++ PayForMe/Strings/en.lproj/Localizable.strings | 11 ++ PayForMe/Strings/es.lproj/Localizable.strings | 11 ++ PayForMe/Strings/fr.lproj/Localizable.strings | 11 ++ PayForMe/Strings/ru.lproj/Localizable.strings | 11 ++ .../Util/FloatingAddButtonViewModifier.swift | 75 ---------- PayForMe/Util/Views+Extensions.swift | 73 +++++++++- PayForMe/Views/Balance/BalanceList.swift | 61 ++++++--- .../Views/BillDetail/BillDetailView.swift | 9 +- PayForMe/Views/BillList/BillList.swift | 14 +- PayForMe/Views/ContentView.swift | 7 +- PayForMe/Views/FancyButton.swift | 2 +- PayForMe/Views/FancyLoadingButton.swift | 2 +- .../Manual/AddProjectManualView.swift | 129 ++++++++++++------ PayForMe/Views/Projects/ProjectList.swift | 51 ++----- 19 files changed, 333 insertions(+), 195 deletions(-) delete mode 100644 PayForMe/Util/FloatingAddButtonViewModifier.swift diff --git a/PayForMe.xcodeproj/project.pbxproj b/PayForMe.xcodeproj/project.pbxproj index f4f8eb6..a46153b 100644 --- a/PayForMe.xcodeproj/project.pbxproj +++ b/PayForMe.xcodeproj/project.pbxproj @@ -28,7 +28,6 @@ 6524D60225385E0800B2AA7B /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 6524D60125385E0800B2AA7B /* GRDB */; }; 6533A02B23D888B100FF822B /* BillCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6533A02A23D888B100FF822B /* BillCell.swift */; }; 654754B82528736F00A82EB6 /* Views+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654754B72528736F00A82EB6 /* Views+Extensions.swift */; }; - 654754BC2528750A00A82EB6 /* FloatingAddButtonViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654754BB2528750A00A82EB6 /* FloatingAddButtonViewModifier.swift */; }; 654754C0252889B200A82EB6 /* ProjectQRPermissionCheckerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654754BF252889B200A82EB6 /* ProjectQRPermissionCheckerView.swift */; }; 654754C8252899FF00A82EB6 /* FancyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654754C7252899FF00A82EB6 /* FancyButton.swift */; }; 654754E42528B29F00A82EB6 /* AddProjectManuallyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654754E32528B29F00A82EB6 /* AddProjectManuallyTests.swift */; }; @@ -111,7 +110,6 @@ 6523FC502558153600BCD843 /* AddPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddPasswordView.swift; sourceTree = ""; }; 6533A02A23D888B100FF822B /* BillCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BillCell.swift; sourceTree = ""; }; 654754B72528736F00A82EB6 /* Views+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views+Extensions.swift"; sourceTree = ""; }; - 654754BB2528750A00A82EB6 /* FloatingAddButtonViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingAddButtonViewModifier.swift; sourceTree = ""; }; 654754BF252889B200A82EB6 /* ProjectQRPermissionCheckerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectQRPermissionCheckerView.swift; sourceTree = ""; }; 654754C7252899FF00A82EB6 /* FancyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FancyButton.swift; sourceTree = ""; }; 654754E12528B29F00A82EB6 /* PayForMeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PayForMeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -379,7 +377,6 @@ 489D35F923E081120029F59F /* Util.swift */, 65A65E3E2527529D00C86533 /* Combine.swift */, 654754B72528736F00A82EB6 /* Views+Extensions.swift */, - 654754BB2528750A00A82EB6 /* FloatingAddButtonViewModifier.swift */, ); path = Util; sourceTree = ""; @@ -575,7 +572,6 @@ 650059FC23DDE1C300D1D599 /* Person.swift in Sources */, 65662A0323D7320000303207 /* AppDelegate.swift in Sources */, 654754C0252889B200A82EB6 /* ProjectQRPermissionCheckerView.swift in Sources */, - 654754BC2528750A00A82EB6 /* FloatingAddButtonViewModifier.swift in Sources */, 6533A02B23D888B100FF822B /* BillCell.swift in Sources */, 48CF709B240FF82000770B97 /* AddMemberView.swift in Sources */, 65D9094025581BE100351F4B /* AddFromURLView.swift in Sources */, @@ -825,7 +821,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = PayForMe/PayForMe.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 109; + CURRENT_PROJECT_VERSION = 110; DEVELOPMENT_ASSET_PATHS = "\"PayForMe/Preview Content\""; DEVELOPMENT_TEAM = 22BT3L9N5V; ENABLE_PREVIEWS = YES; @@ -836,7 +832,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.5.1; + MARKETING_VERSION = 2.6.0; PRODUCT_BUNDLE_IDENTIFIER = de.mayflower.PayForMe; PRODUCT_NAME = PayForMe; SWIFT_VERSION = 5.0; @@ -850,7 +846,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = PayForMe/PayForMe.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 109; + CURRENT_PROJECT_VERSION = 110; DEVELOPMENT_ASSET_PATHS = "\"PayForMe/Preview Content\""; DEVELOPMENT_TEAM = 22BT3L9N5V; ENABLE_PREVIEWS = YES; @@ -861,7 +857,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.5.1; + MARKETING_VERSION = 2.6.0; PRODUCT_BUNDLE_IDENTIFIER = de.mayflower.PayForMe; PRODUCT_NAME = PayForMe; SWIFT_VERSION = 5.0; diff --git a/PayForMe/Services/NetworkService.swift b/PayForMe/Services/NetworkService.swift index 488c438..0a11a26 100644 --- a/PayForMe/Services/NetworkService.swift +++ b/PayForMe/Services/NetworkService.swift @@ -159,6 +159,16 @@ class NetworkService { return sendMemberPublisher(request: request) } + /// Wie `createMemberPublisher`, gibt aber den HTTP-Statuscode zurück (bzw. -1 bei + /// Transportfehler), damit die UI einen echten Fehler statt eines stillen `false` anzeigen kann. + func createMemberStatusPublisher(name: String) -> AnyPublisher { + let request = buildURLRequest("members", params: ["name": name], project: currentProject, httpMethod: "POST") + return URLSession.shared.dataTaskPublisher(for: request) + .map { output -> Int in (output.response as? HTTPURLResponse)?.statusCode ?? -1 } + .replaceError(with: -1) + .eraseToAnyPublisher() + } + func updateMemberPublisher(member: Person) -> AnyPublisher { let request = buildURLRequest("members/\(member.id)", params: ["name": member.name], project: currentProject, httpMethod: "PUT") return sendMemberPublisher(request: request) diff --git a/PayForMe/Services/ProjectManager.swift b/PayForMe/Services/ProjectManager.swift index d74758b..658625d 100644 --- a/PayForMe/Services/ProjectManager.swift +++ b/PayForMe/Services/ProjectManager.swift @@ -240,9 +240,20 @@ extension ProjectManager { deleteBillFromServer(bill: bill, completion: completion) } - func addMember(_ name: String, completion: @escaping () -> Void) { - let newMember = Person(id: -1, weight: -1, name: name, activated: true, color: nil) - sendMemberToServer(newMember, update: false, completion: completion) + /// Legt ein Mitglied an und meldet den HTTP-Statuscode zurück (2xx = Erfolg, sonst Fehler; + /// -1 bei Transportfehler). So kann die UI einen echten Fehler anzeigen statt still zu scheitern. + func addMember(_ name: String, completion: @escaping (Int) -> Void) { + cancellable?.cancel() + cancellable = NetworkService.shared.createMemberStatusPublisher(name: name) + .receive(on: DispatchQueue.main) + .sink { statusCode in + if (200 ... 299).contains(statusCode) { + print("Member successfully created") + } else { + print("Error creating member: HTTP \(statusCode)") + } + completion(statusCode) + } } func updateMember(_ member: Person, completion: @escaping () -> Void) { diff --git a/PayForMe/Strings/cs.lproj/Localizable.strings b/PayForMe/Strings/cs.lproj/Localizable.strings index 356d180..51ff16f 100644 --- a/PayForMe/Strings/cs.lproj/Localizable.strings +++ b/PayForMe/Strings/cs.lproj/Localizable.strings @@ -139,3 +139,14 @@ "What was paid" = "Co bylo placeno?"; + +/* Feedback round 2.6.0 */ +"Cancel" = "Zrušit"; +"Scan QR code" = "Naskenovat QR kód"; +"Backend" = "Backend"; +"server_hint_cospend" = "Váš vlastní server Cospend (Nextcloud), např. https://cloud.priklad.cz"; +"server_hint_ihatemoney" = "Ponechte prázdné pro oficiální ihatemoney.org, nebo zadejte vlastní instanci."; +"Could not add member" = "Člena se nepodařilo přidat"; +"member_error_forbidden" = "To obvykle vyžaduje práva správce projektu. Sdílené heslo projektu může umožňovat jen přístup účastníka."; +"member_error_network" = "Není spojení se serverem. Zkontrolujte síť a zkuste to znovu."; +"member_error_generic" = "Server odmítl požadavek (HTTP %d)."; diff --git a/PayForMe/Strings/de.lproj/Localizable.strings b/PayForMe/Strings/de.lproj/Localizable.strings index 021fe75..af634f2 100644 --- a/PayForMe/Strings/de.lproj/Localizable.strings +++ b/PayForMe/Strings/de.lproj/Localizable.strings @@ -139,3 +139,14 @@ "Server returned an invalid HTTP response" = "Server lieferte eine ungültige HTTP-Antwort"; + +/* Feedback round 2.6.0 */ +"Cancel" = "Abbrechen"; +"Scan QR code" = "QR-Code scannen"; +"Backend" = "Backend"; +"server_hint_cospend" = "Dein selbstgehosteter Cospend-Server (Nextcloud), z. B. https://cloud.beispiel.de"; +"server_hint_ihatemoney" = "Leer lassen für das offizielle ihatemoney.org oder eigene Instanz eintragen."; +"Could not add member" = "Mitglied konnte nicht hinzugefügt werden"; +"member_error_forbidden" = "Dafür sind meist Maintainer-Rechte am Projekt nötig. Das geteilte Projekt-Passwort erlaubt oft nur Teilnehmer-Zugriff."; +"member_error_network" = "Keine Verbindung zum Server. Bitte Netzwerk prüfen und erneut versuchen."; +"member_error_generic" = "Der Server hat die Anfrage abgelehnt (HTTP %d)."; diff --git a/PayForMe/Strings/en.lproj/Localizable.strings b/PayForMe/Strings/en.lproj/Localizable.strings index 6c1422c..0894e67 100644 --- a/PayForMe/Strings/en.lproj/Localizable.strings +++ b/PayForMe/Strings/en.lproj/Localizable.strings @@ -142,3 +142,14 @@ "Server returned an invalid HTTP response" = "Server returned an invalid HTTP response"; + +/* Feedback round 2.6.0 */ +"Cancel" = "Cancel"; +"Scan QR code" = "Scan QR code"; +"Backend" = "Backend"; +"server_hint_cospend" = "Your self-hosted Cospend (Nextcloud) server, e.g. https://cloud.example.com"; +"server_hint_ihatemoney" = "Leave empty for the official ihatemoney.org, or enter your own instance."; +"Could not add member" = "Could not add member"; +"member_error_forbidden" = "This usually requires maintainer rights for the project. The shared project password may only grant participant access."; +"member_error_network" = "No connection to the server. Please check your network and try again."; +"member_error_generic" = "The server rejected the request (HTTP %d)."; diff --git a/PayForMe/Strings/es.lproj/Localizable.strings b/PayForMe/Strings/es.lproj/Localizable.strings index 6fcdf6d..62e03cb 100644 --- a/PayForMe/Strings/es.lproj/Localizable.strings +++ b/PayForMe/Strings/es.lproj/Localizable.strings @@ -142,3 +142,14 @@ "What was paid" = "¿Qué se pagó?"; + +/* Feedback round 2.6.0 */ +"Cancel" = "Cancelar"; +"Scan QR code" = "Escanear código QR"; +"Backend" = "Backend"; +"server_hint_cospend" = "Tu servidor Cospend autoalojado (Nextcloud), p. ej. https://cloud.ejemplo.com"; +"server_hint_ihatemoney" = "Déjalo vacío para el ihatemoney.org oficial, o introduce tu instancia."; +"Could not add member" = "No se pudo añadir el miembro"; +"member_error_forbidden" = "Esto suele requerir permisos de mantenedor del proyecto. La contraseña compartida puede dar solo acceso de participante."; +"member_error_network" = "Sin conexión con el servidor. Comprueba tu red e inténtalo de nuevo."; +"member_error_generic" = "El servidor rechazó la solicitud (HTTP %d)."; diff --git a/PayForMe/Strings/fr.lproj/Localizable.strings b/PayForMe/Strings/fr.lproj/Localizable.strings index d306df8..b9ba5aa 100644 --- a/PayForMe/Strings/fr.lproj/Localizable.strings +++ b/PayForMe/Strings/fr.lproj/Localizable.strings @@ -142,3 +142,14 @@ "What was paid" = "Qu'est-ce qui a été payé?"; + +/* Feedback round 2.6.0 */ +"Cancel" = "Annuler"; +"Scan QR code" = "Scanner le QR code"; +"Backend" = "Backend"; +"server_hint_cospend" = "Votre serveur Cospend auto-hébergé (Nextcloud), par ex. https://cloud.exemple.com"; +"server_hint_ihatemoney" = "Laissez vide pour le ihatemoney.org officiel, ou saisissez votre instance."; +"Could not add member" = "Impossible d'ajouter le membre"; +"member_error_forbidden" = "Cela nécessite généralement des droits de mainteneur sur le projet. Le mot de passe partagé peut ne donner qu'un accès participant."; +"member_error_network" = "Pas de connexion au serveur. Vérifiez votre réseau et réessayez."; +"member_error_generic" = "Le serveur a rejeté la requête (HTTP %d)."; diff --git a/PayForMe/Strings/ru.lproj/Localizable.strings b/PayForMe/Strings/ru.lproj/Localizable.strings index 6813389..52ba8dd 100644 --- a/PayForMe/Strings/ru.lproj/Localizable.strings +++ b/PayForMe/Strings/ru.lproj/Localizable.strings @@ -145,3 +145,14 @@ "What was paid" = "За что платил"; + +/* Feedback round 2.6.0 */ +"Cancel" = "Отмена"; +"Scan QR code" = "Сканировать QR-код"; +"Backend" = "Бэкенд"; +"server_hint_cospend" = "Ваш собственный сервер Cospend (Nextcloud), например https://cloud.example.com"; +"server_hint_ihatemoney" = "Оставьте пустым для официального ihatemoney.org или укажите свой сервер."; +"Could not add member" = "Не удалось добавить участника"; +"member_error_forbidden" = "Обычно для этого нужны права сопровождающего проекта. Пароль общего доступа может давать только права участника."; +"member_error_network" = "Нет соединения с сервером. Проверьте сеть и повторите попытку."; +"member_error_generic" = "Сервер отклонил запрос (HTTP %d)."; diff --git a/PayForMe/Util/FloatingAddButtonViewModifier.swift b/PayForMe/Util/FloatingAddButtonViewModifier.swift deleted file mode 100644 index 26106b6..0000000 --- a/PayForMe/Util/FloatingAddButtonViewModifier.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// FloatingAddButtonViewModifier.swift -// PayForMe -// -// Created by Max Tharr on 03.10.20. -// - -import SwiftUI - -struct FloatingAddButtonViewModifier: ViewModifier { - @State private var sheetToggle = false - - func body(content: Content) -> some View { - content - .sheet(isPresented: $sheetToggle) { - AddBillView(showModal: $sheetToggle) - } - .overlay(FloatingAddButtonView(sheetToggle: $sheetToggle).padding(10), alignment: .bottom) - } -} - -private struct FloatingAddButtonView: View { - @Binding var sheetToggle: Bool - - let size = 70.0 - - var body: some View { - Button(action: { - sheetToggle.toggle() - }) { - Label("Add Bill", systemImage: "bag.badge.plus") - .padding(10) - .font(.headline) - .background(Color.accentColor) - .foregroundColor(.white) - .cornerRadius(20) - .shadow(radius: /*@START_MENU_TOKEN@*/8/*@END_MENU_TOKEN@*/) - - } - - .accessibility(identifier: "Add Bill") - .accessibility(label: Text("Add Bill")) - } -} - -struct FloatingAddButtonViewModifier_Previews: PreviewProvider { - static var previews: some View { - FloatingAddButtonView(sheetToggle: .constant(true)) - let viewModel = BillListViewModel() - previewProject.members = previewPersons - viewModel.currentProject = previewProject - previewProjects.forEach { project in - try? ProjectManager.shared.addProject(project) - } - ProjectManager.shared.currentProject = previewProject - - //return NavigationView { - return TabView { - BillList(viewModel: viewModel) - .tabItem { - Image(systemName: "rectangle.stack") - } - BillList(viewModel: viewModel) - .tabItem { - Image(systemName: "arrow.right.arrow.left") - } - BillList(viewModel: viewModel) - .tabItem { - Image(systemName: "gear") - } - } - .navigationViewStyle(StackNavigationViewStyle()) - .preferredColorScheme(.dark) - } -} diff --git a/PayForMe/Util/Views+Extensions.swift b/PayForMe/Util/Views+Extensions.swift index e393dd3..9271893 100644 --- a/PayForMe/Util/Views+Extensions.swift +++ b/PayForMe/Util/Views+Extensions.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI extension View { + /// Fallback-Look für prominente Aktions-Buttons vor iOS 26 (kein Liquid Glass verfügbar). func fancyStyle(active: Bool = true) -> some View { padding(10) .background(active ? Color.accentColor : Color.secondary) @@ -18,11 +19,79 @@ extension View { .shadow(color: (active ? Color.accentColor : Color.secondary).opacity(0.5), radius: 4, x: 2, y: 2) } + /// Prominenter Aktions-Button: System-Liquid-Glass ab iOS 26, sonst der bisherige `fancyStyle`. + @ViewBuilder + func prominentActionStyle(active: Bool = true) -> some View { + if #available(iOS 26, *) { + self.buttonStyle(.glassProminent) + } else { + self.fancyStyle(active: active) + } + } + + /// Lässt die Tab-Bar beim Scrollen einklappen (Liquid-Glass-Verhalten). No-op vor iOS 26. + @ViewBuilder + func glassTabBarMinimize() -> some View { + if #available(iOS 26, *) { + self.tabBarMinimizeBehavior(.onScrollDown) + } else { + self + } + } + func eraseToAnyView() -> AnyView { AnyView(self) } - func addFloatingAddButton() -> some View { - modifier(FloatingAddButtonViewModifier()) + /// Kreisförmiger Glass-Hintergrund für einen Button. Ab iOS 26 echtes Liquid Glass, + /// sonst ein Accent-Kreis mit Schatten (analog altem Floating-Button). + @ViewBuilder + func glassCircleStyle() -> some View { + if #available(iOS 26, *) { + self + .buttonStyle(.glassProminent) + .buttonBorderShape(.circle) + } else { + self + .background(Color.accentColor) + .foregroundColor(.white) + .clipShape(Circle()) + .shadow(radius: 8) + } + } + + /// Legt einen schwebenden, kreisförmigen Aktions-Button unten rechts über den Inhalt + /// (über der Tab-Bar, à la Apple Music). Daumen-erreichbar. + func glassActionButton(systemImage: String, + accessibilityLabel: LocalizedStringKey, + accessibilityIdentifier: String? = nil, + action: @escaping () -> Void) -> some View { + overlay(alignment: .bottomTrailing) { + GlassActionButton(systemImage: systemImage, + accessibilityLabel: accessibilityLabel, + accessibilityIdentifier: accessibilityIdentifier, + action: action) + .padding(.trailing, 20) + .padding(.bottom, 20) + } + } +} + +/// Schwebender, kreisförmiger Haupt-Aktions-Button (Liquid Glass ab iOS 26). +struct GlassActionButton: View { + let systemImage: String + let accessibilityLabel: LocalizedStringKey + var accessibilityIdentifier: String? + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.title2.weight(.semibold)) + .frame(width: 56, height: 56) + } + .glassCircleStyle() + .accessibilityLabel(Text(accessibilityLabel)) + .accessibilityIdentifier(accessibilityIdentifier ?? "") } } diff --git a/PayForMe/Views/Balance/BalanceList.swift b/PayForMe/Views/Balance/BalanceList.swift index b6237a4..6af8010 100644 --- a/PayForMe/Views/Balance/BalanceList.swift +++ b/PayForMe/Views/Balance/BalanceList.swift @@ -17,22 +17,27 @@ struct BalanceList: View { @State var memberName = "" + @State + private var memberAddError: MemberAddError? + var body: some View { NavigationView { - mainView - .navigationBarTitle("Members") - }.navigationViewStyle(StackNavigationViewStyle()) - } - - @ViewBuilder - var mainView: some View { - VStack(alignment: .center) { - if addingUser { - AddMemberView(memberName: $memberName, addMemberAction: submitUser, cancelButtonAction: cancelAddUser) - } list - .addFloatingAddButton() - } + .navigationTitle("Members") + .glassActionButton(systemImage: "person.badge.plus", + accessibilityLabel: "Add member", + accessibilityIdentifier: "Add member") { + showAddUser() + } + .sheet(isPresented: $addingUser) { + AddMemberView(memberName: $memberName, addMemberAction: submitUser, cancelButtonAction: cancelAddUser) + .alert(item: $memberAddError) { error in + Alert(title: Text("Could not add member"), + message: Text(memberErrorMessage(for: error.code)), + dismissButton: .default(Text("OK"))) + } + } + }.navigationViewStyle(StackNavigationViewStyle()) } @ViewBuilder @@ -63,10 +68,26 @@ struct BalanceList: View { } func submitUser() { - ProjectManager.shared.addMember(memberName) { - self.addingUser = false - self.memberName = "" - ProjectManager.shared.loadBillsAndMembers() + ProjectManager.shared.addMember(memberName) { statusCode in + if (200 ... 299).contains(statusCode) { + self.addingUser = false + self.memberName = "" + ProjectManager.shared.loadBillsAndMembers() + } else { + // Sheet offen lassen, damit der Nutzer die Eingabe behält und es erneut versuchen kann. + self.memberAddError = MemberAddError(code: statusCode) + } + } + } + + private func memberErrorMessage(for code: Int) -> String { + switch code { + case 401, 403: + return NSLocalizedString("member_error_forbidden", comment: "Member add failed due to insufficient rights") + case -1: + return NSLocalizedString("member_error_network", comment: "Member add failed due to a network error") + default: + return String(format: NSLocalizedString("member_error_generic", comment: "Member add failed with HTTP status code"), code) } } @@ -84,6 +105,12 @@ struct BalanceList: View { } } +/// Fehler beim Anlegen eines Mitglieds, identifizierbar über den HTTP-Statuscode (für `.alert(item:)`). +struct MemberAddError: Identifiable { + let code: Int + var id: Int { code } +} + struct BalanceList_Previews: PreviewProvider { static var previews: some View { let vm = BalanceViewModel() diff --git a/PayForMe/Views/BillDetail/BillDetailView.swift b/PayForMe/Views/BillDetail/BillDetailView.swift index 55720d3..bde69a7 100644 --- a/PayForMe/Views/BillDetail/BillDetailView.swift +++ b/PayForMe/Views/BillDetail/BillDetailView.swift @@ -11,8 +11,8 @@ import SlickLoadingSpinner import SwiftUI struct BillDetailView: View { - @Environment(\.presentationMode) - var presentationMode: Binding + @Environment(\.dismiss) + private var dismiss @Binding var showModal: Bool @@ -61,7 +61,8 @@ struct BillDetailView: View { } .padding() } - .navigationBarTitle(navBarTitle, displayMode: .inline) + .navigationTitle(navBarTitle) + .navigationBarTitleDisplayMode(.inline) } func sendBillToServer() { @@ -75,7 +76,7 @@ struct BillDetailView: View { ProjectManager.shared.loadBillsAndMembers() self.showModal.toggle() DispatchQueue.main.async { - self.presentationMode.wrappedValue.dismiss() + self.dismiss() } }) } diff --git a/PayForMe/Views/BillList/BillList.swift b/PayForMe/Views/BillList/BillList.swift index 0911652..59cf628 100644 --- a/PayForMe/Views/BillList/BillList.swift +++ b/PayForMe/Views/BillList/BillList.swift @@ -14,16 +14,26 @@ struct BillList: View { @State var deleteAlert: IndexSet? + @State + private var showAddBill = false + var body: some View { NavigationView { List { iOS15ListContent } - .addFloatingAddButton() .refreshable { await ProjectManager.shared.refresh() } - .navigationBarTitle("Bills") + .navigationTitle("Bills") + .glassActionButton(systemImage: "bag.badge.plus", + accessibilityLabel: "Add Bill", + accessibilityIdentifier: "Add Bill") { + showAddBill = true + } + .sheet(isPresented: $showAddBill) { + AddBillView(showModal: $showAddBill) + } .alert(item: $deleteAlert) { index in Alert(title: Text("Delete Bill"), message: Text("Do you really want to erase the bill from the server?"), diff --git a/PayForMe/Views/ContentView.swift b/PayForMe/Views/ContentView.swift index 87025c6..829ff1e 100644 --- a/PayForMe/Views/ContentView.swift +++ b/PayForMe/Views/ContentView.swift @@ -52,20 +52,21 @@ struct ContentView: View { TabView(selection: $tabBarIndex) { BillList(viewModel: billListViewModel) .tabItem { - Image(systemName: "rectangle.stack") + Label("Bills", systemImage: "rectangle.stack") } .tag(tabBarItems.BillList) BalanceList(viewModel: balanceViewModel) .tabItem { - Image(systemName: "arrow.right.arrow.left") + Label("Members", systemImage: "arrow.right.arrow.left") } .tag(tabBarItems.Balance) ProjectList() .tabItem { - Image(systemName: "gear") + Label("Projects", systemImage: "gear") } .tag(tabBarItems.ServerList) } + .glassTabBarMinimize() } } diff --git a/PayForMe/Views/FancyButton.swift b/PayForMe/Views/FancyButton.swift index a596bed..080e5a3 100644 --- a/PayForMe/Views/FancyButton.swift +++ b/PayForMe/Views/FancyButton.swift @@ -23,7 +23,7 @@ struct FancyButton: View { Text(LocalizedStringKey(text)) } } - .fancyStyle(active: self.isEnabled) + .prominentActionStyle(active: self.isEnabled) .disabled(!isEnabled) } } diff --git a/PayForMe/Views/FancyLoadingButton.swift b/PayForMe/Views/FancyLoadingButton.swift index 4de4d3d..0a0a1ad 100644 --- a/PayForMe/Views/FancyLoadingButton.swift +++ b/PayForMe/Views/FancyLoadingButton.swift @@ -28,7 +28,7 @@ struct FancyLoadingButton: View { Text(LocalizedStringKey(text)) } } - .fancyStyle(active: self.isEnabled) + .prominentActionStyle(active: self.isEnabled) .disabled(!isEnabled) .eraseToAnyView() default: diff --git a/PayForMe/Views/Projects/Manual/AddProjectManualView.swift b/PayForMe/Views/Projects/Manual/AddProjectManualView.swift index 9aaf3ae..368dbdf 100644 --- a/PayForMe/Views/Projects/Manual/AddProjectManualView.swift +++ b/PayForMe/Views/Projects/Manual/AddProjectManualView.swift @@ -8,37 +8,46 @@ import Combine import SlickLoadingSpinner import SwiftUI +import UIKit struct AddProjectManualView: View { - @Environment(\.presentationMode) - var presentationMode: Binding + @Environment(\.dismiss) + private var dismiss @StateObject private var viewmodel = AddProjectManualViewModel() + /// True, wenn die Zwischenablage (datenschutzfreundlich, ohne Inhalt zu lesen) eine URL enthält. + @State private var clipboardHasURL = false + var body: some View { - VStack { - Text("Add project").font(.title) - Picker(selection: $viewmodel.projectType, label: Text("snens")) { - Text("Cospend").tag(ProjectBackend.cospend) - Text("iHateMoney").tag(ProjectBackend.iHateMoney) - } - .pickerStyle(SegmentedPickerStyle()) - .padding(EdgeInsets(top: 8, leading: 8, bottom: 0, trailing: 8)) - if #available(iOS 16.0, *) { - PasteButton(payloadType: String.self) { strings in - pasteLink(pasteString: strings[0]) + NavigationView { + Form { + if clipboardHasURL { + Section { + pasteButton + } } - .padding(.top, 10) - } else { - Button("Paste Link") { - pasteLink() + + Section { + NavigationLink { + ProjectQRPermissionCheckerView() + } label: { + Label("Scan QR code", systemImage: "qrcode.viewfinder") + } } - .padding(.top, 10) - } - Form { + + Section { + Picker("Backend", selection: $viewmodel.projectType) { + Text("Cospend").tag(ProjectBackend.cospend) + Text("iHateMoney").tag(ProjectBackend.iHateMoney) + } + .pickerStyle(.segmented) + } + Section( - header: Text(viewmodel.projectType == .iHateMoney ? "Server Address (Optional)" : "Server Address") + header: Text(LocalizedStringKey(viewmodel.projectType == .iHateMoney ? "Server Address (Optional)" : "Server Address")), + footer: Text(LocalizedStringKey(viewmodel.projectType == .cospend ? "server_hint_cospend" : "server_hint_ihatemoney")) ) { TextFieldContainer( viewmodel.projectType == .cospend @@ -48,10 +57,8 @@ struct AddProjectManualView: View { } Section(header: Text("Project ID & Password")) { - TextField("Enter project id", - text: self.$viewmodel.projectName) - .autocapitalization(.none) - + TextField("Enter project id", text: self.$viewmodel.projectName) + .autocapitalization(.none) SecureField("Enter project password", text: self.$viewmodel.projectPassword) } @@ -62,28 +69,68 @@ struct AddProjectManualView: View { } } - - SlickLoadingSpinner(connectionState: viewmodel.validationProgress) - .frame(width: 50, height: 50) - FancyButton( - add: false, - action: addButton, - text: "Add Project" - ) - .disabled(viewmodel.validationProgress != .success) - if !viewmodel.errorText.isEmpty { - Text(viewmodel.errorText) + Section { + HStack { + Spacer() + if viewmodel.validationProgress == .connecting { + SlickLoadingSpinner(connectionState: viewmodel.validationProgress) + .frame(width: 50, height: 50) + } else { + FancyButton(add: false, action: addButton, text: "Add Project") + .disabled(viewmodel.validationProgress != .success) + } + Spacer() + } + if !viewmodel.errorText.isEmpty { + Text(viewmodel.errorText) + .foregroundColor(.red) + .frame(maxWidth: .infinity, alignment: .center) + } + } + } + .id(viewmodel.projectType == .cospend ? "cospend" : "iHateMoney") + .navigationTitle("Add project") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } } } - .id(viewmodel.projectType == .cospend ? "cospend" : "iHateMoney")} - .padding(.horizontal, 20) - .padding(.vertical, 50) - .background(Color.PFMBackground) + } + .navigationViewStyle(StackNavigationViewStyle()) + .onAppear { detectClipboard() } + } + + @ViewBuilder + private var pasteButton: some View { + if #available(iOS 16.0, *) { + PasteButton(payloadType: String.self) { strings in + guard let first = strings.first else { return } + pasteLink(pasteString: first) + } + } else { + Button { + pasteLink() + } label: { + Label("Paste Link", systemImage: "doc.on.clipboard") + } + } + } + + /// Prüft datenschutzfreundlich (ohne Inhalt zu lesen, kein Paste-Hinweis), ob die + /// Zwischenablage eine URL enthält, und blendet nur dann den Einfügen-Button ein. + private func detectClipboard() { + UIPasteboard.general.detectPatterns(for: [\.probableWebURL]) { result in + guard case let .success(patterns) = result else { return } + DispatchQueue.main.async { + clipboardHasURL = patterns.contains(\.probableWebURL) + } + } } func addButton() { viewmodel.addProject() - presentationMode.wrappedValue.dismiss() + dismiss() } private func pasteLink(pasteString: String) { diff --git a/PayForMe/Views/Projects/ProjectList.swift b/PayForMe/Views/Projects/ProjectList.swift index 8c26914..8f799b1 100644 --- a/PayForMe/Views/Projects/ProjectList.swift +++ b/PayForMe/Views/Projects/ProjectList.swift @@ -12,7 +12,7 @@ struct ProjectList: View { @ObservedObject var manager = ProjectManager.shared - @State private var addProject: AddingProjectMethod? + @State private var showAddProject = false @State private var shareProject: Project? var body: some View { @@ -28,43 +28,22 @@ struct ProjectList: View { ShareProjectQRCode(project: project) } } - .navigationBarTitle("Projects") - .navigationBarItems(trailing: - HStack { - Button(action: { - self.addProject = .manual - }) { - Image(systemName: "plus").fancyStyle() - } - Button(action: { - self.addProject = .qrCode - }) { - Image(systemName: "qrcode").fancyStyle() + .navigationTitle("Projects") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + showAddProject = true + } label: { + Image(systemName: "plus") } + .accessibilityLabel(Text("Add project")) } - .sheet(item: $addProject, content: { method -> AnyView in - switch method { - case .qrCode: - return destination.eraseToAnyView() - case .manual: - return AddProjectManualView().eraseToAnyView() - } - }) - ) - } - .navigationViewStyle(StackNavigationViewStyle()) - } - - private enum AddingProjectMethod: Int, CaseIterable, Identifiable { - var id: Int { - switch self { - case .qrCode: return 0 - case .manual: return 1 + } + .sheet(isPresented: $showAddProject) { + AddProjectManualView() } } - - case qrCode - case manual + .navigationViewStyle(StackNavigationViewStyle()) } func deleteProject(at offsets: IndexSet) { @@ -74,10 +53,6 @@ struct ProjectList: View { } } } - - var destination: some View { - ProjectQRPermissionCheckerView() - } } struct ServerList_Previews: PreviewProvider { From ab575b1c7aa426aee74ffd6f597e15b9abd61e74 Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Wed, 22 Jul 2026 05:07:45 +0200 Subject: [PATCH 02/10] improved error feedback --- PayForMe/Strings/de.lproj/Localizable.strings | 6 +++--- PayForMe/Strings/en.lproj/Localizable.strings | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/PayForMe/Strings/de.lproj/Localizable.strings b/PayForMe/Strings/de.lproj/Localizable.strings index af634f2..0de8b15 100644 --- a/PayForMe/Strings/de.lproj/Localizable.strings +++ b/PayForMe/Strings/de.lproj/Localizable.strings @@ -145,8 +145,8 @@ "Scan QR code" = "QR-Code scannen"; "Backend" = "Backend"; "server_hint_cospend" = "Dein selbstgehosteter Cospend-Server (Nextcloud), z. B. https://cloud.beispiel.de"; -"server_hint_ihatemoney" = "Leer lassen für das offizielle ihatemoney.org oder eigene Instanz eintragen."; -"Could not add member" = "Mitglied konnte nicht hinzugefügt werden"; -"member_error_forbidden" = "Dafür sind meist Maintainer-Rechte am Projekt nötig. Das geteilte Projekt-Passwort erlaubt oft nur Teilnehmer-Zugriff."; +"server_hint_ihatemoney" = "Leer lassen für das offizielle ihatemoney.org oder eigene Domain eintragen."; +"Could not add member" = "Mitglied hinzugefügt gescheitert"; +"member_error_forbidden" = "Für den Erfolg sind meist Maintainer-Rechte am Projekt nötig. Du kannst diese Berechtigungen jederzeit erteilen, indem du die Einstellungen des Freigabelinks in Cospend (deiner Nextcloud-App) änderst."; "member_error_network" = "Keine Verbindung zum Server. Bitte Netzwerk prüfen und erneut versuchen."; "member_error_generic" = "Der Server hat die Anfrage abgelehnt (HTTP %d)."; diff --git a/PayForMe/Strings/en.lproj/Localizable.strings b/PayForMe/Strings/en.lproj/Localizable.strings index 0894e67..38eba34 100644 --- a/PayForMe/Strings/en.lproj/Localizable.strings +++ b/PayForMe/Strings/en.lproj/Localizable.strings @@ -148,8 +148,8 @@ "Scan QR code" = "Scan QR code"; "Backend" = "Backend"; "server_hint_cospend" = "Your self-hosted Cospend (Nextcloud) server, e.g. https://cloud.example.com"; -"server_hint_ihatemoney" = "Leave empty for the official ihatemoney.org, or enter your own instance."; +"server_hint_ihatemoney" = "Leave empty for the official ihatemoney.org, or enter your own domain."; "Could not add member" = "Could not add member"; -"member_error_forbidden" = "This usually requires maintainer rights for the project. The shared project password may only grant participant access."; +"member_error_forbidden" = "This usually requires maintainer rights for the project. You can grant those at any time by changing the share-link's settings in Cospend (your Nextcloud-App)."; "member_error_network" = "No connection to the server. Please check your network and try again."; "member_error_generic" = "The server rejected the request (HTTP %d)."; From c7f11bfa1edf743093b8896ba5246fa7ed8083de Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Wed, 22 Jul 2026 07:32:49 +0200 Subject: [PATCH 03/10] Redesign add-project flow with invite-link support Accept a full iHateMoney invite link (server and project are derived automatically) alongside the raw-token fallback. Make the project password optional (Cospend sends no-pass when empty) and keep separate input state per backend when switching tabs. Move the QR scanner into a sheet that dismisses the whole flow on success, and treat an already-existing project as activation rather than an error. --- PayForMe/Services/ProjectManager.swift | 17 ++- PayForMe/Strings/cs.lproj/Localizable.strings | 6 +- PayForMe/Strings/de.lproj/Localizable.strings | 14 +- PayForMe/Strings/en.lproj/Localizable.strings | 10 +- PayForMe/Strings/es.lproj/Localizable.strings | 6 +- PayForMe/Strings/fr.lproj/Localizable.strings | 6 +- PayForMe/Strings/ru.lproj/Localizable.strings | 6 +- .../Manual/AddProjectManualView.swift | 141 +++++++++++------- .../Manual/AddProjectManualViewModel.swift | 129 ++++++++++++++-- .../Projects/QRCodes/AddProjectQRView.swift | 12 +- .../ProjectQRPermissionCheckerView.swift | 5 +- 11 files changed, 255 insertions(+), 97 deletions(-) diff --git a/PayForMe/Services/ProjectManager.swift b/PayForMe/Services/ProjectManager.swift index 658625d..a8b474c 100644 --- a/PayForMe/Services/ProjectManager.swift +++ b/PayForMe/Services/ProjectManager.swift @@ -185,15 +185,19 @@ enum StoringError: Error { extension ProjectManager { func addProject(_ project: Project) throws { - guard storageService.saveProject(project: project) else { + let didSave = storageService.saveProject(project: project) + let reloaded = storageService.loadProjects() + + // An already-existing project is not an error: it just gets activated (same for manual add + // and QR scan). Only a real save failure throws. + guard didSave || reloaded.contains(project) else { throw StoringError.couldNotSave } DispatchQueue.main.async { [self] in - projects = storageService.loadProjects() + projects = reloaded - if projects.count == 1 { - setCurrentProject(project) - } + // Always activate the added (or already-known) project, whether it's the first or not. + setCurrentProject(project) openedByURL = nil print("project added") } @@ -240,8 +244,7 @@ extension ProjectManager { deleteBillFromServer(bill: bill, completion: completion) } - /// Legt ein Mitglied an und meldet den HTTP-Statuscode zurück (2xx = Erfolg, sonst Fehler; - /// -1 bei Transportfehler). So kann die UI einen echten Fehler anzeigen statt still zu scheitern. + /// Creates a member and reports the HTTP status code (2xx = success, -1 = transport error) so the UI can surface a real error. func addMember(_ name: String, completion: @escaping (Int) -> Void) { cancellable?.cancel() cancellable = NetworkService.shared.createMemberStatusPublisher(name: name) diff --git a/PayForMe/Strings/cs.lproj/Localizable.strings b/PayForMe/Strings/cs.lproj/Localizable.strings index 51ff16f..1e5dfad 100644 --- a/PayForMe/Strings/cs.lproj/Localizable.strings +++ b/PayForMe/Strings/cs.lproj/Localizable.strings @@ -54,7 +54,7 @@ "Enter project id" = "Zadejte identifikátor projektu"; -"Enter project password" = "Zadejte heslo k projektu"; +"Enter project password (Optional)" = "Zadejte heslo k projektu (volitelné)"; /* */ "Expense date" = "Datum výdaje"; @@ -144,8 +144,8 @@ "Cancel" = "Zrušit"; "Scan QR code" = "Naskenovat QR kód"; "Backend" = "Backend"; -"server_hint_cospend" = "Váš vlastní server Cospend (Nextcloud), např. https://cloud.priklad.cz"; -"server_hint_ihatemoney" = "Ponechte prázdné pro oficiální ihatemoney.org, nebo zadejte vlastní instanci."; +"server_hint_cospend" = "Váš vlastní server Cospend (Nextcloud)."; +"server_hint_ihatemoney" = "Ponechte prázdné pro oficiální instanci, nebo zadejte vlastní instanci."; "Could not add member" = "Člena se nepodařilo přidat"; "member_error_forbidden" = "To obvykle vyžaduje práva správce projektu. Sdílené heslo projektu může umožňovat jen přístup účastníka."; "member_error_network" = "Není spojení se serverem. Zkontrolujte síť a zkuste to znovu."; diff --git a/PayForMe/Strings/de.lproj/Localizable.strings b/PayForMe/Strings/de.lproj/Localizable.strings index 0de8b15..ac9b1f5 100644 --- a/PayForMe/Strings/de.lproj/Localizable.strings +++ b/PayForMe/Strings/de.lproj/Localizable.strings @@ -51,7 +51,7 @@ "Enter project id" = "Projekt ID eingeben"; -"Enter project password" = "Projekt Passwort eingeben"; +"Enter project password (Optional)" = "Projekt Passwort eingeben (optional)"; /* */ "Expense date" = "Datum der Aufwendung"; @@ -86,6 +86,10 @@ "Paste Link" = "Link einsetzen"; +"Invite Link" = "Invite-Link"; +"Enter invite link" = "Invite-Link einsetzen"; +"invite_link_hint" = "Kompletten Invite-Link einsetzen. Server und Projekt werden automatisch erkannt."; + "Payer" = "Zahler"; @@ -114,10 +118,10 @@ "Sure" = "Sicher"; -"To get started sharing expenses with friends, you must add a project from Cospend or iHateMoney. \n\n Cospend projects can be added by QR Code or by manually filling out the information, for iHateMoney you need to fill out the information manually." = "Um deine Ausgaben mit Freunden teilen zu können, musst du ein Projekt von Cospend oder iHateMoney hinzufügen \n\n Cospend Projekte können manuell oder mit QR-Code hinzugefügt werden, für iHateMoney musst du das Projekt manuell hinzufügen."; +"To get started sharing expenses with friends, you must add a project from Cospend or iHateMoney. To do this, scan the QR code or click the link for the project that was shared with you." = "Um deine Ausgaben mit Freunden teilen zu können, musst du ein Projekt von Cospend oder iHateMoney hinzufügen. Scanne dazu den QR-Code oder Link des Projekts, der dir geteilt wurde."; -"To use iHateMoney, host an own instance or register at" = "Um iHateMoney zu benutzen, hoste eine eigene Instanz oder registrier dich bei"; +"To use iHateMoney, host an own instance or register at" = "Um iHateMoney zu benutzen, hoste eine eigene Instanz oder registriere dich bei"; "Type password here" = "Hier Passwort eingeben"; @@ -144,8 +148,8 @@ "Cancel" = "Abbrechen"; "Scan QR code" = "QR-Code scannen"; "Backend" = "Backend"; -"server_hint_cospend" = "Dein selbstgehosteter Cospend-Server (Nextcloud), z. B. https://cloud.beispiel.de"; -"server_hint_ihatemoney" = "Leer lassen für das offizielle ihatemoney.org oder eigene Domain eintragen."; +"server_hint_cospend" = "Dein selbstgehosteter Cospend-Server (Nextcloud)."; +"server_hint_ihatemoney" = "Leer lassen für die offizielle Instanz oder eigene Domain eintragen."; "Could not add member" = "Mitglied hinzugefügt gescheitert"; "member_error_forbidden" = "Für den Erfolg sind meist Maintainer-Rechte am Projekt nötig. Du kannst diese Berechtigungen jederzeit erteilen, indem du die Einstellungen des Freigabelinks in Cospend (deiner Nextcloud-App) änderst."; "member_error_network" = "Keine Verbindung zum Server. Bitte Netzwerk prüfen und erneut versuchen."; diff --git a/PayForMe/Strings/en.lproj/Localizable.strings b/PayForMe/Strings/en.lproj/Localizable.strings index 38eba34..a5fbe42 100644 --- a/PayForMe/Strings/en.lproj/Localizable.strings +++ b/PayForMe/Strings/en.lproj/Localizable.strings @@ -54,7 +54,7 @@ "Enter project id" = "Enter project id"; -"Enter project password" = "Enter project password"; +"Enter project password (Optional)" = "Enter project password (Optional)"; "Expense date" = "Expense date"; @@ -89,6 +89,10 @@ "Paste Link" = "Paste Link"; +"Invite Link" = "Invite Link"; +"Enter invite link" = "Enter invite link"; +"invite_link_hint" = "Paste the full invite link. Server and project are detected automatically."; + /* BillDetail.swift */ "Payer" = "Payer"; @@ -147,8 +151,8 @@ "Cancel" = "Cancel"; "Scan QR code" = "Scan QR code"; "Backend" = "Backend"; -"server_hint_cospend" = "Your self-hosted Cospend (Nextcloud) server, e.g. https://cloud.example.com"; -"server_hint_ihatemoney" = "Leave empty for the official ihatemoney.org, or enter your own domain."; +"server_hint_cospend" = "Your self-hosted Cospend (Nextcloud) server."; +"server_hint_ihatemoney" = "Leave empty for the official instance, or enter your own domain."; "Could not add member" = "Could not add member"; "member_error_forbidden" = "This usually requires maintainer rights for the project. You can grant those at any time by changing the share-link's settings in Cospend (your Nextcloud-App)."; "member_error_network" = "No connection to the server. Please check your network and try again."; diff --git a/PayForMe/Strings/es.lproj/Localizable.strings b/PayForMe/Strings/es.lproj/Localizable.strings index 62e03cb..8e48d21 100644 --- a/PayForMe/Strings/es.lproj/Localizable.strings +++ b/PayForMe/Strings/es.lproj/Localizable.strings @@ -51,7 +51,7 @@ "Enter project id" = "Entrar el ID del proyecto"; -"Enter project password" = "Entrar la contraseña del proyecto"; +"Enter project password (Optional)" = "Entrar la contraseña del proyecto (opcional)"; /* */ "Expense date" = "Fecha de gasto"; @@ -147,8 +147,8 @@ "Cancel" = "Cancelar"; "Scan QR code" = "Escanear código QR"; "Backend" = "Backend"; -"server_hint_cospend" = "Tu servidor Cospend autoalojado (Nextcloud), p. ej. https://cloud.ejemplo.com"; -"server_hint_ihatemoney" = "Déjalo vacío para el ihatemoney.org oficial, o introduce tu instancia."; +"server_hint_cospend" = "Tu servidor Cospend autoalojado (Nextcloud)."; +"server_hint_ihatemoney" = "Déjalo vacío para la instancia oficial, o introduce tu instancia."; "Could not add member" = "No se pudo añadir el miembro"; "member_error_forbidden" = "Esto suele requerir permisos de mantenedor del proyecto. La contraseña compartida puede dar solo acceso de participante."; "member_error_network" = "Sin conexión con el servidor. Comprueba tu red e inténtalo de nuevo."; diff --git a/PayForMe/Strings/fr.lproj/Localizable.strings b/PayForMe/Strings/fr.lproj/Localizable.strings index b9ba5aa..a98e63c 100644 --- a/PayForMe/Strings/fr.lproj/Localizable.strings +++ b/PayForMe/Strings/fr.lproj/Localizable.strings @@ -51,7 +51,7 @@ "Enter project id" = "Entrer le ID du projet"; -"Enter project password" = "Entrer le mot de passe du projet"; +"Enter project password (Optional)" = "Entrer le mot de passe du projet (facultatif)"; /* */ "Expense date" = "Date de la dépense"; @@ -147,8 +147,8 @@ "Cancel" = "Annuler"; "Scan QR code" = "Scanner le QR code"; "Backend" = "Backend"; -"server_hint_cospend" = "Votre serveur Cospend auto-hébergé (Nextcloud), par ex. https://cloud.exemple.com"; -"server_hint_ihatemoney" = "Laissez vide pour le ihatemoney.org officiel, ou saisissez votre instance."; +"server_hint_cospend" = "Votre serveur Cospend auto-hébergé (Nextcloud)."; +"server_hint_ihatemoney" = "Laissez vide pour l'instance officielle, ou saisissez votre instance."; "Could not add member" = "Impossible d'ajouter le membre"; "member_error_forbidden" = "Cela nécessite généralement des droits de mainteneur sur le projet. Le mot de passe partagé peut ne donner qu'un accès participant."; "member_error_network" = "Pas de connexion au serveur. Vérifiez votre réseau et réessayez."; diff --git a/PayForMe/Strings/ru.lproj/Localizable.strings b/PayForMe/Strings/ru.lproj/Localizable.strings index 52ba8dd..29896f9 100644 --- a/PayForMe/Strings/ru.lproj/Localizable.strings +++ b/PayForMe/Strings/ru.lproj/Localizable.strings @@ -51,7 +51,7 @@ "Enter project id" = "Введите id проекта"; -"Enter project password" = "Введите пароль проекта"; +"Enter project password (Optional)" = "Введите пароль проекта (необязательно)"; /* */ "Expense date" = "Дата расхода"; @@ -150,8 +150,8 @@ "Cancel" = "Отмена"; "Scan QR code" = "Сканировать QR-код"; "Backend" = "Бэкенд"; -"server_hint_cospend" = "Ваш собственный сервер Cospend (Nextcloud), например https://cloud.example.com"; -"server_hint_ihatemoney" = "Оставьте пустым для официального ihatemoney.org или укажите свой сервер."; +"server_hint_cospend" = "Ваш собственный сервер Cospend (Nextcloud)."; +"server_hint_ihatemoney" = "Оставьте пустым для официального сервера или укажите свой."; "Could not add member" = "Не удалось добавить участника"; "member_error_forbidden" = "Обычно для этого нужны права сопровождающего проекта. Пароль общего доступа может давать только права участника."; "member_error_network" = "Нет соединения с сервером. Проверьте сеть и повторите попытку."; diff --git a/PayForMe/Views/Projects/Manual/AddProjectManualView.swift b/PayForMe/Views/Projects/Manual/AddProjectManualView.swift index 368dbdf..dd70fe1 100644 --- a/PayForMe/Views/Projects/Manual/AddProjectManualView.swift +++ b/PayForMe/Views/Projects/Manual/AddProjectManualView.swift @@ -17,34 +17,13 @@ struct AddProjectManualView: View { @StateObject private var viewmodel = AddProjectManualViewModel() - /// True, wenn die Zwischenablage (datenschutzfreundlich, ohne Inhalt zu lesen) eine URL enthält. @State private var clipboardHasURL = false + @State private var showQRScanner = false + var body: some View { NavigationView { Form { - if clipboardHasURL { - Section { - pasteButton - } - } - - Section { - NavigationLink { - ProjectQRPermissionCheckerView() - } label: { - Label("Scan QR code", systemImage: "qrcode.viewfinder") - } - } - - Section { - Picker("Backend", selection: $viewmodel.projectType) { - Text("Cospend").tag(ProjectBackend.cospend) - Text("iHateMoney").tag(ProjectBackend.iHateMoney) - } - .pickerStyle(.segmented) - } - Section( header: Text(LocalizedStringKey(viewmodel.projectType == .iHateMoney ? "Server Address (Optional)" : "Server Address")), footer: Text(LocalizedStringKey(viewmodel.projectType == .cospend ? "server_hint_cospend" : "server_hint_ihatemoney")) @@ -59,27 +38,37 @@ struct AddProjectManualView: View { Section(header: Text("Project ID & Password")) { TextField("Enter project id", text: self.$viewmodel.projectName) .autocapitalization(.none) - SecureField("Enter project password", text: self.$viewmodel.projectPassword) + SecureField("Enter project password (Optional)", text: self.$viewmodel.projectPassword) } if viewmodel.projectType == .iHateMoney { - Section(header: Text("Invite Token")) { - TextField("Enter invite url", text: self.$viewmodel.inviteUrl) + Section( + header: Text("Invite Link"), + footer: Text("invite_link_hint") + ) { + TextField("Enter invite link", text: self.$viewmodel.inviteUrl) .autocapitalization(.none) + .autocorrectionDisabled() } } Section { - HStack { - Spacer() - if viewmodel.validationProgress == .connecting { + if viewmodel.validationProgress == .connecting { + HStack { + Spacer() SlickLoadingSpinner(connectionState: viewmodel.validationProgress) .frame(width: 50, height: 50) - } else { - FancyButton(add: false, action: addButton, text: "Add Project") - .disabled(viewmodel.validationProgress != .success) + Spacer() + } + } else { + Button(action: addButton) { + Text("Add Project") + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) } - Spacer() + .prominentActionStyle(active: viewmodel.validationProgress == .success) + .disabled(viewmodel.validationProgress != .success) } if !viewmodel.errorText.isEmpty { Text(viewmodel.errorText) @@ -87,8 +76,27 @@ struct AddProjectManualView: View { .frame(maxWidth: .infinity, alignment: .center) } } + .listRowBackground(Color.clear) } .id(viewmodel.projectType == .cospend ? "cospend" : "iHateMoney") + .safeAreaInset(edge: .top, spacing: 0) { + VStack(spacing: 20) { + Picker("Backend", selection: $viewmodel.projectType) { + Text("Cospend").tag(ProjectBackend.cospend) + Text("iHateMoney").tag(ProjectBackend.iHateMoney) + } + .pickerStyle(.segmented) + .controlSize(.large) + + HStack(spacing: 12) { + qrButton + pasteButton + } + } + .padding(.horizontal) + .padding(.top, 8) + .padding(.bottom, 24) + } .navigationTitle("Add project") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -99,31 +107,54 @@ struct AddProjectManualView: View { } .navigationViewStyle(StackNavigationViewStyle()) .onAppear { detectClipboard() } + .sheet(isPresented: $showQRScanner) { + NavigationView { + ProjectQRPermissionCheckerView(onFinish: { dismiss() }) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { showQRScanner = false } + } + } + } + .navigationViewStyle(StackNavigationViewStyle()) + } + } + + private var qrButton: some View { + Button { + showQRScanner = true + } label: { + Text("Scan QR code") + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + .prominentActionStyle() } - @ViewBuilder private var pasteButton: some View { - if #available(iOS 16.0, *) { - PasteButton(payloadType: String.self) { strings in - guard let first = strings.first else { return } - pasteLink(pasteString: first) - } - } else { - Button { - pasteLink() - } label: { - Label("Paste Link", systemImage: "doc.on.clipboard") - } + Button { + handlePaste() + } label: { + Text("Paste Link") + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) } + .prominentActionStyle(active: clipboardHasURL) + .disabled(!clipboardHasURL) } - /// Prüft datenschutzfreundlich (ohne Inhalt zu lesen, kein Paste-Hinweis), ob die - /// Zwischenablage eine URL enthält, und blendet nur dann den Einfügen-Button ein. + /// Reads the clipboard contents only after a pattern check confirms a URL, to avoid a paste prompt. private func detectClipboard() { UIPasteboard.general.detectPatterns(for: [\.probableWebURL]) { result in - guard case let .success(patterns) = result else { return } + guard case let .success(patterns) = result, patterns.contains(\.probableWebURL) else { + DispatchQueue.main.async { clipboardHasURL = false } + return + } + let content = UIPasteboard.general.string ?? "" DispatchQueue.main.async { - clipboardHasURL = patterns.contains(\.probableWebURL) + clipboardHasURL = viewmodel.canPaste(content) } } } @@ -132,16 +163,10 @@ struct AddProjectManualView: View { viewmodel.addProject() dismiss() } - - private func pasteLink(pasteString: String) { - viewmodel.pasteAddress(address: pasteString) - } - private func pasteLink() { - if let pasteString = UIPasteboard.general.string { - print(pasteString) - viewmodel.pasteAddress(address: pasteString) - } + private func handlePaste() { + guard let pasteString = UIPasteboard.general.string else { return } + viewmodel.pasteAddress(address: pasteString) } } diff --git a/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift b/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift index 1b2b4b6..2b652e9 100644 --- a/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift +++ b/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift @@ -39,6 +39,20 @@ class AddProjectManualViewModel: ObservableObject { private var cancellables = Set() + /// Saved input state per backend, so fields persist when switching between Cospend and iHateMoney without mixing. + private struct TabState { + var serverAddress = "" + var projectName = "" + var projectPassword = "" + var inviteUrl = "" + var validationProgress = LoadingState.notStarted + var errorText = "" + var lastProjectTestedSuccessfully: Project? + } + + private var tabStates: [ProjectBackend: TabState] = [:] + private var currentTab: ProjectBackend = .cospend + init() { validatedInput.map { _ in LoadingState.connecting }.assign(to: &$validationProgress) validatedServer.map { $0 == 200 ? LoadingState.success : LoadingState.failure }.assign(to: &$validationProgress) @@ -50,6 +64,56 @@ class AddProjectManualViewModel: ObservableObject { .debounce(for: .seconds(1), scheduler: DispatchQueue.main) .sink { [weak self] token in self?.validateInviteToken(token) } .store(in: &cancellables) + + // On backend switch, save the old tab's state and restore the new one, keeping Cospend and + // iHateMoney inputs separate. Runs synchronously when projectType is set; since `pasteAddress` + // sets projectType before the fields, a pasted link correctly overwrites the restored values. + $projectType + .dropFirst() + .sink { [weak self] newTab in + guard let self, newTab != self.currentTab else { return } + self.tabStates[self.currentTab] = TabState( + serverAddress: self.serverAddress, + projectName: self.projectName, + projectPassword: self.projectPassword, + inviteUrl: self.inviteUrl, + validationProgress: self.validationProgress, + errorText: self.errorText, + lastProjectTestedSuccessfully: self.lastProjectTestedSuccessfully + ) + let restored = self.tabStates[newTab] ?? TabState() + self.currentTab = newTab + self.serverAddress = restored.serverAddress + self.projectName = restored.projectName + self.projectPassword = restored.projectPassword + self.inviteUrl = restored.inviteUrl + self.lastProjectTestedSuccessfully = restored.lastProjectTestedSuccessfully + // Set status last so the synchronous reset/error publishers above (reacting to field changes) don't overwrite it. + self.validationProgress = restored.validationProgress + self.errorText = restored.errorText + } + .store(in: &cancellables) + + // When input becomes incomplete (e.g. pasted link cleared or tab switched), reset the + // connection spinner immediately instead of spinning forever. + Publishers.CombineLatest4($projectType, $serverAddress, $projectName, $projectPassword) + .combineLatest($inviteUrl) + .sink { [weak self] combo, invite in + guard let self else { return } + let (type, server, name, password) = combo + let hasCompleteInput: Bool + switch type { + case .cospend: + hasCompleteInput = !server.isEmpty && !name.isEmpty && !password.isEmpty + case .iHateMoney: + hasCompleteInput = !invite.isEmpty + } + if !hasCompleteInput { + self.validationProgress = .notStarted + self.errorText = "" + } + } + .store(in: &cancellables) } @@ -65,14 +129,16 @@ class AddProjectManualViewModel: ObservableObject { do { try ProjectManager.shared.addProject(project) } catch { - errorText = "Project already exists!" + errorText = "Could not save project" } } - private func validateInviteToken(_ token: String) { - guard !token.isEmpty, !serverAddress.isEmpty, !projectName.isEmpty else { return } - let baseUrl = serverAddress.hasPrefix("https://") ? serverAddress : "https://\(serverAddress)" - let inviteData = InviteData(baseUrl: baseUrl, token: token, project: projectName) + private func validateInviteToken(_ input: String) { + guard let inviteData = inviteData(from: input) else { + // Incomplete/empty input: don't get stuck in the connection spinner. + validationProgress = .notStarted + return + } validationProgress = .connecting Task { @MainActor in do { @@ -86,12 +152,52 @@ class AddProjectManualViewModel: ObservableObject { } } + /// Builds `InviteData` from the field contents. Accepts a full invite URL of the form + /// `https://host//join/` (server and project are derived and filled into the + /// fields) or, as a fallback, a raw token with separately filled server and project. + private func inviteData(from input: String) -> InviteData? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if let url = URL(string: trimmed), + url.pathComponents.contains("join"), + url.pathComponents.count >= 4, + let scheme = url.scheme, let host = url.host { + let baseUrl = "\(scheme)://\(host)" + let project = url.pathComponents[1] + let token = url.pathComponents[3] + serverAddress = baseUrl + projectName = project + return InviteData(baseUrl: baseUrl, token: token, project: project) + } + + guard !serverAddress.isEmpty, !projectName.isEmpty else { return nil } + let baseUrl = serverAddress.hasPrefix("https://") ? serverAddress : "https://\(serverAddress)" + return InviteData(baseUrl: baseUrl, token: trimmed, project: projectName) + } + + /// Checks whether clipboard text is a project link that fits the form; same detection as + /// `pasteAddress` but without mutating the fields. + func canPaste(_ string: String) -> Bool { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: trimmed) else { return false } + switch url.decodeQRCode() { + case is ProjectDataWithPassword, is ProjectDataWithToken: + return true + default: + return url.pathComponents.contains("join") + && url.scheme != nil && url.host != nil + && url.pathComponents.count >= 4 + } + } + func pasteAddress(address: String) { let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines) guard let url = URL(string: trimmedAddress) else { return } switch url.decodeQRCode() { case let project as ProjectDataWithPassword: + projectType = .cospend serverAddress = project.server.absoluteString projectName = project.project projectPassword = project.password ?? "" @@ -162,12 +268,17 @@ class AddProjectManualViewModel: ObservableObject { Publishers.CombineLatest3(validatedAddress, $projectName, $projectPassword) .debounce(for: 1, scheduler: DispatchQueue.main) .compactMap { server, token, password -> Project? in - if let address = server.address, address.isValidURL, !token.isEmpty, !password.isEmpty { - guard let url = URL(string: address) else { return nil } - return Project(name: token, password: password, token: token, backend: server.0, url: url, projectId: self.projectName) + guard let address = server.address, address.isValidURL, !token.isEmpty, + let url = URL(string: address) else { return nil } + // Cospend allows an empty password: "no-pass" is sent then. The user can enter a real password to override. + let effectivePassword: String + if server.0 == .cospend { + effectivePassword = password.isEmpty ? "no-pass" : password } else { - return nil + guard !password.isEmpty else { return nil } + effectivePassword = password } + return Project(name: token, password: effectivePassword, token: token, backend: server.0, url: url, projectId: self.projectName) } .removeDuplicates() .share() diff --git a/PayForMe/Views/Projects/QRCodes/AddProjectQRView.swift b/PayForMe/Views/Projects/QRCodes/AddProjectQRView.swift index b9b67a0..558e9ef 100644 --- a/PayForMe/Views/Projects/QRCodes/AddProjectQRView.swift +++ b/PayForMe/Views/Projects/QRCodes/AddProjectQRView.swift @@ -13,7 +13,11 @@ import SwiftUI struct AddProjectQRView: View { @StateObject var viewmodel = AddProjectQRViewModel() - @Environment(\.presentationMode) var presentationMode + @Environment(\.dismiss) private var dismiss + + /// Called after a successful scan. When set, the presenting view dismisses the whole flow; + /// otherwise only this view is dismissed. + var onFinish: (() -> Void)? @State var scanningCode: [AVMetadataObject.ObjectType] = [.qr] @@ -30,7 +34,11 @@ struct AddProjectQRView: View { case .success: DispatchQueue.main.asyncAfter(deadline: DispatchTime.now().advanced(by: .seconds(1))) { withAnimation { - presentationMode.wrappedValue.dismiss() + if let onFinish { + onFinish() + } else { + dismiss() + } } } case .failure: diff --git a/PayForMe/Views/Projects/QRCodes/ProjectQRPermissionCheckerView.swift b/PayForMe/Views/Projects/QRCodes/ProjectQRPermissionCheckerView.swift index 09fc454..7c47f9b 100644 --- a/PayForMe/Views/Projects/QRCodes/ProjectQRPermissionCheckerView.swift +++ b/PayForMe/Views/Projects/QRCodes/ProjectQRPermissionCheckerView.swift @@ -12,10 +12,13 @@ struct ProjectQRPermissionCheckerView: View { @State var cameraAuthStatus = AVCaptureDevice.authorizationStatus(for: .video) + /// Called after a successful scan so the presenting view can dismiss. + var onFinish: (() -> Void)? + var body: some View { switch cameraAuthStatus { case .authorized: - return AddProjectQRView().eraseToAnyView() + return AddProjectQRView(onFinish: onFinish).eraseToAnyView() case .denied: return permissionDeniedView.eraseToAnyView() default: From 0c5d42852c76fa9d68cbd303fcedb7b771f87975 Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Wed, 22 Jul 2026 07:33:01 +0200 Subject: [PATCH 04/10] Refine Liquid Glass action buttons Composite a plus badge onto the floating action button and use plain base symbols (bag, person, folder) so the badge sits consistently. Switch the project list to the same floating glass button, and translate the remaining German inline comments to English. --- PayForMe/Services/NetworkService.swift | 4 ++-- PayForMe/Util/Views+Extensions.swift | 20 ++++++++++++-------- PayForMe/Views/Balance/BalanceList.swift | 5 ++--- PayForMe/Views/BillList/BillList.swift | 2 +- PayForMe/Views/Projects/ProjectList.swift | 13 ++++--------- 5 files changed, 21 insertions(+), 23 deletions(-) diff --git a/PayForMe/Services/NetworkService.swift b/PayForMe/Services/NetworkService.swift index 0a11a26..42ba278 100644 --- a/PayForMe/Services/NetworkService.swift +++ b/PayForMe/Services/NetworkService.swift @@ -159,8 +159,8 @@ class NetworkService { return sendMemberPublisher(request: request) } - /// Wie `createMemberPublisher`, gibt aber den HTTP-Statuscode zurück (bzw. -1 bei - /// Transportfehler), damit die UI einen echten Fehler statt eines stillen `false` anzeigen kann. + /// Like `createMemberPublisher`, but returns the HTTP status code (-1 on transport error) so the + /// UI can surface a real error instead of a silent `false`. func createMemberStatusPublisher(name: String) -> AnyPublisher { let request = buildURLRequest("members", params: ["name": name], project: currentProject, httpMethod: "POST") return URLSession.shared.dataTaskPublisher(for: request) diff --git a/PayForMe/Util/Views+Extensions.swift b/PayForMe/Util/Views+Extensions.swift index 9271893..dc9e567 100644 --- a/PayForMe/Util/Views+Extensions.swift +++ b/PayForMe/Util/Views+Extensions.swift @@ -9,7 +9,6 @@ import Foundation import SwiftUI extension View { - /// Fallback-Look für prominente Aktions-Buttons vor iOS 26 (kein Liquid Glass verfügbar). func fancyStyle(active: Bool = true) -> some View { padding(10) .background(active ? Color.accentColor : Color.secondary) @@ -19,7 +18,6 @@ extension View { .shadow(color: (active ? Color.accentColor : Color.secondary).opacity(0.5), radius: 4, x: 2, y: 2) } - /// Prominenter Aktions-Button: System-Liquid-Glass ab iOS 26, sonst der bisherige `fancyStyle`. @ViewBuilder func prominentActionStyle(active: Bool = true) -> some View { if #available(iOS 26, *) { @@ -29,7 +27,6 @@ extension View { } } - /// Lässt die Tab-Bar beim Scrollen einklappen (Liquid-Glass-Verhalten). No-op vor iOS 26. @ViewBuilder func glassTabBarMinimize() -> some View { if #available(iOS 26, *) { @@ -43,8 +40,6 @@ extension View { AnyView(self) } - /// Kreisförmiger Glass-Hintergrund für einen Button. Ab iOS 26 echtes Liquid Glass, - /// sonst ein Accent-Kreis mit Schatten (analog altem Floating-Button). @ViewBuilder func glassCircleStyle() -> some View { if #available(iOS 26, *) { @@ -60,8 +55,6 @@ extension View { } } - /// Legt einen schwebenden, kreisförmigen Aktions-Button unten rechts über den Inhalt - /// (über der Tab-Bar, à la Apple Music). Daumen-erreichbar. func glassActionButton(systemImage: String, accessibilityLabel: LocalizedStringKey, accessibilityIdentifier: String? = nil, @@ -77,7 +70,8 @@ extension View { } } -/// Schwebender, kreisförmiger Haupt-Aktions-Button (Liquid Glass ab iOS 26). +/// The plus is composited as a separate badge rather than using the `.badge.plus` symbol variant, +/// which sits in a different spot per symbol. struct GlassActionButton: View { let systemImage: String let accessibilityLabel: LocalizedStringKey @@ -88,10 +82,20 @@ struct GlassActionButton: View { Button(action: action) { Image(systemName: systemImage) .font(.title2.weight(.semibold)) + .overlay(alignment: .bottomLeading) { plusBadge } .frame(width: 56, height: 56) } .glassCircleStyle() .accessibilityLabel(Text(accessibilityLabel)) .accessibilityIdentifier(accessibilityIdentifier ?? "") } + + private var plusBadge: some View { + Image(systemName: "plus") + .font(.system(size: 8, weight: .black)) + .foregroundStyle(Color.accentColor) + .padding(3) + .background(.white, in: Circle()) + .offset(x: -4, y: 4) + } } diff --git a/PayForMe/Views/Balance/BalanceList.swift b/PayForMe/Views/Balance/BalanceList.swift index 6af8010..262c3fd 100644 --- a/PayForMe/Views/Balance/BalanceList.swift +++ b/PayForMe/Views/Balance/BalanceList.swift @@ -24,7 +24,7 @@ struct BalanceList: View { NavigationView { list .navigationTitle("Members") - .glassActionButton(systemImage: "person.badge.plus", + .glassActionButton(systemImage: "person.fill", accessibilityLabel: "Add member", accessibilityIdentifier: "Add member") { showAddUser() @@ -74,7 +74,7 @@ struct BalanceList: View { self.memberName = "" ProjectManager.shared.loadBillsAndMembers() } else { - // Sheet offen lassen, damit der Nutzer die Eingabe behält und es erneut versuchen kann. + // Keep the sheet open so the user keeps their input and can retry. self.memberAddError = MemberAddError(code: statusCode) } } @@ -105,7 +105,6 @@ struct BalanceList: View { } } -/// Fehler beim Anlegen eines Mitglieds, identifizierbar über den HTTP-Statuscode (für `.alert(item:)`). struct MemberAddError: Identifiable { let code: Int var id: Int { code } diff --git a/PayForMe/Views/BillList/BillList.swift b/PayForMe/Views/BillList/BillList.swift index 59cf628..1a197d0 100644 --- a/PayForMe/Views/BillList/BillList.swift +++ b/PayForMe/Views/BillList/BillList.swift @@ -26,7 +26,7 @@ struct BillList: View { await ProjectManager.shared.refresh() } .navigationTitle("Bills") - .glassActionButton(systemImage: "bag.badge.plus", + .glassActionButton(systemImage: "bag", accessibilityLabel: "Add Bill", accessibilityIdentifier: "Add Bill") { showAddBill = true diff --git a/PayForMe/Views/Projects/ProjectList.swift b/PayForMe/Views/Projects/ProjectList.swift index 8f799b1..372130c 100644 --- a/PayForMe/Views/Projects/ProjectList.swift +++ b/PayForMe/Views/Projects/ProjectList.swift @@ -29,15 +29,10 @@ struct ProjectList: View { } } .navigationTitle("Projects") - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - showAddProject = true - } label: { - Image(systemName: "plus") - } - .accessibilityLabel(Text("Add project")) - } + .glassActionButton(systemImage: "folder", + accessibilityLabel: "Add project", + accessibilityIdentifier: "Add project") { + showAddProject = true } .sheet(isPresented: $showAddProject) { AddProjectManualView() From d7a0026758677546755029f66b002068c16a44fe Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Wed, 22 Jul 2026 07:46:53 +0200 Subject: [PATCH 05/10] updates onboarding to reflect new capabilities --- PayForMe/Strings/cs.lproj/Localizable.strings | 2 +- PayForMe/Strings/de.lproj/Localizable.strings | 2 +- PayForMe/Strings/en.lproj/Localizable.strings | 2 +- PayForMe/Strings/es.lproj/Localizable.strings | 2 +- PayForMe/Strings/fr.lproj/Localizable.strings | 2 +- PayForMe/Strings/ru.lproj/Localizable.strings | 2 +- PayForMe/Views/Projects/OnboardingView.swift | 24 +++++++------------ 7 files changed, 14 insertions(+), 22 deletions(-) diff --git a/PayForMe/Strings/cs.lproj/Localizable.strings b/PayForMe/Strings/cs.lproj/Localizable.strings index 1e5dfad..7bbb911 100644 --- a/PayForMe/Strings/cs.lproj/Localizable.strings +++ b/PayForMe/Strings/cs.lproj/Localizable.strings @@ -117,7 +117,7 @@ "Sure" = "Jistě"; -"To get started sharing expenses with friends, you must add a project from Cospend or iHateMoney. \n\n Cospend projects can be added by QR Code or by manually filling out the information, for iHateMoney you need to fill out the information manually." = "Pokud chcete začít sdílet náklady s přáteli, je třeba přidat projekt z Cospend nebo iHateMoney. \n\n Cospend projekty je možné přidávat prostřednictvím QR kódu nebo ručně vyplněním potřebných údajů, pro iHateMoney je třeba vyplnit potřebné údaje vždy ručně."; +"To get started sharing expenses with friends, you must add a project from Cospend or iHateMoney. To do this, scan the QR code or click the link for the project that was shared with you." = "Chcete-li začít sdílet výdaje s přáteli, musíte přidat projekt z Cospend nebo iHateMoney. Naskenujte k tomu QR kód nebo klikněte na odkaz projektu, který vám byl sdílen."; "To use iHateMoney, host an own instance or register at" = "Pokud chcete použít iHateMoney, hostujte si svou vlastní instanci nebo se zaregistrujte na"; diff --git a/PayForMe/Strings/de.lproj/Localizable.strings b/PayForMe/Strings/de.lproj/Localizable.strings index ac9b1f5..0c4cfdb 100644 --- a/PayForMe/Strings/de.lproj/Localizable.strings +++ b/PayForMe/Strings/de.lproj/Localizable.strings @@ -118,7 +118,7 @@ "Sure" = "Sicher"; -"To get started sharing expenses with friends, you must add a project from Cospend or iHateMoney. To do this, scan the QR code or click the link for the project that was shared with you." = "Um deine Ausgaben mit Freunden teilen zu können, musst du ein Projekt von Cospend oder iHateMoney hinzufügen. Scanne dazu den QR-Code oder Link des Projekts, der dir geteilt wurde."; +"To get started sharing expenses with friends, you must add a project from Cospend or iHateMoney. To do this, scan the QR code or click the link for the project that was shared with you." = "Um deine Ausgaben mit Freunden teilen zu können, musst du ein Projekt von Cospend oder iHateMoney hinzufügen. Scanne dazu den QR-Code oder klicke den Link des Projekts, der dir geteilt wurde."; "To use iHateMoney, host an own instance or register at" = "Um iHateMoney zu benutzen, hoste eine eigene Instanz oder registriere dich bei"; diff --git a/PayForMe/Strings/en.lproj/Localizable.strings b/PayForMe/Strings/en.lproj/Localizable.strings index a5fbe42..8fc28df 100644 --- a/PayForMe/Strings/en.lproj/Localizable.strings +++ b/PayForMe/Strings/en.lproj/Localizable.strings @@ -121,7 +121,7 @@ "Sure" = "Sure"; -"To get started sharing expenses with friends, you must add a project from Cospend or iHateMoney. \n\n Cospend projects can be added by QR Code or by manually filling out the information, for iHateMoney you need to fill out the information manually." = "To get started sharing expenses with friends, you must add a project from Cospend or iHateMoney. \n\n Cospend projects can be added by QR Code or by manually filling out the information, for iHateMoney you need to fill out the information manually."; +"To get started sharing expenses with friends, you must add a project from Cospend or iHateMoney. To do this, scan the QR code or click the link for the project that was shared with you." = "To get started sharing expenses with friends, you must add a project from Cospend or iHateMoney. To do this, scan the QR code or click the link for the project that was shared with you."; "To use iHateMoney, host an own instance or register at" = "To use iHateMoney, host an own instance or register at"; diff --git a/PayForMe/Strings/es.lproj/Localizable.strings b/PayForMe/Strings/es.lproj/Localizable.strings index 8e48d21..4d3d34e 100644 --- a/PayForMe/Strings/es.lproj/Localizable.strings +++ b/PayForMe/Strings/es.lproj/Localizable.strings @@ -120,7 +120,7 @@ "Sure" = "Claro"; /* */ -"To get started sharing expenses with friends, you must add a project from Cospend or iHateMoney. \n\n Cospend projects can be added by QR Code or by manually filling out the information, for iHateMoney you need to fill out the information manually." = "Para empezar a compartir gastos con amigos, debes añadir un proyecto de Cospend o iHateMoney. \Los proyectos de Cospend se pueden añadir mediante un código QR o rellenando manualmente la información, para iHateMoney es necesario rellenar la información manualmente."; +"To get started sharing expenses with friends, you must add a project from Cospend or iHateMoney. To do this, scan the QR code or click the link for the project that was shared with you." = "Para empezar a compartir gastos con amigos, debes añadir un proyecto de Cospend o iHateMoney. Para ello, escanea el código QR o haz clic en el enlace del proyecto que te han compartido."; /* */ "To use iHateMoney, host an own instance or register at" = "Para utilizar iHateMoney, aloje una instancia propia o regístrese en"; diff --git a/PayForMe/Strings/fr.lproj/Localizable.strings b/PayForMe/Strings/fr.lproj/Localizable.strings index a98e63c..8862488 100644 --- a/PayForMe/Strings/fr.lproj/Localizable.strings +++ b/PayForMe/Strings/fr.lproj/Localizable.strings @@ -120,7 +120,7 @@ "Sure" = "Bien sûr, c'est une évidence"; /* */ -"To get started sharing expenses with friends, you must add a project from Cospend or iHateMoney. \n\n Cospend projects can be added by QR Code or by manually filling out the information, for iHateMoney you need to fill out the information manually." = "Pour commencer à partager des dépenses avec des amis, vous devez ajouter un projet de Cospend ou de iHateMoney. \Les projets Cospend peuvent être ajoutés par QR Code ou en remplissant manuellement les informations, pour iHateMoney vous devez remplir les informations manuellement."; +"To get started sharing expenses with friends, you must add a project from Cospend or iHateMoney. To do this, scan the QR code or click the link for the project that was shared with you." = "Pour commencer à partager des dépenses avec des amis, vous devez ajouter un projet de Cospend ou de iHateMoney. Pour cela, scannez le QR code ou cliquez sur le lien du projet qui vous a été partagé."; /* */ "To use iHateMoney, host an own instance or register at" = "Pour utiliser iHateMoney, hébergez votre propre instance ou inscrivez-vous à l'adresse suivante"; diff --git a/PayForMe/Strings/ru.lproj/Localizable.strings b/PayForMe/Strings/ru.lproj/Localizable.strings index 29896f9..985ec73 100644 --- a/PayForMe/Strings/ru.lproj/Localizable.strings +++ b/PayForMe/Strings/ru.lproj/Localizable.strings @@ -123,7 +123,7 @@ "Sure" = "Конечно"; /* */ -"To get started sharing expenses with friends, you must add a project from Cospend or iHateMoney. \n\n Cospend projects can be added by QR Code or by manually filling out the information, for iHateMoney you need to fill out the information manually." = "Чтобы начать делиться расходами с друзьями, вы должны добавить проект из Cospend или iHateMoney. \n\n Проекты Cospend можно добавить с помощью QR-кода или заполнив информацию вручную, для iHateMoney необходимо заполнить информацию вручную."; +"To get started sharing expenses with friends, you must add a project from Cospend or iHateMoney. To do this, scan the QR code or click the link for the project that was shared with you." = "Чтобы начать делиться расходами с друзьями, вы должны добавить проект из Cospend или iHateMoney. Для этого отсканируйте QR-код или откройте ссылку на проект, которым с вами поделились."; /* */ "To use iHateMoney, host an own instance or register at" = "Чтобы использовать iHateMoney, создайте собственный экземпляр или зарегистрируйтесь на сайте"; diff --git a/PayForMe/Views/Projects/OnboardingView.swift b/PayForMe/Views/Projects/OnboardingView.swift index a442196..c8f7354 100644 --- a/PayForMe/Views/Projects/OnboardingView.swift +++ b/PayForMe/Views/Projects/OnboardingView.swift @@ -14,22 +14,14 @@ struct OnboardingView: View { NavigationView { VStack(spacing: 32) { Text("Welcome to PayForMe!").font(.largeTitle) - Text("To get started sharing expenses with friends, you must add a project from Cospend or iHateMoney. \n\n Cospend projects can be added by QR Code or by manually filling out the information, for iHateMoney you need to fill out the information manually.") - HStack(spacing: 50) { - NavigationLink(destination: ProjectQRPermissionCheckerView()) { - Image(systemName: "qrcode") - .resizable() - .aspectRatio(contentMode: .fit) - } - NavigationLink( - destination: AddProjectManualView(), - label: { - Image(systemName: "square.and.pencil") - .resizable() - .aspectRatio(contentMode: .fit) - } - ) - }.padding(.horizontal, 30) + Text("To get started sharing expenses with friends, you must add a project from Cospend or iHateMoney. To do this, scan the QR code or click the link for the project that was shared with you.") + NavigationLink(destination: AddProjectManualView()) { + Label("Add project", systemImage: "plus") + .font(.headline) + .padding(.vertical, 4) + .padding(.horizontal, 20) + } + .prominentActionStyle() if moreInfo { Button(action: { withAnimation { From 43bcea569e284076759d34d3cef748fa2164e1be Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Wed, 22 Jul 2026 08:30:11 +0200 Subject: [PATCH 06/10] improve UI tests --- PayForMe/Util/Views+Extensions.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/PayForMe/Util/Views+Extensions.swift b/PayForMe/Util/Views+Extensions.swift index dc9e567..e5d9545 100644 --- a/PayForMe/Util/Views+Extensions.swift +++ b/PayForMe/Util/Views+Extensions.swift @@ -79,7 +79,7 @@ struct GlassActionButton: View { let action: () -> Void var body: some View { - Button(action: action) { + let button = Button(action: action) { Image(systemName: systemImage) .font(.title2.weight(.semibold)) .overlay(alignment: .bottomLeading) { plusBadge } @@ -87,7 +87,12 @@ struct GlassActionButton: View { } .glassCircleStyle() .accessibilityLabel(Text(accessibilityLabel)) - .accessibilityIdentifier(accessibilityIdentifier ?? "") + + if let accessibilityIdentifier { + button.accessibilityIdentifier(accessibilityIdentifier) + } else { + button + } } private var plusBadge: some View { From 880ca8d517fd6c4d7fd0899750ee1db7fdb959f5 Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Wed, 22 Jul 2026 08:35:46 +0200 Subject: [PATCH 07/10] matched backend logic with form validation --- .../Views/Projects/Manual/AddProjectManualViewModel.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift b/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift index 2b652e9..ee123f6 100644 --- a/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift +++ b/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift @@ -100,11 +100,12 @@ class AddProjectManualViewModel: ObservableObject { .combineLatest($inviteUrl) .sink { [weak self] combo, invite in guard let self else { return } - let (type, server, name, password) = combo + let (type, server, name, _) = combo let hasCompleteInput: Bool switch type { case .cospend: - hasCompleteInput = !server.isEmpty && !name.isEmpty && !password.isEmpty + // Mirror validatedInput: Cospend accepts an empty password (sent as "no-pass"), + hasCompleteInput = !server.isEmpty && !name.isEmpty case .iHateMoney: hasCompleteInput = !invite.isEmpty } From f114cd8b671ecac03fe83119fd30fd9d810a274c Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Wed, 22 Jul 2026 08:41:31 +0200 Subject: [PATCH 08/10] account for multi-folder structure and custom ports --- .../Manual/AddProjectManualViewModel.swift | 44 ++++++++++++++----- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift b/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift index ee123f6..d347d4c 100644 --- a/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift +++ b/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift @@ -161,12 +161,22 @@ class AddProjectManualViewModel: ObservableObject { guard !trimmed.isEmpty else { return nil } if let url = URL(string: trimmed), - url.pathComponents.contains("join"), - url.pathComponents.count >= 4, - let scheme = url.scheme, let host = url.host { - let baseUrl = "\(scheme)://\(host)" - let project = url.pathComponents[1] - let token = url.pathComponents[3] + let scheme = url.scheme, let host = url.host, + let joinIndex = url.pathComponents.firstIndex(of: "join"), + joinIndex >= 2, joinIndex + 1 < url.pathComponents.count { + // Locate project/token relative to the "join" segment so a reverse-proxied + // subpath doesn't shift the indices. Keep the port and any prefix path so the + // derived base URL stays reachable. + let project = url.pathComponents[joinIndex - 1] + let token = url.pathComponents[joinIndex + 1] + var baseUrl = "\(scheme)://\(host)" + if let port = url.port { + baseUrl += ":\(port)" + } + let prefix = url.pathComponents[1 ..< (joinIndex - 1)] + if !prefix.isEmpty { + baseUrl += "/" + prefix.joined(separator: "/") + } serverAddress = baseUrl projectName = project return InviteData(baseUrl: baseUrl, token: token, project: project) @@ -208,13 +218,23 @@ class AddProjectManualViewModel: ObservableObject { projectName = project.project inviteUrl = project.token default: - guard url.pathComponents.contains("join"), - let scheme = url.scheme, let host = url.host, - url.pathComponents.count >= 4 else { return } + guard let scheme = url.scheme, let host = url.host, + let joinIndex = url.pathComponents.firstIndex(of: "join"), + joinIndex >= 2, joinIndex + 1 < url.pathComponents.count else { return } + // Same relative parsing as inviteData(from:): tolerate a reverse-proxied subpath + // and preserve the port so the base URL stays reachable. projectType = .iHateMoney - serverAddress = "\(scheme)://\(host)" - projectName = url.pathComponents[1] - inviteUrl = url.pathComponents[3] + var baseUrl = "\(scheme)://\(host)" + if let port = url.port { + baseUrl += ":\(port)" + } + let prefix = url.pathComponents[1 ..< (joinIndex - 1)] + if !prefix.isEmpty { + baseUrl += "/" + prefix.joined(separator: "/") + } + serverAddress = baseUrl + projectName = url.pathComponents[joinIndex - 1] + inviteUrl = url.pathComponents[joinIndex + 1] } } From 8860e4f3e090a8a4aa33dbfd4fa589b4da784837 Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Wed, 22 Jul 2026 08:59:29 +0200 Subject: [PATCH 09/10] iOS15 can't access UIPasteboard --- .../Manual/AddProjectManualView.swift | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/PayForMe/Views/Projects/Manual/AddProjectManualView.swift b/PayForMe/Views/Projects/Manual/AddProjectManualView.swift index dd70fe1..280583f 100644 --- a/PayForMe/Views/Projects/Manual/AddProjectManualView.swift +++ b/PayForMe/Views/Projects/Manual/AddProjectManualView.swift @@ -145,17 +145,20 @@ struct AddProjectManualView: View { .disabled(!clipboardHasURL) } - /// Reads the clipboard contents only after a pattern check confirms a URL, to avoid a paste prompt. private func detectClipboard() { - UIPasteboard.general.detectPatterns(for: [\.probableWebURL]) { result in - guard case let .success(patterns) = result, patterns.contains(\.probableWebURL) else { - DispatchQueue.main.async { clipboardHasURL = false } - return - } - let content = UIPasteboard.general.string ?? "" - DispatchQueue.main.async { - clipboardHasURL = viewmodel.canPaste(content) + // iOS 16+ gates the read behind a pattern check, so contents are only inspected (and the + // paste notification only shown) when a URL is present. That API is unavailable on iOS 15. + if #available(iOS 16.0, *) { + UIPasteboard.general.detectPatterns(for: [\.probableWebURL]) { result in + guard (try? result.get())?.contains(\.probableWebURL) == true else { + DispatchQueue.main.async { clipboardHasURL = false } + return + } + let content = UIPasteboard.general.string ?? "" + DispatchQueue.main.async { clipboardHasURL = viewmodel.canPaste(content) } } + } else { + clipboardHasURL = viewmodel.canPaste(UIPasteboard.general.string ?? "") } } From 27f93045d3d05d16b9956d8b8e5597c8c4a33de9 Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Wed, 22 Jul 2026 09:24:25 +0200 Subject: [PATCH 10/10] pre-boot simulator and retry flaky UI tests --- .github/workflows/ios.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 665e942..aa2d288 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -43,6 +43,11 @@ jobs: ") echo "Found simulator: $simulator_id" echo "SIMULATOR_ID=$simulator_id" >> $GITHUB_ENV + - name: Boot Simulator + # Boot the simulator up front so the first UI-test launch doesn't race the cold boot and hit Xcode's launch timeout. + run: | + xcrun simctl boot "$SIMULATOR_ID" + xcrun simctl bootstatus "$SIMULATOR_ID" -b - name: Build env: scheme: ${{ 'default' }} @@ -58,4 +63,4 @@ jobs: if [ $scheme = default ]; then scheme=$(cat default); fi if [ "`ls -A | grep -i \\.xcworkspace\$`" ]; then filetype_parameter="workspace" && file_to_build="`ls -A | grep -i \\.xcworkspace\$`"; else filetype_parameter="project" && file_to_build="`ls -A | grep -i \\.xcodeproj\$`"; fi file_to_build=`echo $file_to_build | awk '{$1=$1;print}'` - xcodebuild test-without-building -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "platform=iOS Simulator,id=$SIMULATOR_ID" + xcodebuild test-without-building -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "platform=iOS Simulator,id=$SIMULATOR_ID" -retry-tests-on-failure -test-iterations 2