diff --git a/Data/Data.xcodeproj/project.pbxproj b/Data/Data.xcodeproj/project.pbxproj index 05882ac1..0c58899f 100644 --- a/Data/Data.xcodeproj/project.pbxproj +++ b/Data/Data.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 21F41B002D31311A00D37DBB /* CommentRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21F41AFF2D31311A00D37DBB /* CommentRepository.swift */; }; 64E499C5CFAEA368EC21313F /* Pods_DataTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1571EA0A08D442FEF7C09424 /* Pods_DataTests.framework */; }; 7EF3A291581F7EA20CB1042D /* Pods_Data.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E91B3E23688435064A60C0C4 /* Pods_Data.framework */; }; + D81C61532D3542600044A1E6 /* Bundle+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81C61522D3542600044A1E6 /* Bundle+Extension.swift */; }; D826C0DF2BDA95C900AAA449 /* Timestamp+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D826C0DE2BDA95C900AAA449 /* Timestamp+Extension.swift */; }; D83B15052B9996C0004A5F4F /* Groups.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83B15042B9996C0004A5F4F /* Groups.swift */; }; D83B15092B999789004A5F4F /* GroupRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83B15082B999789004A5F4F /* GroupRepository.swift */; }; @@ -31,6 +32,8 @@ D8613F682BFDECC4008BD53D /* Query+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8613F672BFDECC4008BD53D /* Query+Extension.swift */; }; D865F8AE2BD7CB0B0084BD36 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D865F8AD2BD7CB0B0084BD36 /* Array+Extension.swift */; }; D88721432B99F133009DC5BE /* StorageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D88721422B99F133009DC5BE /* StorageManager.swift */; }; + D888EA772D314D180003284B /* Currency.swift in Sources */ = {isa = PBXBuildFile; fileRef = D888EA762D314D130003284B /* Currency.swift */; }; + D888EA792D34E6BF0003284B /* Currencies.json in Resources */ = {isa = PBXBuildFile; fileRef = D888EA782D34E6BF0003284B /* Currencies.json */; }; D8910E382BB6D1D300877CE0 /* ExpenseStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8910E372BB6D1D300877CE0 /* ExpenseStore.swift */; }; D895F2282CA297B900C2E4EB /* NetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D895F2272CA297B900C2E4EB /* NetworkManager.swift */; }; D89BC2802D1C3DB200CDE86B /* DateTimeHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D89BC27F2D1C3DB200CDE86B /* DateTimeHelper.swift */; }; @@ -90,6 +93,7 @@ 5B14CF1A2EEF27479BF50566 /* Pods-DataTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DataTests.debug.xcconfig"; path = "Target Support Files/Pods-DataTests/Pods-DataTests.debug.xcconfig"; sourceTree = ""; }; 803094012FE4C155F2A347B6 /* Pods-DataTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DataTests.release.xcconfig"; path = "Target Support Files/Pods-DataTests/Pods-DataTests.release.xcconfig"; sourceTree = ""; }; BED6C37AA3F8FD2A6350DE1C /* Pods-Data.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Data.debug.xcconfig"; path = "Target Support Files/Pods-Data/Pods-Data.debug.xcconfig"; sourceTree = ""; }; + D81C61522D3542600044A1E6 /* Bundle+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Extension.swift"; sourceTree = ""; }; D826C0DE2BDA95C900AAA449 /* Timestamp+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Timestamp+Extension.swift"; sourceTree = ""; }; D83B15042B9996C0004A5F4F /* Groups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Groups.swift; sourceTree = ""; }; D83B15082B999789004A5F4F /* GroupRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupRepository.swift; sourceTree = ""; }; @@ -99,6 +103,8 @@ D8613F672BFDECC4008BD53D /* Query+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Query+Extension.swift"; sourceTree = ""; }; D865F8AD2BD7CB0B0084BD36 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = ""; }; D88721422B99F133009DC5BE /* StorageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageManager.swift; sourceTree = ""; }; + D888EA762D314D130003284B /* Currency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Currency.swift; sourceTree = ""; }; + D888EA782D34E6BF0003284B /* Currencies.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Currencies.json; sourceTree = ""; }; D8910E372BB6D1D300877CE0 /* ExpenseStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpenseStore.swift; sourceTree = ""; }; D895F2272CA297B900C2E4EB /* NetworkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkManager.swift; sourceTree = ""; }; D89BC27F2D1C3DB200CDE86B /* DateTimeHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTimeHelper.swift; sourceTree = ""; }; @@ -200,9 +206,19 @@ path = Repository; sourceTree = ""; }; + D888EA7A2D34E6C50003284B /* Currency */ = { + isa = PBXGroup; + children = ( + D888EA762D314D130003284B /* Currency.swift */, + D888EA782D34E6BF0003284B /* Currencies.json */, + ); + path = Currency; + sourceTree = ""; + }; D89DBE262B88801F00E5F1BD /* Model */ = { isa = PBXGroup; children = ( + D888EA7A2D34E6C50003284B /* Currency */, D89DBE472B8CBE4C00E5F1BD /* AppUser.swift */, D83B15042B9996C0004A5F4F /* Groups.swift */, D85E86DD2BAB0292002EDF76 /* Expense.swift */, @@ -249,6 +265,7 @@ D865F8AD2BD7CB0B0084BD36 /* Array+Extension.swift */, D8A7CA7F2BA867F80014EC67 /* String+Extension.swift */, D89C934D2BC694C200FACD16 /* Double+Extension.swift */, + D81C61522D3542600044A1E6 /* Bundle+Extension.swift */, D826C0DE2BDA95C900AAA449 /* Timestamp+Extension.swift */, 213D0CC92C89DBC800D65C73 /* Notification+Extension.swift */, D8613F672BFDECC4008BD53D /* Query+Extension.swift */, @@ -453,6 +470,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + D888EA792D34E6BF0003284B /* Currencies.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -545,11 +563,13 @@ D89DBE482B8CBE4C00E5F1BD /* AppUser.swift in Sources */, D8D14A522BA0917D00F45FF2 /* SharedCode.swift in Sources */, D89DBE462B8CBE0F00E5F1BD /* UserRepository.swift in Sources */, + D81C61532D3542600044A1E6 /* Bundle+Extension.swift in Sources */, D83B15052B9996C0004A5F4F /* Groups.swift in Sources */, D8CF5A252C199ADB0014E3AD /* TransactionRepository.swift in Sources */, D8AC25BB2B7F327A00CEAAD3 /* SplitoPreference.swift in Sources */, D8AC25C32B7F390B00CEAAD3 /* AppAssembly.swift in Sources */, 21D8D0832C0857F10061B365 /* Constants.swift in Sources */, + D888EA772D314D180003284B /* Currency.swift in Sources */, D8AC25C12B7F38D300CEAAD3 /* Injector.swift in Sources */, 21F41AFE2D312EDE00D37DBB /* CommentStore.swift in Sources */, D8D14A542BA092F500F45FF2 /* ShareCodeRepository.swift in Sources */, diff --git a/Data/Data/Extension/Bundle+Extension.swift b/Data/Data/Extension/Bundle+Extension.swift new file mode 100644 index 00000000..31e9d4b3 --- /dev/null +++ b/Data/Data/Extension/Bundle+Extension.swift @@ -0,0 +1,16 @@ +// +// Bundle+Extension.swift +// Data +// +// Created by Amisha Italiya on 21/02/24. +// + +import Foundation + +private class UIBundleFakeClass {} + +public extension Bundle { + static var dataBundle: Bundle { + return Bundle(for: UIBundleFakeClass.self) + } +} diff --git a/Data/Data/Extension/Double+Extension.swift b/Data/Data/Extension/Double+Extension.swift index 1d1ba6ca..3dfee58e 100644 --- a/Data/Data/Extension/Double+Extension.swift +++ b/Data/Data/Extension/Double+Extension.swift @@ -10,7 +10,6 @@ import Foundation public extension Double { var formattedCurrency: String { let formatter = NumberFormatter() - formatter.numberStyle = .currency formatter.locale = Locale.current if let formattedAmount = formatter.string(from: NSNumber(value: self)) { @@ -20,9 +19,21 @@ public extension Double { } } + func formattedCurrencyWithSign(_ sign: String) -> String { + let amount: String + let formatter = NumberFormatter() + formatter.locale = Locale.current + + if let formattedAmount = formatter.string(from: NSNumber(value: self)) { + amount = formattedAmount + } else { + amount = String(format: "%.2f", self.rounded()) // Fallback to a basic decimal format + } + return sign + " " + amount + } + var formattedCurrencyWithSign: String { let formatter = NumberFormatter() - formatter.numberStyle = .currency formatter.locale = Locale.current if let formattedAmount = formatter.string(from: NSNumber(value: self)) { diff --git a/Data/Data/Model/Currency/Currencies.json b/Data/Data/Model/Currency/Currencies.json new file mode 100644 index 00000000..f99e00a9 --- /dev/null +++ b/Data/Data/Model/Currency/Currencies.json @@ -0,0 +1,932 @@ +[ + { + "code": "AED", + "name": "United Arab Emirates Dirham", + "symbol": "د.إ", + "region": "AE" + }, + { + "code": "AFN", + "name": "Afghan Afghani", + "symbol": "؋", + "region": "AF" + }, + { + "code": "ALL", + "name": "Albanian Lek", + "symbol": "L", + "region": "AL" + }, + { + "code": "AMD", + "name": "Armenian Dram", + "symbol": "֏", + "region": "AM" + }, + { + "code": "ANG", + "name": "Netherlands Antillean Guilder", + "symbol": "ƒ", + "region": "CW" + }, + { + "code": "AOA", + "name": "Angolan Kwanza", + "symbol": "Kz", + "region": "AO" + }, + { + "code": "ARS", + "name": "Argentine Peso", + "symbol": "$", + "region": "AR" + }, + { + "code": "AUD", + "name": "Australian Dollar", + "symbol": "$", + "region": "AU" + }, + { + "code": "AWG", + "name": "Aruban Florin", + "symbol": "ƒ", + "region": "AW" + }, + { + "code": "AZN", + "name": "Azerbaijani Manat", + "symbol": "₼", + "region": "AZ" + }, + { + "code": "BAM", + "name": "Bosnia and Herzegovina Convertible Mark", + "symbol": "KM", + "region": "BA" + }, + { + "code": "BBD", + "name": "Barbadian Dollar", + "symbol": "$", + "region": "BB" + }, + { + "code": "BDT", + "name": "Bangladeshi Taka", + "symbol": "৳", + "region": "BD" + }, + { + "code": "BGN", + "name": "Bulgarian Lev", + "symbol": "лв", + "region": "BG" + }, + { + "code": "BHD", + "name": "Bahraini Dinar", + "symbol": ".د.ب", + "region": "BH" + }, + { + "code": "BIF", + "name": "Burundian Franc", + "symbol": "FBu", + "region": "BI" + }, + { + "code": "BMD", + "name": "Bermudian Dollar", + "symbol": "$", + "region": "BM" + }, + { + "code": "BND", + "name": "Brunei Dollar", + "symbol": "$", + "region": "BN" + }, + { + "code": "BOB", + "name": "Bolivian Boliviano", + "symbol": "Bs.", + "region": "BO" + }, + { + "code": "BRL", + "name": "Brazilian Real", + "symbol": "R$", + "region": "BR" + }, + { + "code": "BSD", + "name": "Bahamian Dollar", + "symbol": "$", + "region": "BS" + }, + { + "code": "BTN", + "name": "Bhutanese Ngultrum", + "symbol": "Nu.", + "region": "BT" + }, + { + "code": "BWP", + "name": "Botswana Pula", + "symbol": "P", + "region": "BW" + }, + { + "code": "BYN", + "name": "Belarusian Ruble", + "symbol": "Br", + "region": "BY" + }, + { + "code": "BZD", + "name": "Belize Dollar", + "symbol": "BZ$", + "region": "BZ" + }, + { + "code": "CAD", + "name": "Canadian Dollar", + "symbol": "$", + "region": "CA" + }, + { + "code": "CDF", + "name": "Congolese Franc", + "symbol": "FC", + "region": "CD" + }, + { + "code": "CHF", + "name": "Swiss Franc", + "symbol": "CHF", + "region": "CH" + }, + { + "code": "CLP", + "name": "Chilean Peso", + "symbol": "$", + "region": "CL" + }, + { + "code": "CNY", + "name": "Chinese Yuan", + "symbol": "¥", + "region": "CN" + }, + { + "code": "COP", + "name": "Colombian Peso", + "symbol": "$", + "region": "CO" + }, + { + "code": "CRC", + "name": "Costa Rican Colón", + "symbol": "₡", + "region": "CR" + }, + { + "code": "CUP", + "name": "Cuban Peso", + "symbol": "₱", + "region": "CU" + }, + { + "code": "CVE", + "name": "Cape Verdean Escudo", + "symbol": "$", + "region": "CV" + }, + { + "code": "CZK", + "name": "Czech Koruna", + "symbol": "Kč", + "region": "CZ" + }, + { + "code": "DJF", + "name": "Djiboutian Franc", + "symbol": "Fdj", + "region": "DJ" + }, + { + "code": "DKK", + "name": "Danish Krone", + "symbol": "kr", + "region": "DK" + }, + { + "code": "DOP", + "name": "Dominican Peso", + "symbol": "RD$", + "region": "DO" + }, + { + "code": "DZD", + "name": "Algerian Dinar", + "symbol": "د.ج", + "region": "DZ" + }, + { + "code": "EGP", + "name": "Egyptian Pound", + "symbol": "£", + "region": "EG" + }, + { + "code": "ERN", + "name": "Eritrean Nakfa", + "symbol": "Nfk", + "region": "ER" + }, + { + "code": "ETB", + "name": "Ethiopian Birr", + "symbol": "Br", + "region": "ET" + }, + { + "code": "EUR", + "name": "Euro", + "symbol": "€", + "region": "EU" + }, + { + "code": "FJD", + "name": "Fijian Dollar", + "symbol": "$", + "region": "FJ" + }, + { + "code": "FKP", + "name": "Falkland Islands Pound", + "symbol": "£", + "region": "FK" + }, + { + "code": "GBP", + "name": "British Pound", + "symbol": "£", + "region": "GB" + }, + { + "code": "GEL", + "name": "Georgian Lari", + "symbol": "₾", + "region": "GE" + }, + { + "code": "GHS", + "name": "Ghanaian Cedi", + "symbol": "₵", + "region": "GH" + }, + { + "code": "GIP", + "name": "Gibraltar Pound", + "symbol": "£", + "region": "GI" + }, + { + "code": "GMD", + "name": "Gambian Dalasi", + "symbol": "D", + "region": "GM" + }, + { + "code": "GNF", + "name": "Guinean Franc", + "symbol": "FG", + "region": "GN" + }, + { + "code": "GTQ", + "name": "Guatemalan Quetzal", + "symbol": "Q", + "region": "GT" + }, + { + "code": "GYD", + "name": "Guyanese Dollar", + "symbol": "$", + "region": "GY" + }, + { + "code": "HKD", + "name": "Hong Kong Dollar", + "symbol": "$", + "region": "HK" + }, + { + "code": "HNL", + "name": "Honduran Lempira", + "symbol": "L", + "region": "HN" + }, + { + "code": "HRK", + "name": "Croatian Kuna", + "symbol": "kn", + "region": "HR" + }, + { + "code": "HTG", + "name": "Haitian Gourde", + "symbol": "G", + "region": "HT" + }, + { + "code": "HUF", + "name": "Hungarian Forint", + "symbol": "Ft", + "region": "HU" + }, + { + "code": "IDR", + "name": "Indonesian Rupiah", + "symbol": "Rp", + "region": "ID" + }, + { + "code": "ILS", + "name": "Israeli New Shekel", + "symbol": "₪", + "region": "IL" + }, + { + "code": "INR", + "name": "Indian Rupee", + "symbol": "₹", + "region": "IN" + }, + { + "code": "IQD", + "name": "Iraqi Dinar", + "symbol": "ع.د", + "region": "IQ" + }, + { + "code": "IRR", + "name": "Iranian Rial", + "symbol": "﷼", + "region": "IR" + }, + { + "code": "ISK", + "name": "Icelandic Króna", + "symbol": "kr", + "region": "IS" + }, + { + "code": "JMD", + "name": "Jamaican Dollar", + "symbol": "J$", + "region": "JM" + }, + { + "code": "JOD", + "name": "Jordanian Dinar", + "symbol": "د.ا", + "region": "JO" + }, + { + "code": "JPY", + "name": "Japanese Yen", + "symbol": "¥", + "region": "JP" + }, + { + "code": "KES", + "name": "Kenyan Shilling", + "symbol": "KSh", + "region": "KE" + }, + { + "code": "KGS", + "name": "Kyrgyzstani Som", + "symbol": "с", + "region": "KG" + }, + { + "code": "KHR", + "name": "Cambodian Riel", + "symbol": "៛", + "region": "KH" + }, + { + "code": "KMF", + "name": "Comorian Franc", + "symbol": "CF", + "region": "KM" + }, + { + "code": "KPW", + "name": "North Korean Won", + "symbol": "₩", + "region": "KP" + }, + { + "code": "KRW", + "name": "South Korean Won", + "symbol": "₩", + "region": "KR" + }, + { + "code": "KWD", + "name": "Kuwaiti Dinar", + "symbol": "د.ك", + "region": "KW" + }, + { + "code": "KYD", + "name": "Cayman Islands Dollar", + "symbol": "$", + "region": "KY" + }, + { + "code": "KZT", + "name": "Kazakhstani Tenge", + "symbol": "₸", + "region": "KZ" + }, + { + "code": "LAK", + "name": "Lao Kip", + "symbol": "₭", + "region": "LA" + }, + { + "code": "LBP", + "name": "Lebanese Pound", + "symbol": "ل.ل", + "region": "LB" + }, + { + "code": "LKR", + "name": "Sri Lankan Rupee", + "symbol": "₨", + "region": "LK" + }, + { + "code": "LRD", + "name": "Liberian Dollar", + "symbol": "$", + "region": "LR" + }, + { + "code": "LSL", + "name": "Lesotho Loti", + "symbol": "L", + "region": "LS" + }, + { + "code": "LYD", + "name": "Libyan Dinar", + "symbol": "ل.د", + "region": "LY" + }, + { + "code": "MAD", + "name": "Moroccan Dirham", + "symbol": "د.م.", + "region": "MA" + }, + { + "code": "MDL", + "name": "Moldovan Leu", + "symbol": "L", + "region": "MD" + }, + { + "code": "MGA", + "name": "Malagasy Ariary", + "symbol": "Ar", + "region": "MG" + }, + { + "code": "MKD", + "name": "Macedonian Denar", + "symbol": "ден", + "region": "MK" + }, + { + "code": "MMK", + "name": "Myanmar Kyat", + "symbol": "Ks", + "region": "MM" + }, + { + "code": "MNT", + "name": "Mongolian Tögrög", + "symbol": "₮", + "region": "MN" + }, + { + "code": "MOP", + "name": "Macanese Pataca", + "symbol": "P", + "region": "MO" + }, + { + "code": "MRU", + "name": "Mauritanian Ouguiya", + "symbol": "UM", + "region": "MR" + }, + { + "code": "MUR", + "name": "Mauritian Rupee", + "symbol": "₨", + "region": "MU" + }, + { + "code": "MVR", + "name": "Maldivian Rufiyaa", + "symbol": ".ރ", + "region": "MV" + }, + { + "code": "MWK", + "name": "Malawian Kwacha", + "symbol": "MK", + "region": "MW" + }, + { + "code": "MXN", + "name": "Mexican Peso", + "symbol": "$", + "region": "MX" + }, + { + "code": "MYR", + "name": "Malaysian Ringgit", + "symbol": "RM", + "region": "MY" + }, + { + "code": "MZN", + "name": "Mozambican Metical", + "symbol": "MT", + "region": "MZ" + }, + { + "code": "NAD", + "name": "Namibian Dollar", + "symbol": "$", + "region": "NA" + }, + { + "code": "NGN", + "name": "Nigerian Naira", + "symbol": "₦", + "region": "NG" + }, + { + "code": "NIO", + "name": "Nicaraguan Córdoba", + "symbol": "C$", + "region": "NI" + }, + { + "code": "NOK", + "name": "Norwegian Krone", + "symbol": "kr", + "region": "NO" + }, + { + "code": "NPR", + "name": "Nepalese Rupee", + "symbol": "₨", + "region": "NP" + }, + { + "code": "NZD", + "name": "New Zealand Dollar", + "symbol": "$", + "region": "NZ" + }, + { + "code": "OMR", + "name": "Omani Rial", + "symbol": "ر.ع.", + "region": "OM" + }, + { + "code": "PAB", + "name": "Panamanian Balboa", + "symbol": "B/.", + "region": "PA" + }, + { + "code": "PEN", + "name": "Peruvian Sol", + "symbol": "S/", + "region": "PE" + }, + { + "code": "PGK", + "name": "Papua New Guinean Kina", + "symbol": "K", + "region": "PG" + }, + { + "code": "PHP", + "name": "Philippine Peso", + "symbol": "₱", + "region": "PH" + }, + { + "code": "PKR", + "name": "Pakistani Rupee", + "symbol": "₨", + "region": "PK" + }, + { + "code": "PLN", + "name": "Polish Złoty", + "symbol": "zł", + "region": "PL" + }, + { + "code": "PYG", + "name": "Paraguayan Guaraní", + "symbol": "₲", + "region": "PY" + }, + { + "code": "QAR", + "name": "Qatari Riyal", + "symbol": "ر.ق", + "region": "QA" + }, + { + "code": "RON", + "name": "Romanian Leu", + "symbol": "lei", + "region": "RO" + }, + { + "code": "RSD", + "name": "Serbian Dinar", + "symbol": "дин.", + "region": "RS" + }, + { + "code": "RUB", + "name": "Russian Ruble", + "symbol": "₽", + "region": "RU" + }, + { + "code": "RWF", + "name": "Rwandan Franc", + "symbol": "FRw", + "region": "RW" + }, + { + "code": "SAR", + "name": "Saudi Riyal", + "symbol": "ر.س", + "region": "SA" + }, + { + "code": "SBD", + "name": "Solomon Islands Dollar", + "symbol": "$", + "region": "SB" + }, + { + "code": "SCR", + "name": "Seychellois Rupee", + "symbol": "₨", + "region": "SC" + }, + { + "code": "SDG", + "name": "Sudanese Pound", + "symbol": "ج.س.", + "region": "SD" + }, + { + "code": "SEK", + "name": "Swedish Krona", + "symbol": "kr", + "region": "SE" + }, + { + "code": "SGD", + "name": "Singapore Dollar", + "symbol": "$", + "region": "SG" + }, + { + "code": "SHP", + "name": "Saint Helena Pound", + "symbol": "£", + "region": "SH" + }, + { + "code": "SLL", + "name": "Sierra Leonean Leone", + "symbol": "Le", + "region": "SL" + }, + { + "code": "SOS", + "name": "Somali Shilling", + "symbol": "Sh", + "region": "SO" + }, + { + "code": "SRD", + "name": "Surinamese Dollar", + "symbol": "$", + "region": "SR" + }, + { + "code": "SSP", + "name": "South Sudanese Pound", + "symbol": "£", + "region": "SS" + }, + { + "code": "STN", + "name": "São Tomé and Príncipe Dobra", + "symbol": "Db", + "region": "ST" + }, + { + "code": "SYP", + "name": "Syrian Pound", + "symbol": "£", + "region": "SY" + }, + { + "code": "SZL", + "name": "Swazi Lilangeni", + "symbol": "L", + "region": "SZ" + }, + { + "code": "THB", + "name": "Thai Baht", + "symbol": "฿", + "region": "TH" + }, + { + "code": "TJS", + "name": "Tajikistani Somoni", + "symbol": "ЅМ", + "region": "TJ" + }, + { + "code": "TMT", + "name": "Turkmenistan Manat", + "symbol": "m", + "region": "TM" + }, + { + "code": "TND", + "name": "Tunisian Dinar", + "symbol": "د.ت", + "region": "TN" + }, + { + "code": "TOP", + "name": "Tongan Paʻanga", + "symbol": "T$", + "region": "TO" + }, + { + "code": "TRY", + "name": "Turkish Lira", + "symbol": "₺", + "region": "TR" + }, + { + "code": "TTD", + "name": "Trinidad and Tobago Dollar", + "symbol": "$", + "region": "TT" + }, + { + "code": "TWD", + "name": "New Taiwan Dollar", + "symbol": "NT$", + "region": "TW" + }, + { + "code": "TZS", + "name": "Tanzanian Shilling", + "symbol": "TSh", + "region": "TZ" + }, + { + "code": "UAH", + "name": "Ukrainian Hryvnia", + "symbol": "₴", + "region": "UA" + }, + { + "code": "UGX", + "name": "Ugandan Shilling", + "symbol": "USh", + "region": "UG" + }, + { + "code": "USD", + "name": "United States Dollar", + "symbol": "$", + "region": "US" + }, + { + "code": "UYU", + "name": "Uruguayan Peso", + "symbol": "$", + "region": "UY" + }, + { + "code": "UZS", + "name": "Uzbekistani Som", + "symbol": "so'm", + "region": "UZ" + }, + { + "code": "VES", + "name": "Venezuelan Bolívar Soberano", + "symbol": "Bs.", + "region": "VE" + }, + { + "code": "VND", + "name": "Vietnamese Đồng", + "symbol": "₫", + "region": "VN" + }, + { + "code": "VUV", + "name": "Vanuatu Vatu", + "symbol": "VT", + "region": "VU" + }, + { + "code": "WST", + "name": "Samoan Tālā", + "symbol": "T", + "region": "WS" + }, + { + "code": "XAF", + "name": "Central African CFA Franc", + "symbol": "FCFA", + "region": "CM" + }, + { + "code": "XCD", + "name": "East Caribbean Dollar", + "symbol": "$", + "region": "AG" + }, + { + "code": "XOF", + "name": "West African CFA Franc", + "symbol": "CFA", + "region": "BJ" + }, + { + "code": "XPF", + "name": "CFP Franc", + "symbol": "₣", + "region": "PF" + }, + { + "code": "YER", + "name": "Yemeni Rial", + "symbol": "﷼", + "region": "YE" + }, + { + "code": "ZAR", + "name": "South African Rand", + "symbol": "R", + "region": "ZA" + }, + { + "code": "ZMW", + "name": "Zambian Kwacha", + "symbol": "ZK", + "region": "ZM" + }, + { + "code": "ZWL", + "name": "Zimbabwean Dollar", + "symbol": "$", + "region": "ZW" + } +] diff --git a/Data/Data/Model/Currency/Currency.swift b/Data/Data/Model/Currency/Currency.swift new file mode 100644 index 00000000..98c7a0c1 --- /dev/null +++ b/Data/Data/Model/Currency/Currency.swift @@ -0,0 +1,43 @@ +// +// Currency.swift +// Data +// +// Created by Amisha Italiya on 10/01/25. +// + +import Foundation + +public struct Currency: Decodable, Hashable { + public let code: String + public let name: String + public let symbol: String + public let region: String + + public static var defaultCurrency = Currency(code: "INR", name: "Indian Rupee", symbol: "₹", region: "IN") + + public init(code: String, name: String, symbol: String, region: String) { + self.code = code + self.name = name + self.symbol = symbol + self.region = region + } + + public static func getAllCurrencies() -> [Currency] { + let allCurrencies = JSONUtils.readJSONFromFile(fileName: "Currencies", type: [Currency].self, bundle: .dataBundle) ?? [] + return allCurrencies + } + + public static func getCurrencyFromCode(_ code: String?) -> Currency { + let allCurrencies = getAllCurrencies() + let currency = allCurrencies.first(where: { $0.code == code }) ?? defaultCurrency + return currency + } + + public static func getCurrentLocalCurrency() -> Currency { + let allCurrencies = getAllCurrencies() + let currentLocal = Locale.current.region?.identifier + let currency = allCurrencies.first(where: { $0.region == currentLocal }) ?? + (allCurrencies.first ?? defaultCurrency) + return currency + } +} diff --git a/Data/Data/Model/Expense.swift b/Data/Data/Model/Expense.swift index 8793668b..0b4f0547 100644 --- a/Data/Data/Model/Expense.swift +++ b/Data/Data/Model/Expense.swift @@ -15,7 +15,7 @@ public struct Expense: Codable, Hashable, Identifiable { public var name: String public var amount: Double public var category: String? = "General" - public var currencyCode: String? = "INR" + public var currencyCode: String? = Currency.defaultCurrency.code public var date: Timestamp public let addedBy: String public var updatedAt: Timestamp? @@ -29,10 +29,11 @@ public struct Expense: Codable, Hashable, Identifiable { public var participants: [String]? = [] // List of user ids, Used for searching expenses by user public var isActive: Bool - public init(groupId: String, name: String, amount: Double, category: String = "General", currencyCode: String = "INR", - date: Timestamp, addedBy: String, updatedAt: Timestamp? = nil, updatedBy: String? = nil, note: String? = nil, - imageUrl: String? = nil, splitType: SplitType, splitTo: [String], splitData: [String: Double]? = nil, - paidBy: [String: Double], participants: [String], isActive: Bool = true) { + public init(groupId: String, name: String, amount: Double, category: String = "General", + currencyCode: String = Currency.defaultCurrency.code, date: Timestamp, addedBy: String, + updatedAt: Timestamp? = nil, updatedBy: String? = nil, note: String? = nil, imageUrl: String? = nil, + splitType: SplitType, splitTo: [String], splitData: [String: Double]? = nil, paidBy: [String: Double], + participants: [String], isActive: Bool = true) { self.groupId = groupId self.name = name self.amount = amount diff --git a/Data/Data/Model/Groups.swift b/Data/Data/Model/Groups.swift index 0122c29c..2acd30a4 100644 --- a/Data/Data/Model/Groups.swift +++ b/Data/Data/Model/Groups.swift @@ -22,13 +22,17 @@ public struct Groups: Codable, Identifiable { public let createdAt: Timestamp public var updatedAt: Timestamp public var hasExpenses: Bool - public var defaultCurrency: String? = "INR" + private var defaultCurrency: String? = Currency.defaultCurrency.code public var isActive: Bool + public var defaultCurrencyCode: String { + defaultCurrency ?? Currency.defaultCurrency.code + } + public init(name: String, type: GroupType = .splitExpense, createdBy: String, updatedBy: String? = nil, imageUrl: String? = nil, members: [String], initialBalance: Double = 0.0, balances: [GroupMemberBalance], createdAt: Timestamp = Timestamp(), updatedAt: Timestamp = Timestamp(), hasExpenses: Bool = false, - currencyCode: String = "INR", isActive: Bool = true) { + currencyCode: String = Currency.defaultCurrency.code, isActive: Bool = true) { self.name = name self.type = type self.createdBy = createdBy @@ -69,17 +73,29 @@ public enum GroupType: String, Codable { public struct GroupMemberBalance: Codable { public let id: String /// Member Id + public var balanceByCurrency: [String: GroupCurrencyBalance] /// Currency wise member balance + + public init(id: String, balanceByCurrency: [String: GroupCurrencyBalance]) { + self.id = id + self.balanceByCurrency = balanceByCurrency + } + + enum CodingKeys: String, CodingKey { + case id + case balanceByCurrency = "balance_by_currency" + } +} + +public struct GroupCurrencyBalance: Codable { public var balance: Double public var totalSummary: [GroupTotalSummary] - public init(id: String, balance: Double, totalSummary: [GroupTotalSummary]) { - self.id = id + public init(balance: Double, totalSummary: [GroupTotalSummary]) { self.balance = balance self.totalSummary = totalSummary } enum CodingKeys: String, CodingKey { - case id case balance case totalSummary = "total_summary" } @@ -95,6 +111,12 @@ public struct GroupTotalSummary: Codable { self.month = month self.summary = summary } + + enum CodingKeys: String, CodingKey { + case year + case month + case summary + } } public struct GroupMemberSummary: Codable { @@ -134,12 +156,13 @@ public struct GroupMemberSummary: Codable { // MARK: - To show group and expense together public struct GroupInformation { public let group: Groups - public let userBalance: Double - public let memberOweAmount: [String: Double] + public let userBalance: [String: Double] + public let memberOweAmount: [String: [String: Double]] public let members: [AppUser] public let hasExpenses: Bool - public init(group: Groups, userBalance: Double, memberOweAmount: [String: Double], members: [AppUser], hasExpenses: Bool) { + public init(group: Groups, userBalance: [String: Double], memberOweAmount: [String: [String: Double]], + members: [AppUser], hasExpenses: Bool) { self.group = group self.userBalance = userBalance self.memberOweAmount = memberOweAmount diff --git a/Data/Data/Model/Transaction.swift b/Data/Data/Model/Transaction.swift index f71f8233..61e73234 100644 --- a/Data/Data/Model/Transaction.swift +++ b/Data/Data/Model/Transaction.swift @@ -13,28 +13,30 @@ public struct Transactions: Codable, Hashable, Identifiable { public var payerId: String public var receiverId: String + public var date: Timestamp public let addedBy: String + public var amount: Double + public var currencyCode: String? = Currency.defaultCurrency.code public var updatedBy: String? public var note: String? - public var imageUrl: String? public var reason: String? - public var amount: Double - public var date: Timestamp + public var imageUrl: String? public var updatedAt: Timestamp? public var isActive: Bool - public init(payerId: String, receiverId: String, addedBy: String, updatedBy: String? = nil, - note: String? = nil, imageUrl: String? = nil, reason: String? = nil, amount: Double, - date: Timestamp, updatedAt: Timestamp? = nil, isActive: Bool = true) { + public init(payerId: String, receiverId: String, date: Timestamp, addedBy: String, amount: Double, + currencyCode: String? = Currency.defaultCurrency.code, updatedBy: String? = nil, note: String? = nil, + reason: String? = nil, imageUrl: String? = nil, updatedAt: Timestamp? = nil, isActive: Bool = true) { self.payerId = payerId self.receiverId = receiverId + self.date = date self.addedBy = addedBy + self.amount = amount + self.currencyCode = currencyCode self.updatedBy = updatedBy self.note = note - self.imageUrl = imageUrl self.reason = reason - self.amount = amount - self.date = date + self.imageUrl = imageUrl self.updatedAt = updatedAt self.isActive = isActive } @@ -43,13 +45,14 @@ public struct Transactions: Codable, Hashable, Identifiable { case id case payerId = "payer_id" case receiverId = "receiver_id" + case date case addedBy = "added_by" + case amount + case currencyCode = "currency_code" case updatedBy = "updated_by" case note - case imageUrl = "image_url" case reason - case amount - case date + case imageUrl = "image_url" case updatedAt = "updated_at" case isActive = "is_active" } diff --git a/Data/Data/Repository/CommentRepository.swift b/Data/Data/Repository/CommentRepository.swift index c89f206c..18d20476 100644 --- a/Data/Data/Repository/CommentRepository.swift +++ b/Data/Data/Repository/CommentRepository.swift @@ -94,7 +94,7 @@ public class CommentRepository { let payerName = (user.id == transaction.payerId && memberId == transaction.payerId) ? (user.id == transaction.addedBy ? "You" : "you") : (memberId == transaction.payerId) ? "you" : members.payer.nameWithLastInitial - + let receiverName = (memberId == transaction.receiverId) ? "you" : (memberId == transaction.receiverId) ? "you" : members.receiver.nameWithLastInitial context = ActivityLogContext(group: group, transaction: transaction, comment: comment, diff --git a/Data/Data/Repository/UserRepository.swift b/Data/Data/Repository/UserRepository.swift index ed1cf05f..e28e325c 100644 --- a/Data/Data/Repository/UserRepository.swift +++ b/Data/Data/Repository/UserRepository.swift @@ -35,6 +35,16 @@ public class UserRepository: ObservableObject { try await store.fetchUserBy(id: userID) } + public func fetchUsersBy(userIds: [String]) async throws -> [AppUser] { + var users: [AppUser] = [] + for userId in userIds { + let user = try await fetchUserBy(userID: userId) + guard let user else { continue } + users.append(user) + } + return users.uniqued() + } + public func fetchUserBy(email: String) async throws -> AppUser? { try await store.fetchUserBy(email: email) } diff --git a/Data/Data/Utils/JSONUtils.swift b/Data/Data/Utils/JSONUtils.swift index 8a383acc..1ea1d384 100644 --- a/Data/Data/Utils/JSONUtils.swift +++ b/Data/Data/Utils/JSONUtils.swift @@ -16,8 +16,10 @@ public struct JSONUtils { let jsonData = try decoder.decode(T.self, from: data) return jsonData } catch { - LogE("JSONUtils: \(#function) error - \(error).") + LogE("Error decoding JSON file: \(fileName), error: \(error)") } + } else { + LogE("JSON file not found: \(fileName)") } return nil } diff --git a/Splito.xcodeproj/project.pbxproj b/Splito.xcodeproj/project.pbxproj index 031e90fa..b71249cf 100644 --- a/Splito.xcodeproj/project.pbxproj +++ b/Splito.xcodeproj/project.pbxproj @@ -36,6 +36,8 @@ 21D8CF412CFF080F00463E4D /* EmailLoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21D8CF402CFF080F00463E4D /* EmailLoginViewModel.swift */; }; 21F27BDE2C36768D00196D62 /* ExpenseSplitOptionsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21F27BDD2C36768D00196D62 /* ExpenseSplitOptionsTabView.swift */; }; 741540F86E36400CE27B1FAD /* Pods_SplitoTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 701193A10871F36C3EDB356C /* Pods_SplitoTests.framework */; }; + D81C614F2D35141A0044A1E6 /* AddAmountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81C614E2D35141A0044A1E6 /* AddAmountView.swift */; }; + D81C61512D353EB70044A1E6 /* CurrencyPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81C61502D353EB50044A1E6 /* CurrencyPickerView.swift */; }; D826C0E22BDBD65600AAA449 /* GroupBalancesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D826C0E12BDBD65600AAA449 /* GroupBalancesView.swift */; }; D826C0E42BDBD66300AAA449 /* GroupBalancesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D826C0E32BDBD66300AAA449 /* GroupBalancesViewModel.swift */; }; D8302DA02B9F282F005ACA13 /* InviteMemberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8302D9F2B9F282F005ACA13 /* InviteMemberView.swift */; }; @@ -196,6 +198,8 @@ D8015C042B7A47CF0002886A /* Data.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Data.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D8015C082B7A47D80002886A /* UI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = UI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D815DFD62BEA26C200C0F862 /* Secrets.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = ""; }; + D81C614E2D35141A0044A1E6 /* AddAmountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAmountView.swift; sourceTree = ""; }; + D81C61502D353EB50044A1E6 /* CurrencyPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyPickerView.swift; sourceTree = ""; }; D826C0E12BDBD65600AAA449 /* GroupBalancesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupBalancesView.swift; sourceTree = ""; }; D826C0E32BDBD66300AAA449 /* GroupBalancesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupBalancesViewModel.swift; sourceTree = ""; }; D8302D9F2B9F282F005ACA13 /* InviteMemberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteMemberView.swift; sourceTree = ""; }; @@ -549,6 +553,8 @@ isa = PBXGroup; children = ( D85E86E62BB2E189002EDF76 /* ExpenseRouteView.swift */, + D81C614E2D35141A0044A1E6 /* AddAmountView.swift */, + D81C61502D353EB50044A1E6 /* CurrencyPickerView.swift */, D85E86DF2BAB06A3002EDF76 /* AddExpenseView.swift */, D85E86E22BAB06D9002EDF76 /* AddExpenseViewModel.swift */, D856C7302BCFD2080008A341 /* Expense Detail */, @@ -1147,6 +1153,7 @@ D8A7CA722BA486250014EC67 /* GroupSettingViewModel.swift in Sources */, D833445A2C0DD08400CD9F05 /* GroupSettleUpViewModel.swift in Sources */, D85E86EB2BB3FD59002EDF76 /* ChoosePayerViewModel.swift in Sources */, + D81C61512D353EB70044A1E6 /* CurrencyPickerView.swift in Sources */, 2163D3A92D265A7D004B4F20 /* FeedbackView.swift in Sources */, 21C9A7D62D3557F5006C84CE /* CommentListView.swift in Sources */, 21B1C09A2C1C59F10098B4FD /* GroupTransactionListView.swift in Sources */, @@ -1155,6 +1162,7 @@ D8E244BB2B9843A100C6C82A /* CreateGroupView.swift in Sources */, D89DBE422B8CA72700E5F1BD /* HomeRouteView.swift in Sources */, 21B1C09E2C1C5AA30098B4FD /* GroupExpenseListView.swift in Sources */, + D81C614F2D35141A0044A1E6 /* AddAmountView.swift in Sources */, D8D14A602BA2DCDB00F45FF2 /* UserProfileView.swift in Sources */, 21D8CF3F2CFF080300463E4D /* EmailLoginView.swift in Sources */, D88721452B9B2C78009DC5BE /* GroupListView.swift in Sources */, diff --git a/Splito/Localization/Localizable.xcstrings b/Splito/Localization/Localizable.xcstrings index c3f469d8..3ade5fbe 100644 --- a/Splito/Localization/Localizable.xcstrings +++ b/Splito/Localization/Localizable.xcstrings @@ -12,9 +12,6 @@ }, " %@ settled up" : { - }, - " ₹ 0.00" : { - }, " commented on" : { "extractionState" : "manual" @@ -25,6 +22,16 @@ " in" : { "extractionState" : "manual" }, + " in %@ to %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : " in %1$@ to %2$@" + } + } + } + }, " in total" : { }, @@ -33,9 +40,6 @@ }, " to" : { "extractionState" : "manual" - }, - " to %@" : { - }, " updated the group name from" : { "extractionState" : "manual" @@ -45,6 +49,16 @@ }, "%@" : { + }, + "%@ %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ %2$@" + } + } + } }, "%@ %@ " : { "localizations" : { @@ -137,6 +151,9 @@ }, "0" : { + }, + "0.00" : { + }, "1.23" : { "extractionState" : "manual" @@ -546,6 +563,9 @@ }, "no balance" : { + }, + "No currency found for \"%@\"!" : { + }, "No email address" : { "extractionState" : "manual" @@ -972,9 +992,6 @@ }, "You" : { "extractionState" : "manual" - }, - "You %@" : { - }, "You are all settle up!" : { diff --git a/Splito/UI/Home/Expense/AddAmountView.swift b/Splito/UI/Home/Expense/AddAmountView.swift new file mode 100644 index 00000000..ba807ee3 --- /dev/null +++ b/Splito/UI/Home/Expense/AddAmountView.swift @@ -0,0 +1,62 @@ +// +// AddAmountView.swift +// BaseStyle +// +// Created by Amisha Italiya on 13/01/25. +// + +import SwiftUI +import BaseStyle + +struct AddAmountView: View { + + @Binding var amount: Double + @Binding var showCurrencyPicker: Bool + + @State private var amountString: String = "" + + var selectedCurrencySymbol: String + var isAmountFocused: FocusState.Binding + + var body: some View { + HStack(spacing: 8) { + Text(selectedCurrencySymbol) + .font(.Header3()) + .foregroundStyle(primaryText) + .padding(.vertical, 6) + .padding(.horizontal, 12) + .background(inversePrimaryText) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .onTouchGesture { + showCurrencyPicker = true + } + + TextField("0.00", text: $amountString) + .font(.Header1()) + .tint(primaryColor) + .focused(isAmountFocused) + .autocorrectionDisabled() + .keyboardType(.decimalPad) + .multilineTextAlignment(.center) + .tint(amountString.isEmpty ? outlineColor : primaryText) + .onChange(of: amountString) { newValue in + formatAmount(newValue: newValue) + } + .onAppear { + amountString = amount == 0 ? "" : String(format: "%.2f", amount) + } + .frame(maxWidth: .infinity, alignment: .center) + } + .padding(24) + .overlay { + RoundedRectangle(cornerRadius: 16) + .stroke(outlineColor, lineWidth: 1) + } + } + + private func formatAmount(newValue: String) { + let numericInput = newValue.trimmingCharacters(in: .whitespaces) + amountString = numericInput.isEmpty ? "" : numericInput + amount = Double(numericInput) ?? 0 + } +} diff --git a/Splito/UI/Home/Expense/AddExpenseView.swift b/Splito/UI/Home/Expense/AddExpenseView.swift index 5484b190..288770dc 100644 --- a/Splito/UI/Home/Expense/AddExpenseView.swift +++ b/Splito/UI/Home/Expense/AddExpenseView.swift @@ -98,6 +98,10 @@ struct AddExpenseView: View { ImagePickerView(cropOption: .square, sourceType: !viewModel.sourceTypeIsCamera ? .photoLibrary : .camera, image: $viewModel.expenseImage, isPresented: $viewModel.showImagePicker) } + .sheet(isPresented: $viewModel.showCurrencyPicker) { + CurrencyPickerView(selectedCurrency: $viewModel.selectedCurrency, + isPresented: $viewModel.showCurrencyPicker) + } .toolbar { ToolbarItem(placement: .topBarLeading) { CancelButton() @@ -136,7 +140,8 @@ private struct ExpenseInfoView: View { ExpenseDetailRow(name: $viewModel.expenseName, focusedField: focusedField, subtitle: "Description", field: .expenseName) - AmountRowView(amount: $viewModel.expenseAmount, isAmountFocused: $isAmountFocused, subtitle: "Amount") + AddAmountView(amount: $viewModel.expenseAmount, showCurrencyPicker: $viewModel.showCurrencyPicker, + selectedCurrencySymbol: viewModel.selectedCurrency.symbol, isAmountFocused: $isAmountFocused) .focused(focusedField, equals: .amount) HStack(alignment: .top, spacing: 16) { diff --git a/Splito/UI/Home/Expense/AddExpenseViewModel.swift b/Splito/UI/Home/Expense/AddExpenseViewModel.swift index a887e737..2d147004 100644 --- a/Splito/UI/Home/Expense/AddExpenseViewModel.swift +++ b/Splito/UI/Home/Expense/AddExpenseViewModel.swift @@ -39,9 +39,11 @@ class AddExpenseViewModel: BaseViewModel, ObservableObject { @Published var showImageDisplayView = false @Published var showImagePickerOptions = false @Published var showSplitTypeSelection = false + @Published var showCurrencyPicker = false @Published private(set) var showLoader = false @Published private(set) var sourceTypeIsCamera = false + @Published var selectedCurrency: Currency @Published var selectedGroup: Groups? @Published private(set) var expense: Expense? @Published private(set) var viewState: ViewState = .initial @@ -55,6 +57,7 @@ class AddExpenseViewModel: BaseViewModel, ObservableObject { self.router = router self.groupId = groupId self.expenseId = expenseId + self.selectedCurrency = Currency.getCurrentLocalCurrency() super.init() loadInitialData() } @@ -75,6 +78,7 @@ class AddExpenseViewModel: BaseViewModel, ObservableObject { viewState = .loading await fetchAndUpdateGroupData(groupId: groupId) selectedPayers = [userId: expenseAmount] + selectedCurrency = Currency.getCurrencyFromCode(selectedGroup?.defaultCurrencyCode) viewState = .initial LogD("AddExpenseViewModel: \(#function) Group fetched successfully.") } @@ -115,6 +119,7 @@ class AddExpenseViewModel: BaseViewModel, ObservableObject { selectedPayers = expense.paidBy expenseImageUrl = expense.imageUrl expenseNote = expense.note ?? "" + selectedCurrency = Currency.getCurrencyFromCode(expense.currencyCode ?? selectedGroup?.defaultCurrencyCode) if let splitData = expense.splitData { self.splitData = splitData } @@ -242,10 +247,8 @@ extension AddExpenseViewModel { } self?.showAlert = false })) - case .authorized: - authorized() - default: - return + case .authorized: authorized() + default: return } } @@ -310,12 +313,8 @@ extension AddExpenseViewModel { } private func validateMembersInGroup(group: Groups, expense: Expense) -> Bool { - for payer in expense.paidBy where !group.members.contains(payer.key) { - return false - } - for memberId in expense.splitTo where !group.members.contains(memberId) { - return false - } + for payer in expense.paidBy where !group.members.contains(payer.key) { return false } + for memberId in expense.splitTo where !group.members.contains(memberId) { return false } return true } } @@ -375,7 +374,8 @@ extension AddExpenseViewModel { guard let groupId else { return false } let splitTo = splitData.map { $0.key } let participants = Array(Set(splitTo + selectedPayers.keys)) - let expense = Expense(groupId: groupId, name: expenseName.trimming(spaces: .leadingAndTrailing), amount: expenseAmount, + let expense = Expense(groupId: groupId, name: expenseName.trimming(spaces: .leadingAndTrailing), + amount: expenseAmount, currencyCode: selectedCurrency.code, date: Timestamp(date: expenseDate), addedBy: userId, note: expenseNote, splitType: splitType, splitTo: splitTo, splitData: splitData, paidBy: selectedPayers, participants: participants) return await addExpense(group: group, expense: expense) @@ -389,9 +389,7 @@ extension AddExpenseViewModel { let expenseInfo: [String: Any] = ["groupId": groupId, "expense": newExpense] NotificationCenter.default.post(name: .addExpense, object: nil, userInfo: expenseInfo) - if !group.hasExpenses { - selectedGroup?.hasExpenses = true - } + if !group.hasExpenses { selectedGroup?.hasExpenses = true } await updateGroupMemberBalance(expense: newExpense, updateType: .Add) showLoader = false @@ -407,7 +405,6 @@ extension AddExpenseViewModel { private func handleUpdateExpenseAction(userId: String, group: Groups, expense: Expense) async -> Bool { guard let groupId = group.id else { return false } - var newExpense = expense newExpense.groupId = groupId newExpense.name = expenseName.trimming(spaces: .leadingAndTrailing) @@ -416,6 +413,7 @@ extension AddExpenseViewModel { newExpense.updatedAt = Timestamp() newExpense.updatedBy = userId newExpense.note = expenseNote + newExpense.currencyCode = selectedCurrency.code if selectedPayers.count == 1, let payerId = selectedPayers.keys.first { newExpense.paidBy = [payerId: expenseAmount] @@ -428,12 +426,13 @@ extension AddExpenseViewModel { newExpense.splitType = splitType newExpense.participants = Array(Set(newExpense.splitTo + newExpense.paidBy.keys)) + let participants = Array(Set(newExpense.splitTo + newExpense.paidBy.keys)) + newExpense.participants = participants return await updateExpense(group: group, expense: newExpense, oldExpense: expense) } private func updateExpense(group: Groups, expense: Expense, oldExpense: Expense) async -> Bool { guard validateMembersInGroup(group: group, expense: expense), let expenseId else { return false } - do { showLoader = true let updatedExpense = try await expenseRepository.updateExpenseWithImage(imageData: getImageData(), diff --git a/Splito/UI/Home/Expense/CurrencyPickerView.swift b/Splito/UI/Home/Expense/CurrencyPickerView.swift new file mode 100644 index 00000000..86332dc0 --- /dev/null +++ b/Splito/UI/Home/Expense/CurrencyPickerView.swift @@ -0,0 +1,115 @@ +// +// CurrencyPickerView.swift +// Splito +// +// Created by Amisha Italiya on 13/01/25. +// + +import Data +import SwiftUI +import BaseStyle + +struct CurrencyPickerView: View { + + @Environment(\.dismiss) var dismiss + + @Binding var selectedCurrency: Currency + @Binding var isPresented: Bool + + @State private var searchedCurrency: String = "" + @FocusState private var isFocused: Bool + + private var filteredCurrencies: [Currency] { + let currencies = Currency.getAllCurrencies() + guard !searchedCurrency.isEmpty else { return currencies } + return currencies.filter { currency in + currency.name.lowercased().contains(searchedCurrency.lowercased()) || + currency.code.lowercased().contains(searchedCurrency.lowercased()) || + currency.symbol.lowercased().contains(searchedCurrency.lowercased()) + } + } + + var body: some View { + VStack(spacing: 0) { + VStack(spacing: 16) { + NavigationBarTopView(title: "Select Currency", leadingButton: EmptyView(), + trailingButton: DismissButton(padding: (0, 0), foregroundColor: primaryText, + onDismissAction: { dismiss() }) + .fontWeight(.regular) + ) + + SearchBar(text: $searchedCurrency, isFocused: $isFocused, placeholder: "Search") + .padding(.vertical, -7) + .padding(.horizontal, 3) + .overlay(content: { + RoundedRectangle(cornerRadius: 12).stroke(outlineColor, lineWidth: 1) + }) + .focused($isFocused) + .onAppear { isFocused = true } + } + .padding(.bottom, 20) + .padding(.horizontal, 16) + + if filteredCurrencies.isEmpty { + CurrencyNotFoundView(searchedCurrency: searchedCurrency) + } else { + List(filteredCurrencies, id: \.self) { currency in + CurrencyCellView(currency: currency) { + selectedCurrency = currency + isPresented = false + } + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets()) + } + .listStyle(.plain) + } + } + } +} + +private struct CurrencyNotFoundView: View { + + let searchedCurrency: String + + var body: some View { + VStack(spacing: 0) { + Spacer() + Text("No currency found for \"\(searchedCurrency)\"!") + .font(.subTitle1()) + .foregroundStyle(disableText) + .padding(.bottom, 60) + Spacer() + } + .onTapGestureForced { + UIApplication.shared.endEditing() + } + } +} + +private struct CurrencyCellView: View { + + let currency: Currency + let onCellSelect: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 10) { + Text(currency.symbol) + .font(.Header4()) + .frame(width: 50) + + Text(currency.name) + .font(.body1(16)) + } + .padding(.horizontal, 10) + .foregroundStyle(primaryText) + + Divider() + .frame(height: 1) + .background(dividerColor) + .padding(.vertical, 14) + } + .contentShape(Rectangle()) + .onTouchGesture { onCellSelect() } + } +} diff --git a/Splito/UI/Home/Expense/Expense Detail/ExpenseDetailsView.swift b/Splito/UI/Home/Expense/Expense Detail/ExpenseDetailsView.swift index 935819b7..4e7238e3 100644 --- a/Splito/UI/Home/Expense/Expense Detail/ExpenseDetailsView.swift +++ b/Splito/UI/Home/Expense/Expense Detail/ExpenseDetailsView.swift @@ -142,7 +142,7 @@ private struct ExpenseHeaderView: View { .font(.subTitle2()) .foregroundStyle(primaryText) - Text(viewModel.expense?.formattedAmount ?? "₹ 0") + Text(viewModel.expense?.formattedAmount ?? "0") .font(.Header3()) .foregroundStyle(primaryText) diff --git a/Splito/UI/Home/Expense/Note/AddNoteViewModel.swift b/Splito/UI/Home/Expense/Note/AddNoteViewModel.swift index 96882fbe..c16223e5 100644 --- a/Splito/UI/Home/Expense/Note/AddNoteViewModel.swift +++ b/Splito/UI/Home/Expense/Note/AddNoteViewModel.swift @@ -24,7 +24,7 @@ class AddNoteViewModel: BaseViewModel, ObservableObject { private let payment: Transactions? private let handleSaveNoteTap: ((_ note: String, _ reason: String?) -> Void)? - init(group: Groups?, expense: Expense? = nil, payment: Transactions? = nil, note: String, + init(group: Groups?, expense: Expense? = nil, note: String, payment: Transactions? = nil, paymentReason: String? = nil, handleSaveNoteTap: ((_ note: String, _ reason: String?) -> Void)? = nil) { self.group = group self.expense = expense diff --git a/Splito/UI/Home/Groups/CalculateExpensesFunctions.swift b/Splito/UI/Home/Groups/CalculateExpensesFunctions.swift index 45503089..f3549a98 100644 --- a/Splito/UI/Home/Groups/CalculateExpensesFunctions.swift +++ b/Splito/UI/Home/Groups/CalculateExpensesFunctions.swift @@ -15,63 +15,88 @@ public struct Settlement { let sender: String let receiver: String let amount: Double + let currency: String } -public func calculateExpensesSimplified(userId: String, memberBalances: [GroupMemberBalance]) -> ([String: Double]) { - - var memberOwingAmount: [String: Double] = [:] +public func calculateExpensesSimplified(userId: String, + memberBalances: [GroupMemberBalance]) -> [String: [String: Double]] { + var memberOwingAmount: [String: [String: Double]] = [:] + // Calculate settlements based on balances let settlements = calculateSettlements(balances: memberBalances) + + // Loop over each settlement to calculate owed amounts for settlement in settlements where settlement.sender == userId || settlement.receiver == userId { let memberId = settlement.receiver == userId ? settlement.sender : settlement.receiver let amount = settlement.sender == userId ? -settlement.amount : settlement.amount - memberOwingAmount[memberId, default: 0] = amount + + // Add the calculated amount to the corresponding currency and member + memberOwingAmount[settlement.currency, default: [:]][memberId, default: 0] += amount + } + + // Remove entries where the amount is zero for all currencies + memberOwingAmount = memberOwingAmount.mapValues { currencyAmounts in + currencyAmounts.filter { $0.value != 0 } } - return memberOwingAmount.filter { $0.value != 0 } + return memberOwingAmount +} + +/// Helper function to extract all currencies from the balances +private func extractAllCurrencies(from balances: [GroupMemberBalance]) -> [String] { + var currencies: Set = [] + for balance in balances { + currencies.formUnion(balance.balanceByCurrency.keys) + } + return Array(currencies) } /// To decide who owes -> how much amount -> to whom func calculateSettlements(balances: [GroupMemberBalance]) -> [Settlement] { - var creditors: [(String, Double)] = [] - var debtors: [(String, Double)] = [] + var settlements: [Settlement] = [] - // Separate creditors and debtors - for balance in balances { - if balance.balance > 0 { - creditors.append((balance.id, balance.balance)) - } else if balance.balance < 0 { - debtors.append((balance.id, -balance.balance)) // Store positive value for easier calculation + for currency in extractAllCurrencies(from: balances) { + var creditors: [(String, Double)] = [] + var debtors: [(String, Double)] = [] + + // Separate creditors and debtors for the current currency + for balance in balances { + if let currencyBalance = balance.balanceByCurrency[currency]?.balance { + if currencyBalance > 0 { + creditors.append((balance.id, currencyBalance)) + } else if currencyBalance < 0 { + debtors.append((balance.id, -currencyBalance)) // Store positive value for easier calculation + } + } } - } - // Sort creditors and debtors by the amount they owe or are owed - creditors.sort { $0.1 < $1.1 } - debtors.sort { $0.1 < $1.1 } + // Sort creditors and debtors by the amount they owe or are owed + creditors.sort { $0.1 < $1.1 } + debtors.sort { $0.1 < $1.1 } - var i = 0 // creditors index - var j = 0 // debtors index - var settlements: [Settlement] = [] + var i = 0 // creditors index + var j = 0 // debtors index - // Calculate settlements - while i < creditors.count && j < debtors.count { // Process all debts - var (creditor, credAmt) = creditors[i] - var (debtor, debtAmt) = debtors[j] - let minAmount = min(credAmt, debtAmt) + // Calculate settlements for the current currency + while i < creditors.count && j < debtors.count { + var (creditor, credAmt) = creditors[i] + var (debtor, debtAmt) = debtors[j] + let minAmount = min(credAmt, debtAmt) - settlements.append(Settlement(sender: debtor, receiver: creditor, amount: minAmount)) + settlements.append(Settlement(sender: debtor, receiver: creditor, amount: minAmount, currency: currency)) - // Update the amounts - credAmt -= minAmount - debtAmt -= minAmount + // Update the amounts + credAmt -= minAmount + debtAmt -= minAmount - // If the remaining amount is close to zero, treat it as zero - creditors[i].1 = round(credAmt * 100) / 100 - debtors[j].1 = round(debtAmt * 100) / 100 + // If the remaining amount is close to zero, treat it as zero + creditors[i].1 = round(credAmt * 100) / 100 + debtors[j].1 = round(debtAmt * 100) / 100 - // Move the index forward if someone's balance is settled - if creditors[i].1 == 0 { i += 1 } - if debtors[j].1 == 0 { j += 1 } + // Move the index forward if someone's balance is settled + if creditors[i].1 == 0 { i += 1 } + if debtors[j].1 == 0 { j += 1 } + } } return settlements @@ -107,6 +132,7 @@ func getLatestSummaryFrom(totalSummary: [GroupTotalSummary], date: Date) -> Grou public func getUpdatedMemberBalanceFor(expense: Expense, group: Groups, updateType: ExpenseUpdateType) -> [GroupMemberBalance] { var memberBalance = group.balances let expenseDate = expense.date.dateValue() + let currency = expense.currencyCode ?? Currency.defaultCurrency.code for member in group.members { let newSplitAmount = expense.getCalculatedSplitAmountOf(member: member) @@ -116,72 +142,81 @@ public func getUpdatedMemberBalanceFor(expense: Expense, group: Groups, updateTy if let index = memberBalance.firstIndex(where: { $0.id == member }) { switch updateType { case .Add: - memberBalance[index].balance += newSplitAmount + memberBalance[index].balanceByCurrency[currency]?.balance += newSplitAmount // Update the corresponding total summary if it exists for the expense date - if var totalSummary = getLatestSummaryFrom(totalSummary: memberBalance[index].totalSummary, date: expenseDate)?.summary, - let summaryIndex = getLatestSummaryIndex(totalSummary: memberBalance[index].totalSummary, date: expenseDate) { + let groupTotalSummary = memberBalance[index].balanceByCurrency[currency]?.totalSummary ?? [] + if var totalSummary = getLatestSummaryFrom(totalSummary: groupTotalSummary, date: expenseDate)?.summary, + let summaryIndex = getLatestSummaryIndex(totalSummary: groupTotalSummary, date: expenseDate) { totalSummary.groupTotalSpending += expense.amount totalSummary.totalPaidAmount += (expense.paidBy[member] ?? 0) totalSummary.totalShare += totalSplitAmount totalSummary.changeInBalance = (totalSummary.totalPaidAmount - totalSummary.totalShare) - totalSummary.receivedAmount + totalSummary.paidAmount - memberBalance[index].totalSummary[summaryIndex].summary = totalSummary + memberBalance[index].balanceByCurrency[currency]?.totalSummary[summaryIndex].summary = totalSummary } else { // If no summary exists for the date, create a new summary let summary = getInitialGroupSummaryFor(member: member, expense: expense) - memberBalance[index].totalSummary.append(summary) + memberBalance[index].balanceByCurrency[currency]?.totalSummary.append(summary) } case .Update(let oldExpense): let oldSplitAmount = oldExpense.getTotalSplitAmountOf(member: member) // Update the old date's summary by reversing the old expense values - if let oldSummaryIndex = getLatestSummaryIndex(totalSummary: memberBalance[index].totalSummary, + let groupTotalSummary = memberBalance[index].balanceByCurrency[currency]?.totalSummary ?? [] + if let oldSummaryIndex = getLatestSummaryIndex(totalSummary: groupTotalSummary, date: oldExpense.date.dateValue()) { - var oldSummary = memberBalance[index].totalSummary[oldSummaryIndex].summary + var oldSummary = groupTotalSummary[oldSummaryIndex].summary oldSummary.groupTotalSpending -= oldExpense.amount oldSummary.totalPaidAmount -= (oldExpense.paidBy[member] ?? 0) oldSummary.totalShare -= abs(oldSplitAmount) oldSummary.changeInBalance = (oldSummary.totalPaidAmount - oldSummary.totalShare) - oldSummary.receivedAmount + oldSummary.paidAmount - memberBalance[index].totalSummary[oldSummaryIndex].summary = oldSummary + memberBalance[index].balanceByCurrency[currency]?.totalSummary[oldSummaryIndex].summary = oldSummary } let oldCalculatedSplitAmount = oldExpense.getCalculatedSplitAmountOf(member: member) - memberBalance[index].balance += (newSplitAmount - oldCalculatedSplitAmount) + memberBalance[index].balanceByCurrency[currency]?.balance += (newSplitAmount - oldCalculatedSplitAmount) // Update the new date's summary - if var newSummary = getLatestSummaryFrom(totalSummary: memberBalance[index].totalSummary, date: expenseDate)?.summary, - let newSummaryIndex = getLatestSummaryIndex(totalSummary: memberBalance[index].totalSummary, date: expenseDate) { + if var newSummary = getLatestSummaryFrom(totalSummary: groupTotalSummary, date: expenseDate)?.summary, + let newSummaryIndex = getLatestSummaryIndex(totalSummary: groupTotalSummary, date: expenseDate) { newSummary.groupTotalSpending += expense.amount newSummary.totalPaidAmount += (expense.paidBy[member] ?? 0) newSummary.totalShare += totalSplitAmount newSummary.changeInBalance = (newSummary.totalPaidAmount - newSummary.totalShare) - newSummary.receivedAmount + newSummary.paidAmount - memberBalance[index].totalSummary[newSummaryIndex].summary = newSummary + memberBalance[index].balanceByCurrency[currency]?.totalSummary[newSummaryIndex].summary = newSummary } else { let summary = getInitialGroupSummaryFor(member: member, expense: expense) - memberBalance[index].totalSummary.append(summary) + memberBalance[index].balanceByCurrency[currency]?.totalSummary.append(summary) } case .Delete: - memberBalance[index].balance -= newSplitAmount + memberBalance[index].balanceByCurrency[currency]?.balance -= newSplitAmount - if var totalSummary = getLatestSummaryFrom(totalSummary: memberBalance[index].totalSummary, date: expenseDate)?.summary, - let summaryIndex = getLatestSummaryIndex(totalSummary: memberBalance[index].totalSummary, date: expenseDate) { + let groupTotalSummary = memberBalance[index].balanceByCurrency[currency]?.totalSummary ?? [] + if var totalSummary = getLatestSummaryFrom(totalSummary: groupTotalSummary, date: expenseDate)?.summary, + let summaryIndex = getLatestSummaryIndex(totalSummary: groupTotalSummary, date: expenseDate) { totalSummary.groupTotalSpending -= expense.amount totalSummary.totalPaidAmount -= (expense.paidBy[member] ?? 0) totalSummary.totalShare -= totalSplitAmount totalSummary.changeInBalance = (totalSummary.totalPaidAmount - totalSummary.totalShare) - totalSummary.receivedAmount + totalSummary.paidAmount - memberBalance[index].totalSummary[summaryIndex].summary = totalSummary + memberBalance[index].balanceByCurrency[currency]?.totalSummary[summaryIndex].summary = totalSummary } } } else { // If the member doesn't have an existing entry, create a new one with the initial balance and summary let summary = getInitialGroupSummaryFor(member: member, expense: expense) - memberBalance.append(GroupMemberBalance(id: member, balance: newSplitAmount, totalSummary: [summary])) + memberBalance.append( + GroupMemberBalance(id: member, + balanceByCurrency: [currency: + GroupCurrencyBalance(balance: newSplitAmount, + totalSummary: [summary]) + ]) + ) } } @@ -214,6 +249,7 @@ public func getUpdatedMemberBalanceFor(transaction: Transactions, group: Groups, let amount = transaction.amount let payerId = transaction.payerId let receiverId = transaction.receiverId + let currency = transaction.currencyCode ?? Currency.defaultCurrency.code let currentYear = Calendar.current.component(.year, from: transactionDate) let currentMonth = Calendar.current.component(.month, from: transactionDate) @@ -222,21 +258,22 @@ public func getUpdatedMemberBalanceFor(transaction: Transactions, group: Groups, if let payerIndex = memberBalance.firstIndex(where: { $0.id == payerId }) { switch updateType { case .Add: - memberBalance[payerIndex].balance += amount + memberBalance[payerIndex].balanceByCurrency[currency]?.balance += amount // Check if there's an existing summary for this date - if var totalSummary = getLatestSummaryFrom(totalSummary: memberBalance[payerIndex].totalSummary, date: transactionDate)?.summary, - let summaryIndex = getLatestSummaryIndex(totalSummary: memberBalance[payerIndex].totalSummary, date: transactionDate) { + let groupTotalSummary = memberBalance[payerIndex].balanceByCurrency[currency]?.totalSummary ?? [] + if var totalSummary = getLatestSummaryFrom(totalSummary: groupTotalSummary, date: transactionDate)?.summary, + let summaryIndex = getLatestSummaryIndex(totalSummary: groupTotalSummary, date: transactionDate) { totalSummary.paidAmount += amount totalSummary.changeInBalance = (totalSummary.totalPaidAmount - totalSummary.totalShare) - totalSummary.receivedAmount + totalSummary.paidAmount - memberBalance[payerIndex].totalSummary[summaryIndex].summary = totalSummary + memberBalance[payerIndex].balanceByCurrency[currency]?.totalSummary[summaryIndex].summary = totalSummary } else { // If no summary exists, create a new one let memberSummary = GroupMemberSummary(groupTotalSpending: 0, totalPaidAmount: 0, totalShare: 0, paidAmount: amount, receivedAmount: 0, changeInBalance: amount) let totalSummary = GroupTotalSummary(year: currentYear, month: currentMonth, summary: memberSummary) - memberBalance[payerIndex].totalSummary.append(totalSummary) + memberBalance[payerIndex].balanceByCurrency[currency]?.totalSummary.append(totalSummary) } case .Update(let oldTransaction): @@ -246,41 +283,44 @@ public func getUpdatedMemberBalanceFor(transaction: Transactions, group: Groups, // Handle payer role switch: Update the old payer's balance and summary if let oldPayerIndex = memberBalance.firstIndex(where: { $0.id == oldPayerId }) { - memberBalance[oldPayerIndex].balance -= oldAmount - if let oldSummaryIndex = getLatestSummaryIndex(totalSummary: memberBalance[oldPayerIndex].totalSummary, date: oldTransactionDate) { - var oldSummary = memberBalance[oldPayerIndex].totalSummary[oldSummaryIndex].summary + memberBalance[oldPayerIndex].balanceByCurrency[currency]?.balance -= oldAmount + let groupTotalSummary = memberBalance[oldPayerIndex].balanceByCurrency[currency]?.totalSummary ?? [] + if let oldSummaryIndex = getLatestSummaryIndex(totalSummary: groupTotalSummary, date: oldTransactionDate) { + var oldSummary = groupTotalSummary[oldSummaryIndex].summary oldSummary.paidAmount -= oldAmount oldSummary.changeInBalance = (oldSummary.totalPaidAmount - oldSummary.totalShare) - oldSummary.receivedAmount + oldSummary.paidAmount - memberBalance[oldPayerIndex].totalSummary[oldSummaryIndex].summary = oldSummary + memberBalance[oldPayerIndex].balanceByCurrency[currency]?.totalSummary[oldSummaryIndex].summary = oldSummary } } // Update new payer's balance and summary for the new transaction with switched roles if let newPayerIndex = memberBalance.firstIndex(where: { $0.id == payerId }) { - memberBalance[newPayerIndex].balance += amount - if var newSummary = getLatestSummaryFrom(totalSummary: memberBalance[newPayerIndex].totalSummary, date: transactionDate)?.summary, - let newSummaryIndex = getLatestSummaryIndex(totalSummary: memberBalance[newPayerIndex].totalSummary, date: transactionDate) { + memberBalance[newPayerIndex].balanceByCurrency[currency]?.balance += amount + let groupTotalSummary = memberBalance[newPayerIndex].balanceByCurrency[currency]?.totalSummary ?? [] + if var newSummary = getLatestSummaryFrom(totalSummary: groupTotalSummary, date: transactionDate)?.summary, + let newSummaryIndex = getLatestSummaryIndex(totalSummary: groupTotalSummary, date: transactionDate) { newSummary.paidAmount += amount newSummary.changeInBalance = (newSummary.totalPaidAmount - newSummary.totalShare) - newSummary.receivedAmount + newSummary.paidAmount - memberBalance[newPayerIndex].totalSummary[newSummaryIndex].summary = newSummary + memberBalance[newPayerIndex].balanceByCurrency[currency]?.totalSummary[newSummaryIndex].summary = newSummary } else { // If no summary exists for the new date, create a new one let newMemberSummary = GroupMemberSummary(groupTotalSpending: 0, totalPaidAmount: 0, totalShare: 0, paidAmount: amount, receivedAmount: 0, changeInBalance: amount) let newTotalSummary = GroupTotalSummary(year: currentYear, month: currentMonth, summary: newMemberSummary) - memberBalance[newPayerIndex].totalSummary.append(newTotalSummary) + memberBalance[newPayerIndex].balanceByCurrency[currency]?.totalSummary.append(newTotalSummary) } } case .Delete: - memberBalance[payerIndex].balance -= amount + memberBalance[payerIndex].balanceByCurrency[currency]?.balance -= amount - if var totalSummary = getLatestSummaryFrom(totalSummary: memberBalance[payerIndex].totalSummary, date: transactionDate)?.summary, - let summaryIndex = getLatestSummaryIndex(totalSummary: memberBalance[payerIndex].totalSummary, date: transactionDate) { + let groupTotalSummary = memberBalance[payerIndex].balanceByCurrency[currency]?.totalSummary ?? [] + if var totalSummary = getLatestSummaryFrom(totalSummary: groupTotalSummary, date: transactionDate)?.summary, + let summaryIndex = getLatestSummaryIndex(totalSummary: groupTotalSummary, date: transactionDate) { totalSummary.paidAmount -= amount totalSummary.changeInBalance = (totalSummary.totalPaidAmount - totalSummary.totalShare - totalSummary.receivedAmount + totalSummary.paidAmount) - memberBalance[payerIndex].totalSummary[summaryIndex].summary = totalSummary + memberBalance[payerIndex].balanceByCurrency[currency]?.totalSummary[summaryIndex].summary = totalSummary } } } else { @@ -288,28 +328,33 @@ public func getUpdatedMemberBalanceFor(transaction: Transactions, group: Groups, let memberSummary = GroupMemberSummary(groupTotalSpending: 0, totalPaidAmount: 0, totalShare: 0, paidAmount: amount, receivedAmount: 0, changeInBalance: amount) let totalSummary = GroupTotalSummary(year: currentYear, month: currentMonth, summary: memberSummary) - memberBalance.append(GroupMemberBalance(id: payerId, balance: amount, totalSummary: [totalSummary])) + memberBalance.append(GroupMemberBalance(id: payerId, + balanceByCurrency: [ + currency: GroupCurrencyBalance(balance: amount, + totalSummary: [totalSummary]) + ])) } // For receiver if let receiverIndex = memberBalance.firstIndex(where: { $0.id == receiverId }) { switch updateType { case .Add: - memberBalance[receiverIndex].balance -= amount + memberBalance[receiverIndex].balanceByCurrency[currency]?.balance -= amount // Check if there's an existing summary for this date - if var totalSummary = getLatestSummaryFrom(totalSummary: memberBalance[receiverIndex].totalSummary, date: transactionDate)?.summary, - let summaryIndex = getLatestSummaryIndex(totalSummary: memberBalance[receiverIndex].totalSummary, date: transactionDate) { + let groupTotalSummary = memberBalance[receiverIndex].balanceByCurrency[currency]?.totalSummary ?? [] + if var totalSummary = getLatestSummaryFrom(totalSummary: groupTotalSummary, date: transactionDate)?.summary, + let summaryIndex = getLatestSummaryIndex(totalSummary: groupTotalSummary, date: transactionDate) { totalSummary.receivedAmount += amount totalSummary.changeInBalance = (totalSummary.totalPaidAmount - totalSummary.totalShare - totalSummary.receivedAmount + totalSummary.paidAmount) - memberBalance[receiverIndex].totalSummary[summaryIndex].summary = totalSummary + memberBalance[receiverIndex].balanceByCurrency[currency]?.totalSummary[summaryIndex].summary = totalSummary } else { // If no summary exists, create a new one let memberSummary = GroupMemberSummary(groupTotalSpending: 0, totalPaidAmount: 0, totalShare: 0, paidAmount: 0, receivedAmount: amount, changeInBalance: -amount) let totalSummary = GroupTotalSummary(year: currentYear, month: currentMonth, summary: memberSummary) - memberBalance[receiverIndex].totalSummary.append(totalSummary) + memberBalance[receiverIndex].balanceByCurrency[currency]?.totalSummary.append(totalSummary) } case .Update(let oldTransaction): @@ -319,41 +364,44 @@ public func getUpdatedMemberBalanceFor(transaction: Transactions, group: Groups, // Handle receiver role switch: Update the old receiver's balance and summary if let oldReceiverIndex = memberBalance.firstIndex(where: { $0.id == oldReceiverId }) { - memberBalance[oldReceiverIndex].balance += oldAmount - if let oldSummaryIndex = getLatestSummaryIndex(totalSummary: memberBalance[oldReceiverIndex].totalSummary, date: oldTransactionDate) { - var oldSummary = memberBalance[oldReceiverIndex].totalSummary[oldSummaryIndex].summary + memberBalance[oldReceiverIndex].balanceByCurrency[currency]?.balance += oldAmount + let groupTotalSummary = memberBalance[oldReceiverIndex].balanceByCurrency[currency]?.totalSummary ?? [] + if let oldSummaryIndex = getLatestSummaryIndex(totalSummary: groupTotalSummary, date: oldTransactionDate) { + var oldSummary = groupTotalSummary[oldSummaryIndex].summary oldSummary.receivedAmount -= oldAmount oldSummary.changeInBalance = (oldSummary.totalPaidAmount - oldSummary.totalShare) - oldSummary.receivedAmount + oldSummary.paidAmount - memberBalance[oldReceiverIndex].totalSummary[oldSummaryIndex].summary = oldSummary + memberBalance[oldReceiverIndex].balanceByCurrency[currency]?.totalSummary[oldSummaryIndex].summary = oldSummary } } // Update new receiver's balance and summary for the new transaction with switched roles if let newReceiverIndex = memberBalance.firstIndex(where: { $0.id == receiverId }) { - memberBalance[newReceiverIndex].balance -= amount - if var newSummary = getLatestSummaryFrom(totalSummary: memberBalance[newReceiverIndex].totalSummary, date: transactionDate)?.summary, - let newSummaryIndex = getLatestSummaryIndex(totalSummary: memberBalance[newReceiverIndex].totalSummary, date: transactionDate) { + memberBalance[newReceiverIndex].balanceByCurrency[currency]?.balance -= amount + let groupTotalSummary = memberBalance[newReceiverIndex].balanceByCurrency[currency]?.totalSummary ?? [] + if var newSummary = getLatestSummaryFrom(totalSummary: groupTotalSummary, date: transactionDate)?.summary, + let newSummaryIndex = getLatestSummaryIndex(totalSummary: groupTotalSummary, date: transactionDate) { newSummary.receivedAmount += amount newSummary.changeInBalance = (newSummary.totalPaidAmount - newSummary.totalShare) - newSummary.receivedAmount + newSummary.paidAmount - memberBalance[newReceiverIndex].totalSummary[newSummaryIndex].summary = newSummary + memberBalance[newReceiverIndex].balanceByCurrency[currency]?.totalSummary[newSummaryIndex].summary = newSummary } else { // If no summary exists for the new date, create a new one let newMemberSummary = GroupMemberSummary(groupTotalSpending: 0, totalPaidAmount: 0, totalShare: 0, paidAmount: 0, receivedAmount: amount, changeInBalance: -amount) let newTotalSummary = GroupTotalSummary(year: currentYear, month: currentMonth, summary: newMemberSummary) - memberBalance[newReceiverIndex].totalSummary.append(newTotalSummary) + memberBalance[newReceiverIndex].balanceByCurrency[currency]?.totalSummary.append(newTotalSummary) } } case .Delete: - memberBalance[receiverIndex].balance += amount + memberBalance[receiverIndex].balanceByCurrency[currency]?.balance += amount - if var totalSummary = getLatestSummaryFrom(totalSummary: memberBalance[receiverIndex].totalSummary, date: transactionDate)?.summary, - let summaryIndex = getLatestSummaryIndex(totalSummary: memberBalance[receiverIndex].totalSummary, date: transactionDate) { + let groupTotalSummary = memberBalance[receiverIndex].balanceByCurrency[currency]?.totalSummary ?? [] + if var totalSummary = getLatestSummaryFrom(totalSummary: groupTotalSummary, date: transactionDate)?.summary, + let summaryIndex = getLatestSummaryIndex(totalSummary: groupTotalSummary, date: transactionDate) { totalSummary.receivedAmount -= amount totalSummary.changeInBalance = (totalSummary.totalPaidAmount - totalSummary.totalShare - totalSummary.receivedAmount + totalSummary.paidAmount) - memberBalance[receiverIndex].totalSummary[summaryIndex].summary = totalSummary + memberBalance[receiverIndex].balanceByCurrency[currency]?.totalSummary[summaryIndex].summary = totalSummary } } } else { @@ -361,12 +409,16 @@ public func getUpdatedMemberBalanceFor(transaction: Transactions, group: Groups, let memberSummary = GroupMemberSummary(groupTotalSpending: 0, totalPaidAmount: 0, totalShare: 0, paidAmount: 0, receivedAmount: amount, changeInBalance: -amount) let totalSummary = GroupTotalSummary(year: currentYear, month: currentMonth, summary: memberSummary) - memberBalance.append(GroupMemberBalance(id: receiverId, balance: -amount, totalSummary: [totalSummary])) + memberBalance.append(GroupMemberBalance(id: receiverId, + balanceByCurrency: [ + currency: GroupCurrencyBalance(balance: -amount, + totalSummary: [totalSummary]) + ])) } let epsilon = 1e-10 - for i in 0.. [ let currentMonth = Calendar.current.component(.month, from: Date()) let currentYear = Calendar.current.component(.year, from: Date()) - return group.balances.first(where: { $0.id == userId })?.totalSummary.filter { + let currency = group.defaultCurrencyCode + let totalGroupSummary = group.balances.first(where: { $0.id == userId })?.balanceByCurrency[currency]?.totalSummary ?? [] + return totalGroupSummary.filter { $0.month == currentMonth && $0.year == currentYear - } ?? [] + } } diff --git a/Splito/UI/Home/Groups/Create Group/CreateGroupViewModel.swift b/Splito/UI/Home/Groups/Create Group/CreateGroupViewModel.swift index 623b5838..6b1a57e9 100644 --- a/Splito/UI/Home/Groups/Create Group/CreateGroupViewModel.swift +++ b/Splito/UI/Home/Groups/Create Group/CreateGroupViewModel.swift @@ -101,9 +101,10 @@ class CreateGroupViewModel: BaseViewModel, ObservableObject { private func createGroup() async -> Bool { guard let userId = preference.user?.id else { return false } - let memberBalance = GroupMemberBalance(id: userId, balance: 0, totalSummary: []) - let group = Groups(name: groupName.trimming(spaces: .leadingAndTrailing), - createdBy: userId, members: [userId], balances: [memberBalance]) + let localCurrency = Currency.getCurrentLocalCurrency().code + let memberBalance = GroupMemberBalance(id: userId, balanceByCurrency: [:]) + let group = Groups(name: groupName.trimming(spaces: .leadingAndTrailing), createdBy: userId, + members: [userId], balances: [memberBalance], currencyCode: localCurrency) do { showLoader = true diff --git a/Splito/UI/Home/Groups/Group/Group Options/Balances/GroupBalancesView.swift b/Splito/UI/Home/Groups/Group/Group Options/Balances/GroupBalancesView.swift index f1c2a8fc..ca342131 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Balances/GroupBalancesView.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Balances/GroupBalancesView.swift @@ -86,14 +86,15 @@ private struct GroupBalanceItemView: View { var body: some View { VStack(alignment: .leading, spacing: 20) { HStack(spacing: 16) { + let totalOwed = memberBalance.totalOwedAmount.reduce(0) { $0 + $1.value } HStack(spacing: 16) { MemberProfileImageView(imageUrl: imageUrl) - let hasDue = memberBalance.totalOwedAmount < 0 + let hasDue = totalOwed < 0 let name = viewModel.getMemberName(id: memberBalance.id, needFullName: true) let owesOrGetsBack = hasDue ? (memberBalance.id == preference.user?.id ? "owe" : "owes") : (memberBalance.id == preference.user?.id ? "get back" : "gets back") - if memberBalance.totalOwedAmount == 0 { + if totalOwed == 0 { Group { Text(name) .font(.subTitle2()) @@ -108,7 +109,7 @@ private struct GroupBalanceItemView: View { + Text(" \(owesOrGetsBack.localized) ") - + Text(memberBalance.totalOwedAmount.formattedCurrency) + + Text(totalOwed.formattedCurrency) .foregroundColor(hasDue ? errorColor : successColor) + Text(" in total") @@ -120,7 +121,7 @@ private struct GroupBalanceItemView: View { } .frame(maxWidth: .infinity, alignment: .leading) - if memberBalance.totalOwedAmount != 0 { + if totalOwed != 0 { ScrollToTopButton( icon: "chevron.down", iconColor: primaryText, bgColor: container2Color, showWithAnimation: true, size: (10, 7), isFirstGroupCell: memberBalance.isExpanded, @@ -152,7 +153,7 @@ private struct GroupBalanceItemMemberView: View { @Inject private var preference: SplitoPreference let id: String - let balances: [String: Double] + let balances: [String: [String: Double]] let viewModel: GroupBalancesViewModel @State private var showShareReminderSheet = false @@ -163,41 +164,42 @@ private struct GroupBalanceItemMemberView: View { HSpacer(32) VStack(alignment: .leading, spacing: 12) { - ForEach(balances.sorted(by: { $0.key < $1.key }), id: \.key) { (memberId, amount) in - let hasDue = amount < 0 - let imageUrl = viewModel.getMemberImage(id: memberId) - let owesMemberName = viewModel.getMemberName(id: hasDue ? memberId : id) - let owedMemberName = viewModel.getMemberName(id: hasDue ? id : memberId) - let owesText = ((hasDue ? id : memberId) == preference.user?.id) ? "owe" : "owes" - - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .center, spacing: 16) { - MemberProfileImageView(imageUrl: imageUrl, height: SUB_IMAGE_HEIGHT, scaleEffect: 0.6) - - Group { - Text("\(owedMemberName.capitalized) \(owesText.localized) ") - - + Text(amount.formattedCurrency) - .foregroundColor(hasDue ? errorColor : successColor) - - + Text(" to \(owesMemberName)") + ForEach(balances.sorted(by: { $0.key < $1.key }), id: \.key) { (currency, memberBalances) in + ForEach(memberBalances.sorted(by: { $0.key < $1.key }), id: \.key) { (memberId, amount) in + let hasDue = amount < 0 + let imageUrl = viewModel.getMemberImage(id: memberId) + let owesMemberName = viewModel.getMemberName(id: hasDue ? memberId : id) + let owedMemberName = viewModel.getMemberName(id: hasDue ? id : memberId) + let owesText = ((hasDue ? id : memberId) == preference.user?.id) ? "owe" : "owes" + + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 16) { + MemberProfileImageView(imageUrl: imageUrl, height: SUB_IMAGE_HEIGHT, scaleEffect: 0.6) + + Group { + Text("\(owedMemberName.capitalized) \(owesText.localized) ") + + Text(amount.formattedCurrency) + .foregroundColor(hasDue ? errorColor : successColor) + + Text(" in \(currency.localized) to \(owesMemberName)") + } + .font(.body3()) + .foregroundStyle(disableText) } - .font(.body3()) - .foregroundStyle(disableText) - } - RemindAndSettleBtnView( - handleRemindTap: { - let oweText = ((hasDue ? id : memberId) == preference.user?.id) ? "owe" : - (memberId == preference.user?.id || id == preference.user?.id) ? "owes" : "" - reminderText = generateReminderText(owedMemberName: owedMemberName, owesText: oweText, - amount: amount, owesMemberName: owesMemberName) - showShareReminderSheet = true - }, handleSettleUpTap: { - viewModel.handleSettleUpTap(payerId: hasDue ? id : memberId, - receiverId: hasDue ? memberId : id, amount: amount) - } - ) + RemindAndSettleBtnView( + handleRemindTap: { + let oweText = ((hasDue ? id : memberId) == preference.user?.id) ? "owe" : + (memberId == preference.user?.id || id == preference.user?.id) ? "owes" : "" + reminderText = generateReminderText(owedMemberName: owedMemberName, owesText: oweText, + amount: amount, owesMemberName: owesMemberName) + showShareReminderSheet = true + }, + handleSettleUpTap: { + viewModel.handleSettleUpTap(payerId: hasDue ? id : memberId, + receiverId: hasDue ? memberId : id, amount: amount) + } + ) + } } } } diff --git a/Splito/UI/Home/Groups/Group/Group Options/Balances/GroupBalancesViewModel.swift b/Splito/UI/Home/Groups/Group/Group Options/Balances/GroupBalancesViewModel.swift index 27111766..665c67ab 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Balances/GroupBalancesViewModel.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Balances/GroupBalancesViewModel.swift @@ -72,29 +72,38 @@ class GroupBalancesViewModel: BaseViewModel, ObservableObject { let filteredBalances = group.balances.filter { group.members.contains($0.id) } - let memberBalances = filteredBalances.map { - MembersCombinedBalance(id: $0.id, totalOwedAmount: $0.balance) - } - - // Create group member balances for settlements - let groupMemberBalances = filteredBalances.map { - GroupMemberBalance(id: $0.id, balance: $0.balance, totalSummary: $0.totalSummary) + let memberBalances = filteredBalances.flatMap { balance in + balance.balanceByCurrency.map { (currency, balanceDetails) in + // Create a combined balance for each currency + MembersCombinedBalance(id: balance.id, totalOwedAmount: [currency: balanceDetails.balance], + balances: [currency: [balance.id: balanceDetails.balance]]) + } } // Calculate settlements between group members - let settlements = calculateSettlements(balances: groupMemberBalances) + let settlements = calculateSettlements(balances: filteredBalances) // Merge settlements with member balances let combinedBalances = settlements.reduce(into: memberBalances) { balances, settlement in - let senderIndex = balances.firstIndex { $0.id == settlement.sender } - let receiverIndex = balances.firstIndex { $0.id == settlement.receiver } - - if let senderIndex = senderIndex { - balances[senderIndex].balances[settlement.receiver, default: 0.0] -= settlement.amount - } - - if let receiverIndex = receiverIndex { - balances[receiverIndex].balances[settlement.sender, default: 0.0] += settlement.amount + // Find sender and receiver indices in the balances list + if let senderIndex = balances.firstIndex(where: { $0.id == settlement.sender }), + let receiverIndex = balances.firstIndex(where: { $0.id == settlement.receiver }) { + + // Handle sender's balance update + if balances[senderIndex].balances[settlement.currency] != nil { + // If currency balance exists for sender, subtract the settlement amount + balances[senderIndex].balances[settlement.currency]?[settlement.receiver, default: 0.0] -= settlement.amount + } else { + // If no balance exists, initialize the currency balance for the sender + balances[senderIndex].balances[settlement.currency] = [settlement.receiver: -settlement.amount] + } + + // Handle receiver's balance update + if balances[receiverIndex].balances[settlement.currency] != nil { + balances[receiverIndex].balances[settlement.currency]?[settlement.sender, default: 0.0] += settlement.amount + } else { + balances[receiverIndex].balances[settlement.currency] = [settlement.sender: settlement.amount] + } } } @@ -111,7 +120,7 @@ class GroupBalancesViewModel: BaseViewModel, ObservableObject { var sortedMembers = memberBalances var userBalance = sortedMembers.remove(at: userIndex) - userBalance.isExpanded = userBalance.totalOwedAmount != 0 + userBalance.isExpanded = userBalance.totalOwedAmount.values.reduce(0, +) != 0 sortedMembers.insert(userBalance, at: 0) sortedMembers.sort { member1, member2 in @@ -172,8 +181,8 @@ class GroupBalancesViewModel: BaseViewModel, ObservableObject { struct MembersCombinedBalance { let id: String var isExpanded: Bool = false - var totalOwedAmount: Double = 0 - var balances: [String: Double] = [:] + var totalOwedAmount: [String: Double] = [:] + var balances: [String: [String: Double]] = [:] } // MARK: - View States diff --git a/Splito/UI/Home/Groups/Group/Group Options/Settle up/GroupSettleUpView.swift b/Splito/UI/Home/Groups/Group/Group Options/Settle up/GroupSettleUpView.swift index b17ffe3e..7d419a3d 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Settle up/GroupSettleUpView.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Settle up/GroupSettleUpView.swift @@ -88,18 +88,18 @@ private struct GroupMembersListView: View { return name1 < name2 } - ForEach(sortedMembers, id: \.key) { memberId, owingAmount in - if let member = viewModel.getMemberDataBy(id: memberId) { - GroupMemberCellView(member: member, amount: owingAmount) - .onTouchGesture { - viewModel.onMemberTap(memberId: member.id, amount: owingAmount) - } - - Divider() - .frame(height: 1) - .background(dividerColor) - } - } +// ForEach(sortedMembers, id: \.key) { memberId, owingAmount in +// if let member = viewModel.getMemberDataBy(id: memberId) { +// GroupMemberCellView(member: member, amount: owingAmount) +// .onTouchGesture { +// viewModel.onMemberTap(memberId: member.id, amount: owingAmount) +// } +// +// Divider() +// .frame(height: 1) +// .background(dividerColor) +// } +// } } } } diff --git a/Splito/UI/Home/Groups/Group/Group Options/Settle up/GroupSettleUpViewModel.swift b/Splito/UI/Home/Groups/Group/Group Options/Settle up/GroupSettleUpViewModel.swift index fcddc8fd..7151d2b3 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Settle up/GroupSettleUpViewModel.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Settle up/GroupSettleUpViewModel.swift @@ -14,7 +14,7 @@ class GroupSettleUpViewModel: BaseViewModel, ObservableObject { @Inject private var groupRepository: GroupRepository @Published private(set) var viewState: ViewState = .loading - @Published private(set) var memberOwingAmount: [String: Double] = [:] + @Published private(set) var memberOwingAmount: [String: [String: Double]] = [:] /// [currencyCode: [memberId: balance]] private var group: Groups? private var members: [AppUser] = [] @@ -76,17 +76,28 @@ class GroupSettleUpViewModel: BaseViewModel, ObservableObject { } // MARK: - Helper Methods - func getMembersBalance(memberId: String) -> Double { + func getMembersBalance(memberId: String) -> [String: Double] { guard let group else { LogE("GroupSettingViewModel: \(#function) group not found.") - return 0 + return [:] } - if let index = group.balances.firstIndex(where: { $0.id == memberId }) { - return group.balances[index].balance + guard let memberBalance = group.balances.first(where: { $0.id == memberId })?.balanceByCurrency else { + LogE("GroupSettingViewModel: \(#function) Member's balance not found from balances.") + return [:] } - return 0 + var filteredBalances: [String: Double] = [:] + for (currency, balanceInfo) in memberBalance { + if balanceInfo.balance == 0 { continue } + filteredBalances[currency] = balanceInfo.balance + } + + if filteredBalances.isEmpty { // If no non-zero balances, fallback to original data + return memberBalance.mapValues { $0.balance } + } + + return [:] } func getMemberDataBy(id: String) -> AppUser? { diff --git a/Splito/UI/Home/Groups/Group/Group Options/Settle up/Payment/GroupPaymentView.swift b/Splito/UI/Home/Groups/Group/Group Options/Settle up/Payment/GroupPaymentView.swift index bb2f7fa0..7f6823c7 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Settle up/Payment/GroupPaymentView.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Settle up/Payment/GroupPaymentView.swift @@ -61,7 +61,8 @@ struct GroupPaymentView: View { VSpacer(16) - AmountRowView(amount: $viewModel.amount, isAmountFocused: $isAmountFocused, subtitle: "Enter amount") + AddAmountView(amount: $viewModel.amount, showCurrencyPicker: $viewModel.showCurrencyPicker, + selectedCurrencySymbol: viewModel.selectedCurrency.symbol, isAmountFocused: $isAmountFocused) Spacer(minLength: 40) } @@ -110,10 +111,14 @@ struct GroupPaymentView: View { ImagePickerView(cropOption: .square, sourceType: !viewModel.sourceTypeIsCamera ? .photoLibrary : .camera, image: $viewModel.paymentImage, isPresented: $viewModel.showImagePicker) } + .sheet(isPresented: $viewModel.showCurrencyPicker) { + CurrencyPickerView(selectedCurrency: $viewModel.selectedCurrency, + isPresented: $viewModel.showCurrencyPicker) + } .sheet(isPresented: $viewModel.showAddNoteEditor) { NavigationStack { - AddNoteView(viewModel: AddNoteViewModel(group: viewModel.group, payment: viewModel.transaction, - note: viewModel.paymentNote, + AddNoteView(viewModel: AddNoteViewModel(group: viewModel.group, note: viewModel.paymentNote, + payment: viewModel.transaction, paymentReason: viewModel.paymentReason, handleSaveNoteTap: viewModel.handleNoteSaveBtnTap(note:reason:))) } @@ -121,58 +126,6 @@ struct GroupPaymentView: View { } } -struct AmountRowView: View { - - @Binding var amount: Double - var isAmountFocused: FocusState.Binding - - let subtitle: String - - @State private var amountString: String = "" - - var body: some View { - VStack(alignment: .center, spacing: 24) { - Text(subtitle.localized) - .font(.subTitle1()) - .foregroundStyle(primaryText) - .tracking(-0.2) - - TextField(" ₹ 0.00", text: $amountString) - .keyboardType(.decimalPad) - .font(.Header1()) - .tint(primaryColor) - .foregroundStyle(amountString.isEmpty ? outlineColor : primaryText) - .focused(isAmountFocused) - .multilineTextAlignment(.center) - .autocorrectionDisabled() - .onChange(of: amountString) { newValue in - formatAmount(newValue: newValue) - } - .onAppear { - amountString = amount == 0 ? "" : String(format: "₹ %.2f", amount) - } - } - .padding(16) - .overlay { - RoundedRectangle(cornerRadius: 16) - .stroke(outlineColor, lineWidth: 1) - } - } - - private func formatAmount(newValue: String) { - // Remove the "₹" symbol and whitespace to process the numeric value - let numericInput = newValue.replacingOccurrences(of: "₹", with: "").trimmingCharacters(in: .whitespaces) - if let value = Double(numericInput) { - amount = value - } else { - amount = 0 - } - - // Update amountString to include "₹" prefix - amountString = numericInput.isEmpty ? "" : "₹ " + numericInput - } -} - struct DatePickerView: View { @Binding var date: Date diff --git a/Splito/UI/Home/Groups/Group/Group Options/Settle up/Payment/GroupPaymentViewModel.swift b/Splito/UI/Home/Groups/Group/Group Options/Settle up/Payment/GroupPaymentViewModel.swift index 3718e1b0..f2f07318 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Settle up/Payment/GroupPaymentViewModel.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Settle up/Payment/GroupPaymentViewModel.swift @@ -22,7 +22,6 @@ class GroupPaymentViewModel: BaseViewModel, ObservableObject { @Published var amount: Double = 0 @Published var paymentDate = Date() @Published var paymentImage: UIImage? - @Published var paymentNote: String = "" @Published var paymentReason: String = "" @Published private(set) var paymentImageUrl: String? @@ -31,9 +30,11 @@ class GroupPaymentViewModel: BaseViewModel, ObservableObject { @Published var showAddNoteEditor = false @Published var showImageDisplayView = false @Published var showImagePickerOptions = false + @Published var showCurrencyPicker = false @Published private(set) var showLoader: Bool = false @Published private(set) var sourceTypeIsCamera = false + @Published var selectedCurrency: Currency @Published private(set) var payer: AppUser? @Published private(set) var receiver: AppUser? @Published private(set) var viewState: ViewState = .loading @@ -65,7 +66,7 @@ class GroupPaymentViewModel: BaseViewModel, ObservableObject { self.payerId = payerId self.receiverId = receiverId self.transactionId = transactionId - + self.selectedCurrency = Currency.getCurrentLocalCurrency() super.init() fetchInitialViewData() @@ -75,8 +76,7 @@ class GroupPaymentViewModel: BaseViewModel, ObservableObject { Task { [weak self] in await self?.fetchGroup() await self?.fetchTransaction() - await self?.getPayerUserDetail() - await self?.getPayableUserDetail() + await self?.getPaymentUsersData() } } @@ -92,8 +92,8 @@ class GroupPaymentViewModel: BaseViewModel, ObservableObject { // MARK: - Data Loading private func fetchGroup() async { do { - self.group = try await groupRepository.fetchGroupBy(id: groupId) - self.viewState = .initial + group = try await groupRepository.fetchGroupBy(id: groupId) + selectedCurrency = Currency.getCurrencyFromCode(group?.defaultCurrencyCode) LogD("GroupPaymentViewModel: \(#function) Group fetched successfully.") } catch { LogE("GroupPaymentViewModel: \(#function) Failed to fetch group \(groupId): \(error).") @@ -105,15 +105,13 @@ class GroupPaymentViewModel: BaseViewModel, ObservableObject { guard let transactionId else { return } do { - viewState = .loading transaction = try await transactionRepository.fetchTransactionBy(groupId: groupId, - transactionId: transactionId) + transactionId: transactionId) paymentDate = transaction?.date.dateValue() ?? Date.now paymentNote = transaction?.note ?? "" paymentImageUrl = transaction?.imageUrl paymentReason = transaction?.reason ?? "" - - viewState = .initial + selectedCurrency = Currency.getCurrencyFromCode(transaction?.currencyCode ?? group?.defaultCurrencyCode) LogD("GroupPaymentViewModel: \(#function) Payment fetched successfully.") } catch { LogE("GroupPaymentViewModel: \(#function) Failed to fetch payment \(transactionId): \(error).") @@ -121,30 +119,19 @@ class GroupPaymentViewModel: BaseViewModel, ObservableObject { } } - private func getPayerUserDetail() async { + private func getPaymentUsersData() async { do { - viewState = .loading - payer = try await userRepository.fetchUserBy(userID: payerId) + let users = try await userRepository.fetchUsersBy(userIds: [payerId, receiverId]) + payer = users.first(where: { $0.id == payerId }) + receiver = users.first(where: { $0.id == receiverId }) viewState = .initial - LogD("GroupPaymentViewModel: \(#function) Payer fetched successfully.") + LogD("GroupPaymentViewModel: \(#function) users data fetched successfully.") } catch { LogE("GroupPaymentViewModel: \(#function) Failed to fetch payer \(payerId): \(error).") handleServiceError() } } - private func getPayableUserDetail() async { - do { - viewState = .loading - receiver = try await userRepository.fetchUserBy(userID: receiverId) - viewState = .initial - LogD("GroupPaymentViewModel: \(#function) Payable fetched successfully.") - } catch { - LogE("GroupPaymentViewModel: \(#function) Failed to fetch payable \(receiverId): \(error).") - handleServiceError() - } - } - // MARK: - User Actions func handleNoteBtnTap() { showAddNoteEditor = true @@ -248,12 +235,12 @@ class GroupPaymentViewModel: BaseViewModel, ObservableObject { newTransaction.updatedBy = userId newTransaction.note = paymentNote newTransaction.reason = paymentReason - + newTransaction.currencyCode = selectedCurrency.code return await updateTransaction(transaction: newTransaction, oldTransaction: transaction) } else { - let transaction = Transactions(payerId: payerId, receiverId: receiverId, addedBy: userId, - note: paymentNote, reason: paymentReason, - amount: amount, date: .init(date: paymentDate)) + let transaction = Transactions(payerId: payerId, receiverId: receiverId, date: .init(date: paymentDate), + addedBy: userId, amount: amount, currencyCode: selectedCurrency.code, + note: paymentNote, reason: paymentReason) return await addTransaction(transaction: transaction) } } @@ -270,7 +257,6 @@ class GroupPaymentViewModel: BaseViewModel, ObservableObject { members: (payer, receiver), imageData: getImageData()) NotificationCenter.default.post(name: .addTransaction, object: self.transaction) await updateGroupMemberBalance(updateType: .Add) - showLoader = false LogD("GroupPaymentViewModel: \(#function) Payment added successfully.") return true @@ -291,7 +277,6 @@ class GroupPaymentViewModel: BaseViewModel, ObservableObject { do { showLoader = true - self.transaction = try await transactionRepository.updateTransactionWithImage(imageData: getImageData(), newImageUrl: paymentImageUrl, group: group, transaction: (transaction, oldTransaction), members: (payer, receiver)) defer { diff --git a/Splito/UI/Home/Groups/Group/Group Options/Settlements/Transaction Detail/GroupTransactionDetailView.swift b/Splito/UI/Home/Groups/Group/Group Options/Settlements/Transaction Detail/GroupTransactionDetailView.swift index 0b619d8f..65f1e02d 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Settlements/Transaction Detail/GroupTransactionDetailView.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Settlements/Transaction Detail/GroupTransactionDetailView.swift @@ -122,8 +122,9 @@ struct GroupTransactionDetailView: View { } .fullScreenCover(isPresented: $viewModel.showAddNoteEditor) { NavigationStack { - AddNoteView(viewModel: AddNoteViewModel(group: viewModel.group, payment: viewModel.transaction, - note: viewModel.paymentNote, paymentReason: viewModel.paymentReason)) + AddNoteView(viewModel: AddNoteViewModel(group: viewModel.group, note: viewModel.paymentNote, + payment: viewModel.transaction, + paymentReason: viewModel.paymentReason)) } } .navigationDestination(isPresented: $viewModel.showImageDisplayView) { @@ -244,7 +245,7 @@ private struct TransactionSummaryView: View { .padding(.bottom, 8) } - Text(amount?.formattedCurrency ?? "₹ 0") + Text(amount?.formattedCurrency ?? "0") .font(.Header2()) .foregroundStyle(primaryText) diff --git a/Splito/UI/Home/Groups/Group/Group Options/Totals/GroupTotalsViewModel.swift b/Splito/UI/Home/Groups/Group/Group Options/Totals/GroupTotalsViewModel.swift index 23376fb6..5b3b459b 100644 --- a/Splito/UI/Home/Groups/Group/Group Options/Totals/GroupTotalsViewModel.swift +++ b/Splito/UI/Home/Groups/Group/Group Options/Totals/GroupTotalsViewModel.swift @@ -66,7 +66,8 @@ class GroupTotalsViewModel: BaseViewModel, ObservableObject { case .thisYear: summaries = getTotalSummaryForCurrentYear() case .all: - summaries = group.balances.first(where: { $0.id == userId })?.totalSummary ?? [] + let groupBalance = group.balances.first(where: { $0.id == userId })?.balanceByCurrency ?? [:] + summaries = groupBalance["INR"]?.totalSummary ?? [] } summaryData = GroupMemberSummary( @@ -85,7 +86,8 @@ class GroupTotalsViewModel: BaseViewModel, ObservableObject { return [] } let currentYear = Calendar.current.component(.year, from: Date()) - return group.balances.first(where: { $0.id == user.id })?.totalSummary.filter { + let groupBalance = group.balances.first(where: { $0.id == user.id })?.balanceByCurrency ?? [:] + return groupBalance["INR"]?.totalSummary.filter { $0.year == currentYear } ?? [] } diff --git a/Splito/UI/Home/Groups/Group/Group Setting/GroupSettingView.swift b/Splito/UI/Home/Groups/Group/Group Setting/GroupSettingView.swift index a25c2b05..fbbf0e41 100644 --- a/Splito/UI/Home/Groups/Group/Group Setting/GroupSettingView.swift +++ b/Splito/UI/Home/Groups/Group/Group Setting/GroupSettingView.swift @@ -128,7 +128,7 @@ private struct GroupMembersView: View { LazyVStack(spacing: 20) { ForEach(viewModel.members) { member in let balance = viewModel.getMembersBalance(memberId: member.id) - GroupMemberCellView(member: member, amount: balance, + GroupMemberCellView(member: member, balance: balance, isAdmin: member.id == viewModel.group?.createdBy) .onTouchGesture { viewModel.handleMemberTap(member: member) @@ -201,7 +201,7 @@ private struct GroupMemberCellView: View { @Inject var preference: SplitoPreference let member: AppUser - let amount: Double + let balance: [String: Double] let isAdmin: Bool private var userName: String { @@ -247,22 +247,27 @@ private struct GroupMemberCellView: View { Spacer() - let isBorrowed = amount < 0 - VStack(alignment: .trailing, spacing: 4) { - if amount == 0 { - Text("settled up") - .font(.caption1()) - .foregroundStyle(disableText) - } else { - Text(isBorrowed ? "owes" : "gets back") - .font(.caption1()) - - Text(amount.formattedCurrency) - .font(.body1()) + if let firstBalance = balance.first { + let currency = firstBalance.key + let amount = firstBalance.value + let isBorrowed = amount < 0 + VStack(alignment: .trailing, spacing: 4) { + if amount == 0 { + Text("settled up") + .font(.caption1()) + .foregroundStyle(disableText) + } else { + Text(isBorrowed ? "owes" : "gets back") + .font(.caption1()) + + let currencySymbol = Currency.getCurrencyFromCode(currency).symbol + Text("\(currencySymbol) \(amount.formattedCurrency)") + .font(.body1()) + } } + .lineLimit(1) + .foregroundStyle(isBorrowed ? errorColor : successColor) } - .lineLimit(1) - .foregroundStyle(isBorrowed ? errorColor : successColor) } } } diff --git a/Splito/UI/Home/Groups/Group/Group Setting/GroupSettingViewModel.swift b/Splito/UI/Home/Groups/Group/Group Setting/GroupSettingViewModel.swift index 493514ef..f856053c 100644 --- a/Splito/UI/Home/Groups/Group/Group Setting/GroupSettingViewModel.swift +++ b/Splito/UI/Home/Groups/Group/Group Setting/GroupSettingViewModel.swift @@ -75,17 +75,28 @@ class GroupSettingViewModel: BaseViewModel, ObservableObject { // MARK: - Helper Methods - func getMembersBalance(memberId: String) -> Double { + func getMembersBalance(memberId: String) -> [String: Double] { guard let group else { LogE("GroupSettingViewModel: \(#function) group not found.") - return 0 + return [:] } - if let index = group.balances.firstIndex(where: { $0.id == memberId }) { - return group.balances[index].balance + guard let memberBalance = group.balances.first(where: { $0.id == memberId })?.balanceByCurrency else { + LogE("GroupSettingViewModel: \(#function) Member's balance not found from balances.") + return [:] } - return 0 + var filteredBalances: [String: Double] = [:] + for (currency, balanceInfo) in memberBalance { + if balanceInfo.balance == 0 { continue } + filteredBalances[currency] = balanceInfo.balance + } + + if filteredBalances.isEmpty { // If no non-zero balances, fallback to original data + return memberBalance.mapValues { $0.balance } + } + + return [:] } func sortGroupMembers(members: [AppUser]) { @@ -157,7 +168,7 @@ class GroupSettingViewModel: BaseViewModel, ObservableObject { private func showRemoveMemberAlert(member: AppUser) { let memberBalance = getMembersBalance(memberId: member.id) - guard memberBalance == 0 else { + guard memberBalance.values.reduce(0, +) == 0 else { memberRemoveType = .remove showDebtOutstandingAlert(memberId: member.id) return @@ -172,7 +183,7 @@ class GroupSettingViewModel: BaseViewModel, ObservableObject { private func showLeaveGroupAlert(member: AppUser) { let memberBalance = getMembersBalance(memberId: member.id) - guard memberBalance == 0 else { + guard memberBalance.values.reduce(0, +) == 0 else { memberRemoveType = .leave showDebtOutstandingAlert(memberId: member.id) return diff --git a/Splito/UI/Home/Groups/Group/GroupExpenseListView.swift b/Splito/UI/Home/Groups/Group/GroupExpenseListView.swift index 202df87b..651e009d 100644 --- a/Splito/UI/Home/Groups/Group/GroupExpenseListView.swift +++ b/Splito/UI/Home/Groups/Group/GroupExpenseListView.swift @@ -255,7 +255,7 @@ private struct GroupExpenseHeaderView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - if viewModel.overallOwingAmount == 0 { + if viewModel.overallOwingAmount.values.reduce(0, +) == 0 { VStack(alignment: .center, spacing: 16) { Image(.tickmarkIcon) .resizable() @@ -278,10 +278,12 @@ private struct GroupExpenseHeaderView: View { .background(dividerColor) VStack(alignment: .leading, spacing: 12) { - ForEach(viewModel.memberOwingAmount.sorted(by: { $0.key < $1.key }), id: \.key) { (memberId, amount) in - let name = viewModel.getMemberDataBy(id: memberId)?.nameWithLastInitial ?? "Unknown" - GroupExpenseMemberOweView(name: name, amount: amount, - handleSimplifyInfoSheet: viewModel.handleSimplifyInfoSheet) + ForEach(viewModel.memberOwingAmount.sorted(by: { $0.key < $1.key }), id: \.key) { (_, memberOweAmounts) in + ForEach(memberOweAmounts.sorted(by: { $0.key < $1.key }), id: \.key) { (memberId, amount) in + let name = viewModel.getMemberDataBy(id: memberId)?.nameWithLastInitial ?? "Unknown" + GroupExpenseMemberOweView(name: name, amount: amount, + handleSimplifyInfoSheet: viewModel.handleSimplifyInfoSheet) + } } }.padding(16) } @@ -299,14 +301,15 @@ private struct GroupExpenseHeaderOverallView: View { var body: some View { HStack(alignment: .center, spacing: 0) { - let isDue = viewModel.overallOwingAmount < 0 + let (owedText, owedAmounts) = calculateOwedText(from: viewModel.overallOwingAmount) + let isDue = viewModel.overallOwingAmount.values.reduce(0, +) < 0 VStack(alignment: .leading, spacing: 4) { - Text("You \(isDue ? "owe overall" : "are overall owed")") + Text(owedText) .font(.body3()) .foregroundStyle(disableText) - Text("\(abs(viewModel.overallOwingAmount).formattedCurrency)") + Text(owedAmounts) .font(.body1()) .foregroundStyle(isDue ? errorColor : successColor) } @@ -331,6 +334,22 @@ private struct GroupExpenseHeaderOverallView: View { .padding(16) } } + + /// Helper function to calculate owed/owed text and amounts + private func calculateOwedText(from balances: [String: Double]) -> (String, String) { + // Separate positive and negative balances + let owedAmounts = balances.filter { $0.value < 0 } + .map { "\($0.key) \(abs($0.value).formattedCurrency)" } + .joined(separator: " + ") + let owedText = !owedAmounts.isEmpty ? "You owe \(owedAmounts)" : "You are owed" + + let owedByYou = balances.filter { $0.value > 0 } + .map { "\($0.key) \(abs($0.value).formattedCurrency)" } + .joined(separator: " + ") + + let messagePrefix = balances.values.reduce(0, +) < 0 ? "are owed" : "owe" + return ("You \(messagePrefix) ", "\(owedAmounts)") + } } private struct GroupExpenseMemberOweView: View { diff --git a/Splito/UI/Home/Groups/Group/GroupHomeView.swift b/Splito/UI/Home/Groups/Group/GroupHomeView.swift index 96b093ed..e067343d 100644 --- a/Splito/UI/Home/Groups/Group/GroupHomeView.swift +++ b/Splito/UI/Home/Groups/Group/GroupHomeView.swift @@ -219,7 +219,6 @@ struct EmptyStateView: View { .frame(minHeight: minHeight ?? geometry.size.height - 50, maxHeight: .infinity, alignment: .center) } .scrollIndicators(.hidden) - .scrollBounceBehavior(.basedOnSize) } } diff --git a/Splito/UI/Home/Groups/Group/GroupHomeViewModel.swift b/Splito/UI/Home/Groups/Group/GroupHomeViewModel.swift index 4bc711d5..4563e32e 100644 --- a/Splito/UI/Home/Groups/Group/GroupHomeViewModel.swift +++ b/Splito/UI/Home/Groups/Group/GroupHomeViewModel.swift @@ -20,8 +20,8 @@ class GroupHomeViewModel: BaseViewModel, ObservableObject { @Inject private var transactionRepository: TransactionRepository @Published private(set) var groupId: String - @Published private(set) var overallOwingAmount: Double = 0.0 @Published private(set) var currentMonthSpending: Double = 0.0 + @Published private(set) var overallOwingAmount: [String: Double] = [:] @Published var group: Groups? @Published var groupState: GroupState = .loading @@ -29,8 +29,8 @@ class GroupHomeViewModel: BaseViewModel, ObservableObject { @Published var expenses: [Expense] = [] @Published var expensesWithUser: [ExpenseWithUser] = [] @Published var transactionsCount: Int = 0 - @Published private(set) var memberOwingAmount: [String: Double] = [:] @Published private(set) var groupExpenses: [String: [ExpenseWithUser]] = [:] + @Published private(set) var memberOwingAmount: [String: [String: Double]] = [:] @Published var showSettleUpSheet = false @Published var showBalancesSheet = false @@ -280,7 +280,10 @@ class GroupHomeViewModel: BaseViewModel, ObservableObject { memberOwingAmount = Splito.calculateExpensesSimplified(userId: userId, memberBalances: group.balances) withAnimation(.easeOut) { - overallOwingAmount = group.balances.first(where: { $0.id == userId })?.balance ?? 0.0 + let balance = group.balances.first(where: { $0.id == userId })?.balanceByCurrency ?? [:] + for (currency, groupBalance) in balance { + overallOwingAmount[currency] = groupBalance.balance + } setGroupViewState() } } diff --git a/Splito/UI/Home/Groups/GroupListView.swift b/Splito/UI/Home/Groups/GroupListView.swift index fd64ac04..824f3575 100644 --- a/Splito/UI/Home/Groups/GroupListView.swift +++ b/Splito/UI/Home/Groups/GroupListView.swift @@ -224,7 +224,6 @@ private struct NoGroupsState: View { .frame(minHeight: geometry.size.height - 100, maxHeight: .infinity, alignment: .center) } .scrollIndicators(.hidden) - .scrollBounceBehavior(.basedOnSize) } } } diff --git a/Splito/UI/Home/Groups/GroupListViewModel.swift b/Splito/UI/Home/Groups/GroupListViewModel.swift index 2a7c063b..077b40c2 100644 --- a/Splito/UI/Home/Groups/GroupListViewModel.swift +++ b/Splito/UI/Home/Groups/GroupListViewModel.swift @@ -49,11 +49,15 @@ class GroupListViewModel: BaseViewModel, ObservableObject { case .all: return searchedGroup.isEmpty ? combinedGroups : combinedGroups.filter { $0.group.name.localizedCaseInsensitiveContains(searchedGroup) } case .settled: - return searchedGroup.isEmpty ? combinedGroups.filter { $0.userBalance == 0 } : combinedGroups.filter { $0.userBalance == 0 && - $0.group.name.localizedCaseInsensitiveContains(searchedGroup) } + return searchedGroup.isEmpty + ? combinedGroups.filter { $0.userBalance.values.reduce(0, +) == 0 } + : combinedGroups.filter { $0.userBalance.values.reduce(0, +) == 0 && + $0.group.name.localizedCaseInsensitiveContains(searchedGroup) } case .unsettled: - return searchedGroup.isEmpty ? combinedGroups.filter { $0.userBalance != 0 } : combinedGroups.filter { $0.userBalance != 0 && - $0.group.name.localizedCaseInsensitiveContains(searchedGroup) } + return searchedGroup.isEmpty + ? combinedGroups.filter { $0.userBalance.values.reduce(0, +) != 0 } + : combinedGroups.filter { $0.userBalance.values.reduce(0, +) != 0 && + $0.group.name.localizedCaseInsensitiveContains(searchedGroup) } } } @@ -238,11 +242,16 @@ class GroupListViewModel: BaseViewModel, ObservableObject { return groupMembers } - private func getMembersBalance(group: Groups, memberId: String) -> Double { + private func getMembersBalance(group: Groups, memberId: String) -> [String: Double] { + var memberBalance: [String: Double] = [:] if let index = group.balances.firstIndex(where: { $0.id == memberId }) { - return group.balances[index].balance + let groupMemberBalance = group.balances[index].balanceByCurrency + for (currency, groupBalance) in groupMemberBalance { + memberBalance[currency] = groupBalance.balance + } + return memberBalance } - return 0 + return [:] } private func fetchGroup(groupId: String) async -> Groups? { @@ -292,8 +301,8 @@ extension GroupListViewModel { func handleSearchBarTap() { if (combinedGroups.isEmpty) || - (selectedTab == .unsettled && combinedGroups.filter({ $0.userBalance != 0 }).isEmpty) || - (selectedTab == .settled && combinedGroups.filter({ $0.userBalance == 0 }).isEmpty) { + (selectedTab == .unsettled && combinedGroups.filter({ $0.userBalance.values.reduce(0, +) != 0 }).isEmpty) || + (selectedTab == .settled && combinedGroups.filter({ $0.userBalance.values.reduce(0, +) == 0 }).isEmpty) { showToastFor(toast: .init(type: .info, title: "No groups yet", message: "There are no groups available to search.")) } else { withAnimation { @@ -314,8 +323,8 @@ extension GroupListViewModel { func handleTabItemSelection(_ selection: GroupListTabType) { guard case .hasGroup = groupListState else { return } - let settledGroups = combinedGroups.filter { $0.userBalance == 0 } - let unsettledGroups = combinedGroups.filter { $0.userBalance != 0 } + let settledGroups = combinedGroups.filter { $0.userBalance.values.reduce(0, +) == 0 } + let unsettledGroups = combinedGroups.filter { $0.userBalance.values.reduce(0, +) != 0 } withAnimation(.easeInOut(duration: 0.3)) { selectedTab = selection diff --git a/Splito/UI/Home/Groups/GroupListWithDetailView.swift b/Splito/UI/Home/Groups/GroupListWithDetailView.swift index ebd0268e..07115607 100644 --- a/Splito/UI/Home/Groups/GroupListWithDetailView.swift +++ b/Splito/UI/Home/Groups/GroupListWithDetailView.swift @@ -86,7 +86,7 @@ private struct GroupListCellView: View { self.isLastGroup = isLastGroup self.group = group self.viewModel = viewModel - self._showInfo = State(initialValue: isFirstGroup && group.userBalance != 0) + self._showInfo = State(initialValue: isFirstGroup && group.userBalance.values.reduce(0, +) != 0) } var body: some View { @@ -106,9 +106,11 @@ private struct GroupListCellView: View { Spacer(minLength: 8) + let defaultCurrency = group.group.defaultCurrencyCode + let userBalance = group.userBalance[defaultCurrency] ?? group.userBalance.first?.value ?? 0 VStack(alignment: .trailing, spacing: 0) { - let isBorrowed = group.userBalance < 0 - if group.userBalance == 0 { + let isBorrowed = group.userBalance.values.reduce(0, +) < 0 + if group.userBalance.values.reduce(0, +) == 0 { Text(group.group.hasExpenses ? "settled up" : "no expense") .font(.caption1()) .foregroundStyle(disableText) @@ -116,14 +118,17 @@ private struct GroupListCellView: View { } else { Text(isBorrowed ? "you owe" : "you are owed") .font(.caption1()) - Text(group.userBalance.formattedCurrency) + + let currency = group.userBalance[defaultCurrency] == nil ? group.userBalance.first?.key : defaultCurrency + let currencySymbol = Currency.getCurrencyFromCode(currency).symbol + Text("\(currencySymbol) \(userBalance.formattedCurrency)") .font(.body1()) } } .lineLimit(1) - .foregroundStyle(group.userBalance < 0 ? errorColor : successColor) + .foregroundStyle(group.userBalance.values.reduce(0, +) < 0 ? errorColor : successColor) - if group.userBalance != 0 { + if userBalance != 0 { GroupExpandBtnView(showInfo: $showInfo, isFirstGroup: isFirstGroup) } } @@ -134,9 +139,11 @@ private struct GroupListCellView: View { HSpacer(56) // width of image size for padding VStack(alignment: .leading, spacing: 8) { - ForEach(group.memberOweAmount.sorted(by: { $0.key < $1.key }), id: \.key) { (memberId, amount) in - let name = viewModel.getMemberData(from: group.members, of: memberId)?.nameWithLastInitial ?? "Unknown" - GroupExpenseMemberOweView(name: name, amount: amount) + ForEach(group.memberOweAmount.sorted(by: { $0.key < $1.key }), id: \.key) { (_, memberOweAmount) in + ForEach(memberOweAmount.sorted(by: { $0.key < $1.key }), id: \.key) { (memberId, amount) in + let name = viewModel.getMemberData(from: group.members, of: memberId)?.nameWithLastInitial ?? "Unknown" + GroupExpenseMemberOweView(name: name, amount: amount) + } } } .padding(.horizontal, 16)