From 201885f71cb11ed3122c9ad8fcccedc042042640 Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Fri, 29 May 2026 13:51:46 +0200 Subject: [PATCH 1/8] fix: avoid double slash in API URLs when server URL has trailing slash might be rate limited by some reverse proxies --- PayForMe/Model/Project.swift | 4 ++-- PayForMeTests/NetworkRequestTests.swift | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/PayForMe/Model/Project.swift b/PayForMe/Model/Project.swift index 0935ecd..29efc96 100644 --- a/PayForMe/Model/Project.swift +++ b/PayForMe/Model/Project.swift @@ -94,9 +94,9 @@ enum ProjectBackend: Int, Codable { var staticPath: String { switch self { case .cospend: - return "/index.php/apps/cospend/api/projects" + return "index.php/apps/cospend/api/projects" case .iHateMoney: - return "/api/projects" + return "api/projects" } } } diff --git a/PayForMeTests/NetworkRequestTests.swift b/PayForMeTests/NetworkRequestTests.swift index a7b7037..1a76a5b 100644 --- a/PayForMeTests/NetworkRequestTests.swift +++ b/PayForMeTests/NetworkRequestTests.swift @@ -109,6 +109,24 @@ class NetworkRequestTests: XCTestCase { "Cospend must embed token and password. Got: \(url)") } + func testCospend_loadBills_trailingSlashInServerURL_doesNotProduceDoubleSlash() { + let project = Project.makeCospend(token: "tok", password: "pass", + url: "https://cloud.example.com/") + MockURLProtocol.requestHandler = jsonHandler() + + let exp = expectation(description: "request intercepted") + NetworkService.shared.loadBillsPublisher(project) + .sink { _ in exp.fulfill() } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + + let url = MockURLProtocol.lastCapturedRequest?.url?.absoluteString ?? "" + XCTAssertFalse(url.contains("com//"), + "Server URL with trailing slash must not produce a double slash. Got: \(url)") + XCTAssertTrue(url.contains("/index.php/apps/cospend/api/projects/tok/pass/bills"), + "Cospend path must remain correct with trailing-slash server URL. Got: \(url)") + } + // MARK: - Cospend: no auth header func testCospend_loadBills_noAuthorizationHeader() { From 608139c3ca8b750534c64f9c63b3c73cd61509d0 Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Fri, 29 May 2026 14:26:22 +0200 Subject: [PATCH 2/8] dedupe in-flight bill/member loads, own tab VMs via @StateObject --- PayForMe/Services/ProjectManager.swift | 8 ++++++-- PayForMe/Views/ContentView.swift | 10 ++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/PayForMe/Services/ProjectManager.swift b/PayForMe/Services/ProjectManager.swift index 00a327d..55926af 100644 --- a/PayForMe/Services/ProjectManager.swift +++ b/PayForMe/Services/ProjectManager.swift @@ -12,6 +12,7 @@ class ProjectManager: ObservableObject { private let defaults = UserDefaults.standard private var cancellable: Cancellable? + private var loadCancellable: AnyCancellable? @Published private(set) var projects = [Project]() @@ -60,14 +61,16 @@ class ProjectManager: ObservableObject { let billsPublisher = NetworkService.shared.loadBillsPublisher(project) let membersPublisher = NetworkService.shared.loadMembersPublisher(project) - Publishers.Zip(billsPublisher, membersPublisher) + loadCancellable = Publishers.Zip(billsPublisher, membersPublisher) .map { bills, members in project.bills = bills project.members = members return project } .receive(on: DispatchQueue.main) - .assign(to: &$currentProject) + .sink { [weak self] project in + self?.currentProject = project + } } private func sendBillToServer(bill: Bill, update: Bool, completion: @escaping () -> Void) { @@ -235,6 +238,7 @@ extension ProjectManager { }) else { return } + loadCancellable?.cancel() currentProject = project loadBillsAndMembers() defaults.set(project.id, forKey: "projectID") diff --git a/PayForMe/Views/ContentView.swift b/PayForMe/Views/ContentView.swift index 5535cdd..c0c8597 100644 --- a/PayForMe/Views/ContentView.swift +++ b/PayForMe/Views/ContentView.swift @@ -12,6 +12,12 @@ struct ContentView: View { @ObservedObject var manager = ProjectManager.shared + @StateObject + private var billListViewModel = BillListViewModel() + + @StateObject + private var balanceViewModel = BalanceViewModel() + @State var tabBarIndex = tabBarItems.BillList @@ -36,12 +42,12 @@ struct ContentView: View { var tabBar: some View { TabView(selection: $tabBarIndex) { - BillList(viewModel: BillListViewModel()) + BillList(viewModel: billListViewModel) .tabItem { Image(systemName: "rectangle.stack") } .tag(tabBarItems.BillList) - BalanceList(viewModel: BalanceViewModel()) + BalanceList(viewModel: balanceViewModel) .tabItem { Image(systemName: "arrow.right.arrow.left") } From 7831ae92afcd03fa68fcbec663a22805d618b94a Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Fri, 29 May 2026 14:29:07 +0200 Subject: [PATCH 3/8] fix: re-sort bills when currentProject changes, not only on sortBy --- PayForMe/Views/BillList/BillList.swift | 1 - PayForMe/Views/BillList/BillListViewModel.swift | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/PayForMe/Views/BillList/BillList.swift b/PayForMe/Views/BillList/BillList.swift index 826ee71..4d6cc81 100644 --- a/PayForMe/Views/BillList/BillList.swift +++ b/PayForMe/Views/BillList/BillList.swift @@ -20,7 +20,6 @@ struct BillList: View { iOS15ListContent } .addFloatingAddButton() - .id(viewModel.currentProject.bills) .navigationBarTitle("Bills") .alert(item: $deleteAlert) { index in Alert(title: Text("Delete Bill"), diff --git a/PayForMe/Views/BillList/BillListViewModel.swift b/PayForMe/Views/BillList/BillListViewModel.swift index 478f881..ee36344 100644 --- a/PayForMe/Views/BillList/BillListViewModel.swift +++ b/PayForMe/Views/BillList/BillListViewModel.swift @@ -27,9 +27,9 @@ class BillListViewModel: ObservableObject { init() { currentProject = manager.currentProject cancellable = currentProjectChanged - $sortBy - .map { - $0.sort(bills: self.currentProject.bills) + Publishers.CombineLatest($sortBy, $currentProject) + .map { sortBy, project in + sortBy.sort(bills: project.bills) } .assign(to: &$sortedBills) } From 131312774f5cf9163237c244fad2bb3b52a5bdb8 Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Fri, 29 May 2026 14:39:11 +0200 Subject: [PATCH 4/8] refresh on pull-to-refresh and scene-active instead of every onAppear --- PayForMe/Services/ProjectManager.swift | 11 ++++++++++- PayForMe/Views/Balance/BalanceList.swift | 6 +++--- PayForMe/Views/BillList/BillList.swift | 6 +++--- PayForMe/Views/ContentView.swift | 8 ++++++++ 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/PayForMe/Services/ProjectManager.swift b/PayForMe/Services/ProjectManager.swift index 55926af..582deb4 100644 --- a/PayForMe/Services/ProjectManager.swift +++ b/PayForMe/Services/ProjectManager.swift @@ -55,7 +55,15 @@ class ProjectManager: ObservableObject { // MARK: Server Communication - func loadBillsAndMembers() { + func refresh() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + loadBillsAndMembers { + continuation.resume() + } + } + } + + func loadBillsAndMembers(completion: (() -> Void)? = nil) { let project = currentProject let billsPublisher = NetworkService.shared.loadBillsPublisher(project) @@ -70,6 +78,7 @@ class ProjectManager: ObservableObject { .receive(on: DispatchQueue.main) .sink { [weak self] project in self?.currentProject = project + completion?() } } diff --git a/PayForMe/Views/Balance/BalanceList.swift b/PayForMe/Views/Balance/BalanceList.swift index f4bb6b4..7f784a0 100644 --- a/PayForMe/Views/Balance/BalanceList.swift +++ b/PayForMe/Views/Balance/BalanceList.swift @@ -21,9 +21,6 @@ struct BalanceList: View { NavigationView { mainView .navigationBarTitle("Members") - .onAppear { - ProjectManager.shared.loadBillsAndMembers() - } }.navigationViewStyle(StackNavigationViewStyle()) } @@ -52,6 +49,9 @@ struct BalanceList: View { } } } + .refreshable { + await ProjectManager.shared.refresh() + } } func balanceSort(_ a: Balance, _ b: Balance) -> Bool { diff --git a/PayForMe/Views/BillList/BillList.swift b/PayForMe/Views/BillList/BillList.swift index 4d6cc81..0911652 100644 --- a/PayForMe/Views/BillList/BillList.swift +++ b/PayForMe/Views/BillList/BillList.swift @@ -20,6 +20,9 @@ struct BillList: View { iOS15ListContent } .addFloatingAddButton() + .refreshable { + await ProjectManager.shared.refresh() + } .navigationBarTitle("Bills") .alert(item: $deleteAlert) { index in Alert(title: Text("Delete Bill"), @@ -31,9 +34,6 @@ struct BillList: View { } .listStyle(InsetGroupedListStyle()) } - .onAppear { - ProjectManager.shared.loadBillsAndMembers() - } } @ViewBuilder diff --git a/PayForMe/Views/ContentView.swift b/PayForMe/Views/ContentView.swift index c0c8597..87025c6 100644 --- a/PayForMe/Views/ContentView.swift +++ b/PayForMe/Views/ContentView.swift @@ -18,6 +18,9 @@ struct ContentView: View { @StateObject private var balanceViewModel = BalanceViewModel() + @Environment(\.scenePhase) + private var scenePhase + @State var tabBarIndex = tabBarItems.BillList @@ -38,6 +41,11 @@ struct ContentView: View { .sheet(item: $manager.openedByURL) { url in AddFromURLView(viewmodel: AddProjectQRViewModel(openedByURL: url)) } + .onChange(of: scenePhase) { newPhase in + if newPhase == .active && !manager.projects.isEmpty { + manager.loadBillsAndMembers() + } + } } var tabBar: some View { From ce3074d7bb191dfe8a40e2c2a277cdbf11fd1432 Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Fri, 29 May 2026 14:45:55 +0200 Subject: [PATCH 5/8] hop to main thread before invoking mutation completion handlers URLSession.dataTaskPublisher emits on a background queue. The mutation sinks in ProjectManager (sendBill, deleteBill, sendMember, deleteMember) forwarded that thread straight into user-supplied completion handlers, which then touched @State, @Binding and @Published on the caller side producing "Publishing changes from background threads is not allowed" runtime warnings after every save / delete / add. --- PayForMe/Services/ProjectManager.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/PayForMe/Services/ProjectManager.swift b/PayForMe/Services/ProjectManager.swift index 582deb4..c4e824f 100644 --- a/PayForMe/Services/ProjectManager.swift +++ b/PayForMe/Services/ProjectManager.swift @@ -88,6 +88,7 @@ class ProjectManager: ObservableObject { if update { cancellable = NetworkService.shared.updateBillPublisher(bill: bill) + .receive(on: DispatchQueue.main) .sink { success in if success { print("Bill id\(bill.id) updated") @@ -98,6 +99,7 @@ class ProjectManager: ObservableObject { } } else { cancellable = NetworkService.shared.postBillPublisher(bill: bill) + .receive(on: DispatchQueue.main) .sink { success in if success { print("Bill posted") @@ -114,6 +116,7 @@ class ProjectManager: ObservableObject { cancellable = nil cancellable = NetworkService.shared.deleteBillPublisher(bill: bill) + .receive(on: DispatchQueue.main) .sink { success in if success { print("Bill successfully deleted") @@ -130,6 +133,7 @@ class ProjectManager: ObservableObject { if update { cancellable = NetworkService.shared.updateMemberPublisher(member: member) + .receive(on: DispatchQueue.main) .sink { success in if success { print("Member id\(member.id) updated") @@ -140,6 +144,7 @@ class ProjectManager: ObservableObject { } } else { cancellable = NetworkService.shared.createMemberPublisher(name: member.name) + .receive(on: DispatchQueue.main) .sink { success in if success { print("Member successfully created") @@ -156,6 +161,7 @@ class ProjectManager: ObservableObject { cancellable = nil cancellable = NetworkService.shared.deleteMemberPublisher(member: member) + .receive(on: DispatchQueue.main) .sink { success in if success { print("Member id\(member.id) successfully deleted") From b405ec64416e1d36c831f9b8882bdfd4b666af27 Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Fri, 29 May 2026 14:55:59 +0200 Subject: [PATCH 6/8] route QR-scan publishers to main before publishing isProject/url (see prev. commit) --- .../Views/Projects/QRCodes/AddProjectQRViewModel.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift b/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift index 1456a04..e352bf2 100644 --- a/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift +++ b/PayForMe/Views/Projects/QRCodes/AddProjectQRViewModel.swift @@ -27,8 +27,12 @@ class AddProjectQRViewModel: ObservableObject { init() { foundCodeSink.store(in: &subscriptions) - passwordCorrect.assign(to: &$isProject) - isTestingSubject.assign(to: &$isProject) + passwordCorrect + .receive(on: DispatchQueue.main) + .assign(to: &$isProject) + isTestingSubject + .receive(on: DispatchQueue.main) + .assign(to: &$isProject) } convenience init(openedByURL: URL?) { @@ -90,6 +94,7 @@ class AddProjectQRViewModel: ObservableObject { var foundCodeSink: AnyCancellable { foundCode + .receive(on: DispatchQueue.main) .sink { codedUrl in let projectData = codedUrl.decodeQRCode() guard let url = projectData.server, let token = projectData.project else { return } From 4c1d3c9de4743035bd0ada96934694d9e034a278 Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Fri, 29 May 2026 15:02:54 +0200 Subject: [PATCH 7/8] remove @State from view input properties so parent updates propagate --- PayForMe/Views/Balance/BalanceList.swift | 3 +-- PayForMe/Views/BillDetail/WhoPaidView.swift | 3 +-- PayForMe/Views/BillList/BillCell.swift | 5 ++--- PayForMe/Views/BillList/PersonsView.swift | 7 ++----- PayForMe/Views/PersonText.swift | 3 +-- 5 files changed, 7 insertions(+), 14 deletions(-) diff --git a/PayForMe/Views/Balance/BalanceList.swift b/PayForMe/Views/Balance/BalanceList.swift index 7f784a0..b6237a4 100644 --- a/PayForMe/Views/Balance/BalanceList.swift +++ b/PayForMe/Views/Balance/BalanceList.swift @@ -94,8 +94,7 @@ struct BalanceList_Previews: PreviewProvider { } struct BalanceCell: View { - @State - var balance: Balance + let balance: Balance var body: some View { HStack { diff --git a/PayForMe/Views/BillDetail/WhoPaidView.swift b/PayForMe/Views/BillDetail/WhoPaidView.swift index e1fbf36..f7a2d7f 100644 --- a/PayForMe/Views/BillDetail/WhoPaidView.swift +++ b/PayForMe/Views/BillDetail/WhoPaidView.swift @@ -8,8 +8,7 @@ import SwiftUI struct WhoPaidView: View { - @State - var members: [Person] + let members: [Person] @Binding var selectedPayer: Int diff --git a/PayForMe/Views/BillList/BillCell.swift b/PayForMe/Views/BillList/BillCell.swift index 5e5d667..5619102 100644 --- a/PayForMe/Views/BillList/BillCell.swift +++ b/PayForMe/Views/BillList/BillCell.swift @@ -11,14 +11,13 @@ struct BillCell: View { @ObservedObject var viewModel: BillListViewModel - @State - var bill: Bill + let bill: Bill var body: some View { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 10) { Text(bill.what).font(.headline) - PersonsView(bill: $bill, members: $viewModel.currentProject.members) + PersonsView(bill: bill, members: viewModel.currentProject.members) } Spacer() VStack(alignment: .trailing, spacing: 10) { diff --git a/PayForMe/Views/BillList/PersonsView.swift b/PayForMe/Views/BillList/PersonsView.swift index 8db6223..1609d25 100644 --- a/PayForMe/Views/BillList/PersonsView.swift +++ b/PayForMe/Views/BillList/PersonsView.swift @@ -8,11 +8,8 @@ import SwiftUI struct PersonsView: View { - @Binding - var bill: Bill - - @Binding - var members: [Int: Person] + let bill: Bill + let members: [Int: Person] var body: some View { HStack(spacing: 5) { diff --git a/PayForMe/Views/PersonText.swift b/PayForMe/Views/PersonText.swift index dfdbe40..0e29a56 100644 --- a/PayForMe/Views/PersonText.swift +++ b/PayForMe/Views/PersonText.swift @@ -8,8 +8,7 @@ import SwiftUI struct PersonText: View { - @State - var person: Person + let person: Person var body: some View { Text(person.name) From 70991eaebe39ee81ba10bec69d8df4c5e9e522ec Mon Sep 17 00:00:00 2001 From: InteractionEngineer Date: Fri, 29 May 2026 15:22:21 +0200 Subject: [PATCH 8/8] invoke loadBillsAndMembers completion on cancel/finish too --- PayForMe/Services/ProjectManager.swift | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/PayForMe/Services/ProjectManager.swift b/PayForMe/Services/ProjectManager.swift index c4e824f..ffe10a7 100644 --- a/PayForMe/Services/ProjectManager.swift +++ b/PayForMe/Services/ProjectManager.swift @@ -69,6 +69,13 @@ class ProjectManager: ObservableObject { let billsPublisher = NetworkService.shared.loadBillsPublisher(project) let membersPublisher = NetworkService.shared.loadMembersPublisher(project) + var completionInvoked = false + let invokeCompletionOnce: () -> Void = { + guard !completionInvoked else { return } + completionInvoked = true + completion?() + } + loadCancellable = Publishers.Zip(billsPublisher, membersPublisher) .map { bills, members in project.bills = bills @@ -76,10 +83,14 @@ class ProjectManager: ObservableObject { return project } .receive(on: DispatchQueue.main) - .sink { [weak self] project in - self?.currentProject = project - completion?() - } + .handleEvents(receiveCancel: invokeCompletionOnce) + .sink( + receiveCompletion: { _ in invokeCompletionOnce() }, + receiveValue: { [weak self] project in + self?.currentProject = project + invokeCompletionOnce() + } + ) } private func sendBillToServer(bill: Bill, update: Bool, completion: @escaping () -> Void) {