diff --git a/PayForMe.xcodeproj/project.pbxproj b/PayForMe.xcodeproj/project.pbxproj index d434265..8fcf112 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 */; }; @@ -28,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 */; }; @@ -45,6 +47,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 */; }; @@ -67,8 +70,12 @@ 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 */; }; + 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 */; }; + 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 */ @@ -92,6 +99,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 = ""; }; @@ -111,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 = ""; }; @@ -132,6 +141,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 = ""; }; @@ -160,9 +170,13 @@ 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 = ""; }; 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 */ /* Begin PBXFrameworksBuildPhase section */ @@ -236,6 +250,8 @@ 6523FC4B25580EEF00BCD843 /* UrlExtensionsTests.swift */, 0865D2DAF541B24078D1DC41 /* TestHelpers.swift */, 7EED3194A2342518B0E74079 /* BillTests.swift */, + A391473E9D9DE0002A4D8F46 /* CategorySupportTests.swift */, + BBFD0B11814133F77ACBA4A3 /* LocalCategoryStoreTests.swift */, 8F4D55C3EF5EFCD925428041 /* JSONDecodingTests.swift */, 143F41EE78329905894C8A29 /* BalanceCalculationTests.swift */, 92ABB02BEA28BEA7C8C64841 /* BillSortingTests.swift */, @@ -311,6 +327,7 @@ children = ( 65662A1A23D733B700303207 /* NetworkService.swift */, 65662A2323D83ED100303207 /* StorageService.swift */, + 16973AEB3337A370B125D368 /* LocalCategoryStore.swift */, 481FB4FA23E964C8003BD108 /* ProjectManager.swift */, ); path = Services; @@ -370,6 +387,7 @@ children = ( 65005A0323DE0C5C00D1D599 /* BillList.swift */, 6533A02A23D888B100FF822B /* BillCell.swift */, + 08D1989BA96A4911A60FD3F3 /* CategoryChip.swift */, 6591C8E6240542250054BA24 /* LoadingDots.swift */, 6591C8E2240527750054BA24 /* PersonsView.swift */, 656E8CB723D9FDA300B3ED10 /* BillListViewModel.swift */, @@ -382,6 +400,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 +574,8 @@ 6523FC4C25580EEF00BCD843 /* UrlExtensionsTests.swift in Sources */, 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 */, @@ -580,11 +603,15 @@ 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 */, 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 */, @@ -604,6 +631,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/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..28efc15 100644 --- a/PayForMe/Model/CospendTag.swift +++ b/PayForMe/Model/CospendTag.swift @@ -19,30 +19,76 @@ 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 + /// 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..04479bc 100644 --- a/PayForMe/Model/Project.swift +++ b/PayForMe/Model/Project.swift @@ -19,6 +19,11 @@ 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 = [] + /// bill id → category id, for servers without a category field. Loaded from the device. + var localCategories: [Int: Int] = [:] var me: Int? let projectId: String @@ -86,8 +91,35 @@ struct StoredProject: Codable { } extension Project { + /// 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 || 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. 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 else { return categories } + return CospendTag.globalCategories + categories + } + func category(for bill: Bill) -> CospendTag? { - tag(id: bill.categoryid, in: categories) + tag(id: categoryId(of: bill), in: pickableCategories) } func paymentMode(for bill: Bill) -> CospendTag? { @@ -99,7 +131,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/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 a7a8004..447304a 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,28 @@ 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) } + /// 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), 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..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) @@ -98,6 +99,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. @@ -113,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)") @@ -122,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") @@ -135,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 890aa33..2e57502 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. 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 0b5159e..e55e687 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. 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 687ab1d..1fe1769 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. 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 624d6c8..d5d11e7 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. 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 8ff24f2..f40118e 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. 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 71e1966..217f018 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. Servers without categories keep them on this device only." = "Предлагает категорию по названию счёта. На серверах без категорий они хранятся только на этом устройстве."; 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..8ac136a 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 + } ) } @@ -110,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/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/PayForMe/Views/Projects/ProjectList.swift b/PayForMe/Views/Projects/ProjectList.swift index 372130c..c67f59c 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. Servers without categories keep them on this device only.")) { + 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..96b5219 --- /dev/null +++ b/PayForMeTests/CategorySupportTests.swift @@ -0,0 +1,259 @@ +// +// 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_categorizesLocally() { + let project = Project.makeIHateMoney() + 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() { + 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) + } +} + +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") + } + } +} 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") + } +} 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 diff --git a/README.md b/README.md index dbfc823..a0a3d06 100644 --- a/README.md +++ b/README.md @@ -33,6 +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; on servers without categories they are kept on the device ## How to contribute Localization