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/PayForMe/Services/ProjectManager.swift b/PayForMe/Services/ProjectManager.swift index 00a327d..ffe10a7 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]() @@ -54,20 +55,42 @@ 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) let membersPublisher = NetworkService.shared.loadMembersPublisher(project) - Publishers.Zip(billsPublisher, membersPublisher) + 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 project.members = members return project } .receive(on: DispatchQueue.main) - .assign(to: &$currentProject) + .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) { @@ -76,6 +99,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") @@ -86,6 +110,7 @@ class ProjectManager: ObservableObject { } } else { cancellable = NetworkService.shared.postBillPublisher(bill: bill) + .receive(on: DispatchQueue.main) .sink { success in if success { print("Bill posted") @@ -102,6 +127,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") @@ -118,6 +144,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") @@ -128,6 +155,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") @@ -144,6 +172,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") @@ -235,6 +264,7 @@ extension ProjectManager { }) else { return } + loadCancellable?.cancel() currentProject = project loadBillsAndMembers() defaults.set(project.id, forKey: "projectID") diff --git a/PayForMe/Views/Balance/BalanceList.swift b/PayForMe/Views/Balance/BalanceList.swift index f4bb6b4..b6237a4 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 { @@ -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/BillList.swift b/PayForMe/Views/BillList/BillList.swift index 826ee71..0911652 100644 --- a/PayForMe/Views/BillList/BillList.swift +++ b/PayForMe/Views/BillList/BillList.swift @@ -20,7 +20,9 @@ struct BillList: View { iOS15ListContent } .addFloatingAddButton() - .id(viewModel.currentProject.bills) + .refreshable { + await ProjectManager.shared.refresh() + } .navigationBarTitle("Bills") .alert(item: $deleteAlert) { index in Alert(title: Text("Delete Bill"), @@ -32,9 +34,6 @@ struct BillList: View { } .listStyle(InsetGroupedListStyle()) } - .onAppear { - ProjectManager.shared.loadBillsAndMembers() - } } @ViewBuilder 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) } 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/ContentView.swift b/PayForMe/Views/ContentView.swift index 5535cdd..87025c6 100644 --- a/PayForMe/Views/ContentView.swift +++ b/PayForMe/Views/ContentView.swift @@ -12,6 +12,15 @@ struct ContentView: View { @ObservedObject var manager = ProjectManager.shared + @StateObject + private var billListViewModel = BillListViewModel() + + @StateObject + private var balanceViewModel = BalanceViewModel() + + @Environment(\.scenePhase) + private var scenePhase + @State var tabBarIndex = tabBarItems.BillList @@ -32,16 +41,21 @@ 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 { 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") } 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) 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 } 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() {