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 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..42ba278 100644 --- a/PayForMe/Services/NetworkService.swift +++ b/PayForMe/Services/NetworkService.swift @@ -159,6 +159,16 @@ class NetworkService { return sendMemberPublisher(request: request) } + /// 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) + .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..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,9 +244,19 @@ 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) + /// 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) + .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..7bbb911 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"; @@ -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"; @@ -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)."; +"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."; +"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..0c4cfdb 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 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 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"; @@ -139,3 +143,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)."; +"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."; +"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..8fc28df 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"; @@ -117,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"; @@ -142,3 +146,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."; +"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."; +"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..4d3d34e 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"; @@ -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"; @@ -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)."; +"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."; +"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..8862488 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"; @@ -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"; @@ -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)."; +"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."; +"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..985ec73 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" = "Дата расхода"; @@ -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, создайте собственный экземпляр или зарегистрируйтесь на сайте"; @@ -145,3 +145,14 @@ "What was paid" = "За что платил"; + +/* Feedback round 2.6.0 */ +"Cancel" = "Отмена"; +"Scan QR code" = "Сканировать QR-код"; +"Backend" = "Бэкенд"; +"server_hint_cospend" = "Ваш собственный сервер Cospend (Nextcloud)."; +"server_hint_ihatemoney" = "Оставьте пустым для официального сервера или укажите свой."; +"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..e5d9545 100644 --- a/PayForMe/Util/Views+Extensions.swift +++ b/PayForMe/Util/Views+Extensions.swift @@ -18,11 +18,89 @@ extension View { .shadow(color: (active ? Color.accentColor : Color.secondary).opacity(0.5), radius: 4, x: 2, y: 2) } + @ViewBuilder + func prominentActionStyle(active: Bool = true) -> some View { + if #available(iOS 26, *) { + self.buttonStyle(.glassProminent) + } else { + self.fancyStyle(active: active) + } + } + + @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()) + @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) + } + } + + 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) + } + } +} + +/// 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 + var accessibilityIdentifier: String? + let action: () -> Void + + var body: some View { + let button = Button(action: action) { + Image(systemName: systemImage) + .font(.title2.weight(.semibold)) + .overlay(alignment: .bottomLeading) { plusBadge } + .frame(width: 56, height: 56) + } + .glassCircleStyle() + .accessibilityLabel(Text(accessibilityLabel)) + + if let accessibilityIdentifier { + button.accessibilityIdentifier(accessibilityIdentifier) + } else { + button + } + } + + 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 b6237a4..262c3fd 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.fill", + 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 { + // Keep the sheet open so the user keeps their input and can retry. + 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,11 @@ struct BalanceList: View { } } +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..1a197d0 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", + 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..280583f 100644 --- a/PayForMe/Views/Projects/Manual/AddProjectManualView.swift +++ b/PayForMe/Views/Projects/Manual/AddProjectManualView.swift @@ -8,37 +8,25 @@ 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() + @State private var clipboardHasURL = false + + @State private var showQRScanner = 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]) - } - .padding(.top, 10) - } else { - Button("Paste Link") { - pasteLink() - } - .padding(.top, 10) - } + NavigationView { Form { 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,53 +36,140 @@ 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) + TextField("Enter project id", text: self.$viewmodel.projectName) + .autocapitalization(.none) + 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 { + if viewmodel.validationProgress == .connecting { + HStack { + Spacer() + SlickLoadingSpinner(connectionState: viewmodel.validationProgress) + .frame(width: 50, height: 50) + Spacer() + } + } else { + Button(action: addButton) { + Text("Add Project") + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + .prominentActionStyle(active: viewmodel.validationProgress == .success) + .disabled(viewmodel.validationProgress != .success) + } + if !viewmodel.errorText.isEmpty { + Text(viewmodel.errorText) + .foregroundColor(.red) + .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 { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + .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() + } - 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) + private var pasteButton: some View { + Button { + handlePaste() + } label: { + Text("Paste Link") + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + .prominentActionStyle(active: clipboardHasURL) + .disabled(!clipboardHasURL) + } + + private func detectClipboard() { + // 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) } } - .id(viewmodel.projectType == .cospend ? "cospend" : "iHateMoney")} - .padding(.horizontal, 20) - .padding(.vertical, 50) - .background(Color.PFMBackground) + } else { + clipboardHasURL = viewmodel.canPaste(UIPasteboard.general.string ?? "") + } } func addButton() { viewmodel.addProject() - presentationMode.wrappedValue.dismiss() - } - - private func pasteLink(pasteString: String) { - viewmodel.pasteAddress(address: pasteString) + dismiss() } - 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..d347d4c 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,57 @@ 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, _) = combo + let hasCompleteInput: Bool + switch type { + case .cospend: + // Mirror validatedInput: Cospend accepts an empty password (sent as "no-pass"), + hasCompleteInput = !server.isEmpty && !name.isEmpty + case .iHateMoney: + hasCompleteInput = !invite.isEmpty + } + if !hasCompleteInput { + self.validationProgress = .notStarted + self.errorText = "" + } + } + .store(in: &cancellables) } @@ -65,14 +130,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 +153,62 @@ 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), + 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) + } + + 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 ?? "" @@ -101,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] } } @@ -162,12 +289,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/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 { diff --git a/PayForMe/Views/Projects/ProjectList.swift b/PayForMe/Views/Projects/ProjectList.swift index 8c26914..372130c 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,17 @@ 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() - } - } - .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 + .navigationTitle("Projects") + .glassActionButton(systemImage: "folder", + accessibilityLabel: "Add project", + accessibilityIdentifier: "Add project") { + showAddProject = true + } + .sheet(isPresented: $showAddProject) { + AddProjectManualView() } } - - case qrCode - case manual + .navigationViewStyle(StackNavigationViewStyle()) } func deleteProject(at offsets: IndexSet) { @@ -74,10 +48,6 @@ struct ProjectList: View { } } } - - var destination: some View { - ProjectQRPermissionCheckerView() - } } struct ServerList_Previews: PreviewProvider { 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: