From d808a9e05ab178c45200103dfd495e771d5ac0cf Mon Sep 17 00:00:00 2001 From: modularidea <266259136+modularidea@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:49:48 +0200 Subject: [PATCH 1/8] feat: category support for iHateMoney category forks Why: forks of iHateMoney add bill categories; the app showed them only for Cospend. Co-Authored-By: Claude Sonnet 5.5 --- PayForMe.xcodeproj/project.pbxproj | 16 ++ .../xcshareddata/swiftpm/Package.resolved | 52 +++-- PayForMe/Model/Bill.swift | 11 +- PayForMe/Model/CospendTag.swift | 25 ++- PayForMe/Model/Project.swift | 24 ++- PayForMe/Services/NetworkService.swift | 9 +- PayForMe/Services/ProjectManager.swift | 1 + PayForMe/Strings/cs.lproj/Localizable.strings | 5 + PayForMe/Strings/de.lproj/Localizable.strings | 5 + PayForMe/Strings/en.lproj/Localizable.strings | 5 + PayForMe/Strings/es.lproj/Localizable.strings | 5 + PayForMe/Strings/fr.lproj/Localizable.strings | 5 + PayForMe/Strings/ru.lproj/Localizable.strings | 5 + PayForMe/Util/AppSettings.swift | 17 ++ PayForMe/Util/CategoryClassifier.swift | 123 +++++++++++ PayForMe/Util/TextMatchUtils.swift | 48 +++++ .../Views/BillDetail/BillDetailView.swift | 6 +- .../BillDetail/BillDetailViewModel.swift | 34 ++- PayForMe/Views/Projects/ProjectList.swift | 15 +- PayForMeTests/CategorySupportTests.swift | 194 ++++++++++++++++++ PayForMeTests/NetworkRequestTests.swift | 55 ++++- 21 files changed, 611 insertions(+), 49 deletions(-) create mode 100644 PayForMe/Util/AppSettings.swift create mode 100644 PayForMe/Util/CategoryClassifier.swift create mode 100644 PayForMe/Util/TextMatchUtils.swift create mode 100644 PayForMeTests/CategorySupportTests.swift diff --git a/PayForMe.xcodeproj/project.pbxproj b/PayForMe.xcodeproj/project.pbxproj index d434265..982cf4f 100644 --- a/PayForMe.xcodeproj/project.pbxproj +++ b/PayForMe.xcodeproj/project.pbxproj @@ -61,6 +61,9 @@ 6591C8E524052C240054BA24 /* CommunicationIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6591C8E424052C240054BA24 /* CommunicationIndicator.swift */; }; 6591C8E7240542250054BA24 /* LoadingDots.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6591C8E6240542250054BA24 /* LoadingDots.swift */; }; 65A65E3F2527529D00C86533 /* Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65A65E3E2527529D00C86533 /* Combine.swift */; }; + A9A3E941FF5A941B353C8928 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5CE07E061AEA59F79C0E31 /* AppSettings.swift */; }; + 57C3F60411D3F3343CB70831 /* CategoryClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF8E2876A59F196BE1C24F0 /* CategoryClassifier.swift */; }; + D2B9E84A59C861734B317D95 /* TextMatchUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0DE3B9BA48181BBB1E6664F /* TextMatchUtils.swift */; }; 65AF00022F1A000100000002 /* StorageMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65AF00012F1A000100000001 /* StorageMigrationTests.swift */; }; 65AF00042F1A000100000004 /* BillDetailViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65AF00032F1A000100000003 /* BillDetailViewModelTests.swift */; }; 65C9E2CE23E1B8F500814B37 /* BalanceList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65C9E2CD23E1B8F500814B37 /* BalanceList.swift */; }; @@ -69,6 +72,7 @@ 65D9094025581BE100351F4B /* AddFromURLView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65D9093F25581BE100351F4B /* AddFromURLView.swift */; }; BF4D7AF265FC4A90B1AC5AEB /* JSONDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4D55C3EF5EFCD925428041 /* JSONDecodingTests.swift */; }; D279295CFA25D9CC69C6F27C /* BillTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EED3194A2342518B0E74079 /* BillTests.swift */; }; + A7089D0A97757AB90C9CB97E /* CategorySupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A391473E9D9DE0002A4D8F46 /* CategorySupportTests.swift */; }; DBBD86EF399BCF232C021935 /* NetworkRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316FBA7C0CE4ABB51EC2A121 /* NetworkRequestTests.swift */; }; /* End PBXBuildFile section */ @@ -149,6 +153,9 @@ 6591C8E424052C240054BA24 /* CommunicationIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunicationIndicator.swift; sourceTree = ""; }; 6591C8E6240542250054BA24 /* LoadingDots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LoadingDots.swift; path = PayForMe/Views/LoadingDots.swift; sourceTree = SOURCE_ROOT; }; 65A65E3E2527529D00C86533 /* Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Combine.swift; sourceTree = ""; }; + 2A5CE07E061AEA59F79C0E31 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; + 6FF8E2876A59F196BE1C24F0 /* CategoryClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryClassifier.swift; sourceTree = ""; }; + B0DE3B9BA48181BBB1E6664F /* TextMatchUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextMatchUtils.swift; sourceTree = ""; }; 65AA00A6252C881E00C5DEE8 /* PayForMe.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PayForMe.entitlements; sourceTree = ""; }; 65AF00012F1A000100000001 /* StorageMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageMigrationTests.swift; sourceTree = ""; }; 65AF00032F1A000100000003 /* BillDetailViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BillDetailViewModelTests.swift; sourceTree = ""; }; @@ -161,6 +168,7 @@ 65D9093F25581BE100351F4B /* AddFromURLView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddFromURLView.swift; sourceTree = ""; }; 65FE0E8E2544968B002B80CF /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 7EED3194A2342518B0E74079 /* BillTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BillTests.swift; sourceTree = ""; }; + A391473E9D9DE0002A4D8F46 /* CategorySupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategorySupportTests.swift; sourceTree = ""; }; 8F4D55C3EF5EFCD925428041 /* JSONDecodingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = JSONDecodingTests.swift; sourceTree = ""; }; 92ABB02BEA28BEA7C8C64841 /* BillSortingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BillSortingTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -236,6 +244,7 @@ 6523FC4B25580EEF00BCD843 /* UrlExtensionsTests.swift */, 0865D2DAF541B24078D1DC41 /* TestHelpers.swift */, 7EED3194A2342518B0E74079 /* BillTests.swift */, + A391473E9D9DE0002A4D8F46 /* CategorySupportTests.swift */, 8F4D55C3EF5EFCD925428041 /* JSONDecodingTests.swift */, 143F41EE78329905894C8A29 /* BalanceCalculationTests.swift */, 92ABB02BEA28BEA7C8C64841 /* BillSortingTests.swift */, @@ -382,6 +391,9 @@ children = ( 489D35F923E081120029F59F /* Util.swift */, 65A65E3E2527529D00C86533 /* Combine.swift */, + 2A5CE07E061AEA59F79C0E31 /* AppSettings.swift */, + 6FF8E2876A59F196BE1C24F0 /* CategoryClassifier.swift */, + B0DE3B9BA48181BBB1E6664F /* TextMatchUtils.swift */, 654754B72528736F00A82EB6 /* Views+Extensions.swift */, ); path = Util; @@ -553,6 +565,7 @@ 6523FC4C25580EEF00BCD843 /* UrlExtensionsTests.swift in Sources */, 57BC8D25B91CE40F4669650C /* TestHelpers.swift in Sources */, D279295CFA25D9CC69C6F27C /* BillTests.swift in Sources */, + A7089D0A97757AB90C9CB97E /* CategorySupportTests.swift in Sources */, BF4D7AF265FC4A90B1AC5AEB /* JSONDecodingTests.swift in Sources */, 56E7DB46FDCDC5B46C475020 /* BalanceCalculationTests.swift in Sources */, 62C73597878BA5AD4572FE90 /* BillSortingTests.swift in Sources */, @@ -585,6 +598,9 @@ 658FC55925273E4D004A49AA /* AddProjectQRViewModel.swift in Sources */, 489D35FA23E081120029F59F /* Util.swift in Sources */, 65A65E3F2527529D00C86533 /* Combine.swift in Sources */, + A9A3E941FF5A941B353C8928 /* AppSettings.swift in Sources */, + 57C3F60411D3F3343CB70831 /* CategoryClassifier.swift in Sources */, + D2B9E84A59C861734B317D95 /* TextMatchUtils.swift in Sources */, 62475CF1301F718C0097B7C9 /* CospendTag.swift in Sources */, 489995DA23F6EC6F008B7E38 /* OnboardingView.swift in Sources */, 6591C8E524052C240054BA24 /* CommunicationIndicator.swift in Sources */, diff --git a/PayForMe.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/PayForMe.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a6c00e1..2ab31b1 100644 --- a/PayForMe.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/PayForMe.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,25 +1,33 @@ { - "object": { - "pins": [ - { - "package": "TimelaneCombine", - "repositoryURL": "https://github.com/icanzilb/TimelaneCombine", - "state": { - "branch": null, - "revision": "7366c0243860b8dbf265b5a5674e6c4797c44a8f", - "version": "1.0.2" - } - }, - { - "package": "TimelaneCore", - "repositoryURL": "https://github.com/icanzilb/TimelaneCore", - "state": { - "branch": null, - "revision": "7317572859017ecb1a0e7252d865b659e2647e20", - "version": "1.0.1" - } + "originHash" : "07ab1d97001a5cb88c6ba268e55e0b0c409783fd7807f68a8d37691c1887acd1", + "pins" : [ + { + "identity" : "carbode-barcode-scanner-for-swiftui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/heart/CarBode-Barcode-Scanner-For-SwiftUI", + "state" : { + "revision" : "d0192bd6ccc61e426eb7a301f16521f1da05dc66", + "version" : "2.2.4" } - ] - }, - "version": 1 + }, + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift.git", + "state" : { + "revision" : "b83108d10f42680d78f23fe4d4d80fc88dab3212", + "version" : "7.11.1" + } + }, + { + "identity" : "slickloadingspinner", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Hustenbonbon/SlickLoadingSpinner.git", + "state" : { + "revision" : "c1b125fabf245717df0a44655cd8a96b603ee55e", + "version" : "2.0.1" + } + } + ], + "version" : 3 } diff --git a/PayForMe/Model/Bill.swift b/PayForMe/Model/Bill.swift index 15e64a0..176bdf7 100644 --- a/PayForMe/Model/Bill.swift +++ b/PayForMe/Model/Bill.swift @@ -19,7 +19,9 @@ struct Bill: Codable, Identifiable, Hashable { var paymentmodeid: Int? var lastchanged: Int? - func paramsFor(_ backend: ProjectBackend) -> [String: Any] { + /// `features` are the capabilities an iHateMoney server announces; category fields are only + /// sent to servers that know them (stock iHateMoney has none). + func paramsFor(_ backend: ProjectBackend, features: Set = []) -> [String: Any] { var dict: [String: Any] = [ "date": DateFormatter.cospend.string(from: date), "what": what, @@ -40,6 +42,13 @@ struct Bill: Codable, Identifiable, Hashable { } if backend == .iHateMoney { dict["payed_for"] = owers.map { $0.id.description } + // The fork's form treats "" as "none" and rejects "0". + if features.contains("categoryid") { + dict["categoryid"] = (categoryid ?? 0) == 0 ? "" : categoryid!.description + } + if features.contains("paymentmodes") { + dict["paymentmodeid"] = (paymentmodeid ?? 0) == 0 ? "" : paymentmodeid!.description + } } return dict diff --git a/PayForMe/Model/CospendTag.swift b/PayForMe/Model/CospendTag.swift index 34e6262..df103a7 100644 --- a/PayForMe/Model/CospendTag.swift +++ b/PayForMe/Model/CospendTag.swift @@ -21,28 +21,51 @@ struct CospendTag: Codable, Identifiable, Hashable { guard let icon = icon, !icon.isEmpty else { return displayName } return "\(icon) \(displayName)" } + + /// Cospend's built-in categories (negative ids). Servers do not list them in the project + /// info, but accept them as `categoryid`: Nextcloud Cospend and iHateMoney category forks. + /// -11 (Reimbursement) is not a category. + static let globalCategories: [CospendTag] = [ + CospendTag(id: -1, name: "Grocery", color: "#ffaa00", icon: "🛒", order: nil), + CospendTag(id: -2, name: "Bar/Party", color: "#aa55ff", icon: "🎉", order: nil), + CospendTag(id: -3, name: "Rent", color: "#da8733", icon: "🏠", order: nil), + CospendTag(id: -4, name: "Bill", color: "#4aa6b0", icon: "🌩", order: nil), + CospendTag(id: -5, name: "Excursion/Culture", color: "#0055ff", icon: "🚸", order: nil), + CospendTag(id: -6, name: "Health", color: "#bf090c", icon: "💚", order: nil), + CospendTag(id: -10, name: "Shopping", color: "#e167d1", icon: "🛍", order: nil), + CospendTag(id: -12, name: "Restaurant", color: "#d0d5e1", icon: "🍴", order: nil), + CospendTag(id: -13, name: "Accommodation", color: "#5de1a3", icon: "🛌", order: nil), + CospendTag(id: -14, name: "Transport", color: "#6f2ee1", icon: "🚌", order: nil), + CospendTag(id: -15, name: "Sport", color: "#69e177", icon: "🎾", order: nil), + ] } struct CospendProjectTags: Decodable { let categories: [CospendTag] let paymentModes: [CospendTag] + /// Optional capability list of iHateMoney category forks (`categoryid`, `categories`, + /// `paymentmodes`, ...). Empty for Cospend and for stock iHateMoney. + let features: Set static let empty = CospendProjectTags(categories: [], paymentModes: []) - init(categories: [CospendTag], paymentModes: [CospendTag]) { + init(categories: [CospendTag], paymentModes: [CospendTag], features: Set = []) { self.categories = categories self.paymentModes = paymentModes + self.features = features } private enum CodingKeys: String, CodingKey { case categories case paymentmodes + case features } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) categories = Self.decodeList(from: container, forKey: .categories) paymentModes = Self.decodeList(from: container, forKey: .paymentmodes) + features = Set((try? container.decode([String].self, forKey: .features)) ?? []) } private static func decodeList(from container: KeyedDecodingContainer, forKey key: CodingKeys) -> [CospendTag] { diff --git a/PayForMe/Model/Project.swift b/PayForMe/Model/Project.swift index 2648eb1..957587a 100644 --- a/PayForMe/Model/Project.swift +++ b/PayForMe/Model/Project.swift @@ -19,6 +19,9 @@ class Project: Codable, Identifiable { var bills: [Bill] var categories: [CospendTag] var paymentModes: [CospendTag] + /// Capabilities announced by an iHateMoney category fork; empty otherwise. Not persisted, + /// refreshed with every tag load. + var features: Set = [] var me: Int? let projectId: String @@ -86,8 +89,25 @@ struct StoredProject: Codable { } extension Project { + /// Whether bills of this project carry a category. Cospend always does; iHateMoney only when + /// the server is a category fork (stock iHateMoney has no such field). + var supportsCategories: Bool { + backend == .cospend || features.contains("categoryid") + } + + var supportsPaymentModes: Bool { + backend == .cospend || features.contains("paymentmodes") + } + + /// Categories offered in the bill form. An iHateMoney fork accepts Cospend's global ids on + /// top of the project's own; Cospend lists only the project's own. + var pickableCategories: [CospendTag] { + guard backend == .iHateMoney, supportsCategories else { return categories } + return CospendTag.globalCategories + categories + } + func category(for bill: Bill) -> CospendTag? { - tag(id: bill.categoryid, in: categories) + tag(id: bill.categoryid, in: pickableCategories) } func paymentMode(for bill: Bill) -> CospendTag? { @@ -99,7 +119,7 @@ extension Project { return tags.first { $0.id == id } } - func listedCategoryId(_ id: Int) -> Int { listedId(id, in: categories) } + func listedCategoryId(_ id: Int) -> Int { listedId(id, in: pickableCategories) } func listedPaymentModeId(_ id: Int) -> Int { listedId(id, in: paymentModes) } diff --git a/PayForMe/Services/NetworkService.swift b/PayForMe/Services/NetworkService.swift index a7a8004..751c2dc 100644 --- a/PayForMe/Services/NetworkService.swift +++ b/PayForMe/Services/NetworkService.swift @@ -79,9 +79,8 @@ class NetworkService { func loadTagsPublisher(_ project: Project) -> AnyPublisher { - guard project.backend == .cospend else { - return Just(CospendProjectTags.empty).eraseToAnyPublisher() - } + // Cospend and iHateMoney both answer the bare project URL; stock iHateMoney simply has no + // categories in it, which decodes to `.empty`. let request = buildURLRequest("", params: [:], project: project) return URLSession.shared.dataTaskPublisher(for: request) .compactMap { data, response -> Data? in @@ -152,12 +151,12 @@ class NetworkService { } func postBillPublisher(bill: Bill) -> AnyPublisher { - let request = buildURLRequest("bills", params: bill.paramsFor(currentProject.backend), project: currentProject, httpMethod: "POST") + let request = buildURLRequest("bills", params: bill.paramsFor(currentProject.backend, features: currentProject.features), project: currentProject, httpMethod: "POST") return sendBillPublisher(request: request) } func updateBillPublisher(bill: Bill) -> AnyPublisher { - let request = buildURLRequest("bills/\(bill.id)", params: bill.paramsFor(currentProject.backend), project: currentProject, httpMethod: "PUT") + let request = buildURLRequest("bills/\(bill.id)", params: bill.paramsFor(currentProject.backend, features: currentProject.features), project: currentProject, httpMethod: "PUT") return sendBillPublisher(request: request) } diff --git a/PayForMe/Services/ProjectManager.swift b/PayForMe/Services/ProjectManager.swift index 70865cd..dcde5c5 100644 --- a/PayForMe/Services/ProjectManager.swift +++ b/PayForMe/Services/ProjectManager.swift @@ -98,6 +98,7 @@ class ProjectManager: ObservableObject { .sink { [weak self] tags in project.categories = tags.categories project.paymentModes = tags.paymentModes + project.features = tags.features // `Project` is a class, so the assignments above are already visible everywhere — // but only re-publishing makes SwiftUI redraw. Compared by identity, because // `Project: Equatable` ignores the id and would accept a different project. diff --git a/PayForMe/Strings/cs.lproj/Localizable.strings b/PayForMe/Strings/cs.lproj/Localizable.strings index 890aa33..a25c7ae 100644 --- a/PayForMe/Strings/cs.lproj/Localizable.strings +++ b/PayForMe/Strings/cs.lproj/Localizable.strings @@ -162,3 +162,8 @@ "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)."; + +/* ProjectList.swift: auto category */ +"Categories" = "Kategorie"; +"Suggest category from title" = "Navrhnout kategorii podle názvu"; +"Suggest a category from the bill title. Needs a project whose server supports categories." = "Navrhne kategorii podle názvu účtu. Vyžaduje projekt, jehož server podporuje kategorie."; diff --git a/PayForMe/Strings/de.lproj/Localizable.strings b/PayForMe/Strings/de.lproj/Localizable.strings index 0b5159e..9819198 100644 --- a/PayForMe/Strings/de.lproj/Localizable.strings +++ b/PayForMe/Strings/de.lproj/Localizable.strings @@ -166,3 +166,8 @@ "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)."; + +/* ProjectList.swift: auto category */ +"Categories" = "Kategorien"; +"Suggest category from title" = "Kategorie aus Titel vorschlagen"; +"Suggest a category from the bill title. Needs a project whose server supports categories." = "Schlägt anhand des Rechnungstitels eine Kategorie vor. Benötigt ein Projekt, dessen Server Kategorien unterstützt."; diff --git a/PayForMe/Strings/en.lproj/Localizable.strings b/PayForMe/Strings/en.lproj/Localizable.strings index 687ab1d..ac776fd 100644 --- a/PayForMe/Strings/en.lproj/Localizable.strings +++ b/PayForMe/Strings/en.lproj/Localizable.strings @@ -169,3 +169,8 @@ "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)."; + +/* ProjectList.swift: auto category */ +"Categories" = "Categories"; +"Suggest category from title" = "Suggest category from title"; +"Suggest a category from the bill title. Needs a project whose server supports categories." = "Suggest a category from the bill title. Needs a project whose server supports categories."; diff --git a/PayForMe/Strings/es.lproj/Localizable.strings b/PayForMe/Strings/es.lproj/Localizable.strings index 624d6c8..f5b0d5e 100644 --- a/PayForMe/Strings/es.lproj/Localizable.strings +++ b/PayForMe/Strings/es.lproj/Localizable.strings @@ -169,3 +169,8 @@ "Enter invite link" = "Introducir el enlace de invitación"; "invite_link_hint" = "Pega el enlace de invitación completo. El servidor y el proyecto se detectan automáticamente."; "Server returned an invalid HTTP response" = "El servidor devolvió una respuesta HTTP no válida"; + +/* ProjectList.swift: auto category */ +"Categories" = "Categorías"; +"Suggest category from title" = "Sugerir categoría según el título"; +"Suggest a category from the bill title. Needs a project whose server supports categories." = "Sugiere una categoría a partir del título del gasto. Requiere un proyecto cuyo servidor admita categorías."; diff --git a/PayForMe/Strings/fr.lproj/Localizable.strings b/PayForMe/Strings/fr.lproj/Localizable.strings index 8ff24f2..4c1ed42 100644 --- a/PayForMe/Strings/fr.lproj/Localizable.strings +++ b/PayForMe/Strings/fr.lproj/Localizable.strings @@ -169,3 +169,8 @@ "Enter invite link" = "Saisir le lien d'invitation"; "invite_link_hint" = "Collez le lien d'invitation complet. Le serveur et le projet sont détectés automatiquement."; "Server returned an invalid HTTP response" = "Le serveur a renvoyé une réponse HTTP non valide"; + +/* ProjectList.swift: auto category */ +"Categories" = "Catégories"; +"Suggest category from title" = "Suggérer une catégorie d'après le titre"; +"Suggest a category from the bill title. Needs a project whose server supports categories." = "Suggère une catégorie à partir du titre de la dépense. Nécessite un projet dont le serveur gère les catégories."; diff --git a/PayForMe/Strings/ru.lproj/Localizable.strings b/PayForMe/Strings/ru.lproj/Localizable.strings index 71e1966..b44a30f 100644 --- a/PayForMe/Strings/ru.lproj/Localizable.strings +++ b/PayForMe/Strings/ru.lproj/Localizable.strings @@ -168,3 +168,8 @@ "member_error_forbidden" = "Обычно для этого нужны права сопровождающего проекта. Пароль общего доступа может давать только права участника."; "member_error_network" = "Нет соединения с сервером. Проверьте сеть и повторите попытку."; "member_error_generic" = "Сервер отклонил запрос (HTTP %d)."; + +/* ProjectList.swift: auto category */ +"Categories" = "Категории"; +"Suggest category from title" = "Предлагать категорию по названию"; +"Suggest a category from the bill title. Needs a project whose server supports categories." = "Предлагает категорию по названию счёта. Нужен проект, сервер которого поддерживает категории."; diff --git a/PayForMe/Util/AppSettings.swift b/PayForMe/Util/AppSettings.swift new file mode 100644 index 0000000..c6cb5a7 --- /dev/null +++ b/PayForMe/Util/AppSettings.swift @@ -0,0 +1,17 @@ +// +// AppSettings.swift +// PayForMe +// + +import Foundation + +/// App-wide user preferences stored in `UserDefaults`. +enum AppSettings { + /// Suggest a bill category from its title. Off by default so existing behaviour is unchanged. + static let autoCategoryKey = "autoCategoryEnabled" + + static var autoCategoryEnabled: Bool { + get { UserDefaults.standard.bool(forKey: autoCategoryKey) } + set { UserDefaults.standard.set(newValue, forKey: autoCategoryKey) } + } +} diff --git a/PayForMe/Util/CategoryClassifier.swift b/PayForMe/Util/CategoryClassifier.swift new file mode 100644 index 0000000..11732e1 --- /dev/null +++ b/PayForMe/Util/CategoryClassifier.swift @@ -0,0 +1,123 @@ +// +// CategoryClassifier.swift +// PayForMe +// + +import Foundation + +/// A built-in category with the keywords that select it. +struct CategoryRule: Equatable { + /// Matching Cospend global category id (also accepted by iHateMoney category forks). + let cospendId: Int + /// Names a project's own category may carry to be treated as this rule (normalized on compare). + let names: [String] + let keywords: [String] +} + +/// Suggests a bill category from its title. Keyword based, longest phrase first, word-boundary +/// safe — the same rules the IHM Tracker Obsidian plugin uses, so both apps agree on a title. +enum CategoryClassifier { + static func classify(title: String, rules: [CategoryRule] = defaultRules) -> CategoryRule? { + let titleTokens = TextMatchUtils.tokenize(title) + guard !titleTokens.isEmpty else { return nil } + + var index: [(tokens: [String], rule: CategoryRule)] = [] + for rule in rules { + for keyword in rule.keywords { + let tokens = TextMatchUtils.tokenize(keyword) + if !tokens.isEmpty { index.append((tokens, rule)) } + } + } + // Stable sort keeps rule order for equally long keywords. + let sorted = index.enumerated() + .sorted { lhs, rhs in + lhs.element.tokens.count != rhs.element.tokens.count + ? lhs.element.tokens.count > rhs.element.tokens.count + : lhs.offset < rhs.offset + } + .map(\.element) + return sorted.first { TextMatchUtils.containsTokenSequence(titleTokens, $0.tokens) }?.rule + } + + /// The id of the entry in `categories` that fits `title`, or nil when nothing matches or the + /// project has no matching category. An id match wins over a name match. + static func suggestCategoryId(title: String, in categories: [CospendTag], rules: [CategoryRule] = defaultRules) -> Int? { + guard let rule = classify(title: title, rules: rules) else { return nil } + if let byId = categories.first(where: { $0.id == rule.cospendId }) { + return byId.id + } + let wanted = Set(rule.names.map(TextMatchUtils.normalize)) + return categories.first { wanted.contains(TextMatchUtils.normalize($0.name ?? "")) }?.id + } + + static let defaultRules: [CategoryRule] = [ + CategoryRule( + cospendId: -1, + names: ["grocery", "groceries", "lebensmittel", "einkauf", "supermarkt"], + keywords: [ + "supermarkt", "supermarket", "groceries", "markt", "rewe", "edeka", "aldi", "lidl", "netto", + "kaufland", "penny", "dm", "rossmann", "real", "tegut", "denns", + "alnatura", "spar", "billa", "hofer", "migros", "coop", "norma", + "nahkauf", "wochenmarkt", + "gemüse", "obst", "frisches obst", "salat", "karotte", "karotten", + "tomate", "tomaten", "kartoffel", "kartoffeln", "zwiebel", "zwiebeln", + "gurke", "paprika", "brokkoli", "zucchini", "apfel", "äpfel", + "banane", "bananen", "orange", "orangen", "beeren", "zitrone", + "brot", "brötchen", "backwaren", "milch", "käse", "joghurt", + "butter", "eier", "wurst", "fleisch", "hähnchen", "fisch", + "nudeln", "pasta", "reis", "mehl", "zucker", "gewürze", + "getränke", "wasser", "saft", "kaffee", "tee", "einkauf", + "lebensmittel", "wocheneinkauf", + ] + ), + CategoryRule( + cospendId: -12, + names: ["restaurant", "essen gehen"], + keywords: [ + "restaurant", "café", "cafe", "imbiss", "pizza", "pizzeria", "burger", + "sushi", "bar", "kneipe", "döner", "bistro", "bäckerei", "takeaway", "delivery", + "lieferando", "wolt", "uber eats", "mcdonalds", "burger king", + ] + ), + CategoryRule( + cospendId: -14, + names: ["transport", "verkehr", "mobilität"], + keywords: [ + "bahn", "db", "uber", "taxi", "bus", "tram", "u-bahn", "s-bahn", "train", "fuel", + "sprit", "tanken", "benzin", "diesel", "parken", "parking", "flixbus", + "fahrkarte", "ticket", "sixt", "nextbike", "öbb", "oebb", "sbb", + ] + ), + CategoryRule( + cospendId: -3, + names: ["rent", "miete", "housing", "wohnen"], + keywords: [ + "miete", "rent", "nebenkosten", "kaution", "strom", "electricity", "stadtwerke", "gas", + "internet", "telekom", "vodafone", "o2", "congstar", "möbel", + "ikea", "rundfunkbeitrag", + ] + ), + CategoryRule( + cospendId: -5, + names: ["excursion/culture", "freizeit", "leisure", "kultur"], + keywords: [ + "kino", "cinema", "konzert", "concert", "event", "party", "spiel", "steam", "netflix", + "spotify", "disney+", "amazon prime", "hobby", "sport", "fitness", + "gym", "fitx", "mcfit", "schwimmbad", + ] + ), + CategoryRule( + cospendId: -6, + names: ["health", "gesundheit"], + keywords: [ + "arzt", "doctor", "apotheke", "pharmacy", "medikament", "zahnarzt", "dentist", "versicherung", + "krankenkasse", "physiotherapie", + ] + ), + CategoryRule( + cospendId: -10, + names: ["shopping", "purchases", "anschaffungen"], + keywords: ["anschaffung", "gerät", "elektronik", "electronics", "saturn", "mediamarkt"] + ), + ] +} diff --git a/PayForMe/Util/TextMatchUtils.swift b/PayForMe/Util/TextMatchUtils.swift new file mode 100644 index 0000000..15fccba --- /dev/null +++ b/PayForMe/Util/TextMatchUtils.swift @@ -0,0 +1,48 @@ +// +// TextMatchUtils.swift +// PayForMe +// + +import Foundation + +/// Text-matching helpers for the title → category suggestion. Pure functions, no UI or network. +enum TextMatchUtils { + /// Folds ä/ö/ü/ß to ae/oe/ue/ss so ASCII-typed and native spellings match, then reduces + /// everything that is not a-z/0-9 to single spaces. + static func normalize(_ text: String) -> String { + let folded = text.lowercased() + .replacingOccurrences(of: "ä", with: "ae") + .replacingOccurrences(of: "ö", with: "oe") + .replacingOccurrences(of: "ü", with: "ue") + .replacingOccurrences(of: "ß", with: "ss") + var result = "" + var lastWasSpace = true + for scalar in folded.unicodeScalars { + let isAlphanumeric = (scalar >= "a" && scalar <= "z") || (scalar >= "0" && scalar <= "9") + if isAlphanumeric { + result.unicodeScalars.append(scalar) + lastWasSpace = false + } else if !lastWasSpace { + result.append(" ") + lastWasSpace = true + } + } + return result.trimmingCharacters(in: .whitespaces) + } + + static func tokenize(_ text: String) -> [String] { + let normalized = normalize(text) + return normalized.isEmpty ? [] : normalized.components(separatedBy: " ") + } + + /// Word-boundary-safe phrase containment: a raw substring check would match keyword "gas" + /// inside "Gaststätte". + static func containsTokenSequence(_ haystack: [String], _ needle: [String]) -> Bool { + guard !needle.isEmpty, needle.count <= haystack.count else { return false } + for start in 0 ... (haystack.count - needle.count) + where Array(haystack[start ..< start + needle.count]) == needle { + return true + } + return false + } +} diff --git a/PayForMe/Views/BillDetail/BillDetailView.swift b/PayForMe/Views/BillDetail/BillDetailView.swift index 002fcb6..7d72652 100644 --- a/PayForMe/Views/BillDetail/BillDetailView.swift +++ b/PayForMe/Views/BillDetail/BillDetailView.swift @@ -51,12 +51,12 @@ struct BillDetailView: View { } } - if !viewModel.currentProject.categories.isEmpty || !viewModel.currentProject.paymentModes.isEmpty { + if !viewModel.currentProject.pickableCategories.isEmpty || !viewModel.currentProject.paymentModes.isEmpty { Section { - if !viewModel.currentProject.categories.isEmpty { + if !viewModel.currentProject.pickableCategories.isEmpty { Picker("Category", selection: viewModel.categoryPickerSelection) { Text("No category").tag(0) - ForEach(viewModel.currentProject.categories) { category in + ForEach(viewModel.currentProject.pickableCategories) { category in Text(category.label).tag(category.id) } } diff --git a/PayForMe/Views/BillDetail/BillDetailViewModel.swift b/PayForMe/Views/BillDetail/BillDetailViewModel.swift index eb23c61..09ce540 100644 --- a/PayForMe/Views/BillDetail/BillDetailViewModel.swift +++ b/PayForMe/Views/BillDetail/BillDetailViewModel.swift @@ -39,6 +39,15 @@ class BillDetailViewModel: ObservableObject { var povm: PotentialOwersViewModel + /// Read at suggestion time so toggling the setting applies to the next keystroke. + var autoCategoryEnabled: () -> Bool = { AppSettings.autoCategoryEnabled } + + /// Once the user picks a category themself, titles no longer change it. + private var categoryChosenByUser = false + /// The current selection came from a suggestion and may be replaced by a newer one. + private var categorySuggested = false + private var topicCancellable: AnyCancellable? + init(currentBill: Bill) { self.currentBill = currentBill povm = PotentialOwersViewModel(members: ProjectManager.shared.currentProject.members) @@ -46,6 +55,26 @@ class BillDetailViewModel: ObservableObject { manager.$currentProject.assign(to: &$currentProject) prefillData() + + // Only new bills get suggestions; an existing bill keeps the category it was saved with. + if currentBill.id == -1 { + topicCancellable = $topic.dropFirst().removeDuplicates().sink { [weak self] in + self?.suggestCategory(for: $0) + } + } + } + + func suggestCategory(for title: String) { + guard autoCategoryEnabled(), !categoryChosenByUser else { return } + let suggestion = CategoryClassifier.suggestCategoryId(title: title, in: currentProject.pickableCategories) + if let suggestion = suggestion { + selectedCategoryId = suggestion + categorySuggested = true + } else if categorySuggested { + // The title moved away from the keyword that triggered the suggestion. + selectedCategoryId = 0 + categorySuggested = false + } } var validatedInput: AnyPublisher { @@ -67,7 +96,10 @@ class BillDetailViewModel: ObservableObject { var categoryPickerSelection: Binding { Binding( get: { self.currentProject.listedCategoryId(self.selectedCategoryId) }, - set: { self.selectedCategoryId = $0 } + set: { + self.categoryChosenByUser = true + self.selectedCategoryId = $0 + } ) } diff --git a/PayForMe/Views/Projects/ProjectList.swift b/PayForMe/Views/Projects/ProjectList.swift index 372130c..feaffd1 100644 --- a/PayForMe/Views/Projects/ProjectList.swift +++ b/PayForMe/Views/Projects/ProjectList.swift @@ -15,14 +15,23 @@ struct ProjectList: View { @State private var showAddProject = false @State private var shareProject: Project? + @AppStorage(AppSettings.autoCategoryKey) + private var autoCategoryEnabled = false + var body: some View { NavigationView { VStack { List { - ForEach(manager.projects) { project in - ProjectListEntry(project: project, currentProject: manager.currentProject, shareProject: self.$shareProject) + Section { + ForEach(manager.projects) { project in + ProjectListEntry(project: project, currentProject: manager.currentProject, shareProject: self.$shareProject) + } + .onDelete(perform: deleteProject) + } + Section(header: Text("Categories"), footer: Text("Suggest a category from the bill title. Needs a project whose server supports categories.")) { + Toggle("Suggest category from title", isOn: $autoCategoryEnabled) + .disabled(!manager.currentProject.supportsCategories) } - .onDelete(perform: deleteProject) } .sheet(item: $shareProject) { project in ShareProjectQRCode(project: project) diff --git a/PayForMeTests/CategorySupportTests.swift b/PayForMeTests/CategorySupportTests.swift new file mode 100644 index 0000000..e3f85c5 --- /dev/null +++ b/PayForMeTests/CategorySupportTests.swift @@ -0,0 +1,194 @@ +// +// CategorySupportTests.swift +// PayForMeTests +// +// Category support for iHateMoney category forks (and the title → category suggestion). +// Stock iHateMoney has no categories and must keep working unchanged. +// + +import XCTest +@testable import PayForMe + +private let forkFeatures: Set = ["categoryid", "categories", "paymentmodes", "settle", "repeat"] + +class CategoryTextMatchTests: XCTestCase { + func testNormalize_foldsUmlautsAndPunctuation() { + XCTAssertEqual(TextMatchUtils.normalize(" Käse-Brötchen, ÄPFEL! "), "kaese broetchen aepfel") + XCTAssertEqual(TextMatchUtils.normalize("Straße"), "strasse") + } + + func testTokenize_emptyAndSymbolOnlyGiveNoTokens() { + XCTAssertEqual(TextMatchUtils.tokenize(""), []) + XCTAssertEqual(TextMatchUtils.tokenize("--- !!!"), []) + } + + func testContainsTokenSequence_respectsWordBoundaries() { + // "gas" must not match inside "gaststaette". + XCTAssertFalse(TextMatchUtils.containsTokenSequence(["gaststaette"], ["gas"])) + XCTAssertTrue(TextMatchUtils.containsTokenSequence(["uber", "eats", "x"], ["uber", "eats"])) + XCTAssertFalse(TextMatchUtils.containsTokenSequence(["a"], ["a", "b"])) + } +} + +class CategoryClassifierTests: XCTestCase { + func testClassify_knownShopsAndKeywords() { + XCTAssertEqual(CategoryClassifier.classify(title: "REWE Einkauf")?.cospendId, -1) + XCTAssertEqual(CategoryClassifier.classify(title: "Pizza am Hafen")?.cospendId, -12) + XCTAssertEqual(CategoryClassifier.classify(title: "Tanken")?.cospendId, -14) + XCTAssertEqual(CategoryClassifier.classify(title: "Miete Oktober")?.cospendId, -3) + XCTAssertEqual(CategoryClassifier.classify(title: "Apotheke")?.cospendId, -6) + } + + func testClassify_longestPhraseWins() { + // "uber eats" (2 tokens, restaurant) beats "uber" (1 token, transport). + XCTAssertEqual(CategoryClassifier.classify(title: "Uber Eats")?.cospendId, -12) + XCTAssertEqual(CategoryClassifier.classify(title: "Uber nach Hause")?.cospendId, -14) + } + + func testClassify_noMatchAndEmpty() { + XCTAssertNil(CategoryClassifier.classify(title: "Geschenk für Oma")) + XCTAssertNil(CategoryClassifier.classify(title: "")) + } + + func testClassify_substringDoesNotMatch() { + XCTAssertNil(CategoryClassifier.classify(title: "Gaststätte")) + } + + func testSuggest_prefersExactGlobalIdInTheList() { + let id = CategoryClassifier.suggestCategoryId(title: "Rewe", in: CospendTag.globalCategories) + XCTAssertEqual(id, -1) + } + + func testSuggest_matchesOwnCategoryByName() { + let own = [CospendTag(id: 5, name: "Lebensmittel", color: nil, icon: "🥦", order: 0)] + XCTAssertEqual(CategoryClassifier.suggestCategoryId(title: "Rewe", in: own), 5) + } + + func testSuggest_nilWhenProjectHasNoFittingCategory() { + let own = [CospendTag(id: 5, name: "Haustiere", color: nil, icon: "", order: 0)] + XCTAssertNil(CategoryClassifier.suggestCategoryId(title: "Rewe", in: own)) + XCTAssertNil(CategoryClassifier.suggestCategoryId(title: "Rewe", in: [])) + } +} + +class CategoryProjectModelTests: XCTestCase { + func testStockIHateMoney_hasNoCategories() { + let project = Project.makeIHateMoney() + XCTAssertFalse(project.supportsCategories) + XCTAssertTrue(project.pickableCategories.isEmpty) + } + + func testForkIHateMoney_offersGlobalPlusOwnCategories() { + let project = Project.makeIHateMoney() + project.features = forkFeatures + project.categories = [CospendTag(id: 3, name: "Pets", color: nil, icon: "🐕", order: 0)] + + XCTAssertTrue(project.supportsCategories) + XCTAssertEqual(project.pickableCategories.count, CospendTag.globalCategories.count + 1) + XCTAssertEqual(project.category(for: .make(categoryid: -1))?.name, "Grocery") + XCTAssertEqual(project.category(for: .make(categoryid: 3))?.name, "Pets") + XCTAssertEqual(project.listedCategoryId(-12), -12) + XCTAssertEqual(project.listedCategoryId(999), 0) + } + + func testCospend_pickableCategoriesStayTheProjectsOwn() { + // Upstream behaviour: Cospend lists only the project's categories. + let project = Project.makeCospend(categories: [testCategoryGrocery]) + XCTAssertEqual(project.pickableCategories.map(\.id), [122]) + } + + func testBillDecoding_nullCategoryFromFork() throws { + let json = """ + [{"id": 1, "payer_id": 1, "owers": [], "amount": 5.0, "date": "2026-05-14", "what": "x", + "categoryid": null, "paymentmodeid": null, "repeat": "n"}] + """.data(using: .utf8)! + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(DateFormatter.cospend) + let bills = try decoder.decode([Bill].self, from: json) + XCTAssertNil(bills.first?.categoryid) + } +} + +class IHateMoneyBillParamsTests: XCTestCase { + func testStockServer_sendsNoCategoryFields() { + let params = Bill.make(categoryid: -1, paymentmodeid: 4).paramsFor(.iHateMoney) + XCTAssertNil(params["categoryid"]) + XCTAssertNil(params["paymentmodeid"]) + } + + func testFork_sendsCategoryAndPaymentModeAsStrings() { + let params = Bill.make(categoryid: -1, paymentmodeid: 4).paramsFor(.iHateMoney, features: forkFeatures) + XCTAssertEqual(params["categoryid"] as? String, "-1") + XCTAssertEqual(params["paymentmodeid"] as? String, "4") + } + + func testFork_noneIsEmptyStringNotZero() { + // The fork's form rejects "0" but accepts "" as "none". + for bill in [Bill.make(), Bill.make(categoryid: 0, paymentmodeid: 0)] { + let params = bill.paramsFor(.iHateMoney, features: forkFeatures) + XCTAssertEqual(params["categoryid"] as? String, "") + XCTAssertEqual(params["paymentmodeid"] as? String, "") + } + } + + func testFork_payedForStaysAnArray() { + let params = Bill.make().paramsFor(.iHateMoney, features: forkFeatures) + XCTAssertNotNil(params["payed_for"] as? [String]) + } +} + +class AutoCategoryViewModelTests: XCTestCase { + private var savedProject: Project! + + override func setUp() { + super.setUp() + savedProject = ProjectManager.shared.currentProject + } + + override func tearDown() { + ProjectManager.shared.currentProject = savedProject + super.tearDown() + } + + private func makeViewModel(enabled: Bool, bill: Bill = .newBill()) -> BillDetailViewModel { + let project = Project.makeIHateMoney() + project.features = forkFeatures + project.members = [testAlice.id: testAlice] + ProjectManager.shared.currentProject = project + let vm = BillDetailViewModel(currentBill: bill) + vm.autoCategoryEnabled = { enabled } + return vm + } + + func testTitleSetsCategory_whenEnabled() { + let vm = makeViewModel(enabled: true) + vm.topic = "Rewe" + XCTAssertEqual(vm.selectedCategoryId, -1) + } + + func testTitleDoesNothing_whenDisabled() { + let vm = makeViewModel(enabled: false) + vm.topic = "Rewe" + XCTAssertEqual(vm.selectedCategoryId, 0) + } + + func testManualChoiceIsNeverOverwritten() { + let vm = makeViewModel(enabled: true) + vm.categoryPickerSelection.wrappedValue = -3 + vm.topic = "Rewe" + XCTAssertEqual(vm.selectedCategoryId, -3) + } + + func testSuggestionIsRevertedWhenTitleNoLongerMatches() { + let vm = makeViewModel(enabled: true) + vm.topic = "Rewe" + vm.topic = "Rew" + XCTAssertEqual(vm.selectedCategoryId, 0) + } + + func testExistingBillKeepsItsCategory() { + let vm = makeViewModel(enabled: true, bill: .make(id: 7, what: "x", categoryid: -6)) + vm.topic = "Rewe" + XCTAssertEqual(vm.selectedCategoryId, -6) + } +} diff --git a/PayForMeTests/NetworkRequestTests.swift b/PayForMeTests/NetworkRequestTests.swift index ff9365c..c84012c 100644 --- a/PayForMeTests/NetworkRequestTests.swift +++ b/PayForMeTests/NetworkRequestTests.swift @@ -397,21 +397,54 @@ class NetworkRequestTests: XCTestCase { XCTAssertEqual(received?.paymentModes.first?.label, "💵 Cash") } - func testIHateMoney_loadTags_emitsEmptyWithoutSendingARequest() { - // iHateMoney has no equivalent concept, so asking its server would be a wasted round trip. - let project = Project.makeIHateMoney() - MockURLProtocol.requestHandler = jsonHandler() + func testIHateMoney_loadTags_stockServerYieldsEmptyLists() { + // Stock iHateMoney answers the project root without any category keys. + let project = Project.makeIHateMoney(token: "tok", password: "pass", projectId: "proj") + MockURLProtocol.requestHandler = jsonHandler(status: 200, body: """ + {"id": "proj", "name": "Test", "contact_email": "a@b.c", "members": []} + """) - var received: [CospendProjectTags] = [] + var received: CospendProjectTags? + let exp = expectation(description: "tags received") NetworkService.shared.loadTagsPublisher(project) - .sink { received.append($0) } + .sink { tags in + received = tags + exp.fulfill() + } .store(in: &subscriptions) + waitForExpectations(timeout: 2) - XCTAssertEqual(received.count, 1) - XCTAssertTrue(received.first?.categories.isEmpty ?? false) - XCTAssertTrue(received.first?.paymentModes.isEmpty ?? false) - XCTAssertNil(MockURLProtocol.lastCapturedRequest, - "iHateMoney must not hit the network for Cospend tags") + let url = MockURLProtocol.lastCapturedRequest?.url?.absoluteString ?? "" + XCTAssertTrue(url.hasSuffix("/api/projects/proj"), "Got: \(url)") + XCTAssertEqual(received?.categories.count, 0) + XCTAssertEqual(received?.paymentModes.count, 0) + XCTAssertEqual(received?.features.count, 0) + } + + func testIHateMoney_loadTags_forkServerYieldsCategoriesAndFeatures() { + let project = Project.makeIHateMoney(projectId: "proj") + MockURLProtocol.requestHandler = jsonHandler(status: 200, body: """ + { + "id": "proj", "name": "Test", "members": [], + "categories": [{"id": 3, "name": "Pets", "icon": "🐕", "color": "#112233", "order": 0}], + "paymentmodes": [{"id": 8, "name": "Cash", "icon": "💵", "color": "", "order": 0}], + "features": ["categoryid", "categories", "paymentmodes", "settle", "repeat"] + } + """) + + var received: CospendProjectTags? + let exp = expectation(description: "tags received") + NetworkService.shared.loadTagsPublisher(project) + .sink { tags in + received = tags + exp.fulfill() + } + .store(in: &subscriptions) + waitForExpectations(timeout: 2) + + XCTAssertEqual(received?.categories.map(\.id), [3]) + XCTAssertEqual(received?.paymentModes.map(\.id), [8]) + XCTAssertTrue(received?.features.contains("categoryid") ?? false) } // MARK: - HTTP methods From 5ddb77e27a76df32057b79b07fd6232c234a414a Mon Sep 17 00:00:00 2001 From: modularidea <266259136+modularidea@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:56:52 +0200 Subject: [PATCH 2/8] docs: mention categories and title suggestion in README Why: new user-facing options. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index dbfc823..e76c526 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ Ways to contribute: * Clearing debt of single members * ~Creating new projects on iHateMoney~ * Adding new members to a project +* Bill categories and payment modes (Cospend, and iHateMoney servers that support categories) +* Optional: suggest a category from the bill title (Projects tab → Categories) ## How to contribute Localization From 43c71a690581667fc53b46eb6396ff35492639d3 Mon Sep 17 00:00:00 2001 From: modularidea <266259136+modularidea@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:02:58 +0200 Subject: [PATCH 3/8] chore: keep Xcode's canonical order in project.pbxproj Why: Xcode re-sorted the entries of the new files on open. Co-Authored-By: Claude Sonnet 5.5 --- PayForMe.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/PayForMe.xcodeproj/project.pbxproj b/PayForMe.xcodeproj/project.pbxproj index 982cf4f..904534f 100644 --- a/PayForMe.xcodeproj/project.pbxproj +++ b/PayForMe.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 48E7F75B2403FD6B000CE4E6 /* FancyLoadingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48E7F75A2403FD6B000CE4E6 /* FancyLoadingButton.swift */; }; 56E7DB46FDCDC5B46C475020 /* BalanceCalculationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143F41EE78329905894C8A29 /* BalanceCalculationTests.swift */; }; 57BC8D25B91CE40F4669650C /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0865D2DAF541B24078D1DC41 /* TestHelpers.swift */; }; + 57C3F60411D3F3343CB70831 /* CategoryClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF8E2876A59F196BE1C24F0 /* CategoryClassifier.swift */; }; 62475CF1301F718C0097B7C9 /* CospendTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62475CF0301F718C0097B7C9 /* CospendTag.swift */; }; 62A13D412FA3AB760036EE34 /* ShareProjectQRCodeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62A13D402FA3AB700036EE34 /* ShareProjectQRCodeViewModel.swift */; }; 62C73597878BA5AD4572FE90 /* BillSortingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92ABB02BEA28BEA7C8C64841 /* BillSortingTests.swift */; }; @@ -61,18 +62,17 @@ 6591C8E524052C240054BA24 /* CommunicationIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6591C8E424052C240054BA24 /* CommunicationIndicator.swift */; }; 6591C8E7240542250054BA24 /* LoadingDots.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6591C8E6240542250054BA24 /* LoadingDots.swift */; }; 65A65E3F2527529D00C86533 /* Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65A65E3E2527529D00C86533 /* Combine.swift */; }; - A9A3E941FF5A941B353C8928 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5CE07E061AEA59F79C0E31 /* AppSettings.swift */; }; - 57C3F60411D3F3343CB70831 /* CategoryClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF8E2876A59F196BE1C24F0 /* CategoryClassifier.swift */; }; - D2B9E84A59C861734B317D95 /* TextMatchUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0DE3B9BA48181BBB1E6664F /* TextMatchUtils.swift */; }; 65AF00022F1A000100000002 /* StorageMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65AF00012F1A000100000001 /* StorageMigrationTests.swift */; }; 65AF00042F1A000100000004 /* BillDetailViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65AF00032F1A000100000003 /* BillDetailViewModelTests.swift */; }; 65C9E2CE23E1B8F500814B37 /* BalanceList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65C9E2CD23E1B8F500814B37 /* BalanceList.swift */; }; 65C9E2D023E1B96B00814B37 /* BalanceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65C9E2CF23E1B96B00814B37 /* BalanceViewModel.swift */; }; 65D225F32428038500C3F1EC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 65D225F52428038500C3F1EC /* Localizable.strings */; }; 65D9094025581BE100351F4B /* AddFromURLView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65D9093F25581BE100351F4B /* AddFromURLView.swift */; }; + A7089D0A97757AB90C9CB97E /* CategorySupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A391473E9D9DE0002A4D8F46 /* CategorySupportTests.swift */; }; + A9A3E941FF5A941B353C8928 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5CE07E061AEA59F79C0E31 /* AppSettings.swift */; }; BF4D7AF265FC4A90B1AC5AEB /* JSONDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4D55C3EF5EFCD925428041 /* JSONDecodingTests.swift */; }; D279295CFA25D9CC69C6F27C /* BillTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EED3194A2342518B0E74079 /* BillTests.swift */; }; - A7089D0A97757AB90C9CB97E /* CategorySupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A391473E9D9DE0002A4D8F46 /* CategorySupportTests.swift */; }; + D2B9E84A59C861734B317D95 /* TextMatchUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0DE3B9BA48181BBB1E6664F /* TextMatchUtils.swift */; }; DBBD86EF399BCF232C021935 /* NetworkRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316FBA7C0CE4ABB51EC2A121 /* NetworkRequestTests.swift */; }; /* End PBXBuildFile section */ @@ -96,6 +96,7 @@ /* Begin PBXFileReference section */ 0865D2DAF541B24078D1DC41 /* TestHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = ""; }; 143F41EE78329905894C8A29 /* BalanceCalculationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BalanceCalculationTests.swift; sourceTree = ""; }; + 2A5CE07E061AEA59F79C0E31 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; 316FBA7C0CE4ABB51EC2A121 /* NetworkRequestTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NetworkRequestTests.swift; sourceTree = ""; }; 481FB4FA23E964C8003BD108 /* ProjectManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectManager.swift; sourceTree = ""; }; 481FB4FC23EAD78F003BD108 /* AddProjectManualViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProjectManualViewModel.swift; sourceTree = ""; }; @@ -153,9 +154,6 @@ 6591C8E424052C240054BA24 /* CommunicationIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunicationIndicator.swift; sourceTree = ""; }; 6591C8E6240542250054BA24 /* LoadingDots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LoadingDots.swift; path = PayForMe/Views/LoadingDots.swift; sourceTree = SOURCE_ROOT; }; 65A65E3E2527529D00C86533 /* Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Combine.swift; sourceTree = ""; }; - 2A5CE07E061AEA59F79C0E31 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; - 6FF8E2876A59F196BE1C24F0 /* CategoryClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryClassifier.swift; sourceTree = ""; }; - B0DE3B9BA48181BBB1E6664F /* TextMatchUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextMatchUtils.swift; sourceTree = ""; }; 65AA00A6252C881E00C5DEE8 /* PayForMe.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PayForMe.entitlements; sourceTree = ""; }; 65AF00012F1A000100000001 /* StorageMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageMigrationTests.swift; sourceTree = ""; }; 65AF00032F1A000100000003 /* BillDetailViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BillDetailViewModelTests.swift; sourceTree = ""; }; @@ -167,10 +165,12 @@ 65D225F7242803C800C3F1EC /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 65D9093F25581BE100351F4B /* AddFromURLView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddFromURLView.swift; sourceTree = ""; }; 65FE0E8E2544968B002B80CF /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + 6FF8E2876A59F196BE1C24F0 /* CategoryClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryClassifier.swift; sourceTree = ""; }; 7EED3194A2342518B0E74079 /* BillTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BillTests.swift; sourceTree = ""; }; - A391473E9D9DE0002A4D8F46 /* CategorySupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategorySupportTests.swift; sourceTree = ""; }; 8F4D55C3EF5EFCD925428041 /* JSONDecodingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = JSONDecodingTests.swift; sourceTree = ""; }; 92ABB02BEA28BEA7C8C64841 /* BillSortingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BillSortingTests.swift; sourceTree = ""; }; + A391473E9D9DE0002A4D8F46 /* CategorySupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategorySupportTests.swift; sourceTree = ""; }; + B0DE3B9BA48181BBB1E6664F /* TextMatchUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextMatchUtils.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ From 088fcb1cf151daec135223de17134dbb584ef67b Mon Sep 17 00:00:00 2001 From: modularidea <266259136+modularidea@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:04:43 +0200 Subject: [PATCH 4/8] chore: drop unrelated Package.resolved change Why: Xcode touched it during local builds; not part of this feature. Co-Authored-By: Claude Sonnet 5.5 --- .../xcshareddata/swiftpm/Package.resolved | 52 ++++++++----------- 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/PayForMe.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/PayForMe.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2ab31b1..a6c00e1 100644 --- a/PayForMe.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/PayForMe.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,33 +1,25 @@ { - "originHash" : "07ab1d97001a5cb88c6ba268e55e0b0c409783fd7807f68a8d37691c1887acd1", - "pins" : [ - { - "identity" : "carbode-barcode-scanner-for-swiftui", - "kind" : "remoteSourceControl", - "location" : "https://github.com/heart/CarBode-Barcode-Scanner-For-SwiftUI", - "state" : { - "revision" : "d0192bd6ccc61e426eb7a301f16521f1da05dc66", - "version" : "2.2.4" + "object": { + "pins": [ + { + "package": "TimelaneCombine", + "repositoryURL": "https://github.com/icanzilb/TimelaneCombine", + "state": { + "branch": null, + "revision": "7366c0243860b8dbf265b5a5674e6c4797c44a8f", + "version": "1.0.2" + } + }, + { + "package": "TimelaneCore", + "repositoryURL": "https://github.com/icanzilb/TimelaneCore", + "state": { + "branch": null, + "revision": "7317572859017ecb1a0e7252d865b659e2647e20", + "version": "1.0.1" + } } - }, - { - "identity" : "grdb.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/groue/GRDB.swift.git", - "state" : { - "revision" : "b83108d10f42680d78f23fe4d4d80fc88dab3212", - "version" : "7.11.1" - } - }, - { - "identity" : "slickloadingspinner", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Hustenbonbon/SlickLoadingSpinner.git", - "state" : { - "revision" : "c1b125fabf245717df0a44655cd8a96b603ee55e", - "version" : "2.0.1" - } - } - ], - "version" : 3 + ] + }, + "version": 1 } From 081c38eb0e977994b093487a1767782ae0211134 Mon Sep 17 00:00:00 2001 From: modularidea <266259136+modularidea@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:15:25 +0200 Subject: [PATCH 5/8] docs: shorten README feature note Why: one line is enough for a feature list. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index e76c526..3e8a793 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,7 @@ Ways to contribute: * Clearing debt of single members * ~Creating new projects on iHateMoney~ * Adding new members to a project -* Bill categories and payment modes (Cospend, and iHateMoney servers that support categories) -* Optional: suggest a category from the bill title (Projects tab → Categories) +* Bill categories and payment modes, also on iHateMoney servers that support them ## How to contribute Localization From f13b740ab285855cc51f6a85f06d9b770ff01f8f Mon Sep 17 00:00:00 2001 From: modularidea <266259136+modularidea@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:34:54 +0200 Subject: [PATCH 6/8] feat: keep categories on the device for servers without them Why: stock iHateMoney has no category field; categories are assigned and stored locally there. Co-Authored-By: Claude Sonnet 5.5 --- PayForMe.xcodeproj/project.pbxproj | 8 +++ PayForMe/Model/Project.swift | 26 +++++--- PayForMe/Services/LocalCategoryStore.swift | 55 +++++++++++++++++ PayForMe/Services/NetworkService.swift | 16 +++++ PayForMe/Services/ProjectManager.swift | 24 ++++++-- PayForMe/Services/StorageService.swift | 10 ++++ 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 +- .../BillDetail/BillDetailViewModel.swift | 2 +- PayForMe/Views/Projects/ProjectList.swift | 2 +- PayForMeTests/CategorySupportTests.swift | 28 ++++++++- PayForMeTests/LocalCategoryStoreTests.swift | 59 +++++++++++++++++++ 16 files changed, 220 insertions(+), 22 deletions(-) create mode 100644 PayForMe/Services/LocalCategoryStore.swift create mode 100644 PayForMeTests/LocalCategoryStoreTests.swift diff --git a/PayForMe.xcodeproj/project.pbxproj b/PayForMe.xcodeproj/project.pbxproj index 904534f..a247040 100644 --- a/PayForMe.xcodeproj/project.pbxproj +++ b/PayForMe.xcodeproj/project.pbxproj @@ -46,6 +46,7 @@ 65662A1D23D7353400303207 /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65662A1C23D7353400303207 /* Server.swift */; }; 65662A2223D75C5F00303207 /* AddProjectManualView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65662A2123D75C5F00303207 /* AddProjectManualView.swift */; }; 65662A2423D83ED100303207 /* StorageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65662A2323D83ED100303207 /* StorageService.swift */; }; + F210835FF66EAB36C4EF24E5 /* LocalCategoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16973AEB3337A370B125D368 /* LocalCategoryStore.swift */; }; 65662A2623D8546900303207 /* ProjectList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65662A2523D8546900303207 /* ProjectList.swift */; }; 656A2AD7241BB4F300786A1E /* PayForMeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656A2AD6241BB4F300786A1E /* PayForMeUITests.swift */; }; 656A2ADF241BBAF200786A1E /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656A2ADE241BBAF200786A1E /* SnapshotHelper.swift */; }; @@ -69,6 +70,7 @@ 65D225F32428038500C3F1EC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 65D225F52428038500C3F1EC /* Localizable.strings */; }; 65D9094025581BE100351F4B /* AddFromURLView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65D9093F25581BE100351F4B /* AddFromURLView.swift */; }; A7089D0A97757AB90C9CB97E /* CategorySupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A391473E9D9DE0002A4D8F46 /* CategorySupportTests.swift */; }; + 27BA4EFA758D236CE23AEDA7 /* LocalCategoryStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFD0B11814133F77ACBA4A3 /* LocalCategoryStoreTests.swift */; }; A9A3E941FF5A941B353C8928 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5CE07E061AEA59F79C0E31 /* AppSettings.swift */; }; BF4D7AF265FC4A90B1AC5AEB /* JSONDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F4D55C3EF5EFCD925428041 /* JSONDecodingTests.swift */; }; D279295CFA25D9CC69C6F27C /* BillTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EED3194A2342518B0E74079 /* BillTests.swift */; }; @@ -137,6 +139,7 @@ 65662A1C23D7353400303207 /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; 65662A2123D75C5F00303207 /* AddProjectManualView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProjectManualView.swift; sourceTree = ""; }; 65662A2323D83ED100303207 /* StorageService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageService.swift; sourceTree = ""; }; + 16973AEB3337A370B125D368 /* LocalCategoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalCategoryStore.swift; sourceTree = ""; }; 65662A2523D8546900303207 /* ProjectList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectList.swift; sourceTree = ""; }; 656A2AD4241BB4F300786A1E /* PayForMeUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PayForMeUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 656A2AD6241BB4F300786A1E /* PayForMeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayForMeUITests.swift; sourceTree = ""; }; @@ -170,6 +173,7 @@ 8F4D55C3EF5EFCD925428041 /* JSONDecodingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = JSONDecodingTests.swift; sourceTree = ""; }; 92ABB02BEA28BEA7C8C64841 /* BillSortingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BillSortingTests.swift; sourceTree = ""; }; A391473E9D9DE0002A4D8F46 /* CategorySupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategorySupportTests.swift; sourceTree = ""; }; + BBFD0B11814133F77ACBA4A3 /* LocalCategoryStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalCategoryStoreTests.swift; sourceTree = ""; }; B0DE3B9BA48181BBB1E6664F /* TextMatchUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextMatchUtils.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -245,6 +249,7 @@ 0865D2DAF541B24078D1DC41 /* TestHelpers.swift */, 7EED3194A2342518B0E74079 /* BillTests.swift */, A391473E9D9DE0002A4D8F46 /* CategorySupportTests.swift */, + BBFD0B11814133F77ACBA4A3 /* LocalCategoryStoreTests.swift */, 8F4D55C3EF5EFCD925428041 /* JSONDecodingTests.swift */, 143F41EE78329905894C8A29 /* BalanceCalculationTests.swift */, 92ABB02BEA28BEA7C8C64841 /* BillSortingTests.swift */, @@ -320,6 +325,7 @@ children = ( 65662A1A23D733B700303207 /* NetworkService.swift */, 65662A2323D83ED100303207 /* StorageService.swift */, + 16973AEB3337A370B125D368 /* LocalCategoryStore.swift */, 481FB4FA23E964C8003BD108 /* ProjectManager.swift */, ); path = Services; @@ -566,6 +572,7 @@ 57BC8D25B91CE40F4669650C /* TestHelpers.swift in Sources */, D279295CFA25D9CC69C6F27C /* BillTests.swift in Sources */, A7089D0A97757AB90C9CB97E /* CategorySupportTests.swift in Sources */, + 27BA4EFA758D236CE23AEDA7 /* LocalCategoryStoreTests.swift in Sources */, BF4D7AF265FC4A90B1AC5AEB /* JSONDecodingTests.swift in Sources */, 56E7DB46FDCDC5B46C475020 /* BalanceCalculationTests.swift in Sources */, 62C73597878BA5AD4572FE90 /* BillSortingTests.swift in Sources */, @@ -620,6 +627,7 @@ 658FC54B25272639004A49AA /* AddProjectQRView.swift in Sources */, 481FB4FD23EAD78F003BD108 /* AddProjectManualViewModel.swift in Sources */, 65662A2423D83ED100303207 /* StorageService.swift in Sources */, + F210835FF66EAB36C4EF24E5 /* LocalCategoryStore.swift in Sources */, 655DD2AE2993DACB00E06675 /* ProjectListEntry.swift in Sources */, 656E8CB823D9FDA300B3ED10 /* BillListViewModel.swift in Sources */, 62A13D412FA3AB760036EE34 /* ShareProjectQRCodeViewModel.swift in Sources */, diff --git a/PayForMe/Model/Project.swift b/PayForMe/Model/Project.swift index 957587a..04479bc 100644 --- a/PayForMe/Model/Project.swift +++ b/PayForMe/Model/Project.swift @@ -22,6 +22,8 @@ class Project: Codable, Identifiable { /// Capabilities announced by an iHateMoney category fork; empty otherwise. Not persisted, /// refreshed with every tag load. var features: Set = [] + /// bill id → category id, for servers without a category field. Loaded from the device. + var localCategories: [Int: Int] = [:] var me: Int? let projectId: String @@ -89,25 +91,35 @@ struct StoredProject: Codable { } extension Project { - /// Whether bills of this project carry a category. Cospend always does; iHateMoney only when - /// the server is a category fork (stock iHateMoney has no such field). + /// Whether bills of this project can be categorized: Cospend and category forks keep it on the + /// server, stock iHateMoney locally (see `usesLocalCategories`). var supportsCategories: Bool { - backend == .cospend || features.contains("categoryid") + backend == .cospend || backend == .iHateMoney + } + + /// iHateMoney without a category field: categories are assigned and kept on this device. + var usesLocalCategories: Bool { + backend == .iHateMoney && !features.contains("categoryid") + } + + /// The server's category wins; the local one only fills in where the server has none. + func categoryId(of bill: Bill) -> Int? { + bill.categoryid ?? (usesLocalCategories ? localCategories[bill.id] : nil) } var supportsPaymentModes: Bool { backend == .cospend || features.contains("paymentmodes") } - /// Categories offered in the bill form. An iHateMoney fork accepts Cospend's global ids on - /// top of the project's own; Cospend lists only the project's own. + /// Categories offered in the bill form. iHateMoney (fork or stock) offers Cospend's built-in + /// ids on top of the project's own; Cospend lists only the project's own. var pickableCategories: [CospendTag] { - guard backend == .iHateMoney, supportsCategories else { return categories } + guard backend == .iHateMoney else { return categories } return CospendTag.globalCategories + categories } func category(for bill: Bill) -> CospendTag? { - tag(id: bill.categoryid, in: pickableCategories) + tag(id: categoryId(of: bill), in: pickableCategories) } func paymentMode(for bill: Bill) -> CospendTag? { diff --git a/PayForMe/Services/LocalCategoryStore.swift b/PayForMe/Services/LocalCategoryStore.swift new file mode 100644 index 0000000..d51ddac --- /dev/null +++ b/PayForMe/Services/LocalCategoryStore.swift @@ -0,0 +1,55 @@ +// +// LocalCategoryStore.swift +// PayForMe +// + +import Foundation +import GRDB + +/// Bill categories kept on this device only, for servers that have no category field of their +/// own (stock iHateMoney). Keyed by project and bill id; nothing here is synced. +final class LocalCategoryStore { + private let dbQueue: DatabaseQueue + + init(dbQueue: DatabaseQueue) { + self.dbQueue = dbQueue + } + + /// Server URL + project id: stable across app restarts, unlike the database row id which is + /// reassigned when a project is removed and added again. + static func key(for project: Project) -> String { + "\(project.url.absoluteString)|\(project.projectId)" + } + + /// bill id → category id + func categories(projectKey: String) -> [Int: Int] { + do { + return try dbQueue.read { db in + let rows = try Row.fetchAll(db, sql: "SELECT billId, categoryId FROM localBillCategory WHERE projectKey = ?", arguments: [projectKey]) + return Dictionary(rows.map { ($0["billId"] as Int, $0["categoryId"] as Int) }) { _, last in last } + } + } catch { + print("Couldn't read local categories \(error.localizedDescription)") + return [:] + } + } + + /// 0 means "no category" and clears the entry, like the server APIs encode it. + func set(categoryId: Int, billId: Int, projectKey: String) { + do { + try dbQueue.write { db in + if categoryId == 0 { + try db.execute(sql: "DELETE FROM localBillCategory WHERE projectKey = ? AND billId = ?", arguments: [projectKey, billId]) + } else { + try db.execute(sql: "INSERT OR REPLACE INTO localBillCategory (projectKey, billId, categoryId) VALUES (?, ?, ?)", arguments: [projectKey, billId, categoryId]) + } + } + } catch { + print("Couldn't store local category \(error.localizedDescription)") + } + } + + func remove(billId: Int, projectKey: String) { + set(categoryId: 0, billId: billId, projectKey: projectKey) + } +} diff --git a/PayForMe/Services/NetworkService.swift b/PayForMe/Services/NetworkService.swift index 751c2dc..447304a 100644 --- a/PayForMe/Services/NetworkService.swift +++ b/PayForMe/Services/NetworkService.swift @@ -155,6 +155,22 @@ class NetworkService { return sendBillPublisher(request: request) } + /// Creates a bill and returns the id the server assigned (iHateMoney answers with the bare id). + /// nil on failure or when the response carries none. + func postBillWithIdPublisher(bill: Bill) -> AnyPublisher<(success: Bool, id: Int?), Never> { + let request = buildURLRequest("bills", params: bill.paramsFor(currentProject.backend, features: currentProject.features), project: currentProject, httpMethod: "POST") + return URLSession.shared.dataTaskPublisher(for: request) + .map { output -> (success: Bool, id: Int?) in + guard let response = output.response as? HTTPURLResponse, response.statusCode / 100 == 2 else { + return (false, nil) + } + let text = String(data: output.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + return (true, text.flatMap { Int($0) }) + } + .replaceError(with: (false, nil)) + .eraseToAnyPublisher() + } + func updateBillPublisher(bill: Bill) -> AnyPublisher { let request = buildURLRequest("bills/\(bill.id)", params: bill.paramsFor(currentProject.backend, features: currentProject.features), project: currentProject, httpMethod: "PUT") return sendBillPublisher(request: request) diff --git a/PayForMe/Services/ProjectManager.swift b/PayForMe/Services/ProjectManager.swift index dcde5c5..e7b89d3 100644 --- a/PayForMe/Services/ProjectManager.swift +++ b/PayForMe/Services/ProjectManager.swift @@ -63,6 +63,7 @@ class ProjectManager: ObservableObject { let project = currentProject loadTags(for: project) + project.localCategories = storageService.localCategories.categories(projectKey: LocalCategoryStore.key(for: project)) let billsPublisher = NetworkService.shared.loadBillsPublisher(project) let membersPublisher = NetworkService.shared.loadMembersPublisher(project) @@ -114,8 +115,9 @@ class ProjectManager: ObservableObject { if update { cancellable = NetworkService.shared.updateBillPublisher(bill: bill) .receive(on: DispatchQueue.main) - .sink { success in + .sink { [weak self] success in if success { + self?.storeLocalCategoryIfNeeded(bill, billId: bill.id) print("Bill id\(bill.id) updated") } else { print("error updating bill id\(bill.id)") @@ -123,10 +125,12 @@ class ProjectManager: ObservableObject { completion() } } else { - cancellable = NetworkService.shared.postBillPublisher(bill: bill) + cancellable = NetworkService.shared.postBillWithIdPublisher(bill: bill) .receive(on: DispatchQueue.main) - .sink { success in + .sink { [weak self] result in + let success = result.success if success { + if let id = result.id { self?.storeLocalCategoryIfNeeded(bill, billId: id) } print("Bill posted") } else { print("Error posting bill") @@ -136,14 +140,26 @@ class ProjectManager: ObservableObject { } } + /// For servers without a category field the choice stays on this device. + private func storeLocalCategoryIfNeeded(_ bill: Bill, billId: Int) { + let project = currentProject + guard project.usesLocalCategories else { return } + let category = bill.categoryid ?? 0 + storageService.localCategories.set(categoryId: category, billId: billId, projectKey: LocalCategoryStore.key(for: project)) + project.localCategories[billId] = category == 0 ? nil : category + } + private func deleteBillFromServer(bill: Bill, completion: @escaping () -> Void) { cancellable?.cancel() cancellable = nil cancellable = NetworkService.shared.deleteBillPublisher(bill: bill) .receive(on: DispatchQueue.main) - .sink { success in + .sink { [weak self] success in if success { + if let self = self, self.currentProject.usesLocalCategories { + self.storageService.localCategories.remove(billId: bill.id, projectKey: LocalCategoryStore.key(for: self.currentProject)) + } print("Bill successfully deleted") } else { print("Error deleting bill") diff --git a/PayForMe/Services/StorageService.swift b/PayForMe/Services/StorageService.swift index 3a8ce51..ad08cb4 100644 --- a/PayForMe/Services/StorageService.swift +++ b/PayForMe/Services/StorageService.swift @@ -18,6 +18,7 @@ class StorageService { } private let dbQueue: DatabaseQueue + let localCategories: LocalCategoryStore static func makeMigrator() -> DatabaseMigrator { var migrator = DatabaseMigrator() @@ -49,6 +50,14 @@ class StorageService { }) try db.execute(sql: "UPDATE storedProject SET projectId = token;") } + migrator.registerMigration("v5") { db in + try db.create(table: "localBillCategory", ifNotExists: true) { table in + table.column("projectKey", .text).notNull() + table.column("billId", .integer).notNull() + table.column("categoryId", .integer).notNull() + table.primaryKey(["projectKey", "billId"]) + } + } // #if DEBUG //// Speed up development by nuking the database when migrations change // migrator.eraseDatabaseOnSchemaChange = true @@ -60,6 +69,7 @@ class StorageService { do { dbQueue = try DatabaseQueue(path: databasePath.appendingPathComponent("payforme.sqlite").path) try Self.makeMigrator().migrate(dbQueue) + localCategories = LocalCategoryStore(dbQueue: dbQueue) } catch { print("Storage couldn't be initialized \(error.localizedDescription)") fatalError() diff --git a/PayForMe/Strings/cs.lproj/Localizable.strings b/PayForMe/Strings/cs.lproj/Localizable.strings index a25c7ae..2e57502 100644 --- a/PayForMe/Strings/cs.lproj/Localizable.strings +++ b/PayForMe/Strings/cs.lproj/Localizable.strings @@ -166,4 +166,4 @@ /* ProjectList.swift: auto category */ "Categories" = "Kategorie"; "Suggest category from title" = "Navrhnout kategorii podle názvu"; -"Suggest a category from the bill title. Needs a project whose server supports categories." = "Navrhne kategorii podle názvu účtu. Vyžaduje projekt, jehož server podporuje kategorie."; +"Suggest a category from the bill title. Servers without categories keep them on this device only." = "Navrhne kategorii podle názvu účtu. Na serverech bez kategorií zůstanou jen v tomto zařízení."; diff --git a/PayForMe/Strings/de.lproj/Localizable.strings b/PayForMe/Strings/de.lproj/Localizable.strings index 9819198..e55e687 100644 --- a/PayForMe/Strings/de.lproj/Localizable.strings +++ b/PayForMe/Strings/de.lproj/Localizable.strings @@ -170,4 +170,4 @@ /* ProjectList.swift: auto category */ "Categories" = "Kategorien"; "Suggest category from title" = "Kategorie aus Titel vorschlagen"; -"Suggest a category from the bill title. Needs a project whose server supports categories." = "Schlägt anhand des Rechnungstitels eine Kategorie vor. Benötigt ein Projekt, dessen Server Kategorien unterstützt."; +"Suggest a category from the bill title. Servers without categories keep them on this device only." = "Schlägt anhand des Rechnungstitels eine Kategorie vor. Auf Servern ohne Kategorien bleiben sie nur auf diesem Gerät."; diff --git a/PayForMe/Strings/en.lproj/Localizable.strings b/PayForMe/Strings/en.lproj/Localizable.strings index ac776fd..1fe1769 100644 --- a/PayForMe/Strings/en.lproj/Localizable.strings +++ b/PayForMe/Strings/en.lproj/Localizable.strings @@ -173,4 +173,4 @@ /* ProjectList.swift: auto category */ "Categories" = "Categories"; "Suggest category from title" = "Suggest category from title"; -"Suggest a category from the bill title. Needs a project whose server supports categories." = "Suggest a category from the bill title. Needs a project whose server supports categories."; +"Suggest a category from the bill title. Servers without categories keep them on this device only." = "Suggest a category from the bill title. Servers without categories keep them on this device only."; diff --git a/PayForMe/Strings/es.lproj/Localizable.strings b/PayForMe/Strings/es.lproj/Localizable.strings index f5b0d5e..d5d11e7 100644 --- a/PayForMe/Strings/es.lproj/Localizable.strings +++ b/PayForMe/Strings/es.lproj/Localizable.strings @@ -173,4 +173,4 @@ /* ProjectList.swift: auto category */ "Categories" = "Categorías"; "Suggest category from title" = "Sugerir categoría según el título"; -"Suggest a category from the bill title. Needs a project whose server supports categories." = "Sugiere una categoría a partir del título del gasto. Requiere un proyecto cuyo servidor admita categorías."; +"Suggest a category from the bill title. Servers without categories keep them on this device only." = "Sugiere una categoría a partir del título del gasto. En servidores sin categorías, solo se guardan en este dispositivo."; diff --git a/PayForMe/Strings/fr.lproj/Localizable.strings b/PayForMe/Strings/fr.lproj/Localizable.strings index 4c1ed42..f40118e 100644 --- a/PayForMe/Strings/fr.lproj/Localizable.strings +++ b/PayForMe/Strings/fr.lproj/Localizable.strings @@ -173,4 +173,4 @@ /* ProjectList.swift: auto category */ "Categories" = "Catégories"; "Suggest category from title" = "Suggérer une catégorie d'après le titre"; -"Suggest a category from the bill title. Needs a project whose server supports categories." = "Suggère une catégorie à partir du titre de la dépense. Nécessite un projet dont le serveur gère les catégories."; +"Suggest a category from the bill title. Servers without categories keep them on this device only." = "Suggère une catégorie à partir du titre de la dépense. Sur les serveurs sans catégories, elles restent uniquement sur cet appareil."; diff --git a/PayForMe/Strings/ru.lproj/Localizable.strings b/PayForMe/Strings/ru.lproj/Localizable.strings index b44a30f..217f018 100644 --- a/PayForMe/Strings/ru.lproj/Localizable.strings +++ b/PayForMe/Strings/ru.lproj/Localizable.strings @@ -172,4 +172,4 @@ /* ProjectList.swift: auto category */ "Categories" = "Категории"; "Suggest category from title" = "Предлагать категорию по названию"; -"Suggest a category from the bill title. Needs a project whose server supports categories." = "Предлагает категорию по названию счёта. Нужен проект, сервер которого поддерживает категории."; +"Suggest a category from the bill title. Servers without categories keep them on this device only." = "Предлагает категорию по названию счёта. На серверах без категорий они хранятся только на этом устройстве."; diff --git a/PayForMe/Views/BillDetail/BillDetailViewModel.swift b/PayForMe/Views/BillDetail/BillDetailViewModel.swift index 09ce540..8ac136a 100644 --- a/PayForMe/Views/BillDetail/BillDetailViewModel.swift +++ b/PayForMe/Views/BillDetail/BillDetailViewModel.swift @@ -142,7 +142,7 @@ class BillDetailViewModel: ObservableObject { } } billDate = currentBill.date - selectedCategoryId = currentBill.categoryid ?? 0 + selectedCategoryId = currentProject.categoryId(of: currentBill) ?? 0 selectedPaymentModeId = currentBill.paymentmodeid ?? 0 } } diff --git a/PayForMe/Views/Projects/ProjectList.swift b/PayForMe/Views/Projects/ProjectList.swift index feaffd1..c67f59c 100644 --- a/PayForMe/Views/Projects/ProjectList.swift +++ b/PayForMe/Views/Projects/ProjectList.swift @@ -28,7 +28,7 @@ struct ProjectList: View { } .onDelete(perform: deleteProject) } - Section(header: Text("Categories"), footer: Text("Suggest a category from the bill title. Needs a project whose server supports categories.")) { + Section(header: Text("Categories"), footer: Text("Suggest a category from the bill title. Servers without categories keep them on this device only.")) { Toggle("Suggest category from title", isOn: $autoCategoryEnabled) .disabled(!manager.currentProject.supportsCategories) } diff --git a/PayForMeTests/CategorySupportTests.swift b/PayForMeTests/CategorySupportTests.swift index e3f85c5..18179c1 100644 --- a/PayForMeTests/CategorySupportTests.swift +++ b/PayForMeTests/CategorySupportTests.swift @@ -72,10 +72,32 @@ class CategoryClassifierTests: XCTestCase { } class CategoryProjectModelTests: XCTestCase { - func testStockIHateMoney_hasNoCategories() { + func testStockIHateMoney_categorizesLocally() { let project = Project.makeIHateMoney() - XCTAssertFalse(project.supportsCategories) - XCTAssertTrue(project.pickableCategories.isEmpty) + XCTAssertTrue(project.supportsCategories) + XCTAssertTrue(project.usesLocalCategories) + XCTAssertEqual(project.pickableCategories.count, CospendTag.globalCategories.count) + } + + func testStockIHateMoney_localCategoryFillsInWhereServerHasNone() { + let project = Project.makeIHateMoney() + project.localCategories = [7: -1] + XCTAssertEqual(project.categoryId(of: .make(id: 7)), -1) + XCTAssertEqual(project.category(for: .make(id: 7))?.name, "Grocery") + XCTAssertNil(project.categoryId(of: .make(id: 8))) + } + + func testFork_serverCategoryWinsAndLocalIsIgnored() { + let project = Project.makeIHateMoney() + project.features = forkFeatures + project.localCategories = [7: -1] + XCTAssertFalse(project.usesLocalCategories) + XCTAssertNil(project.categoryId(of: .make(id: 7))) + XCTAssertEqual(project.categoryId(of: .make(id: 7, categoryid: -12)), -12) + } + + func testCospend_neverUsesLocalCategories() { + XCTAssertFalse(Project.makeCospend().usesLocalCategories) } func testForkIHateMoney_offersGlobalPlusOwnCategories() { diff --git a/PayForMeTests/LocalCategoryStoreTests.swift b/PayForMeTests/LocalCategoryStoreTests.swift new file mode 100644 index 0000000..4123a93 --- /dev/null +++ b/PayForMeTests/LocalCategoryStoreTests.swift @@ -0,0 +1,59 @@ +// +// LocalCategoryStoreTests.swift +// PayForMeTests +// +// Categories kept on the device for servers without a category field. +// + +import GRDB +import XCTest +@testable import PayForMe + +class LocalCategoryStoreTests: XCTestCase { + private func makeStore() throws -> LocalCategoryStore { + let dbQueue = try DatabaseQueue() + try StorageService.makeMigrator().migrate(dbQueue) + return LocalCategoryStore(dbQueue: dbQueue) + } + + func testSetAndRead() throws { + let store = try makeStore() + store.set(categoryId: -1, billId: 5, projectKey: "a") + store.set(categoryId: -12, billId: 6, projectKey: "a") + XCTAssertEqual(store.categories(projectKey: "a"), [5: -1, 6: -12]) + } + + func testSetReplacesExistingEntry() throws { + let store = try makeStore() + store.set(categoryId: -1, billId: 5, projectKey: "a") + store.set(categoryId: -3, billId: 5, projectKey: "a") + XCTAssertEqual(store.categories(projectKey: "a"), [5: -3]) + } + + func testZeroClearsTheEntry() throws { + let store = try makeStore() + store.set(categoryId: -1, billId: 5, projectKey: "a") + store.set(categoryId: 0, billId: 5, projectKey: "a") + XCTAssertTrue(store.categories(projectKey: "a").isEmpty) + } + + func testProjectsAreSeparate() throws { + let store = try makeStore() + store.set(categoryId: -1, billId: 5, projectKey: "a") + store.set(categoryId: -2, billId: 5, projectKey: "b") + XCTAssertEqual(store.categories(projectKey: "a"), [5: -1]) + XCTAssertEqual(store.categories(projectKey: "b"), [5: -2]) + } + + func testRemove() throws { + let store = try makeStore() + store.set(categoryId: -1, billId: 5, projectKey: "a") + store.remove(billId: 5, projectKey: "a") + XCTAssertTrue(store.categories(projectKey: "a").isEmpty) + } + + func testKeyCombinesServerAndProjectId() { + let project = Project.makeIHateMoney(url: "https://ihatemoney.org", projectId: "trip") + XCTAssertEqual(LocalCategoryStore.key(for: project), "https://ihatemoney.org|trip") + } +} From ef14617e63fe8f8e811e8949b7a57f810ea628f5 Mon Sep 17 00:00:00 2001 From: modularidea <266259136+modularidea@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:36:40 +0200 Subject: [PATCH 7/8] docs: mention device-local categories in README Why: stock iHateMoney keeps categories on the device now. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3e8a793..a0a3d06 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Ways to contribute: * Clearing debt of single members * ~Creating new projects on iHateMoney~ * Adding new members to a project -* Bill categories and payment modes, also on iHateMoney servers that support them +* Bill categories and payment modes, also on iHateMoney servers that support them; on servers without categories they are kept on the device ## How to contribute Localization From 9ef6d0dc2156efea12565b32ae0a83a8c123cc9d Mon Sep 17 00:00:00 2001 From: modularidea <266259136+modularidea@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:43:06 +0200 Subject: [PATCH 8/8] feat: show categories as chips and fix emoji icons Why: tags were plain gray text, and some built-in icons rendered as monochrome glyphs. Co-Authored-By: Claude Sonnet 5.5 --- PayForMe.xcodeproj/project.pbxproj | 4 ++ PayForMe/Model/CospendTag.swift | 25 ++++++++++++- PayForMe/Views/BillList/BillCell.swift | 24 +++--------- PayForMe/Views/BillList/CategoryChip.swift | 37 +++++++++++++++++++ PayForMeTests/CategorySupportTests.swift | 43 ++++++++++++++++++++++ 5 files changed, 113 insertions(+), 20 deletions(-) create mode 100644 PayForMe/Views/BillList/CategoryChip.swift diff --git a/PayForMe.xcodeproj/project.pbxproj b/PayForMe.xcodeproj/project.pbxproj index a247040..8fcf112 100644 --- a/PayForMe.xcodeproj/project.pbxproj +++ b/PayForMe.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 6523FC512558153600BCD843 /* AddPasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6523FC502558153600BCD843 /* AddPasswordView.swift */; }; 6524D60225385E0800B2AA7B /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 6524D60125385E0800B2AA7B /* GRDB */; }; 6533A02B23D888B100FF822B /* BillCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6533A02A23D888B100FF822B /* BillCell.swift */; }; + AF3C149C69191129E15F7CA7 /* CategoryChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08D1989BA96A4911A60FD3F3 /* CategoryChip.swift */; }; 654754B82528736F00A82EB6 /* Views+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654754B72528736F00A82EB6 /* Views+Extensions.swift */; }; 654754C0252889B200A82EB6 /* ProjectQRPermissionCheckerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654754BF252889B200A82EB6 /* ProjectQRPermissionCheckerView.swift */; }; 654754C8252899FF00A82EB6 /* FancyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654754C7252899FF00A82EB6 /* FancyButton.swift */; }; @@ -118,6 +119,7 @@ 6523FC4B25580EEF00BCD843 /* UrlExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UrlExtensionsTests.swift; sourceTree = ""; }; 6523FC502558153600BCD843 /* AddPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddPasswordView.swift; sourceTree = ""; }; 6533A02A23D888B100FF822B /* BillCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BillCell.swift; sourceTree = ""; }; + 08D1989BA96A4911A60FD3F3 /* CategoryChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryChip.swift; sourceTree = ""; }; 654754B72528736F00A82EB6 /* Views+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Views+Extensions.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 = ""; }; @@ -385,6 +387,7 @@ children = ( 65005A0323DE0C5C00D1D599 /* BillList.swift */, 6533A02A23D888B100FF822B /* BillCell.swift */, + 08D1989BA96A4911A60FD3F3 /* CategoryChip.swift */, 6591C8E6240542250054BA24 /* LoadingDots.swift */, 6591C8E2240527750054BA24 /* PersonsView.swift */, 656E8CB723D9FDA300B3ED10 /* BillListViewModel.swift */, @@ -600,6 +603,7 @@ 65662A0323D7320000303207 /* AppDelegate.swift in Sources */, 654754C0252889B200A82EB6 /* ProjectQRPermissionCheckerView.swift in Sources */, 6533A02B23D888B100FF822B /* BillCell.swift in Sources */, + AF3C149C69191129E15F7CA7 /* CategoryChip.swift in Sources */, 48CF709B240FF82000770B97 /* AddMemberView.swift in Sources */, 65D9094025581BE100351F4B /* AddFromURLView.swift in Sources */, 658FC55925273E4D004A49AA /* AddProjectQRViewModel.swift in Sources */, diff --git a/PayForMe/Model/CospendTag.swift b/PayForMe/Model/CospendTag.swift index df103a7..28efc15 100644 --- a/PayForMe/Model/CospendTag.swift +++ b/PayForMe/Model/CospendTag.swift @@ -19,7 +19,30 @@ struct CospendTag: Codable, Identifiable, Hashable { var label: String { guard let icon = icon, !icon.isEmpty else { return displayName } - return "\(icon) \(displayName)" + return "\(Self.emojiPresentation(icon)) \(displayName)" + } + + /// Server colors come as "#rrggbb" or "rrggbb"; empty or malformed values yield nil. + var rgb: (red: Double, green: Double, blue: Double)? { + guard var hex = color?.trimmingCharacters(in: .whitespaces) else { return nil } + if hex.hasPrefix("#") { hex.removeFirst() } + guard hex.count == 6, let value = UInt32(hex, radix: 16) else { return nil } + return (Double((value >> 16) & 0xFF) / 255, Double((value >> 8) & 0xFF) / 255, Double(value & 0xFF) / 255) + } + + /// Emoji such as 🌩 or 🛍 default to a small monochrome text glyph unless followed by the + /// emoji variation selector (U+FE0F), which servers usually leave out. Add it where missing. + static func emojiPresentation(_ icon: String) -> String { + var result = "" + for character in icon { + result.append(character) + guard let first = character.unicodeScalars.first, + character.unicodeScalars.count == 1, + first.properties.isEmoji, !first.properties.isEmojiPresentation, first.value > 0xFF + else { continue } + result.unicodeScalars.append("\u{FE0F}") + } + return result } /// Cospend's built-in categories (negative ids). Servers do not list them in the project diff --git a/PayForMe/Views/BillList/BillCell.swift b/PayForMe/Views/BillList/BillCell.swift index ee65511..2f0e937 100644 --- a/PayForMe/Views/BillList/BillCell.swift +++ b/PayForMe/Views/BillList/BillCell.swift @@ -38,25 +38,11 @@ struct BillCell: View { @ViewBuilder private var tagLine: some View { - if let tagText = tagText { - tagText - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - } - } - - private var tagText: Text? { - switch (category, paymentMode) { - case let (category?, paymentMode?): - return Text(category.label) + Text(verbatim: " · ") + Text(paymentMode.label) - case let (category?, nil): - return Text(category.label) - case let (nil, paymentMode?): - return Text(paymentMode.label) - case (nil, nil): - return nil + if category != nil || paymentMode != nil { + HStack(spacing: 6) { + if let category = category { CategoryChip(tag: category) } + if let paymentMode = paymentMode { CategoryChip(tag: paymentMode) } + } } } diff --git a/PayForMe/Views/BillList/CategoryChip.swift b/PayForMe/Views/BillList/CategoryChip.swift new file mode 100644 index 0000000..6fd1d26 --- /dev/null +++ b/PayForMe/Views/BillList/CategoryChip.swift @@ -0,0 +1,37 @@ +// +// CategoryChip.swift +// PayForMe +// + +import SwiftUI + +/// A category or payment mode as a small rounded tag: icon and name on a soft tint of the tag's +/// own color (neutral when the server sent none). The one place tags are drawn in lists. +struct CategoryChip: View { + let tag: CospendTag + + var body: some View { + Text(tag.label) + .font(.caption) + .lineLimit(1) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(tint.opacity(0.18), in: Capsule()) + } + + private var tint: Color { + guard let rgb = tag.rgb else { return Color.secondary } + return Color(red: rgb.red, green: rgb.green, blue: rgb.blue) + } +} + +struct CategoryChip_Previews: PreviewProvider { + static var previews: some View { + HStack { + CategoryChip(tag: previewCategories[0]) + CategoryChip(tag: previewPaymentModes[0]) + CategoryChip(tag: CospendTag(id: 1, name: "No color", color: "", icon: "🐕", order: 0)) + } + .padding() + } +} diff --git a/PayForMeTests/CategorySupportTests.swift b/PayForMeTests/CategorySupportTests.swift index 18179c1..96b5219 100644 --- a/PayForMeTests/CategorySupportTests.swift +++ b/PayForMeTests/CategorySupportTests.swift @@ -214,3 +214,46 @@ class AutoCategoryViewModelTests: XCTestCase { XCTAssertEqual(vm.selectedCategoryId, -6) } } + +class CategoryIconTests: XCTestCase { + func testTextDefaultEmojiGetsVariationSelector() { + // 🌩 and 🛍 would render as monochrome text glyphs without it. + XCTAssertEqual(CospendTag.emojiPresentation("🌩"), "🌩\u{FE0F}") + XCTAssertEqual(CospendTag.emojiPresentation("🛍"), "🛍\u{FE0F}") + } + + func testColorEmojiAndSelectorAreLeftAlone() { + XCTAssertEqual(CospendTag.emojiPresentation("🛒"), "🛒") + XCTAssertEqual(CospendTag.emojiPresentation("🛍\u{FE0F}"), "🛍\u{FE0F}") + XCTAssertEqual(CospendTag.emojiPresentation("🐕"), "🐕") + } + + func testPlainTextIconsAreUntouched() { + XCTAssertEqual(CospendTag.emojiPresentation("#"), "#") + XCTAssertEqual(CospendTag.emojiPresentation("1"), "1") + XCTAssertEqual(CospendTag.emojiPresentation("A"), "A") + } + + func testEveryBuiltInCategoryLabelIsClean() { + for tag in CospendTag.globalCategories { + XCTAssertFalse(tag.label.hasPrefix(" "), tag.label) + XCTAssertTrue(tag.label.hasSuffix(tag.displayName), tag.label) + } + } +} + +class CategoryColorTests: XCTestCase { + func testParsesHexWithAndWithoutHash() throws { + let a = try XCTUnwrap(CospendTag(id: 1, name: "a", color: "#ff0000", icon: nil, order: nil).rgb) + XCTAssertEqual(a.red, 1) + XCTAssertEqual(a.green, 0) + let b = try XCTUnwrap(CospendTag(id: 1, name: "a", color: "00FF00", icon: nil, order: nil).rgb) + XCTAssertEqual(b.green, 1) + } + + func testEmptyOrMalformedColorIsNil() { + for value in [nil, "", "#12", "zzzzzz", "#1234567"] { + XCTAssertNil(CospendTag(id: 1, name: "a", color: value, icon: nil, order: nil).rgb, value ?? "nil") + } + } +}