From 21cafcfea09caca538095edcb59d1be6ea6ea0b2 Mon Sep 17 00:00:00 2001 From: Aleksey Ivanovsky Date: Sat, 23 Aug 2025 10:44:53 +0200 Subject: [PATCH 1/4] [BACKEND] Add /currency endpoint --- .../com/github/ai/split/api/CurrencyDto.kt | 10 + .../api/response/GetCurrenciesResponse.kt | 9 + .../github/ai/split/client/ApiClient.scala | 8 + .../ai/split/client/ApiClientMain.scala | 3 + .../com/github/ai/split/api/CurrencyDto.scala | 13 + .../api/response/GetCurrenciesResponse.scala | 12 + .../app/src/main/resources/currencies.json | 2071 +++++++++++++++++ backend/app/src/main/resources/init.sql | 6 + .../scala/com/github/ai/split/Layers.scala | 46 +- .../main/scala/com/github/ai/split/Main.scala | 24 +- .../split/data/currency/CurrencyParser.scala | 50 + .../split/data/db/dao/CurrencyEntityDao.scala | 111 + .../db/repository/CurrencyRepository.scala | 18 + .../usecases/FillCurrencyDataUseCase.scala | 41 + .../usecases/StartUpServerUseCase.scala | 25 + .../ai/split/entity/db/CurrencyEntity.scala | 7 + .../ai/split/entity/db/GroupEntity.scala | 2 - .../controllers/CurrencyController.scala | 33 + .../presentation/routes/CurrencyRoutes.scala | 18 + .../com/github/ai/split/utils/Resources.scala | 18 + .../ai/split/utils/StringExtensions.scala | 16 +- 21 files changed, 2493 insertions(+), 48 deletions(-) create mode 100644 android/backend-api/src/main/kotlin/com/github/ai/split/api/CurrencyDto.kt create mode 100644 android/backend-api/src/main/kotlin/com/github/ai/split/api/response/GetCurrenciesResponse.kt create mode 100644 backend/api/src/main/scala/com/github/ai/split/api/CurrencyDto.scala create mode 100644 backend/api/src/main/scala/com/github/ai/split/api/response/GetCurrenciesResponse.scala create mode 100644 backend/app/src/main/resources/currencies.json create mode 100644 backend/app/src/main/scala/com/github/ai/split/data/currency/CurrencyParser.scala create mode 100644 backend/app/src/main/scala/com/github/ai/split/data/db/dao/CurrencyEntityDao.scala create mode 100644 backend/app/src/main/scala/com/github/ai/split/data/db/repository/CurrencyRepository.scala create mode 100644 backend/app/src/main/scala/com/github/ai/split/domain/usecases/FillCurrencyDataUseCase.scala create mode 100644 backend/app/src/main/scala/com/github/ai/split/domain/usecases/StartUpServerUseCase.scala create mode 100644 backend/app/src/main/scala/com/github/ai/split/entity/db/CurrencyEntity.scala create mode 100644 backend/app/src/main/scala/com/github/ai/split/presentation/controllers/CurrencyController.scala create mode 100644 backend/app/src/main/scala/com/github/ai/split/presentation/routes/CurrencyRoutes.scala create mode 100644 backend/app/src/main/scala/com/github/ai/split/utils/Resources.scala diff --git a/android/backend-api/src/main/kotlin/com/github/ai/split/api/CurrencyDto.kt b/android/backend-api/src/main/kotlin/com/github/ai/split/api/CurrencyDto.kt new file mode 100644 index 0000000..1f924c0 --- /dev/null +++ b/android/backend-api/src/main/kotlin/com/github/ai/split/api/CurrencyDto.kt @@ -0,0 +1,10 @@ +package com.github.ai.split.api + +import kotlinx.serialization.Serializable + +@Serializable +data class CurrencyDto( + val isoCode: String, + val name: String, + val symbol: String +) \ No newline at end of file diff --git a/android/backend-api/src/main/kotlin/com/github/ai/split/api/response/GetCurrenciesResponse.kt b/android/backend-api/src/main/kotlin/com/github/ai/split/api/response/GetCurrenciesResponse.kt new file mode 100644 index 0000000..646bc03 --- /dev/null +++ b/android/backend-api/src/main/kotlin/com/github/ai/split/api/response/GetCurrenciesResponse.kt @@ -0,0 +1,9 @@ +package com.github.ai.split.api.response + +import kotlinx.serialization.Serializable +import com.github.ai.split.api.CurrencyDto + +@Serializable +data class GetCurrenciesResponse( + val currencies: List +) \ No newline at end of file diff --git a/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClient.scala b/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClient.scala index 226eb7a..1b78caa 100644 --- a/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClient.scala +++ b/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClient.scala @@ -26,6 +26,14 @@ class ApiClient( ) } + def getCurrencies(): ApiResponse = { + client.request( + Request.get( + path = s"$baseUrl/currency" + ) + ) + } + def postGroup(): ApiResponse = { client.request( Request.post( diff --git a/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClientMain.scala b/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClientMain.scala index c6a0164..371bd74 100644 --- a/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClientMain.scala +++ b/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClientMain.scala @@ -24,6 +24,7 @@ object ApiClientMain extends ZIOAppDefault { |update-member [MEMBER_UID] [NAME] Update member name by MEMBER_UID |gen-members [GROUP_UID] |delete-member [MEMBER_UID] Delete member by MEMBER_UID + |currencies Get list of currencies |help Print help |""".stripMargin @@ -75,6 +76,8 @@ object ApiClientMain extends ZIOAppDefault { api.postMember(groupUid = groupUid, userName = "Donald").run } case s"delete-member $memberUid" => api.deleteMember(memberUid = memberUid).run + + case s"currencies" => api.getCurrencies().run case _ => ZIO.fail(InvalidCliArgumentException(s"Illegal arguments: $arguments")).run } diff --git a/backend/api/src/main/scala/com/github/ai/split/api/CurrencyDto.scala b/backend/api/src/main/scala/com/github/ai/split/api/CurrencyDto.scala new file mode 100644 index 0000000..d07fcd0 --- /dev/null +++ b/backend/api/src/main/scala/com/github/ai/split/api/CurrencyDto.scala @@ -0,0 +1,13 @@ +package com.github.ai.split.api + +import zio.json.{DeriveJsonEncoder, JsonEncoder} + +case class CurrencyDto( + isoCode: String, + name: String, + symbol: String +) + +object CurrencyDto { + implicit val encoder: JsonEncoder[CurrencyDto] = DeriveJsonEncoder.gen[CurrencyDto] +} \ No newline at end of file diff --git a/backend/api/src/main/scala/com/github/ai/split/api/response/GetCurrenciesResponse.scala b/backend/api/src/main/scala/com/github/ai/split/api/response/GetCurrenciesResponse.scala new file mode 100644 index 0000000..86331e3 --- /dev/null +++ b/backend/api/src/main/scala/com/github/ai/split/api/response/GetCurrenciesResponse.scala @@ -0,0 +1,12 @@ +package com.github.ai.split.api.response + +import com.github.ai.split.api.CurrencyDto +import zio.json.{DeriveJsonEncoder, JsonEncoder} + +case class GetCurrenciesResponse( + currencies: List[CurrencyDto] +) + +object GetCurrenciesResponse { + implicit val encoder: JsonEncoder[GetCurrenciesResponse] = DeriveJsonEncoder.gen[GetCurrenciesResponse] +} \ No newline at end of file diff --git a/backend/app/src/main/resources/currencies.json b/backend/app/src/main/resources/currencies.json new file mode 100644 index 0000000..35796f5 --- /dev/null +++ b/backend/app/src/main/resources/currencies.json @@ -0,0 +1,2071 @@ +[ + { + "currencies": { + "TND": { + "name": "Tunisian dinar", + "symbol": "د.ت" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "VND": { + "name": "Vietnamese đồng", + "symbol": "₫" + } + } + }, + { + "currencies": { + "USD": { + "name": "United States dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "USD": { + "name": "United States dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "USD": { + "name": "United States dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "GBP": { + "name": "British pound", + "symbol": "£" + } + } + }, + { + "currencies": { + "ILS": { + "name": "Israeli new shekel", + "symbol": "₪" + } + } + }, + { + "currencies": { + "GBP": { + "name": "British pound", + "symbol": "£" + } + } + }, + { + "currencies": { + "USD": { + "name": "United States dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "MMK": { + "name": "Burmese kyat", + "symbol": "Ks" + } + } + }, + { + "currencies": { + "PLN": { + "name": "Polish złoty", + "symbol": "zł" + } + } + }, + { + "currencies": { + "USD": { + "name": "United States dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "AFN": { + "name": "Afghan afghani", + "symbol": "؋" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "KES": { + "name": "Kenyan shilling", + "symbol": "Sh" + } + } + }, + { + "currencies": { + "USD": { + "name": "United States dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "AOA": { + "name": "Angolan kwanza", + "symbol": "Kz" + } + } + }, + { + "currencies": { + "BTN": { + "name": "Bhutanese ngultrum", + "symbol": "Nu." + }, + "INR": { + "name": "Indian rupee", + "symbol": "₹" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "GBP": { + "name": "British pound", + "symbol": "£" + }, + "JEP": { + "name": "Jersey pound", + "symbol": "£" + } + } + }, + { + "currencies": { + "RUB": { + "name": "Russian ruble", + "symbol": "₽" + } + } + }, + { + "currencies": { + "NPR": { + "name": "Nepalese rupee", + "symbol": "₨" + } + } + }, + { + "currencies": { + "XOF": { + "name": "West African CFA franc", + "symbol": "Fr" + } + } + }, + { + "currencies": { + "IQD": { + "name": "Iraqi dinar", + "symbol": "ع.د" + } + } + }, + { + "currencies": { + "SDG": { + "name": "Sudanese pound", + "symbol": "ج.س" + } + } + }, + { + "currencies": { + "XPF": { + "name": "CFP franc", + "symbol": "₣" + } + } + }, + { + "currencies": { + "DJF": { + "name": "Djiboutian franc", + "symbol": "Fr" + } + } + }, + { + "currencies": { + "MZN": { + "name": "Mozambican metical", + "symbol": "MT" + } + } + }, + { + "currencies": { + "USD": { + "name": "United States dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "AWG": { + "name": "Aruban florin", + "symbol": "ƒ" + } + } + }, + { + "currencies": { + "GBP": { + "name": "Pound sterling", + "symbol": "£" + }, + "SHP": { + "name": "Saint Helena pound", + "symbol": "£" + } + } + }, + { + "currencies": { + "ZAR": { + "name": "South African rand", + "symbol": "R" + } + } + }, + { + "currencies": { + "CHF": { + "name": "Swiss franc", + "symbol": "Fr" + } + } + }, + { + "currencies": { + "STN": { + "name": "São Tomé and Príncipe dobra", + "symbol": "Db" + } + } + }, + { + "currencies": { + "BIF": { + "name": "Burundian franc", + "symbol": "Fr" + } + } + }, + { + "currencies": { + "RWF": { + "name": "Rwandan franc", + "symbol": "Fr" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "USD": { + "name": "United States dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "JOD": { + "name": "Jordanian dinar", + "symbol": "د.ا" + } + } + }, + { + "currencies": { + "USD": { + "name": "United States dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "XAF": { + "name": "Central African CFA franc", + "symbol": "Fr" + } + } + }, + { + "currencies": { + "USD": { + "name": "United States dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "NZD": { + "name": "New Zealand dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "KGS": { + "name": "Kyrgyzstani som", + "symbol": "с" + } + } + }, + { + "currencies": {} + }, + { + "currencies": { + "MRU": { + "name": "Mauritanian ouguiya", + "symbol": "UM" + } + } + }, + { + "currencies": { + "WST": { + "name": "Samoan tālā", + "symbol": "T" + } + } + }, + { + "currencies": { + "MWK": { + "name": "Malawian kwacha", + "symbol": "MK" + } + } + }, + { + "currencies": { + "PAB": { + "name": "Panamanian balboa", + "symbol": "B/." + }, + "USD": { + "name": "United States dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "THB": { + "name": "Thai baht", + "symbol": "฿" + } + } + }, + { + "currencies": { + "TRY": { + "name": "Turkish lira", + "symbol": "₺" + } + } + }, + { + "currencies": { + "PHP": { + "name": "Philippine peso", + "symbol": "₱" + } + } + }, + { + "currencies": { + "NZD": { + "name": "New Zealand dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "AED": { + "name": "United Arab Emirates dirham", + "symbol": "د.إ" + } + } + }, + { + "currencies": { + "GEL": { + "name": "lari", + "symbol": "₾" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "AMD": { + "name": "Armenian dram", + "symbol": "֏" + } + } + }, + { + "currencies": { + "AUD": { + "name": "Australian dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "CRC": { + "name": "Costa Rican colón", + "symbol": "₡" + } + } + }, + { + "currencies": { + "AUD": { + "name": "Australian dollar", + "symbol": "$" + }, + "KID": { + "name": "Kiribati dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "TZS": { + "name": "Tanzanian shilling", + "symbol": "Sh" + } + } + }, + { + "currencies": { + "SSP": { + "name": "South Sudanese pound", + "symbol": "£" + } + } + }, + { + "currencies": { + "IRR": { + "name": "Iranian rial", + "symbol": "﷼" + } + } + }, + { + "currencies": { + "USD": { + "name": "United States dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "NOK": { + "name": "krone", + "symbol": "kr" + } + } + }, + { + "currencies": { + "MNT": { + "name": "Mongolian tögrög", + "symbol": "₮" + } + } + }, + { + "currencies": { + "MDL": { + "name": "Moldovan leu", + "symbol": "L" + } + } + }, + { + "currencies": { + "NZD": { + "name": "New Zealand dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "SZL": { + "name": "Swazi lilangeni", + "symbol": "L" + }, + "ZAR": { + "name": "South African rand", + "symbol": "R" + } + } + }, + { + "currencies": { + "SOS": { + "name": "Somali shilling", + "symbol": "Sh" + } + } + }, + { + "currencies": { + "XCD": { + "name": "Eastern Caribbean dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "GYD": { + "name": "Guyanese dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "GBP": { + "name": "British pound", + "symbol": "£" + }, + "IMP": { + "name": "Manx pound", + "symbol": "£" + } + } + }, + { + "currencies": { + "DKK": { + "name": "Danish krone", + "symbol": "kr" + }, + "FOK": { + "name": "Faroese króna", + "symbol": "kr" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "XPF": { + "name": "CFP franc", + "symbol": "₣" + } + } + }, + { + "currencies": { + "INR": { + "name": "Indian rupee", + "symbol": "₹" + } + } + }, + { + "currencies": { + "XOF": { + "name": "West African CFA franc", + "symbol": "Fr" + } + } + }, + { + "currencies": { + "ANG": { + "name": "Netherlands Antillean guilder", + "symbol": "ƒ" + } + } + }, + { + "currencies": { + "CDF": { + "name": "Congolese franc", + "symbol": "FC" + } + } + }, + { + "currencies": { + "XAF": { + "name": "Central African CFA franc", + "symbol": "Fr" + } + } + }, + { + "currencies": { + "KRW": { + "name": "South Korean won", + "symbol": "₩" + } + } + }, + { + "currencies": { + "XOF": { + "name": "West African CFA franc", + "symbol": "Fr" + } + } + }, + { + "currencies": { + "MVR": { + "name": "Maldivian rufiyaa", + "symbol": ".ރ" + } + } + }, + { + "currencies": { + "RSD": { + "name": "Serbian dinar", + "symbol": "дин." + } + } + }, + { + "currencies": { + "XOF": { + "name": "West African CFA franc", + "symbol": "Fr" + } + } + }, + { + "currencies": { + "DZD": { + "name": "Algerian dinar", + "symbol": "د.ج" + } + } + }, + { + "currencies": { + "LAK": { + "name": "Lao kip", + "symbol": "₭" + } + } + }, + { + "currencies": { + "XAF": { + "name": "Central African CFA franc", + "symbol": "Fr" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "ZWL": { + "name": "Zimbabwean dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "XCD": { + "name": "Eastern Caribbean dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "BOB": { + "name": "Bolivian boliviano", + "symbol": "Bs." + } + } + }, + { + "currencies": { + "LSL": { + "name": "Lesotho loti", + "symbol": "L" + }, + "ZAR": { + "name": "South African rand", + "symbol": "R" + } + } + }, + { + "currencies": { + "HUF": { + "name": "Hungarian forint", + "symbol": "Ft" + } + } + }, + { + "currencies": { + "TMT": { + "name": "Turkmenistan manat", + "symbol": "m" + } + } + }, + { + "currencies": { + "NOK": { + "name": "Norwegian krone", + "symbol": "kr" + } + } + }, + { + "currencies": { + "PEN": { + "name": "Peruvian sol", + "symbol": "S/ " + } + } + }, + { + "currencies": { + "CUC": { + "name": "Cuban convertible peso", + "symbol": "$" + }, + "CUP": { + "name": "Cuban peso", + "symbol": "$" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "XCD": { + "name": "Eastern Caribbean dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "GBP": { + "name": "British pound", + "symbol": "£" + }, + "GGP": { + "name": "Guernsey pound", + "symbol": "£" + } + } + }, + { + "currencies": { + "MUR": { + "name": "Mauritian rupee", + "symbol": "₨" + } + } + }, + { + "currencies": { + "SAR": { + "name": "Saudi riyal", + "symbol": "ر.س" + } + } + }, + { + "currencies": { + "ANG": { + "name": "Netherlands Antillean guilder", + "symbol": "ƒ" + } + } + }, + { + "currencies": { + "XOF": { + "name": "West African CFA franc", + "symbol": "Fr" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "USD": { + "name": "United States dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "AUD": { + "name": "Australian dollar", + "symbol": "$" + }, + "TVD": { + "name": "Tuvaluan dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "DKK": { + "name": "krone", + "symbol": "kr." + } + } + }, + { + "currencies": { + "CVE": { + "name": "Cape Verdean escudo", + "symbol": "Esc" + } + } + }, + { + "currencies": { + "UZS": { + "name": "Uzbekistani soʻm", + "symbol": "so'm" + } + } + }, + { + "currencies": { + "SEK": { + "name": "Swedish krona", + "symbol": "kr" + } + } + }, + { + "currencies": { + "BDT": { + "name": "Bangladeshi taka", + "symbol": "৳" + } + } + }, + { + "currencies": { + "TWD": { + "name": "New Taiwan dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "XAF": { + "name": "Central African CFA franc", + "symbol": "Fr" + } + } + }, + { + "currencies": { + "SYP": { + "name": "Syrian pound", + "symbol": "£" + } + } + }, + { + "currencies": { + "CLP": { + "name": "Chilean peso", + "symbol": "$" + } + } + }, + { + "currencies": { + "XOF": { + "name": "West African CFA franc", + "symbol": "Fr" + } + } + }, + { + "currencies": { + "XOF": { + "name": "West African CFA franc", + "symbol": "Fr" + } + } + }, + { + "currencies": { + "RON": { + "name": "Romanian leu", + "symbol": "lei" + } + } + }, + { + "currencies": { + "BRL": { + "name": "Brazilian real", + "symbol": "R$" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "XCD": { + "name": "Eastern Caribbean dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "XOF": { + "name": "West African CFA franc", + "symbol": "Fr" + } + } + }, + { + "currencies": { + "KHR": { + "name": "Cambodian riel", + "symbol": "៛" + }, + "USD": { + "name": "United States dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "DOP": { + "name": "Dominican peso", + "symbol": "$" + } + } + }, + { + "currencies": { + "AUD": { + "name": "Australian dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "CAD": { + "name": "Canadian dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "PYG": { + "name": "Paraguayan guaraní", + "symbol": "₲" + } + } + }, + { + "currencies": { + "KZT": { + "name": "Kazakhstani tenge", + "symbol": "₸" + } + } + }, + { + "currencies": { + "IDR": { + "name": "Indonesian rupiah", + "symbol": "Rp" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "DZD": { + "name": "Algerian dinar", + "symbol": "دج" + }, + "MAD": { + "name": "Moroccan dirham", + "symbol": "DH" + }, + "MRU": { + "name": "Mauritanian ouguiya", + "symbol": "UM" + } + } + }, + { + "currencies": { + "KMF": { + "name": "Comorian franc", + "symbol": "Fr" + } + } + }, + { + "currencies": { + "XCD": { + "name": "Eastern Caribbean dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "BGN": { + "name": "Bulgarian lev", + "symbol": "лв" + } + } + }, + { + "currencies": { + "FKP": { + "name": "Falkland Islands pound", + "symbol": "£" + } + } + }, + { + "currencies": { + "HNL": { + "name": "Honduran lempira", + "symbol": "L" + } + } + }, + { + "currencies": { + "UYU": { + "name": "Uruguayan peso", + "symbol": "$" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "ETB": { + "name": "Ethiopian birr", + "symbol": "Br" + } + } + }, + { + "currencies": { + "TJS": { + "name": "Tajikistani somoni", + "symbol": "ЅМ" + } + } + }, + { + "currencies": { + "NZD": { + "name": "New Zealand dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "BWP": { + "name": "Botswana pula", + "symbol": "P" + } + } + }, + { + "currencies": { + "ALL": { + "name": "Albanian lek", + "symbol": "L" + } + } + }, + { + "currencies": { + "NGN": { + "name": "Nigerian naira", + "symbol": "₦" + } + } + }, + { + "currencies": { + "GMD": { + "name": "dalasi", + "symbol": "D" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "USD": { + "name": "United States dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "VUV": { + "name": "Vanuatu vatu", + "symbol": "Vt" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "CNY": { + "name": "Chinese yuan", + "symbol": "¥" + } + } + }, + { + "currencies": { + "SGD": { + "name": "Singapore dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "DKK": { + "name": "Danish krone", + "symbol": "kr" + } + } + }, + { + "currencies": { + "GHS": { + "name": "Ghanaian cedi", + "symbol": "₵" + } + } + }, + { + "currencies": { + "XCD": { + "name": "Eastern Caribbean dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "LRD": { + "name": "Liberian dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "HKD": { + "name": "Hong Kong dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "GTQ": { + "name": "Guatemalan quetzal", + "symbol": "Q" + } + } + }, + { + "currencies": { + "SLE": { + "name": "Leone", + "symbol": "Le" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "LBP": { + "name": "Lebanese pound", + "symbol": "ل.ل" + } + } + }, + { + "currencies": { + "SCR": { + "name": "Seychellois rupee", + "symbol": "₨" + } + } + }, + { + "currencies": { + "ZMW": { + "name": "Zambian kwacha", + "symbol": "ZK" + } + } + }, + { + "currencies": { + "VES": { + "name": "Venezuelan bolívar soberano", + "symbol": "Bs.S." + } + } + }, + { + "currencies": { + "SRD": { + "name": "Surinamese dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "UAH": { + "name": "Ukrainian hryvnia", + "symbol": "₴" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "ERN": { + "name": "Eritrean nakfa", + "symbol": "Nfk" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "PGK": { + "name": "Papua New Guinean kina", + "symbol": "K" + } + } + }, + { + "currencies": { + "USD": { + "name": "United States dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "BAM": { + "name": "Bosnia and Herzegovina convertible mark", + "symbol": "KM" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "LYD": { + "name": "Libyan dinar", + "symbol": "ل.د" + } + } + }, + { + "currencies": { + "QAR": { + "name": "Qatari riyal", + "symbol": "ر.ق" + } + } + }, + { + "currencies": { + "PKR": { + "name": "Pakistani rupee", + "symbol": "₨" + } + } + }, + { + "currencies": { + "KPW": { + "name": "North Korean won", + "symbol": "₩" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "GNF": { + "name": "Guinean franc", + "symbol": "Fr" + } + } + }, + { + "currencies": { + "XCD": { + "name": "Eastern Caribbean dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "BND": { + "name": "Brunei dollar", + "symbol": "$" + }, + "SGD": { + "name": "Singapore dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "MGA": { + "name": "Malagasy ariary", + "symbol": "Ar" + } + } + }, + { + "currencies": { + "AZN": { + "name": "Azerbaijani manat", + "symbol": "₼" + } + } + }, + { + "currencies": { + "EGP": { + "name": "Egyptian pound", + "symbol": "E£" + }, + "ILS": { + "name": "Israeli new shekel", + "symbol": "₪" + }, + "JOD": { + "name": "Jordanian dinar", + "symbol": "JD" + } + } + }, + { + "currencies": { + "XAF": { + "name": "Central African CFA franc", + "symbol": "Fr" + } + } + }, + { + "currencies": { + "XCD": { + "name": "Eastern Caribbean dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "BHD": { + "name": "Bahraini dinar", + "symbol": ".د.ب" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "GIP": { + "name": "Gibraltar pound", + "symbol": "£" + } + } + }, + { + "currencies": { + "USD": { + "name": "United States dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "MKD": { + "name": "denar", + "symbol": "den" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "NAD": { + "name": "Namibian dollar", + "symbol": "$" + }, + "ZAR": { + "name": "South African rand", + "symbol": "R" + } + } + }, + { + "currencies": { + "AUD": { + "name": "Australian dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "MAD": { + "name": "Moroccan dirham", + "symbol": "د.م." + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "BMD": { + "name": "Bermudian dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "NIO": { + "name": "Nicaraguan córdoba", + "symbol": "C$" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "BSD": { + "name": "Bahamian dollar", + "symbol": "$" + }, + "USD": { + "name": "United States dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "OMR": { + "name": "Omani rial", + "symbol": "ر.ع." + } + } + }, + { + "currencies": { + "CHF": { + "name": "Swiss franc", + "symbol": "Fr." + } + } + }, + { + "currencies": { + "CZK": { + "name": "Czech koruna", + "symbol": "Kč" + } + } + }, + { + "currencies": { + "ISK": { + "name": "Icelandic króna", + "symbol": "kr" + } + } + }, + { + "currencies": { + "JPY": { + "name": "Japanese yen", + "symbol": "¥" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "BZD": { + "name": "Belize dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "BBD": { + "name": "Barbadian dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "BYN": { + "name": "Belarusian ruble", + "symbol": "Br" + } + } + }, + { + "currencies": { + "USD": { + "name": "United States dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "UGX": { + "name": "Ugandan shilling", + "symbol": "Sh" + } + } + }, + { + "currencies": { + "YER": { + "name": "Yemeni rial", + "symbol": "﷼" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "HTG": { + "name": "Haitian gourde", + "symbol": "G" + } + } + }, + { + "currencies": { + "ARS": { + "name": "Argentine peso", + "symbol": "$" + } + } + }, + { + "currencies": { + "EGP": { + "name": "Egyptian pound", + "symbol": "£" + } + } + }, + { + "currencies": { + "KWD": { + "name": "Kuwaiti dinar", + "symbol": "د.ك" + } + } + }, + { + "currencies": { + "KYD": { + "name": "Cayman Islands dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "CKD": { + "name": "Cook Islands dollar", + "symbol": "$" + }, + "NZD": { + "name": "New Zealand dollar", + "symbol": "$" + } + } + }, + { + "currencies": {} + }, + { + "currencies": { + "XAF": { + "name": "Central African CFA franc", + "symbol": "Fr" + } + } + }, + { + "currencies": { + "LKR": { + "name": "Sri Lankan rupee", + "symbol": "Rs රු" + } + } + }, + { + "currencies": { + "TTD": { + "name": "Trinidad and Tobago dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "COP": { + "name": "Colombian peso", + "symbol": "$" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "AUD": { + "name": "Australian dollar", + "symbol": "$" + } + } + }, + { + "currencies": {} + }, + { + "currencies": { + "MOP": { + "name": "Macanese pataca", + "symbol": "P" + } + } + }, + { + "currencies": { + "MXN": { + "name": "Mexican peso", + "symbol": "$" + } + } + }, + { + "currencies": { + "XPF": { + "name": "CFP franc", + "symbol": "₣" + } + } + }, + { + "currencies": { + "SBD": { + "name": "Solomon Islands dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "EUR": { + "name": "Euro", + "symbol": "€" + } + } + }, + { + "currencies": { + "AUD": { + "name": "Australian dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "TOP": { + "name": "Tongan paʻanga", + "symbol": "T$" + } + } + }, + { + "currencies": { + "MYR": { + "name": "Malaysian ringgit", + "symbol": "RM" + } + } + }, + { + "currencies": { + "FJD": { + "name": "Fijian dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "JMD": { + "name": "Jamaican dollar", + "symbol": "$" + } + } + }, + { + "currencies": { + "USD": { + "name": "United States dollar", + "symbol": "$" + } + } + } +] diff --git a/backend/app/src/main/resources/init.sql b/backend/app/src/main/resources/init.sql index 85a0dfb..5779910 100644 --- a/backend/app/src/main/resources/init.sql +++ b/backend/app/src/main/resources/init.sql @@ -37,4 +37,10 @@ CREATE TABLE IF NOT EXISTS split_between ( group_uid UUID NOT NULL, member_uid UUID NOT NULL, PRIMARY KEY (expense_uid, member_uid) -- Composite primary key to ensure uniqueness +); + +CREATE TABLE IF NOT EXISTS currencies ( + iso_code VARCHAR(3) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + symbol VARCHAR(16) NOT NULL ); \ No newline at end of file diff --git a/backend/app/src/main/scala/com/github/ai/split/Layers.scala b/backend/app/src/main/scala/com/github/ai/split/Layers.scala index 722d06b..c1dd23b 100644 --- a/backend/app/src/main/scala/com/github/ai/split/Layers.scala +++ b/backend/app/src/main/scala/com/github/ai/split/Layers.scala @@ -1,39 +1,11 @@ package com.github.ai.split -import com.github.ai.split.data.db.dao.{ - ExpenseEntityDao, - GroupEntityDao, - GroupMemberEntityDao, - PaidByEntityDao, - SplitBetweenEntityDao, - UserEntityDao -} -import com.github.ai.split.data.db.repository.{ExpenseRepository, GroupRepository} +import com.github.ai.split.data.currency.CurrencyParser +import com.github.ai.split.data.db.dao.{CurrencyEntityDao, ExpenseEntityDao, GroupEntityDao, GroupMemberEntityDao, PaidByEntityDao, SplitBetweenEntityDao, UserEntityDao} +import com.github.ai.split.data.db.repository.{CurrencyRepository, ExpenseRepository, GroupRepository} import com.github.ai.split.domain.{AccessResolverService, AuthService, PasswordService} -import com.github.ai.split.domain.usecases.{ - AddExpenseUseCase, - AddGroupUseCase, - AddMembersUseCase, - AddUserUseCase, - AssembleExpenseUseCase, - AssembleGroupResponseUseCase, - AssembleGroupsResponseUseCase, - CalculateSettlementUseCase, - ConvertExpensesToTransactionsUseCase, - ExportGroupDataUseCase, - FillTestDataUseCase, - GetAllUsersUseCase, - GetGroupUseCase, - RemoveExpenseUseCase, - RemoveMembersUseCase, - ResolveUserReferencesUseCase, - UpdateExpenseUseCase, - UpdateGroupUseCase, - UpdateMemberUseCase, - ValidateExpenseUseCase, - ValidateMemberNameUseCase -} -import com.github.ai.split.presentation.controllers.{ExpenseController, GroupController, MemberController} +import com.github.ai.split.domain.usecases.{AddExpenseUseCase, AddGroupUseCase, AddMembersUseCase, AddUserUseCase, AssembleExpenseUseCase, AssembleGroupResponseUseCase, AssembleGroupsResponseUseCase, CalculateSettlementUseCase, ConvertExpensesToTransactionsUseCase, ExportGroupDataUseCase, FillCurrencyDataUseCase, FillTestDataUseCase, GetAllUsersUseCase, GetGroupUseCase, RemoveExpenseUseCase, RemoveMembersUseCase, ResolveUserReferencesUseCase, StartUpServerUseCase, UpdateExpenseUseCase, UpdateGroupUseCase, UpdateMemberUseCase, ValidateExpenseUseCase, ValidateMemberNameUseCase} +import com.github.ai.split.presentation.controllers.{CurrencyController, ExpenseController, GroupController, MemberController} import zio.{ZIO, ZLayer} object Layers { @@ -45,10 +17,12 @@ object Layers { val expenseDao = ZLayer.fromFunction(ExpenseEntityDao(_)) val paidByDao = ZLayer.fromFunction(PaidByEntityDao(_)) val splitBetweenDao = ZLayer.fromFunction(SplitBetweenEntityDao(_)) + val currencyDao = ZLayer.fromFunction(CurrencyEntityDao(_)) // Repositories val expenseRepository = ZLayer.fromFunction(ExpenseRepository(_, _, _)) val groupRepository = ZLayer.fromFunction(GroupRepository(_, _, _)) + val currencyRepository = ZLayer.fromFunction(CurrencyRepository(_)) // Services val passwordService = ZLayer.succeed(PasswordService()) @@ -73,6 +47,8 @@ object Layers { val removeExpenseUseCase = ZLayer.fromFunction(RemoveExpenseUseCase(_)) val exportGroupDataUseCase = ZLayer.fromFunction(ExportGroupDataUseCase(_, _)) val updateMemberUseCase = ZLayer.fromFunction(UpdateMemberUseCase(_, _, _, _)) + val startUpServerUseCase = ZLayer.fromFunction(StartUpServerUseCase(_, _, _)) + val fillCurrencyDataUseCase = ZLayer.fromFunction(FillCurrencyDataUseCase(_, _)) // Response use cases val assembleGroupResponseUseCase = ZLayer.fromFunction(AssembleGroupResponseUseCase(_, _, _, _, _, _)) @@ -83,4 +59,8 @@ object Layers { val groupController = ZLayer.fromFunction(GroupController(_, _, _, _, _, _, _, _, _, _)) val memberController = ZLayer.fromFunction(MemberController(_, _, _, _, _, _, _, _)) val expenseController = ZLayer.fromFunction(ExpenseController(_, _, _, _, _, _, _)) + val currencyController = ZLayer.fromFunction(CurrencyController(_)) + + // Other + val currencyParser = ZLayer.succeed(CurrencyParser()) } diff --git a/backend/app/src/main/scala/com/github/ai/split/Main.scala b/backend/app/src/main/scala/com/github/ai/split/Main.scala index 66904bf..f524146 100644 --- a/backend/app/src/main/scala/com/github/ai/split/Main.scala +++ b/backend/app/src/main/scala/com/github/ai/split/Main.scala @@ -1,9 +1,10 @@ package com.github.ai.split +import com.github.ai.split.data.currency.CurrencyParser import com.github.ai.split.domain.CliArgumentParser -import com.github.ai.split.domain.usecases.FillTestDataUseCase +import com.github.ai.split.domain.usecases.{FillTestDataUseCase, StartUpServerUseCase} import com.github.ai.split.entity.CliArguments -import com.github.ai.split.presentation.routes.{ExpenseRoutes, ExportRoutes, GroupRoutes, MemberRoutes} +import com.github.ai.split.presentation.routes.{CurrencyRoutes, ExpenseRoutes, ExportRoutes, GroupRoutes, MemberRoutes} import io.getquill.SnakeCase import io.getquill.jdbczio.Quill import zio.* @@ -17,6 +18,7 @@ object Main extends ZIOAppDefault { ++ ExportRoutes.routes() ++ MemberRoutes.routes() ++ ExpenseRoutes.routes() + ++ CurrencyRoutes.routes() override val bootstrap: ZLayer[Any, Nothing, Unit] = { Runtime.removeDefaultLoggers >>> SLF4J.slf4j(LogFormat.colored) @@ -24,16 +26,8 @@ object Main extends ZIOAppDefault { private def application() = { for { - fillTestDataUseCase <- ZIO.service[FillTestDataUseCase] - arguments <- ZIO.service[CliArguments] - - _ <- - if (arguments.isPopulateTestData) { - fillTestDataUseCase.createTestData() - } else { - ZIO.succeed(()) - } - + startupUseCase <- ZIO.service[StartUpServerUseCase] + _ <- startupUseCase.startUpServer() _ <- Server.serve(routes) } yield () } @@ -67,6 +61,8 @@ object Main extends ZIOAppDefault { Layers.removeExpenseUseCase, Layers.exportGroupDataUseCase, Layers.updateMemberUseCase, + Layers.startUpServerUseCase, + Layers.fillCurrencyDataUseCase, // Response assemblers use cases Layers.assembleGroupResponseUseCase, @@ -77,6 +73,7 @@ object Main extends ZIOAppDefault { Layers.memberController, Layers.groupController, Layers.expenseController, + Layers.currencyController, // Services Layers.passwordService, @@ -85,6 +82,7 @@ object Main extends ZIOAppDefault { // Repositories Layers.expenseRepository, Layers.groupRepository, + Layers.currencyRepository, // Dao Layers.expenseDao, @@ -93,8 +91,10 @@ object Main extends ZIOAppDefault { Layers.userDao, Layers.paidByDao, Layers.splitBetweenDao, + Layers.currencyDao, // Others + Layers.currencyParser, Server.defaultWithPort(8080), Quill.H2.fromNamingStrategy(SnakeCase), if (arguments.isUseInMemoryDatabase) { diff --git a/backend/app/src/main/scala/com/github/ai/split/data/currency/CurrencyParser.scala b/backend/app/src/main/scala/com/github/ai/split/data/currency/CurrencyParser.scala new file mode 100644 index 0000000..80429e2 --- /dev/null +++ b/backend/app/src/main/scala/com/github/ai/split/data/currency/CurrencyParser.scala @@ -0,0 +1,50 @@ +package com.github.ai.split.data.currency + +import com.github.ai.split.entity.db.CurrencyEntity +import com.github.ai.split.entity.exception.DomainError +import com.github.ai.split.utils.{Resources, parseJson, some} +import zio.* +import zio.direct.* +import zio.json.{DeriveJsonDecoder, JsonDecoder} + +class CurrencyParser { + + def parse(): IO[DomainError, List[CurrencyEntity]] = { + defer { + val jsonContent = Resources.readResourceAsString("/currencies.json").run + val items = jsonContent.parseJson[List[CurrencyInfo]].run + + items.flatMap { item => + val isoCode = item.currencies.keys.headOption + val nameAndSymbol = isoCode.flatMap { code => item.currencies.get(code) } + + if (isoCode.isDefined + && nameAndSymbol.isDefined + && !isoCode.get.isBlank + && !nameAndSymbol.get.name.isBlank + && !nameAndSymbol.get.symbol.isBlank) { + + CurrencyEntity( + name = nameAndSymbol.get.name.trim, + isoCode = isoCode.get.trim, + symbol = nameAndSymbol.get.symbol.trim + ).some + } else { + None + } + } + } + } + + case class CurrencyItem( + name: String, + symbol: String + ) + + case class CurrencyInfo( + currencies: Map[String, CurrencyItem] + ) + + implicit val infoDecoder: JsonDecoder[CurrencyInfo] = DeriveJsonDecoder.gen[CurrencyInfo] + implicit val itemDecoder: JsonDecoder[CurrencyItem] = DeriveJsonDecoder.gen[CurrencyItem] +} diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/CurrencyEntityDao.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/CurrencyEntityDao.scala new file mode 100644 index 0000000..af828f6 --- /dev/null +++ b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/CurrencyEntityDao.scala @@ -0,0 +1,111 @@ +package com.github.ai.split.data.db.dao + +import com.github.ai.split.entity.db.CurrencyEntity +import com.github.ai.split.entity.exception.DomainError +import com.github.ai.split.utils.toDomainError +import com.github.ai.split.utils.some +import io.getquill.jdbczio.Quill +import io.getquill.generic.* +import io.getquill.* +import zio.* + +class CurrencyEntityDao( + quill: Quill.H2[SnakeCase] +) { + + import quill._ + + def getAll(): IO[DomainError, List[CurrencyEntity]] = { + val query = quote { + querySchema[CurrencyEntity]("currencies") + } + + run(query) + .mapError(_.toDomainError()) + } + + def findByIsoCode(isoCode: String): IO[DomainError, Option[CurrencyEntity]] = { + val query = quote { + querySchema[CurrencyEntity]("currencies") + .filter(_.isoCode == lift(isoCode)) + } + + for { + currencies <- run(query).mapError(_.toDomainError()) + } yield currencies.headOption + } + + def getByIsoCode(isoCode: String): IO[DomainError, CurrencyEntity] = { + val query = quote { + querySchema[CurrencyEntity]("currencies") + .filter(_.isoCode == lift(isoCode)) + } + + for { + currencies <- run(query).mapError(_.toDomainError()) + currency <- + if (currencies.nonEmpty) { + ZIO.succeed(currencies.head) + } else { + ZIO.fail(DomainError(message = s"Failed to find currency by ISO code: $isoCode".some)) + } + } yield currency + } + + def getByIsoCodes(isoCodes: List[String]): IO[DomainError, List[CurrencyEntity]] = { + val isoCodeSet = isoCodes.toSet + + val query = quote { + querySchema[CurrencyEntity]("currencies") + .filter(currency => liftQuery(isoCodeSet).contains(currency.isoCode)) + } + + for { + currencies <- run(query).mapError(_.toDomainError()) + _ <- + if (currencies.size != isoCodes.size) { + val foundIsoCodes = currencies.map(_.isoCode).toSet + val notFoundIsoCodes = isoCodes.filterNot(foundIsoCodes.contains).mkString(", ") + ZIO.fail(DomainError(message = s"Failed to find currencies: $notFoundIsoCodes".some)) + } else { + ZIO.succeed(()) + } + } yield currencies + } + + def add(currency: CurrencyEntity): IO[DomainError, CurrencyEntity] = { + run( + quote { + querySchema[CurrencyEntity]("currencies") + .insertValue(lift(currency)) + } + ) + .map(_ => currency) + .mapError(_.toDomainError()) + } + + def addBatch(currencies: List[CurrencyEntity]): IO[DomainError, List[CurrencyEntity]] = { + run( + quote { + liftQuery(currencies).foreach(currency => + querySchema[CurrencyEntity]("currencies") + .insertValue(currency) + ) + } + ) + .map(_ => currencies) + .mapError(_.toDomainError()) + } + + def update(currency: CurrencyEntity): IO[DomainError, CurrencyEntity] = { + val updateQuery = quote { + querySchema[CurrencyEntity]("currencies") + .filter(_.isoCode == lift(currency.isoCode)) + .updateValue(lift(currency)) + } + + run(updateQuery) + .map(_ => currency) + .mapError(_.toDomainError()) + } +} diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/repository/CurrencyRepository.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/repository/CurrencyRepository.scala new file mode 100644 index 0000000..8ecfe88 --- /dev/null +++ b/backend/app/src/main/scala/com/github/ai/split/data/db/repository/CurrencyRepository.scala @@ -0,0 +1,18 @@ +package com.github.ai.split.data.db.repository + +import com.github.ai.split.data.db.dao.CurrencyEntityDao +import com.github.ai.split.entity.db.CurrencyEntity + +class CurrencyRepository( + private val dao: CurrencyEntityDao +) { + + def getAll() = + dao.getAll() + + def add(currency: CurrencyEntity) = + dao.add(currency) + + def update(currency: CurrencyEntity) = + dao.update(currency) +} diff --git a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/FillCurrencyDataUseCase.scala b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/FillCurrencyDataUseCase.scala new file mode 100644 index 0000000..57563d3 --- /dev/null +++ b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/FillCurrencyDataUseCase.scala @@ -0,0 +1,41 @@ +package com.github.ai.split.domain.usecases + +import com.github.ai.split.data.currency.CurrencyParser +import com.github.ai.split.data.db.repository.CurrencyRepository +import com.github.ai.split.entity.exception.DomainError +import zio.* +import zio.direct.* + +class FillCurrencyDataUseCase( + private val repository: CurrencyRepository, + private val currencyParser: CurrencyParser +) { + + def parseAndFillCurrencyData(): IO[DomainError, Unit] = { + defer { + val currencies = currencyParser + .parse() + .run + .distinctBy(currency => currency.isoCode) + + val existingCurrencies = repository + .getAll() + .run + .map(currency => (currency.isoCode, currency)) + .toMap + + for (newCurrency <- currencies) { + val existingCurrency = existingCurrencies.get(newCurrency.isoCode) + + if (existingCurrency.isDefined) { + val existing = existingCurrency.get + if (existing.name != newCurrency.name || existing.symbol != newCurrency.symbol) { + repository.update(newCurrency).run + } + } else { + repository.add(newCurrency).run + } + } + } + } +} diff --git a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/StartUpServerUseCase.scala b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/StartUpServerUseCase.scala new file mode 100644 index 0000000..230abe4 --- /dev/null +++ b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/StartUpServerUseCase.scala @@ -0,0 +1,25 @@ +package com.github.ai.split.domain.usecases + +import com.github.ai.split.entity.CliArguments +import com.github.ai.split.entity.exception.DomainError +import zio.* +import zio.direct.* + +class StartUpServerUseCase( + private val fillTestDataUseCase: FillTestDataUseCase, + private val fillCurrencyDataUseCase: FillCurrencyDataUseCase, + private val cliArguments: CliArguments +) { + + def startUpServer(): IO[DomainError, Unit] = { + defer { + if (cliArguments.isPopulateTestData) { + fillTestDataUseCase.createTestData().run + } + + fillCurrencyDataUseCase.parseAndFillCurrencyData().run + + ZIO.unit.run + } + } +} diff --git a/backend/app/src/main/scala/com/github/ai/split/entity/db/CurrencyEntity.scala b/backend/app/src/main/scala/com/github/ai/split/entity/db/CurrencyEntity.scala new file mode 100644 index 0000000..c15cf8a --- /dev/null +++ b/backend/app/src/main/scala/com/github/ai/split/entity/db/CurrencyEntity.scala @@ -0,0 +1,7 @@ +package com.github.ai.split.entity.db + +case class CurrencyEntity( + isoCode: String, + name: String, + symbol: String +) diff --git a/backend/app/src/main/scala/com/github/ai/split/entity/db/GroupEntity.scala b/backend/app/src/main/scala/com/github/ai/split/entity/db/GroupEntity.scala index de533f2..60005e6 100644 --- a/backend/app/src/main/scala/com/github/ai/split/entity/db/GroupEntity.scala +++ b/backend/app/src/main/scala/com/github/ai/split/entity/db/GroupEntity.scala @@ -1,7 +1,5 @@ package com.github.ai.split.entity.db -import com.github.ai.split.utils.UuidUtils.EMPTY_UID - case class GroupEntity( uid: GroupUid, title: String, diff --git a/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/CurrencyController.scala b/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/CurrencyController.scala new file mode 100644 index 0000000..a66f310 --- /dev/null +++ b/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/CurrencyController.scala @@ -0,0 +1,33 @@ +package com.github.ai.split.presentation.controllers + +import com.github.ai.split.api.CurrencyDto +import com.github.ai.split.api.response.GetCurrenciesResponse +import com.github.ai.split.data.db.repository.CurrencyRepository +import com.github.ai.split.entity.exception.DomainError +import zio.* +import zio.direct.* +import zio.http.Response +import zio.json.* + +class CurrencyController( + private val currencyRepository: CurrencyRepository +) { + + def getCurrencies(): IO[DomainError, Response] = { + defer { + val currencies = currencyRepository.getAll().run + + val response = GetCurrenciesResponse( + currencies.map { currency => + CurrencyDto( + isoCode = currency.isoCode, + name = currency.name, + symbol = currency.symbol + ) + } + ) + + Response.json(response.toJsonPretty) + } + } +} diff --git a/backend/app/src/main/scala/com/github/ai/split/presentation/routes/CurrencyRoutes.scala b/backend/app/src/main/scala/com/github/ai/split/presentation/routes/CurrencyRoutes.scala new file mode 100644 index 0000000..12497bb --- /dev/null +++ b/backend/app/src/main/scala/com/github/ai/split/presentation/routes/CurrencyRoutes.scala @@ -0,0 +1,18 @@ +package com.github.ai.split.presentation.routes + +import com.github.ai.split.utils.toDomainResponse +import com.github.ai.split.presentation.controllers.{CurrencyController} +import zio.ZIO +import zio.http.{Method, Request, Routes, handler} + +object CurrencyRoutes { + + def routes() = Routes( + Method.GET / "currency" -> handler { (request: Request) => + for { + controller <- ZIO.service[CurrencyController] + response <- controller.getCurrencies().mapError(_.toDomainResponse) + } yield response + }, + ) +} diff --git a/backend/app/src/main/scala/com/github/ai/split/utils/Resources.scala b/backend/app/src/main/scala/com/github/ai/split/utils/Resources.scala new file mode 100644 index 0000000..129f2db --- /dev/null +++ b/backend/app/src/main/scala/com/github/ai/split/utils/Resources.scala @@ -0,0 +1,18 @@ +package com.github.ai.split.utils + +import com.github.ai.split.entity.exception.DomainError +import zio.* + +import scala.io.Source + +object Resources { + + def readResourceAsString(path: String): IO[DomainError, String] = { + ZIO + .attempt { + val content = getClass.getResourceAsStream(path) + Source.fromInputStream(content).mkString + } + .mapError(error => DomainError(message = s"Failed to read resource: $path".some, cause = error.some)) + } +} diff --git a/backend/app/src/main/scala/com/github/ai/split/utils/StringExtensions.scala b/backend/app/src/main/scala/com/github/ai/split/utils/StringExtensions.scala index 78cc06c..53568dc 100644 --- a/backend/app/src/main/scala/com/github/ai/split/utils/StringExtensions.scala +++ b/backend/app/src/main/scala/com/github/ai/split/utils/StringExtensions.scala @@ -1,7 +1,21 @@ package com.github.ai.split.utils -extension (str: String) +import com.github.ai.split.entity.exception.DomainError +import zio.json.JsonDecoder +import zio.* +import zio.json.* + +extension (str: String) { def removeSuffixAfter(symbol: String): String = { val lastIndex = str.lastIndexOf(symbol) if lastIndex >= 0 then str.substring(0, lastIndex) else str } + + def parseJson[T](implicit decoder: JsonDecoder[T]): IO[DomainError, T] = { + ZIO.fromEither( + str.fromJson[T](using decoder) + .left + .map(error => new DomainError(message = error.some)) + ) + } +} \ No newline at end of file From dc942953cd2adee6b68a2a92848368dce60b4215 Mon Sep 17 00:00:00 2001 From: Aleksey Ivanovsky Date: Sat, 23 Aug 2025 15:20:54 +0200 Subject: [PATCH 2/4] [API] Rewrite api.sh to use sha256 code in order to have latest jar file --- backend/api.sh | 112 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 75 insertions(+), 37 deletions(-) diff --git a/backend/api.sh b/backend/api.sh index 261b8af..787eee6 100755 --- a/backend/api.sh +++ b/backend/api.sh @@ -1,21 +1,68 @@ #!/bin/bash # Script to run the simple-split-api-client.jar -# Checks if jar exists, assembles if needed, then runs with all passed arguments -# Use --rebuild to force rebuilding the jar file +# Automatically detects source changes using SHA256 checksums and rebuilds if necessary JAR_NAME="simple-split-api-client.jar" TARGET_DIR="api-client/target" +SOURCE_DIR="api-client/src/main/scala" +CHECKSUM_FILE="$TARGET_DIR/source.sha256" # Function to find the jar file find_jar() { find "$TARGET_DIR" -name "$JAR_NAME" -type f 2>/dev/null | head -1 } +# Function to calculate SHA256 of all source files +calculate_source_checksum() { + if [ ! -d "$SOURCE_DIR" ]; then + echo "Error: Source directory $SOURCE_DIR does not exist" + exit 1 + fi + + # Find all .scala files and calculate their combined SHA256 + find "$SOURCE_DIR" -name "*.scala" -type f -exec sha256sum {} \; | sort | sha256sum | cut -d' ' -f1 +} + +# Function to get stored checksum +get_stored_checksum() { + if [ -f "$CHECKSUM_FILE" ]; then + cat "$CHECKSUM_FILE" + else + echo "" + fi +} + +# Function to store checksum +store_checksum() { + local checksum="$1" + mkdir -p "$(dirname "$CHECKSUM_FILE")" + echo "$checksum" > "$CHECKSUM_FILE" +} + +# Function to check if source has changed +source_has_changed() { + local current_checksum=$(calculate_source_checksum) + local stored_checksum=$(get_stored_checksum) + + if [ "$current_checksum" != "$stored_checksum" ]; then + return 0 # true - source has changed + else + return 1 # false - source hasn't changed + fi +} + # Function to build the jar build_jar() { echo "Building jar file..." sbt apiClient/assembly -warn + + # Store the new checksum after successful build + if [ $? -eq 0 ]; then + local current_checksum=$(calculate_source_checksum) + store_checksum "$current_checksum" + echo "Source checksum updated." + fi } # Function to run the jar with arguments @@ -26,49 +73,40 @@ run_jar() { java -jar "$jar_path" "$@" } -# Check for --rebuild option -REBUILD=false -ARGS=() +# Main logic +JAR_PATH=$(find_jar) -for arg in "$@"; do - if [ "$arg" = "--rebuild" ]; then - REBUILD=true +if [ -n "$JAR_PATH" ]; then + # Jar exists, check if source has changed + if source_has_changed; then + echo "Source files have changed. Rebuilding jar..." + build_jar + + # Get the jar path again after rebuild + JAR_PATH=$(find_jar) + if [ -n "$JAR_PATH" ]; then + run_jar "$JAR_PATH" "$@" + else + echo "Error: Could not find jar file after rebuild" + exit 1 + fi else - ARGS+=("$arg") + echo "Source files unchanged. Using existing jar." + run_jar "$JAR_PATH" "$@" fi -done - -# If rebuild is requested, force rebuild -if [ "$REBUILD" = true ]; then - echo "Rebuild requested. Forcing jar rebuild..." +else + # Jar doesn't exist, build it + echo "Jar file not found. Compiling..." build_jar - JAR_PATH=$(find_jar) - if [ -n "$JAR_PATH" ]; then - run_jar "$JAR_PATH" "${ARGS[@]}" - else - echo "Error: Could not find jar file after rebuild" - exit 1 - fi -else - # Normal flow: check if jar exists + # Check again after assembly JAR_PATH=$(find_jar) if [ -n "$JAR_PATH" ]; then - run_jar "$JAR_PATH" "${ARGS[@]}" + echo "Assembly completed." + run_jar "$JAR_PATH" "$@" else - echo "Jar file not found. Compiling..." - build_jar - - # Check again after assembly - JAR_PATH=$(find_jar) - - if [ -n "$JAR_PATH" ]; then - echo "Assembly completed." - run_jar "$JAR_PATH" "${ARGS[@]}" - else - echo "Error: Could not find jar file even after assembly" - exit 1 - fi + echo "Error: Could not find jar file even after assembly" + exit 1 fi fi \ No newline at end of file From 6a95b7bce841f096c2aa558c66356c1e669fd71c Mon Sep 17 00:00:00 2001 From: Aleksey Ivanovsky Date: Sat, 23 Aug 2025 15:21:37 +0200 Subject: [PATCH 3/4] [BAKCEND] Implement currency in groups and expenses --- .../com/github/ai/split/api/ExpenseDto.kt | 1 + .../com/github/ai/split/api/GroupDto.kt | 1 + .../ai/split/api/request/PostGroupRequest.kt | 1 + .../ai/split/api/request/PutGroupRequest.kt | 1 + .../github/ai/split/client/ApiClient.scala | 1 + .../ai/split/client/ApiClientMain.scala | 9 ++- .../com/github/ai/split/api/CurrencyDto.scala | 7 +- .../com/github/ai/split/api/ExpenseDto.scala | 1 + .../com/github/ai/split/api/GroupDto.scala | 1 + .../split/api/request/PostGroupRequest.scala | 1 + .../split/api/request/PutGroupRequest.scala | 1 + .../api/response/GetCurrenciesResponse.scala | 2 +- backend/app/src/main/resources/init.sql | 3 +- .../scala/com/github/ai/split/Layers.scala | 57 ++++++++++++--- .../main/scala/com/github/ai/split/Main.scala | 1 + .../split/data/currency/CurrencyParser.scala | 6 +- .../split/data/db/dao/CurrencyEntityDao.scala | 4 +- .../db/repository/CurrencyRepository.scala | 31 ++++++--- .../data/db/repository/GroupRepository.scala | 69 ++++++++++++++++++- .../domain/usecases/AddGroupUseCase.scala | 9 ++- .../usecases/AssembleExpenseUseCase.scala | 5 +- .../AssembleGroupResponseUseCase.scala | 14 ++-- .../AssembleGroupsResponseUseCase.scala | 26 +++---- .../domain/usecases/FillTestDataUseCase.scala | 3 +- .../domain/usecases/UpdateGroupUseCase.scala | 22 +++++- .../usecases/ValidateCurrencyUseCase.scala | 15 ++++ .../ai/split/entity/GroupWithMembers.scala | 3 +- .../com/github/ai/split/entity/NewGroup.scala | 1 + .../ai/split/entity/db/GroupEntity.scala | 3 +- .../controllers/GroupController.scala | 6 +- .../presentation/routes/CurrencyRoutes.scala | 2 +- .../ai/split/utils/DataConverters.scala | 21 +++++- .../ai/split/utils/RequestExtensions.scala | 16 +++-- .../ai/split/utils/StringExtensions.scala | 5 +- 34 files changed, 279 insertions(+), 70 deletions(-) create mode 100644 backend/app/src/main/scala/com/github/ai/split/domain/usecases/ValidateCurrencyUseCase.scala diff --git a/android/backend-api/src/main/kotlin/com/github/ai/split/api/ExpenseDto.kt b/android/backend-api/src/main/kotlin/com/github/ai/split/api/ExpenseDto.kt index c215efe..b5d6f70 100644 --- a/android/backend-api/src/main/kotlin/com/github/ai/split/api/ExpenseDto.kt +++ b/android/backend-api/src/main/kotlin/com/github/ai/split/api/ExpenseDto.kt @@ -8,6 +8,7 @@ data class ExpenseDto( val title: String, val description: String?, val amount: Double, + val currency: CurrencyDto, val paidBy: List, val splitBetween: List ) \ No newline at end of file diff --git a/android/backend-api/src/main/kotlin/com/github/ai/split/api/GroupDto.kt b/android/backend-api/src/main/kotlin/com/github/ai/split/api/GroupDto.kt index 0334792..2644a05 100644 --- a/android/backend-api/src/main/kotlin/com/github/ai/split/api/GroupDto.kt +++ b/android/backend-api/src/main/kotlin/com/github/ai/split/api/GroupDto.kt @@ -7,6 +7,7 @@ data class GroupDto( val uid: String, val title: String, val description: String, + val currency: CurrencyDto, val members: List, val expenses: List, val paybackTransactions: List diff --git a/android/backend-api/src/main/kotlin/com/github/ai/split/api/request/PostGroupRequest.kt b/android/backend-api/src/main/kotlin/com/github/ai/split/api/request/PostGroupRequest.kt index 136443d..78611ef 100644 --- a/android/backend-api/src/main/kotlin/com/github/ai/split/api/request/PostGroupRequest.kt +++ b/android/backend-api/src/main/kotlin/com/github/ai/split/api/request/PostGroupRequest.kt @@ -9,6 +9,7 @@ data class PostGroupRequest( val password: String, val title: String, val description: String?, + val currencyIsoCode: String, val members: List?, val expenses: List? ) \ No newline at end of file diff --git a/android/backend-api/src/main/kotlin/com/github/ai/split/api/request/PutGroupRequest.kt b/android/backend-api/src/main/kotlin/com/github/ai/split/api/request/PutGroupRequest.kt index ec24bf8..f822ec8 100644 --- a/android/backend-api/src/main/kotlin/com/github/ai/split/api/request/PutGroupRequest.kt +++ b/android/backend-api/src/main/kotlin/com/github/ai/split/api/request/PutGroupRequest.kt @@ -8,5 +8,6 @@ data class PutGroupRequest( val title: String?, val password: String?, val description: String?, + val currencyIsoCode: String?, val members: List? ) \ No newline at end of file diff --git a/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClient.scala b/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClient.scala index 1b78caa..013bb5e 100644 --- a/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClient.scala +++ b/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClient.scala @@ -43,6 +43,7 @@ class ApiClient( password = DefaultPassword, title = "Oktoberfest", description = Some("Amazing party"), + currencyIsoCode = "USD", members = Some(List("Bob", "Alan").map(UserNameDto(_))), expenses = Some( List( diff --git a/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClientMain.scala b/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClientMain.scala index 371bd74..7b7e67c 100644 --- a/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClientMain.scala +++ b/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClientMain.scala @@ -29,6 +29,7 @@ object ApiClientMain extends ZIOAppDefault { |""".stripMargin class InvalidCliArgumentException(message: String) extends Exception(message) + class EmptyCliArgumentException extends InvalidCliArgumentException("Empty arguments") override def run: ZIO[ZIOAppArgs, Any, ExitCode] = { val application = for { @@ -45,7 +46,9 @@ object ApiClientMain extends ZIOAppDefault { application .catchAll { error => defer { - Console.printLine(s"Error: $error").run + if (!error.isInstanceOf[EmptyCliArgumentException]) { + Console.printLine(s"Error: $error").run + } if (error.isInstanceOf[InvalidCliArgumentException]) { Console.printLine(HelpText).run @@ -60,6 +63,10 @@ object ApiClientMain extends ZIOAppDefault { val api = ZIO.service[ApiClient].run val printer = ZIO.service[Printer].run + if (arguments.isBlank) { + ZIO.fail(EmptyCliArgumentException()).run + } + val response = arguments match { case "group" => api.getGroup(uid = Groups.TripToDisneyLand).run case s"group $groupUid" => api.getGroup(uid = groupUid).run diff --git a/backend/api/src/main/scala/com/github/ai/split/api/CurrencyDto.scala b/backend/api/src/main/scala/com/github/ai/split/api/CurrencyDto.scala index d07fcd0..0f2f74f 100644 --- a/backend/api/src/main/scala/com/github/ai/split/api/CurrencyDto.scala +++ b/backend/api/src/main/scala/com/github/ai/split/api/CurrencyDto.scala @@ -1,6 +1,6 @@ package com.github.ai.split.api -import zio.json.{DeriveJsonEncoder, JsonEncoder} +import zio.json.{DeriveJsonDecoder, DeriveJsonEncoder, JsonDecoder, JsonEncoder} case class CurrencyDto( isoCode: String, @@ -9,5 +9,6 @@ case class CurrencyDto( ) object CurrencyDto { - implicit val encoder: JsonEncoder[CurrencyDto] = DeriveJsonEncoder.gen[CurrencyDto] -} \ No newline at end of file + implicit val encoder: JsonEncoder[CurrencyDto] = DeriveJsonEncoder.gen[CurrencyDto] + implicit val decoder: JsonDecoder[CurrencyDto] = DeriveJsonDecoder.gen[CurrencyDto] +} diff --git a/backend/api/src/main/scala/com/github/ai/split/api/ExpenseDto.scala b/backend/api/src/main/scala/com/github/ai/split/api/ExpenseDto.scala index 947aa93..5efcebc 100644 --- a/backend/api/src/main/scala/com/github/ai/split/api/ExpenseDto.scala +++ b/backend/api/src/main/scala/com/github/ai/split/api/ExpenseDto.scala @@ -7,6 +7,7 @@ case class ExpenseDto( title: String, description: Option[String], amount: Double, + currency: CurrencyDto, paidBy: List[MemberDto], splitBetween: List[MemberDto] ) diff --git a/backend/api/src/main/scala/com/github/ai/split/api/GroupDto.scala b/backend/api/src/main/scala/com/github/ai/split/api/GroupDto.scala index 30c72a6..3059ce1 100644 --- a/backend/api/src/main/scala/com/github/ai/split/api/GroupDto.scala +++ b/backend/api/src/main/scala/com/github/ai/split/api/GroupDto.scala @@ -6,6 +6,7 @@ case class GroupDto( uid: String, title: String, description: String, + currency: CurrencyDto, members: List[MemberDto], expenses: List[ExpenseDto], paybackTransactions: List[TransactionDto] diff --git a/backend/api/src/main/scala/com/github/ai/split/api/request/PostGroupRequest.scala b/backend/api/src/main/scala/com/github/ai/split/api/request/PostGroupRequest.scala index 7268fbc..c78ee25 100644 --- a/backend/api/src/main/scala/com/github/ai/split/api/request/PostGroupRequest.scala +++ b/backend/api/src/main/scala/com/github/ai/split/api/request/PostGroupRequest.scala @@ -7,6 +7,7 @@ case class PostGroupRequest( password: String, title: String, description: Option[String], + currencyIsoCode: String, members: Option[List[UserNameDto]], expenses: Option[List[NewExpenseDto]] ) diff --git a/backend/api/src/main/scala/com/github/ai/split/api/request/PutGroupRequest.scala b/backend/api/src/main/scala/com/github/ai/split/api/request/PutGroupRequest.scala index bcd5ef4..d33c29b 100644 --- a/backend/api/src/main/scala/com/github/ai/split/api/request/PutGroupRequest.scala +++ b/backend/api/src/main/scala/com/github/ai/split/api/request/PutGroupRequest.scala @@ -7,6 +7,7 @@ case class PutGroupRequest( title: Option[String], password: Option[String], description: Option[String], + currencyIsoCode: Option[String], members: Option[List[UserUidDto]] ) diff --git a/backend/api/src/main/scala/com/github/ai/split/api/response/GetCurrenciesResponse.scala b/backend/api/src/main/scala/com/github/ai/split/api/response/GetCurrenciesResponse.scala index 86331e3..ea9735b 100644 --- a/backend/api/src/main/scala/com/github/ai/split/api/response/GetCurrenciesResponse.scala +++ b/backend/api/src/main/scala/com/github/ai/split/api/response/GetCurrenciesResponse.scala @@ -9,4 +9,4 @@ case class GetCurrenciesResponse( object GetCurrenciesResponse { implicit val encoder: JsonEncoder[GetCurrenciesResponse] = DeriveJsonEncoder.gen[GetCurrenciesResponse] -} \ No newline at end of file +} diff --git a/backend/app/src/main/resources/init.sql b/backend/app/src/main/resources/init.sql index 5779910..ec94a01 100644 --- a/backend/app/src/main/resources/init.sql +++ b/backend/app/src/main/resources/init.sql @@ -7,7 +7,8 @@ CREATE TABLE IF NOT EXISTS groups ( uid UUID PRIMARY KEY, title VARCHAR(255) NOT NULL, description VARCHAR(255) NOT NULL, - password_hash VARCHAR(255) + password_hash VARCHAR(255), + currency_iso_code VARCHAR(3) NOT NULL ); CREATE TABLE IF NOT EXISTS group_members ( diff --git a/backend/app/src/main/scala/com/github/ai/split/Layers.scala b/backend/app/src/main/scala/com/github/ai/split/Layers.scala index c1dd23b..842f4fe 100644 --- a/backend/app/src/main/scala/com/github/ai/split/Layers.scala +++ b/backend/app/src/main/scala/com/github/ai/split/Layers.scala @@ -1,11 +1,49 @@ package com.github.ai.split import com.github.ai.split.data.currency.CurrencyParser -import com.github.ai.split.data.db.dao.{CurrencyEntityDao, ExpenseEntityDao, GroupEntityDao, GroupMemberEntityDao, PaidByEntityDao, SplitBetweenEntityDao, UserEntityDao} +import com.github.ai.split.data.db.dao.{ + CurrencyEntityDao, + ExpenseEntityDao, + GroupEntityDao, + GroupMemberEntityDao, + PaidByEntityDao, + SplitBetweenEntityDao, + UserEntityDao +} import com.github.ai.split.data.db.repository.{CurrencyRepository, ExpenseRepository, GroupRepository} import com.github.ai.split.domain.{AccessResolverService, AuthService, PasswordService} -import com.github.ai.split.domain.usecases.{AddExpenseUseCase, AddGroupUseCase, AddMembersUseCase, AddUserUseCase, AssembleExpenseUseCase, AssembleGroupResponseUseCase, AssembleGroupsResponseUseCase, CalculateSettlementUseCase, ConvertExpensesToTransactionsUseCase, ExportGroupDataUseCase, FillCurrencyDataUseCase, FillTestDataUseCase, GetAllUsersUseCase, GetGroupUseCase, RemoveExpenseUseCase, RemoveMembersUseCase, ResolveUserReferencesUseCase, StartUpServerUseCase, UpdateExpenseUseCase, UpdateGroupUseCase, UpdateMemberUseCase, ValidateExpenseUseCase, ValidateMemberNameUseCase} -import com.github.ai.split.presentation.controllers.{CurrencyController, ExpenseController, GroupController, MemberController} +import com.github.ai.split.domain.usecases.{ + AddExpenseUseCase, + AddGroupUseCase, + AddMembersUseCase, + AddUserUseCase, + AssembleExpenseUseCase, + AssembleGroupResponseUseCase, + AssembleGroupsResponseUseCase, + CalculateSettlementUseCase, + ConvertExpensesToTransactionsUseCase, + ExportGroupDataUseCase, + FillCurrencyDataUseCase, + FillTestDataUseCase, + GetAllUsersUseCase, + GetGroupUseCase, + RemoveExpenseUseCase, + RemoveMembersUseCase, + ResolveUserReferencesUseCase, + StartUpServerUseCase, + UpdateExpenseUseCase, + UpdateGroupUseCase, + UpdateMemberUseCase, + ValidateCurrencyUseCase, + ValidateExpenseUseCase, + ValidateMemberNameUseCase +} +import com.github.ai.split.presentation.controllers.{ + CurrencyController, + ExpenseController, + GroupController, + MemberController +} import zio.{ZIO, ZLayer} object Layers { @@ -21,8 +59,8 @@ object Layers { // Repositories val expenseRepository = ZLayer.fromFunction(ExpenseRepository(_, _, _)) - val groupRepository = ZLayer.fromFunction(GroupRepository(_, _, _)) - val currencyRepository = ZLayer.fromFunction(CurrencyRepository(_)) + val groupRepository = ZLayer.fromFunction(GroupRepository(_, _, _, _)) + val currencyRepository = ZLayer.fromFunction(CurrencyRepository(_, _)) // Services val passwordService = ZLayer.succeed(PasswordService()) @@ -31,14 +69,14 @@ object Layers { // Use cases val addUserUseCase = ZLayer.fromFunction(AddUserUseCase(_)) val getAllUsersUseCase = ZLayer.fromFunction(GetAllUsersUseCase(_)) - val addGroupUseCase = ZLayer.fromFunction(AddGroupUseCase(_, _, _, _, _, _, _)) + val addGroupUseCase = ZLayer.fromFunction(AddGroupUseCase(_, _, _, _, _, _, _, _)) val getGroupByUidUseCase = ZLayer.fromFunction(GetGroupUseCase(_, _)) val addMemberUseCase = ZLayer.fromFunction(AddMembersUseCase(_, _, _, _, _)) val addExpenseUseCase = ZLayer.fromFunction(AddExpenseUseCase(_, _, _, _, _, _, _)) val convertToTransactionsUseCase = ZLayer.succeed(ConvertExpensesToTransactionsUseCase()) val calculateSettlementUseCase = ZLayer.succeed(CalculateSettlementUseCase()) val fillTestDataUseCase = ZLayer.fromFunction(FillTestDataUseCase(_, _, _, _, _, _, _, _)) - val updateGroupUseCase = ZLayer.fromFunction(UpdateGroupUseCase(_, _, _, _, _, _, _, _)) + val updateGroupUseCase = ZLayer.fromFunction(UpdateGroupUseCase(_, _, _, _, _, _, _, _, _)) val updateExpenseUseCase = ZLayer.fromFunction(UpdateExpenseUseCase(_, _, _, _, _, _, _)) val removeMembersUseCase = ZLayer.fromFunction(RemoveMembersUseCase(_, _, _, _)) val resolveUserReferencesUseCase = ZLayer.fromFunction(ResolveUserReferencesUseCase(_)) @@ -49,11 +87,12 @@ object Layers { val updateMemberUseCase = ZLayer.fromFunction(UpdateMemberUseCase(_, _, _, _)) val startUpServerUseCase = ZLayer.fromFunction(StartUpServerUseCase(_, _, _)) val fillCurrencyDataUseCase = ZLayer.fromFunction(FillCurrencyDataUseCase(_, _)) + val validateCurrencyUseCase = ZLayer.fromFunction(ValidateCurrencyUseCase(_)) // Response use cases val assembleGroupResponseUseCase = ZLayer.fromFunction(AssembleGroupResponseUseCase(_, _, _, _, _, _)) - val assembleGroupsResponseUseCase = ZLayer.fromFunction(AssembleGroupsResponseUseCase(_, _, _, _, _, _, _, _)) - val assembleExpenseUseCase = ZLayer.fromFunction(AssembleExpenseUseCase(_, _, _)) + val assembleGroupsResponseUseCase = ZLayer.fromFunction(AssembleGroupsResponseUseCase(_, _, _, _, _, _, _, _, _)) + val assembleExpenseUseCase = ZLayer.fromFunction(AssembleExpenseUseCase(_, _, _, _)) // Controllers val groupController = ZLayer.fromFunction(GroupController(_, _, _, _, _, _, _, _, _, _)) diff --git a/backend/app/src/main/scala/com/github/ai/split/Main.scala b/backend/app/src/main/scala/com/github/ai/split/Main.scala index f524146..ef880a9 100644 --- a/backend/app/src/main/scala/com/github/ai/split/Main.scala +++ b/backend/app/src/main/scala/com/github/ai/split/Main.scala @@ -63,6 +63,7 @@ object Main extends ZIOAppDefault { Layers.updateMemberUseCase, Layers.startUpServerUseCase, Layers.fillCurrencyDataUseCase, + Layers.validateCurrencyUseCase, // Response assemblers use cases Layers.assembleGroupResponseUseCase, diff --git a/backend/app/src/main/scala/com/github/ai/split/data/currency/CurrencyParser.scala b/backend/app/src/main/scala/com/github/ai/split/data/currency/CurrencyParser.scala index 80429e2..9d05145 100644 --- a/backend/app/src/main/scala/com/github/ai/split/data/currency/CurrencyParser.scala +++ b/backend/app/src/main/scala/com/github/ai/split/data/currency/CurrencyParser.scala @@ -18,11 +18,13 @@ class CurrencyParser { val isoCode = item.currencies.keys.headOption val nameAndSymbol = isoCode.flatMap { code => item.currencies.get(code) } - if (isoCode.isDefined + if ( + isoCode.isDefined && nameAndSymbol.isDefined && !isoCode.get.isBlank && !nameAndSymbol.get.name.isBlank - && !nameAndSymbol.get.symbol.isBlank) { + && !nameAndSymbol.get.symbol.isBlank + ) { CurrencyEntity( name = nameAndSymbol.get.name.trim, diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/CurrencyEntityDao.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/CurrencyEntityDao.scala index af828f6..244b5c0 100644 --- a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/CurrencyEntityDao.scala +++ b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/CurrencyEntityDao.scala @@ -63,9 +63,9 @@ class CurrencyEntityDao( for { currencies <- run(query).mapError(_.toDomainError()) _ <- - if (currencies.size != isoCodes.size) { + if (currencies.size != isoCodeSet.size) { val foundIsoCodes = currencies.map(_.isoCode).toSet - val notFoundIsoCodes = isoCodes.filterNot(foundIsoCodes.contains).mkString(", ") + val notFoundIsoCodes = isoCodeSet.diff(foundIsoCodes).mkString(", ") ZIO.fail(DomainError(message = s"Failed to find currencies: $notFoundIsoCodes".some)) } else { ZIO.succeed(()) diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/repository/CurrencyRepository.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/repository/CurrencyRepository.scala index 8ecfe88..0ca8913 100644 --- a/backend/app/src/main/scala/com/github/ai/split/data/db/repository/CurrencyRepository.scala +++ b/backend/app/src/main/scala/com/github/ai/split/data/db/repository/CurrencyRepository.scala @@ -1,18 +1,31 @@ package com.github.ai.split.data.db.repository -import com.github.ai.split.data.db.dao.CurrencyEntityDao -import com.github.ai.split.entity.db.CurrencyEntity +import com.github.ai.split.data.db.dao.{CurrencyEntityDao, GroupEntityDao} +import com.github.ai.split.entity.db.{CurrencyEntity, GroupUid} +import com.github.ai.split.entity.exception.DomainError +import zio.{IO, *} +import zio.direct.* class CurrencyRepository( - private val dao: CurrencyEntityDao + private val currencyDao: CurrencyEntityDao, + private val groupDao: GroupEntityDao ) { - def getAll() = - dao.getAll() + def getAll(): IO[DomainError, List[CurrencyEntity]] = + currencyDao.getAll() - def add(currency: CurrencyEntity) = - dao.add(currency) + def add(currency: CurrencyEntity): IO[DomainError, CurrencyEntity] = + currencyDao.add(currency) - def update(currency: CurrencyEntity) = - dao.update(currency) + def update(currency: CurrencyEntity): IO[DomainError, CurrencyEntity] = + currencyDao.update(currency) + + def getByIsoCode(isoCode: String): IO[DomainError, CurrencyEntity] = + currencyDao.getByIsoCode(isoCode) + + def getByGroupUid(groupUid: GroupUid): IO[DomainError, CurrencyEntity] = + defer { + val group = groupDao.getByUid(groupUid).run + currencyDao.getByIsoCode(group.currencyIsoCode).run + } } diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/repository/GroupRepository.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/repository/GroupRepository.scala index afdb8c6..e9784dc 100644 --- a/backend/app/src/main/scala/com/github/ai/split/data/db/repository/GroupRepository.scala +++ b/backend/app/src/main/scala/com/github/ai/split/data/db/repository/GroupRepository.scala @@ -1,24 +1,89 @@ package com.github.ai.split.data.db.repository -import com.github.ai.split.data.db.dao.{GroupEntityDao, GroupMemberEntityDao, UserEntityDao} +import com.github.ai.split.data.db.dao.{CurrencyEntityDao, GroupEntityDao, GroupMemberEntityDao, UserEntityDao} import com.github.ai.split.entity.db.GroupUid import com.github.ai.split.entity.{GroupWithMembers, Member} import com.github.ai.split.entity.exception.DomainError import zio.* import zio.direct.* +import scala.collection.mutable.ListBuffer + class GroupRepository( private val userDao: UserEntityDao, private val groupDao: GroupEntityDao, - private val groupMemberDao: GroupMemberEntityDao + private val groupMemberDao: GroupMemberEntityDao, + private val currencyDao: CurrencyEntityDao ) { + def getByUids(uids: List[GroupUid]): IO[DomainError, List[GroupWithMembers]] = { + defer { + val groups = groupDao.getByUids(uids).run + + val currencyIsoCodeToCurrencyMap = currencyDao + .getByIsoCodes(isoCodes = groups.map(_.currencyIsoCode)) + .run + .map(currency => (currency.isoCode, currency)) + .toMap + + val groupUidToMembersMap = getMembersByGroupsUids(groupUids = uids).run.toMap + + groups.map { group => + GroupWithMembers( + entity = group, + currency = currencyIsoCodeToCurrencyMap(group.currencyIsoCode), + members = groupUidToMembersMap(group.uid) + ) + } + } + } + + private def getMembersByGroupsUids( + groupUids: List[GroupUid] + ): IO[DomainError, List[(GroupUid, List[Member])]] = { + defer { + val uidsAndMembers = ZIO + .collectAll( + groupUids + .map { groupUid => + groupMemberDao + .getByGroupUid(groupUid = groupUid) + .map(members => (groupUid, members)) + } + ) + .run + + val userUids = uidsAndMembers + .flatMap((_, members) => members.map(_.userUid)) + .distinct + + val userUidToUserMap = userDao + .getByUids(userUids) + .run + .map(user => (user.uid, user)) + .toMap + + uidsAndMembers.map { (groupUid, members) => + val membersWithUsers = members.map { member => + Member( + user = userUidToUserMap(member.userUid), + entity = member + ) + } + + (groupUid, membersWithUsers) + } + } + } + def getByUid(groupUid: GroupUid): IO[DomainError, GroupWithMembers] = { defer { val group = groupDao.getByUid(groupUid).run + val currency = currencyDao.getByIsoCode(group.currencyIsoCode).run GroupWithMembers( entity = group, + currency = currency, members = getMembers(groupUid).run ) } diff --git a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AddGroupUseCase.scala b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AddGroupUseCase.scala index 27a2d9f..4adafe1 100644 --- a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AddGroupUseCase.scala +++ b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AddGroupUseCase.scala @@ -2,6 +2,7 @@ package com.github.ai.split.domain.usecases import com.github.ai.split.entity.db.{GroupEntity, GroupMemberEntity, GroupUid, MemberUid} import com.github.ai.split.data.db.dao.{GroupEntityDao, GroupMemberEntityDao} +import com.github.ai.split.data.db.repository.CurrencyRepository import com.github.ai.split.domain.PasswordService import com.github.ai.split.entity.{NewExpense, NewGroup} import com.github.ai.split.entity.exception.DomainError @@ -18,7 +19,8 @@ class AddGroupUseCase( private val addMemberUserCase: AddMembersUseCase, private val addExpenseUseCase: AddExpenseUseCase, private val validateMemberUseCase: ValidateMemberNameUseCase, - private val validateExpenseUseCase: ValidateExpenseUseCase + private val validateExpenseUseCase: ValidateExpenseUseCase, + private val validateCurrencyUseCase: ValidateCurrencyUseCase ) { def addGroup( @@ -39,7 +41,8 @@ class AddGroupUseCase( passwordService.hashPassword(newGroup.password).some } else { None - } + }, + currencyIsoCode = newGroup.currencyIsoCode ) ) .run @@ -84,6 +87,8 @@ class AddGroupUseCase( ZIO.fail(DomainError(message = "Group title is empty".some)).run } + validateCurrencyUseCase.isCurrencyIsoCodeValid(newGroup.currencyIsoCode).run + if (newGroup.members.nonEmpty) { validateMemberUseCase .validateNewMembers( diff --git a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AssembleExpenseUseCase.scala b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AssembleExpenseUseCase.scala index 1956c7b..f6f5cfc 100644 --- a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AssembleExpenseUseCase.scala +++ b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AssembleExpenseUseCase.scala @@ -2,7 +2,7 @@ package com.github.ai.split.domain.usecases import com.github.ai.split.data.db.dao.GroupMemberEntityDao import com.github.ai.split.api.ExpenseDto -import com.github.ai.split.data.db.repository.ExpenseRepository +import com.github.ai.split.data.db.repository.{CurrencyRepository, ExpenseRepository} import com.github.ai.split.entity.db.ExpenseUid import com.github.ai.split.utils.toExpenseDto import com.github.ai.split.entity.exception.DomainError @@ -12,6 +12,7 @@ import java.util.UUID class AssembleExpenseUseCase( private val expenseRepository: ExpenseRepository, + private val currencyRepository: CurrencyRepository, private val groupMemberDao: GroupMemberEntityDao, private val getAllUsersUseCase: GetAllUsersUseCase ) { @@ -23,8 +24,10 @@ class AssembleExpenseUseCase( expense <- expenseRepository.getByUid(expenseUid) members <- groupMemberDao.getByGroupUid(groupUid = expense.entity.groupUid) userUidToUserMap <- getAllUsersUseCase.getUserUidToUserMap() + currency <- currencyRepository.getByGroupUid(groupUid = expense.entity.groupUid) dto <- toExpenseDto( expense = expense, + currency = currency, members = members, userUidToUserMap = userUidToUserMap ) diff --git a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AssembleGroupResponseUseCase.scala b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AssembleGroupResponseUseCase.scala index a14344e..e046b58 100644 --- a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AssembleGroupResponseUseCase.scala +++ b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AssembleGroupResponseUseCase.scala @@ -3,7 +3,7 @@ package com.github.ai.split.domain.usecases import com.github.ai.split.data.db.dao.{GroupEntityDao, GroupMemberEntityDao} import com.github.ai.split.api.GroupDto import com.github.ai.split.entity.db.GroupUid -import com.github.ai.split.data.db.repository.ExpenseRepository +import com.github.ai.split.data.db.repository.{CurrencyRepository, ExpenseRepository, GroupRepository} import com.github.ai.split.entity.exception.DomainError import zio.* import com.github.ai.split.utils.* @@ -12,8 +12,8 @@ import java.util.UUID class AssembleGroupResponseUseCase( private val expenseRepository: ExpenseRepository, - private val groupDao: GroupEntityDao, - private val groupMemberDao: GroupMemberEntityDao, + private val currencyRepository: CurrencyRepository, + private val groupRepository: GroupRepository, private val getAllUsersUseCase: GetAllUsersUseCase, private val convertExpensesUseCase: ConvertExpensesToTransactionsUseCase, private val calculateSettlementUseCase: CalculateSettlementUseCase @@ -24,17 +24,19 @@ class AssembleGroupResponseUseCase( ): IO[DomainError, GroupDto] = { for { userUidToUserMap <- getAllUsersUseCase.getUserUidToUserMap() - group <- groupDao.getByUid(groupUid) - members <- groupMemberDao.getByGroupUid(groupUid) + group <- groupRepository.getByUid(groupUid) expenses <- expenseRepository.getByGroupUid(groupUid) dto <- { + val members = group.members.map(_.entity) + val transactions = convertExpensesUseCase.convertToTransactions( expenses = expenses, members = members.map(member => member.uid) ) toGroupDto( - group = group, + group = group.entity, + currency = group.currency, members = members, expenses = expenses, userUidToUserMap = userUidToUserMap, diff --git a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AssembleGroupsResponseUseCase.scala b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AssembleGroupsResponseUseCase.scala index 6e781db..466df36 100644 --- a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AssembleGroupsResponseUseCase.scala +++ b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AssembleGroupsResponseUseCase.scala @@ -2,7 +2,7 @@ package com.github.ai.split.domain.usecases import com.github.ai.split.data.db.dao.{GroupEntityDao, GroupMemberEntityDao, PaidByEntityDao, SplitBetweenEntityDao} import com.github.ai.split.api.GroupDto -import com.github.ai.split.data.db.repository.ExpenseRepository +import com.github.ai.split.data.db.repository.{ExpenseRepository, GroupRepository} import com.github.ai.split.entity.db.GroupUid import com.github.ai.split.entity.exception.DomainError import com.github.ai.split.utils.toGroupDto @@ -11,6 +11,7 @@ import zio.* import java.util.UUID class AssembleGroupsResponseUseCase( + private val groupRepository: GroupRepository, private val expenseRepository: ExpenseRepository, private val groupDao: GroupEntityDao, private val groupMemberDao: GroupMemberEntityDao, @@ -25,15 +26,14 @@ class AssembleGroupsResponseUseCase( uids: List[GroupUid] ): IO[DomainError, List[GroupDto]] = { for { - userUidToUserMap <- getAllUsersUseCase.getUserUidToUserMap() // TODO: Optimize DB querying - groups <- groupDao.getByUids(uids) - allMembers <- groupMemberDao.getAll() + userUidToUserMap <- getAllUsersUseCase.getUserUidToUserMap() + groups <- groupRepository.getByUids(uids) allExpenses <- expenseRepository.getEntitiesByGroupUids(uids) allPaidBy <- paidByDao.getAll() allSplitBetween <- splitBetweenDao.getAll() + data <- { - val groupUidToMembersMap = allMembers.groupBy(_.groupUid) val groupUidToExpenseMap = allExpenses.groupBy(_.groupUid) val expenseUidToPaidByMap = allPaidBy.groupBy(_.expenseUid) val expenseUidToSplitBetweenMap = allSplitBetween.groupBy(_.expenseUid) @@ -41,14 +41,13 @@ class AssembleGroupsResponseUseCase( ZIO.collectAll( groups .map { group => - val groupExpenses = groupUidToExpenseMap.getOrElse(group.uid, List.empty) - val groupPaidBy = allPaidBy.filter(_.groupUid == group.uid) - val groupSplitBetween = allSplitBetween.filter(_.groupUid == group.uid) - val groupMembers = groupUidToMembersMap.getOrElse(group.uid, List.empty) + val groupExpenses = groupUidToExpenseMap.getOrElse(group.entity.uid, List.empty) + val groupPaidBy = allPaidBy.filter(_.groupUid == group.entity.uid) + val groupSplitBetween = allSplitBetween.filter(_.groupUid == group.entity.uid) val groupTransactions = convertExpensesUseCase.convertToTransactions( expenses = groupExpenses, - members = groupMembers.map(_.uid), + members = group.members.map(_.entity.uid), paidBy = groupPaidBy, splitBetween = groupSplitBetween ) @@ -56,9 +55,10 @@ class AssembleGroupsResponseUseCase( val paybackTransactions = settlementCalculator.calculateSettlement(groupTransactions) toGroupDto( - group = group, - members = groupUidToMembersMap.getOrElse(group.uid, List.empty), - expenses = groupUidToExpenseMap.getOrElse(group.uid, List.empty), + group = group.entity, + currency = group.currency, + members = group.members.map(_.entity), + expenses = groupUidToExpenseMap.getOrElse(group.entity.uid, List.empty), expenseUidToPaidByMap = expenseUidToPaidByMap, expenseUidToSplitBetweenMap = expenseUidToSplitBetweenMap, userUidToUserMap = userUidToUserMap, diff --git a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/FillTestDataUseCase.scala b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/FillTestDataUseCase.scala index 9a06f7d..049c271 100644 --- a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/FillTestDataUseCase.scala +++ b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/FillTestDataUseCase.scala @@ -104,7 +104,8 @@ class FillTestDataUseCase( uid = group.uid, title = group.title, description = group.description, - passwordHash = Some(passwordService.hashPassword(group.password)) + passwordHash = Some(passwordService.hashPassword(group.password)), + currencyIsoCode = "EUR" ) ) diff --git a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/UpdateGroupUseCase.scala b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/UpdateGroupUseCase.scala index 50f62ab..24bbd6f 100644 --- a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/UpdateGroupUseCase.scala +++ b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/UpdateGroupUseCase.scala @@ -12,6 +12,7 @@ import com.github.ai.split.domain.PasswordService import com.github.ai.split.entity.db.{GroupEntity, GroupMemberEntity, GroupUid, MemberUid, UserUid} import com.github.ai.split.entity.exception.DomainError import zio.* +import zio.direct.* import java.util.UUID @@ -23,7 +24,8 @@ class UpdateGroupUseCase( private val paidByDao: PaidByEntityDao, private val splitBetweenDao: SplitBetweenEntityDao, private val addMemberUseCase: AddMembersUseCase, - private val removeMembersUseCase: RemoveMembersUseCase + private val removeMembersUseCase: RemoveMembersUseCase, + private val validateCurrencyUseCase: ValidateCurrencyUseCase ) { def updateGroup( @@ -31,9 +33,12 @@ class UpdateGroupUseCase( newPassword: Option[String], newTitle: Option[String], newDescription: Option[String], + newCurrencyIsoCode: Option[String], newMemberUids: Option[List[UserUid]] ): IO[DomainError, GroupUid] = { for { + _ <- isCurrencyIsoCodeValid(newCurrencyIsoCode) + group <- groupDao.getByUid(uid = groupUid) currentMembers <- groupMemberDao.getByGroupUid(groupUid = groupUid) @@ -70,7 +75,8 @@ class UpdateGroupUseCase( Some(passwordService.hashPassword(newPassword.get)) } else { group.passwordHash - } + }, + currencyIsoCode = newCurrencyIsoCode.getOrElse(group.currencyIsoCode) ) ) } yield groupUid @@ -131,4 +137,16 @@ class UpdateGroupUseCase( ) } yield result } + + private def isCurrencyIsoCodeValid( + newCurrencyIsoCode: Option[String] + ): IO[DomainError, Unit] = { + defer { + if (newCurrencyIsoCode.isDefined) { + validateCurrencyUseCase.isCurrencyIsoCodeValid(newCurrencyIsoCode.get).run + } + + () + } + } } diff --git a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/ValidateCurrencyUseCase.scala b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/ValidateCurrencyUseCase.scala new file mode 100644 index 0000000..61589d3 --- /dev/null +++ b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/ValidateCurrencyUseCase.scala @@ -0,0 +1,15 @@ +package com.github.ai.split.domain.usecases + +import com.github.ai.split.data.db.repository.CurrencyRepository +import com.github.ai.split.entity.exception.DomainError +import zio.* + +class ValidateCurrencyUseCase( + private val currencyRepository: CurrencyRepository +) { + + def isCurrencyIsoCodeValid(isoCode: String): IO[DomainError, Unit] = + currencyRepository + .getByIsoCode(isoCode) + .map(_ => ()) +} diff --git a/backend/app/src/main/scala/com/github/ai/split/entity/GroupWithMembers.scala b/backend/app/src/main/scala/com/github/ai/split/entity/GroupWithMembers.scala index e4345bb..4657d27 100644 --- a/backend/app/src/main/scala/com/github/ai/split/entity/GroupWithMembers.scala +++ b/backend/app/src/main/scala/com/github/ai/split/entity/GroupWithMembers.scala @@ -1,8 +1,9 @@ package com.github.ai.split.entity -import com.github.ai.split.entity.db.GroupEntity +import com.github.ai.split.entity.db.{CurrencyEntity, GroupEntity} case class GroupWithMembers( entity: GroupEntity, + currency: CurrencyEntity, members: List[Member] ) diff --git a/backend/app/src/main/scala/com/github/ai/split/entity/NewGroup.scala b/backend/app/src/main/scala/com/github/ai/split/entity/NewGroup.scala index 1e6cb6e..7f9c52b 100644 --- a/backend/app/src/main/scala/com/github/ai/split/entity/NewGroup.scala +++ b/backend/app/src/main/scala/com/github/ai/split/entity/NewGroup.scala @@ -4,6 +4,7 @@ case class NewGroup( password: String, title: String, description: String, + currencyIsoCode: String, members: List[NewUser], expenses: List[NewExpense] ) diff --git a/backend/app/src/main/scala/com/github/ai/split/entity/db/GroupEntity.scala b/backend/app/src/main/scala/com/github/ai/split/entity/db/GroupEntity.scala index 60005e6..124b64d 100644 --- a/backend/app/src/main/scala/com/github/ai/split/entity/db/GroupEntity.scala +++ b/backend/app/src/main/scala/com/github/ai/split/entity/db/GroupEntity.scala @@ -4,7 +4,8 @@ case class GroupEntity( uid: GroupUid, title: String, description: String, - passwordHash: Option[String] + passwordHash: Option[String], + currencyIsoCode: String ) object GroupEntity { diff --git a/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/GroupController.scala b/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/GroupController.scala index 2eb6666..bfd8ab0 100644 --- a/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/GroupController.scala +++ b/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/GroupController.scala @@ -104,6 +104,7 @@ class GroupController( newPassword = data.password.map(_.trim).filter(_.nonEmpty), newTitle = data.title.map(_.trim).filter(_.nonEmpty), newDescription = data.description.map(_.trim).filter(_.nonEmpty), + newCurrencyIsoCode = data.currencyIsoCode.map(_.trim).filter(_.nonEmpty), newMemberUids = newMembers ) @@ -128,9 +129,10 @@ class GroupController( addGroupUseCase.addGroup( NewGroup( - password = data.password, - title = data.title, + password = data.password.trim, + title = data.title.trim, description = data.description.getOrElse(""), + currencyIsoCode = data.currencyIsoCode.trim, members = newUsers, expenses = newExpenses ) diff --git a/backend/app/src/main/scala/com/github/ai/split/presentation/routes/CurrencyRoutes.scala b/backend/app/src/main/scala/com/github/ai/split/presentation/routes/CurrencyRoutes.scala index 12497bb..70f1a6e 100644 --- a/backend/app/src/main/scala/com/github/ai/split/presentation/routes/CurrencyRoutes.scala +++ b/backend/app/src/main/scala/com/github/ai/split/presentation/routes/CurrencyRoutes.scala @@ -13,6 +13,6 @@ object CurrencyRoutes { controller <- ZIO.service[CurrencyController] response <- controller.getCurrencies().mapError(_.toDomainResponse) } yield response - }, + } ) } diff --git a/backend/app/src/main/scala/com/github/ai/split/utils/DataConverters.scala b/backend/app/src/main/scala/com/github/ai/split/utils/DataConverters.scala index 513e6df..59886f2 100644 --- a/backend/app/src/main/scala/com/github/ai/split/utils/DataConverters.scala +++ b/backend/app/src/main/scala/com/github/ai/split/utils/DataConverters.scala @@ -1,8 +1,9 @@ package com.github.ai.split.utils import com.github.ai.split.entity.{ExpenseWithRelations, Transaction} -import com.github.ai.split.api.{ExpenseDto, GroupDto, TransactionDto, MemberDto} +import com.github.ai.split.api.{CurrencyDto, ExpenseDto, GroupDto, MemberDto, TransactionDto} import com.github.ai.split.entity.db.{ + CurrencyEntity, ExpenseEntity, ExpenseUid, GroupEntity, @@ -21,11 +22,13 @@ import java.util.UUID def toExpenseDto( expense: ExpenseWithRelations, + currency: CurrencyEntity, members: List[GroupMemberEntity], userUidToUserMap: Map[UserUid, UserEntity] ): IO[DomainError, ExpenseDto] = toExpenseDto( expense = expense.entity, + currency = currency, members = members, paidBy = expense.paidBy, splitBetween = expense.splitBetween, @@ -34,6 +37,7 @@ def toExpenseDto( def toExpenseDto( expense: ExpenseEntity, + currency: CurrencyEntity, members: List[GroupMemberEntity], paidBy: List[PaidByEntity], splitBetween: List[SplitBetweenEntity], @@ -68,6 +72,7 @@ def toExpenseDto( title = expense.title, description = expense.description.some, amount = expense.amount, + currency = toCurrencyDto(currency), paidBy = paidByUsers, splitBetween = splitBetweenUsers ) @@ -99,6 +104,7 @@ def toMemberDtos( def toGroupDto( group: GroupEntity, + currency: CurrencyEntity, members: List[GroupMemberEntity], expenses: List[ExpenseWithRelations], userUidToUserMap: Map[UserUid, UserEntity], @@ -106,6 +112,7 @@ def toGroupDto( ): IO[DomainError, GroupDto] = toGroupDto( group = group, + currency = currency, members = members, expenses = expenses.map(_.entity), expenseUidToPaidByMap = expenses.map(expense => (expense.entity.uid, expense.paidBy)).toMap, @@ -116,6 +123,7 @@ def toGroupDto( def toGroupDto( group: GroupEntity, + currency: CurrencyEntity, members: List[GroupMemberEntity], expenses: List[ExpenseEntity], expenseUidToPaidByMap: Map[ExpenseUid, List[PaidByEntity]], @@ -139,6 +147,7 @@ def toGroupDto( toExpenseDto( expense = expense, + currency = currency, members = members, paidBy = paidBy, splitBetween = splitBetween, @@ -150,6 +159,7 @@ def toGroupDto( uid = group.uid.toString, title = group.title, description = group.description, + currency = toCurrencyDto(currency), members = memberDtos, expenses = transformedExpenses, paybackTransactions = paybackTransactions.map(transaction => toTransactionDto(transaction)) @@ -164,3 +174,12 @@ def toTransactionDto( debtorUid = transaction.debtor.toString, amount = transaction.amount ) + +def toCurrencyDto( + currency: CurrencyEntity +): CurrencyDto = + CurrencyDto( + isoCode = currency.isoCode, + name = currency.name, + symbol = currency.symbol + ) diff --git a/backend/app/src/main/scala/com/github/ai/split/utils/RequestExtensions.scala b/backend/app/src/main/scala/com/github/ai/split/utils/RequestExtensions.scala index c828409..0b9ae73 100644 --- a/backend/app/src/main/scala/com/github/ai/split/utils/RequestExtensions.scala +++ b/backend/app/src/main/scala/com/github/ai/split/utils/RequestExtensions.scala @@ -5,19 +5,21 @@ import zio.IO import zio.ZIO import zio.http.{Body, Request} import zio.json.* +import zio.direct.* import java.util.UUID extension (body: Body) { def parse[T](implicit decoder: JsonDecoder[T]): IO[DomainError, T] = { - for - text <- body.asString.mapError(error => new DomainError(cause = error.some)) + defer { + val text = body.asString.mapError(error => new DomainError(cause = error.some)).run - dto <- ZIO.fromEither( - text.fromJson[T](using decoder).left.map(error => new DomainError(message = error.some)) - ) - yield dto + ZIO + .fromEither(text.fromJson[T](using decoder)) + .mapError(message => DomainError(message = s"Invalid request format: $message".some)) + .run + } } } @@ -33,7 +35,7 @@ extension (request: Request) { if (parameter.nonEmpty) { ZIO.succeed(parameter) } else { - ZIO.fail(new DomainError(message = Some("Invalid groupId"))) + ZIO.fail(new DomainError(message = Some("Invalid id parameter"))) } } } diff --git a/backend/app/src/main/scala/com/github/ai/split/utils/StringExtensions.scala b/backend/app/src/main/scala/com/github/ai/split/utils/StringExtensions.scala index 53568dc..5eb3dd1 100644 --- a/backend/app/src/main/scala/com/github/ai/split/utils/StringExtensions.scala +++ b/backend/app/src/main/scala/com/github/ai/split/utils/StringExtensions.scala @@ -13,9 +13,10 @@ extension (str: String) { def parseJson[T](implicit decoder: JsonDecoder[T]): IO[DomainError, T] = { ZIO.fromEither( - str.fromJson[T](using decoder) + str + .fromJson[T](using decoder) .left .map(error => new DomainError(message = error.some)) ) } -} \ No newline at end of file +} From b4526054fab76fa64dfaa41d44ac07d55b4c217b Mon Sep 17 00:00:00 2001 From: Aleksey Ivanovsky Date: Thu, 28 Aug 2025 18:19:28 +0200 Subject: [PATCH 4/4] [ANDROID] Implement currencies --- .../simplesplit/android/data/api/ApiClient.kt | 20 +- .../android/data/api/HttpClientExtensions.kt | 12 +- .../data/api/coverters/ApiDataConverters.kt | 15 ++ .../android/data/database/AppDatabase.kt | 8 +- .../data/database/dao/CurrencyEntityDao.kt | 27 ++ .../data/database/model/CurrencyEntity.kt | 12 + .../data/repository/CurrencyRepository.kt | 54 ++++ .../android/data/repository/MergeUtils.kt | 38 +++ .../android/di/AndroidAppModule.kt | 18 ++ .../core/compose/AppDropdownField.kt | 49 ++++ .../presentation/core/compose/AppTextField.kt | 63 ++++- .../presentation/core/compose/Components.kt | 7 +- .../presentation/core/compose/EmptyState.kt | 12 +- .../core/compose/cells/model/MenuCellModel.kt | 6 +- .../core/compose/cells/ui/MenuCell.kt | 33 ++- .../core/compose/navigation/Router.kt | 6 + .../presentation/core/compose/theme/Dimens.kt | 7 +- .../presentation/core/mvi/MviViewModel.kt | 12 +- .../android/presentation/dialogs/Dialog.kt | 6 + .../ExpenseDetailsDialogCellFactory.kt | 20 +- .../dialogs/menuDialog/MenuDialogViewModel.kt | 4 +- .../dialogs/root/BottomSheetRootDialog.kt | 10 +- .../dialogs/root/RootDialogComponent.kt | 7 + .../SelectCurrencyDialogCellFactory.kt | 129 +++++++++ .../SelectCurrencyDialogComponent.kt | 33 +++ .../SelectCurrencyDialogScreen.kt | 246 ++++++++++++++++++ .../SelectCurrencyDialogViewModel.kt | 171 ++++++++++++ .../SelectCurrencyInteractor.kt | 19 ++ .../model/SelectCurrencyDialogArgs.kt | 8 + .../model/SelectCurrencyDialogIntent.kt | 14 + .../model/SelectCurrencyDialogState.kt | 22 ++ .../expenseEditor/ExpenseEditorScreen.kt | 2 +- .../expenseEditor/ExpenseEditorViewModel.kt | 4 + .../expenseEditor/model/ExpenseEditorState.kt | 1 + .../cells/GroupDetailsCellFactory.kt | 7 +- .../groupEditor/GroupEditorInteractor.kt | 24 +- .../screens/groupEditor/GroupEditorScreen.kt | 12 + .../groupEditor/GroupEditorViewModel.kt | 146 ++++++++--- .../groupEditor/model/GroupEditorData.kt | 9 + .../groupEditor/model/GroupEditorIntent.kt | 3 + .../groupEditor/model/GroupEditorState.kt | 2 + .../screens/groups/GroupsInteractor.kt | 6 +- .../screens/groups/cells/CellFactory.kt | 4 +- .../screens/groups/model/GroupsData.kt | 6 +- .../android/utils/ArrowEtensions.kt | 6 +- .../android/utils/CurrencyFormatter.kt | 23 ++ android/app/src/main/res/values/strings.xml | 7 + 47 files changed, 1258 insertions(+), 92 deletions(-) create mode 100644 android/app/src/main/java/com/github/ai/simplesplit/android/data/api/coverters/ApiDataConverters.kt create mode 100644 android/app/src/main/java/com/github/ai/simplesplit/android/data/database/dao/CurrencyEntityDao.kt create mode 100644 android/app/src/main/java/com/github/ai/simplesplit/android/data/database/model/CurrencyEntity.kt create mode 100644 android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/CurrencyRepository.kt create mode 100644 android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/MergeUtils.kt create mode 100644 android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/SelectCurrencyDialogCellFactory.kt create mode 100644 android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/SelectCurrencyDialogComponent.kt create mode 100644 android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/SelectCurrencyDialogScreen.kt create mode 100644 android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/SelectCurrencyDialogViewModel.kt create mode 100644 android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/SelectCurrencyInteractor.kt create mode 100644 android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/model/SelectCurrencyDialogArgs.kt create mode 100644 android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/model/SelectCurrencyDialogIntent.kt create mode 100644 android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/model/SelectCurrencyDialogState.kt create mode 100644 android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorData.kt create mode 100644 android/app/src/main/java/com/github/ai/simplesplit/android/utils/CurrencyFormatter.kt diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/ApiClient.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/ApiClient.kt index 22c6f8e..9f6f29e 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/ApiClient.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/ApiClient.kt @@ -1,7 +1,6 @@ package com.github.ai.simplesplit.android.data.api import arrow.core.Either -import arrow.core.Some import com.github.ai.simplesplit.android.data.json.JsonSerializer import com.github.ai.simplesplit.android.data.settings.Settings import com.github.ai.simplesplit.android.model.exception.ApiException @@ -14,6 +13,7 @@ import com.github.ai.split.api.request.PutGroupRequest import com.github.ai.split.api.request.PutMemberRequest import com.github.ai.split.api.response.DeleteExpenseResponse import com.github.ai.split.api.response.DeleteMemberResponse +import com.github.ai.split.api.response.GetCurrenciesResponse import com.github.ai.split.api.response.GetGroupsResponse import com.github.ai.split.api.response.PostExpenseResponse import com.github.ai.split.api.response.PostGroupResponse @@ -69,7 +69,7 @@ class ApiClient( httpClient.sendRequest( type = RequestType.POST, url = "$baseUrl/group", - body = Some(request) + body = request ) suspend fun postExpense( @@ -79,7 +79,7 @@ class ApiClient( httpClient.sendRequest( type = RequestType.POST, url = "$baseUrl/expense?password=$password", - body = Some(request) + body = request ) suspend fun putExpense( @@ -90,7 +90,7 @@ class ApiClient( httpClient.sendRequest( type = RequestType.PUT, url = "$baseUrl/expense/$expenseUid?password=$password", - body = Some(request) + body = request ) suspend fun removeExpense( @@ -110,7 +110,7 @@ class ApiClient( httpClient.sendRequest( type = RequestType.PUT, url = "$baseUrl/group/$uid?password=$password", - body = Some(request) + body = request ) suspend fun postMember( @@ -120,7 +120,7 @@ class ApiClient( httpClient.sendRequest( type = RequestType.POST, url = "$baseUrl/member?password=$password", - body = Some(request) + body = request ) suspend fun removeMember( @@ -140,7 +140,13 @@ class ApiClient( httpClient.sendRequest( type = RequestType.PUT, url = "$baseUrl/member/$memberUid?password=$password", - body = Some(request) + body = request + ) + + suspend fun getCurrencies(): Either = + httpClient.sendRequest( + type = RequestType.GET, + url = "$baseUrl/currency" ) companion object { diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/HttpClientExtensions.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/HttpClientExtensions.kt index 7c05958..15b4605 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/HttpClientExtensions.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/HttpClientExtensions.kt @@ -1,8 +1,6 @@ package com.github.ai.simplesplit.android.data.api import arrow.core.Either -import arrow.core.None -import arrow.core.Option import arrow.core.raise.either import com.github.ai.simplesplit.android.model.exception.ApiException import com.github.ai.simplesplit.android.model.exception.InvalidResponseException @@ -30,7 +28,7 @@ enum class RequestType { suspend inline fun HttpClient.sendRequest( type: RequestType, url: String, - body: Option = None + body: Request? = null ): Either { val client = this @@ -41,12 +39,16 @@ suspend inline fun HttpClient.sendRequest( RequestType.POST -> client.post(url) { contentType(ContentType.Application.Json) - setBody(body.getOrNull()) + if (body != null) { + setBody(body) + } } RequestType.PUT -> client.put(url) { contentType(ContentType.Application.Json) - setBody(body.getOrNull()) + if (body != null) { + setBody(body) + } } RequestType.DELETE -> client.delete(url) diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/coverters/ApiDataConverters.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/coverters/ApiDataConverters.kt new file mode 100644 index 0000000..f7db42e --- /dev/null +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/coverters/ApiDataConverters.kt @@ -0,0 +1,15 @@ +package com.github.ai.simplesplit.android.data.api.coverters + +import com.github.ai.simplesplit.android.data.database.model.CurrencyEntity +import com.github.ai.split.api.CurrencyDto + +fun List.toCurrencies(): List { + return this.map { dto -> dto.toCurrency() } +} + +fun CurrencyDto.toCurrency(): CurrencyEntity = + CurrencyEntity( + isoCode = isoCode, + name = name, + symbol = symbol + ) \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/data/database/AppDatabase.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/database/AppDatabase.kt index 7e9a4e1..9cef27e 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/data/database/AppDatabase.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/database/AppDatabase.kt @@ -4,17 +4,23 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import com.github.ai.simplesplit.android.data.database.dao.CurrencyEntityDao import com.github.ai.simplesplit.android.data.database.dao.GroupCredentialsDao +import com.github.ai.simplesplit.android.data.database.model.CurrencyEntity import com.github.ai.simplesplit.android.data.database.model.GroupCredentials @Database( - entities = [GroupCredentials::class], + entities = [ + GroupCredentials::class, + CurrencyEntity::class + ], version = 1, exportSchema = false ) abstract class AppDatabase : RoomDatabase() { abstract fun groupCredentialsDao(): GroupCredentialsDao + abstract fun currencyEntityDao(): CurrencyEntityDao companion object { diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/data/database/dao/CurrencyEntityDao.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/database/dao/CurrencyEntityDao.kt new file mode 100644 index 0000000..96b5135 --- /dev/null +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/database/dao/CurrencyEntityDao.kt @@ -0,0 +1,27 @@ +package com.github.ai.simplesplit.android.data.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.github.ai.simplesplit.android.data.database.model.CurrencyEntity + +@Dao +interface CurrencyEntityDao { + + @Query("SELECT * FROM currencies WHERE isoCode = :isoCode") + fun findByIsoCode(isoCode: String): CurrencyEntity? + + @Query("SELECT * FROM currencies") + fun getAll(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(currency: CurrencyEntity) + + @Update + fun update(currency: CurrencyEntity) + + @Query("DELETE FROM currencies WHERE isoCode = :isoCode") + fun deleteByIsoCode(isoCode: String) +} \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/data/database/model/CurrencyEntity.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/database/model/CurrencyEntity.kt new file mode 100644 index 0000000..db12ea7 --- /dev/null +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/database/model/CurrencyEntity.kt @@ -0,0 +1,12 @@ +package com.github.ai.simplesplit.android.data.database.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity("currencies") +data class CurrencyEntity( + @PrimaryKey + val isoCode: String, + val name: String, + val symbol: String +) \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/CurrencyRepository.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/CurrencyRepository.kt new file mode 100644 index 0000000..0996e92 --- /dev/null +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/CurrencyRepository.kt @@ -0,0 +1,54 @@ +package com.github.ai.simplesplit.android.data.repository + +import arrow.core.Either +import arrow.core.raise.either +import com.github.ai.simplesplit.android.data.api.ApiClient +import com.github.ai.simplesplit.android.data.api.coverters.toCurrencies +import com.github.ai.simplesplit.android.data.database.dao.CurrencyEntityDao +import com.github.ai.simplesplit.android.data.database.model.CurrencyEntity +import com.github.ai.simplesplit.android.model.exception.AppException + +class CurrencyRepository( + private val api: ApiClient, + private val dao: CurrencyEntityDao +) { + + fun getAllCached(): Either> = + either { + val currencies = dao.getAll() + if (currencies.isEmpty()) { + raise(AppException(message = "Unable to load currencies")) + } + + currencies + } + + suspend fun getAllOrDownload(): Either> = + either { + val localCurrencies = dao.getAll() + if (localCurrencies.isNotEmpty()) { + localCurrencies + } else { + downloadAndSave().bind() + } + } + + private suspend fun downloadAndSave(): Either> = + either { + val getCurrenciesResult = api.getCurrencies().bind() + + val remoteCurrencies = getCurrenciesResult.currencies.toCurrencies() + + mergeEntities( + localEntities = dao.getAll(), + remoteEntities = remoteCurrencies, + entityToUidMapper = { currency -> currency.isoCode }, + isEqual = { local, remote -> local == remote }, + onInsert = { remote -> dao.insert(remote) }, + onUpdate = { _, remote -> dao.update(remote) }, + onDelete = { local -> dao.deleteByIsoCode(local.isoCode) } + ) + + dao.getAll() + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/MergeUtils.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/MergeUtils.kt new file mode 100644 index 0000000..e1843c7 --- /dev/null +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/MergeUtils.kt @@ -0,0 +1,38 @@ +package com.github.ai.simplesplit.android.data.repository + +inline fun mergeEntities( + localEntities: List, + remoteEntities: List, + entityToUidMapper: (T) -> String, + isEqual: (local: T, remote: T) -> Boolean, + onInsert: (T) -> Unit, + onUpdate: (local: T, remote: T) -> Unit, + onDelete: (T) -> Unit +): Boolean { + val uidToLocalEntityMap = localEntities + .associateBy { entity -> entityToUidMapper.invoke(entity) } + .toMutableMap() + + var isDataChanged = false + + for (remote in remoteEntities) { + val uid = entityToUidMapper.invoke(remote) + val local = uidToLocalEntityMap.remove(uid) + if (local != null) { + if (!isEqual.invoke(local, remote)) { + onUpdate.invoke(local, remote) + isDataChanged = true + } + } else { + onInsert.invoke(remote) + isDataChanged = true + } + } + + for (entity in uidToLocalEntityMap.values) { + onDelete.invoke(entity) + isDataChanged = true + } + + return isDataChanged +} \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/di/AndroidAppModule.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/di/AndroidAppModule.kt index 8641e9f..ae84d5f 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/di/AndroidAppModule.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/di/AndroidAppModule.kt @@ -3,6 +3,7 @@ package com.github.ai.simplesplit.android.di import com.github.ai.simplesplit.android.data.api.ApiClient import com.github.ai.simplesplit.android.data.database.AppDatabase import com.github.ai.simplesplit.android.data.json.JsonSerializer +import com.github.ai.simplesplit.android.data.repository.CurrencyRepository import com.github.ai.simplesplit.android.data.repository.ExpenseRepository import com.github.ai.simplesplit.android.data.repository.GroupCredentialsRepository import com.github.ai.simplesplit.android.data.repository.GroupRepository @@ -25,6 +26,10 @@ import com.github.ai.simplesplit.android.presentation.dialogs.expenseDetails.Exp import com.github.ai.simplesplit.android.presentation.dialogs.expenseDetails.model.ExpenseDetailsDialogArgs import com.github.ai.simplesplit.android.presentation.dialogs.menuDialog.MenuDialogViewModel import com.github.ai.simplesplit.android.presentation.dialogs.menuDialog.model.MenuDialogArgs +import com.github.ai.simplesplit.android.presentation.dialogs.selectCurrency.SelectCurrencyDialogCellFactory +import com.github.ai.simplesplit.android.presentation.dialogs.selectCurrency.SelectCurrencyDialogViewModel +import com.github.ai.simplesplit.android.presentation.dialogs.selectCurrency.SelectCurrencyInteractor +import com.github.ai.simplesplit.android.presentation.dialogs.selectCurrency.model.SelectCurrencyDialogArgs import com.github.ai.simplesplit.android.presentation.screens.checkoutGroup.CheckoutGroupInteractor import com.github.ai.simplesplit.android.presentation.screens.checkoutGroup.CheckoutGroupViewModel import com.github.ai.simplesplit.android.presentation.screens.checkoutGroup.model.CheckoutGroupArgs @@ -59,6 +64,7 @@ object AndroidAppModule { // Database single { AppDatabase.buildDatabase(get()) } single { get().groupCredentialsDao() } + single { get().currencyEntityDao() } // Api singleOf(::JsonSerializer) @@ -69,6 +75,7 @@ object AndroidAppModule { singleOf(::GroupCredentialsRepository) singleOf(::ExpenseRepository) singleOf(::MemberRepository) + singleOf(::CurrencyRepository) // UseCases singleOf(::ParseGroupUrlUseCase) @@ -82,10 +89,12 @@ object AndroidAppModule { singleOf(::ExpenseEditorInteractor) singleOf(::CheckoutGroupInteractor) singleOf(::SettingsInteractor) + singleOf(::SelectCurrencyInteractor) // CellFactories singleOf(::GroupDetailsCellFactory) singleOf(::ExpenseDetailsDialogCellFactory) + singleOf(::SelectCurrencyDialogCellFactory) singleOf(::SettingsCellFactory) // Router @@ -163,5 +172,14 @@ object AndroidAppModule { args ) } + factory { (args: SelectCurrencyDialogArgs) -> + SelectCurrencyDialogViewModel( + get(), + get(), + get(), + get(), + args + ) + } } } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/AppDropdownField.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/AppDropdownField.kt index 1a49f46..a355d03 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/AppDropdownField.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/AppDropdownField.kt @@ -24,6 +24,47 @@ import com.github.ai.simplesplit.android.presentation.core.compose.theme.Element import com.github.ai.simplesplit.android.presentation.core.compose.theme.LightTheme import com.github.ai.simplesplit.android.presentation.core.compose.theme.MediumMargin +@Composable +fun AppDropdownFieldNoMenu( + value: String, + label: String, + error: String? = null, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val isError = (error != null) + + OutlinedTextField( + value = value, + onValueChange = { }, + label = { + Text(label) + }, + isError = isError, + supportingText = { + if (isError) { + Text( + text = error.orEmpty(), + color = MaterialTheme.colorScheme.error + ) + } + }, + readOnly = true, + trailingIcon = { + Icon( + imageVector = AppIcon.EXPAND_MORE.vector, + contentDescription = null, + modifier = Modifier + .clickable( + onClick = onClick + ) + .padding(MediumMargin) + ) + }, + modifier = modifier + ) +} + @Composable fun AppDropdownField( value: String, @@ -44,6 +85,14 @@ fun AppDropdownField( Text(label) }, isError = isError, + supportingText = { + if (isError) { + Text( + text = error.orEmpty(), + color = MaterialTheme.colorScheme.error + ) + } + }, readOnly = true, trailingIcon = { Icon( diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/AppTextField.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/AppTextField.kt index a5f76e4..a229884 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/AppTextField.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/AppTextField.kt @@ -32,9 +32,11 @@ fun AppTextField( error: String? = null, onValueChange: (String) -> Unit, isPasswordToggleEnabled: Boolean = false, + isResetIconEnabled: Boolean = false, isPasswordVisible: Boolean = false, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, onPasswordToggleClicked: ((isPasswordVisible: Boolean) -> Unit)? = null, + onResetIconClick: (() -> Unit)? = null, modifier: Modifier = Modifier ) { // TODO: resolve issue with inner state @@ -74,13 +76,6 @@ fun AppTextField( }, trailingIcon = { when { - isError -> { - Icon( - imageVector = AppIcon.ERROR_CIRCLE.vector, - contentDescription = null - ) - } - isPasswordToggleEnabled -> { val icon = if (isPasswordVisible) { AppIcon.VISIBILITY_OFF @@ -94,13 +89,39 @@ fun AppTextField( modifier = Modifier .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false) - ) { - onPasswordToggleClicked?.invoke(!isPasswordVisible) - } + indication = rememberRipple(bounded = false), + onClick = { + onPasswordToggleClicked?.invoke(!isPasswordVisible) + } + ) .padding(8.dp) ) } + + isError -> { + Icon( + imageVector = AppIcon.ERROR_CIRCLE.vector, + contentDescription = null + ) + } + + isResetIconEnabled -> { + if (observedInnerValue.isNotEmpty()) { + Icon( + imageVector = AppIcon.CLOSE.vector, + contentDescription = null, + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false), + onClick = { + // TODO: remember + onResetIconClick?.invoke() + } + ) + ) + } + } } }, keyboardOptions = keyboardOptions, @@ -162,6 +183,26 @@ fun TextFieldsLightPreview() { .fillMaxWidth() .padding(horizontal = 16.dp) ) + + AppTextField( + value = "john.doe", + label = "Username", + isResetIconEnabled = true, + onValueChange = {}, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) + + AppTextField( + value = "", + label = "Username", + isResetIconEnabled = true, + onValueChange = {}, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) } } } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/Components.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/Components.kt index 9453c72..c621e79 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/Components.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/Components.kt @@ -8,10 +8,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @Composable -fun CenteredBox(content: @Composable BoxScope.() -> Unit) { +fun CenteredBox( + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit +) { Box( contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize() + modifier = modifier.fillMaxSize() ) { content.invoke(this) } diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/EmptyState.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/EmptyState.kt index 758813a..b57335b 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/EmptyState.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/EmptyState.kt @@ -2,16 +2,24 @@ package com.github.ai.simplesplit.android.presentation.core.compose import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import com.github.ai.simplesplit.android.presentation.core.compose.preview.ThemedScreenPreview import com.github.ai.simplesplit.android.presentation.core.compose.theme.LightTheme @Composable -fun EmptyState(text: String) { +fun EmptyState( + text: String, + textAlign: TextAlign = TextAlign.Center, + modifier: Modifier = Modifier +) { Text( text = text, style = TextSize.TITLE_LARGE.toTextStyle(), - color = TextColor.PRIMARY.toColor() + color = TextColor.PRIMARY.toColor(), + textAlign = textAlign, + modifier = modifier ) } diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/model/MenuCellModel.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/model/MenuCellModel.kt index c3d6df4..7d44389 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/model/MenuCellModel.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/model/MenuCellModel.kt @@ -1,10 +1,12 @@ package com.github.ai.simplesplit.android.presentation.core.compose.cells.model import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellModel data class MenuCellModel( override val id: String, - val icon: ImageVector, - val title: String + val icon: ImageVector?, + val title: String, + val height: Dp ) : CellModel \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/ui/MenuCell.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/ui/MenuCell.kt index 6678fbc..ef53167 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/ui/MenuCell.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/ui/MenuCell.kt @@ -1,11 +1,14 @@ package com.github.ai.simplesplit.android.presentation.core.compose.cells.ui import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -25,7 +28,8 @@ import com.github.ai.simplesplit.android.presentation.core.compose.theme.AppThem import com.github.ai.simplesplit.android.presentation.core.compose.theme.ElementMargin import com.github.ai.simplesplit.android.presentation.core.compose.theme.HalfMargin import com.github.ai.simplesplit.android.presentation.core.compose.theme.LightTheme -import com.github.ai.simplesplit.android.presentation.core.compose.theme.OneLineItemHeight +import com.github.ai.simplesplit.android.presentation.core.compose.theme.OneLineSmallItemHeight +import com.github.ai.simplesplit.android.presentation.core.compose.theme.SmallIconSize @Composable fun MenuCell(viewModel: MenuCellViewModel) { @@ -41,13 +45,22 @@ fun MenuCell(viewModel: MenuCellViewModel) { .fillMaxWidth() .clickable(onClick = onClick) .padding(horizontal = ElementMargin) - .height(height = OneLineItemHeight) + .height(height = model.height) ) { - Icon( - imageVector = model.icon, - tint = AppTheme.theme.colors.primaryIcon, - contentDescription = null - ) + Box( + modifier = Modifier + .size(size = SmallIconSize) + ) { + if (model.icon != null) { + Icon( + imageVector = model.icon, + tint = AppTheme.theme.colors.primaryIcon, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + ) + } + } Text( text = model.title, @@ -70,18 +83,20 @@ fun MenuCellPreview() { ) { Column { MenuCell(newMenuCell()) + MenuCell(newMenuCell(icon = null)) } } } fun newMenuCell( - icon: ImageVector = AppIcon.SETTINGS.vector, + icon: ImageVector? = AppIcon.SETTINGS.vector, title: String = "Settings" ) = MenuCellViewModel( model = MenuCellModel( id = "id", icon = icon, - title = title + title = title, + height = OneLineSmallItemHeight ), eventProvider = PreviewEventProvider ) \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/navigation/Router.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/navigation/Router.kt index d059ce5..bc21688 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/navigation/Router.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/navigation/Router.kt @@ -163,6 +163,12 @@ class RouterImpl : Router { val fragmentManager = getNavigatorOrThrow().getFragmentManager() val key = dialog::class.key() + + Timber.d( + "removeActiveDialog: key=%s", + key + ) + val fragment = fragmentManager.findFragmentByTag(key) if (fragment != null && fragment is DialogFragment) { fragment.dismiss() diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/theme/Dimens.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/theme/Dimens.kt index 9eb5c04..ffe69b9 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/theme/Dimens.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/theme/Dimens.kt @@ -14,10 +14,13 @@ val DoubleGroupMargin = 48.dp val HugeMargin = 64.dp val EmptyMessageItemHeight = 200.dp -val OneLineItemHeight = 48.dp +val OneLineSmallItemHeight = 48.dp +val OneLineMediumItemHeight = 64.dp val TwoLineItemHeight = 72.dp val GroupTwoLineItemHeight = 52.dp val GroupThreeLineItemHeight = 68.dp val CardCornerSize = 22.dp -val DialogCardCornerSize = 16.dp \ No newline at end of file +val DialogCardCornerSize = 16.dp + +val SmallIconSize = 24.dp \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/mvi/MviViewModel.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/mvi/MviViewModel.kt index 53045f2..9469957 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/mvi/MviViewModel.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/mvi/MviViewModel.kt @@ -32,7 +32,17 @@ abstract class MviViewModel( viewModelScope.launch { intents.receiveAsFlow() .onStart { emit(initialIntent) } - .flatMapLatest { intent -> handleIntent(intent) } + .flatMapLatest { intent -> + val flow = handleIntent(intent) + + if (flow is SingleFlow<*> && !intent.isImmediate) { + throw IllegalStateException( + "Intent ${intent::class.simpleName} must be immediate" + ) + } + + flow + } .flowOn(Dispatchers.IO) .collect { newState -> state.value = newState diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/Dialog.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/Dialog.kt index d93195e..c096175 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/Dialog.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/Dialog.kt @@ -4,6 +4,7 @@ import com.github.ai.simplesplit.android.presentation.core.compose.navigation.Re import com.github.ai.simplesplit.android.presentation.dialogs.confirmationDialog.model.ConfirmationDialogArgs import com.github.ai.simplesplit.android.presentation.dialogs.expenseDetails.model.ExpenseDetailsDialogArgs import com.github.ai.simplesplit.android.presentation.dialogs.menuDialog.model.MenuDialogArgs +import com.github.ai.simplesplit.android.presentation.dialogs.selectCurrency.model.SelectCurrencyDialogArgs import kotlinx.serialization.Serializable @Serializable @@ -23,4 +24,9 @@ sealed interface Dialog : ResultOwner { data class ExpenseDetails( val args: ExpenseDetailsDialogArgs ) : Dialog + + @Serializable + data class SelectCurrency( + val args: SelectCurrencyDialogArgs + ) : Dialog } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/expenseDetails/ExpenseDetailsDialogCellFactory.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/expenseDetails/ExpenseDetailsDialogCellFactory.kt index 06cd530..d22341d 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/expenseDetails/ExpenseDetailsDialogCellFactory.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/expenseDetails/ExpenseDetailsDialogCellFactory.kt @@ -2,6 +2,7 @@ package com.github.ai.simplesplit.android.presentation.dialogs.expenseDetails import androidx.compose.ui.unit.dp import com.github.ai.simplesplit.android.R +import com.github.ai.simplesplit.android.data.api.coverters.toCurrency import com.github.ai.simplesplit.android.presentation.core.ResourceProvider import com.github.ai.simplesplit.android.presentation.core.compose.TextColor import com.github.ai.simplesplit.android.presentation.core.compose.TextSize @@ -21,8 +22,10 @@ import com.github.ai.simplesplit.android.presentation.core.compose.theme.AppIcon import com.github.ai.simplesplit.android.presentation.core.compose.theme.ElementMargin import com.github.ai.simplesplit.android.presentation.core.compose.theme.GroupMargin import com.github.ai.simplesplit.android.presentation.core.compose.theme.HalfMargin +import com.github.ai.simplesplit.android.presentation.core.compose.theme.OneLineSmallItemHeight import com.github.ai.simplesplit.android.presentation.core.compose.theme.ThemeProvider import com.github.ai.simplesplit.android.presentation.core.compose.theme.TinyMargin +import com.github.ai.simplesplit.android.utils.formatAsMoney import com.github.ai.split.api.ExpenseDto class ExpenseDetailsDialogCellFactory( @@ -59,13 +62,15 @@ class ExpenseDetailsDialogCellFactory( ) ) + val currency = expense.currency.toCurrency() + // Title cells.add( BottomSheetHeaderCellViewModel( model = BottomSheetHeaderCellModel( id = "title", title = expense.title, - description = expense.amount.toString(), + description = expense.amount.formatAsMoney(currency), titleTextSize = TextSize.TITLE_MEDIUM, descriptionTextSize = TextSize.TITLE_LARGE, icon = AppIcon.CLOSE.vector @@ -129,12 +134,14 @@ class ExpenseDetailsDialogCellFactory( ) ) + val currency = expense.currency.toCurrency() + for ((index, payer) in expense.paidBy.withIndex()) { cells.add( TextCellViewModel( TextCellModel( id = "payer_$index", - text = payer.name + " paid " + expense.amount, + text = payer.name + " paid " + expense.amount.formatAsMoney(currency), textSize = TextSize.BODY_LARGE, textColor = TextColor.PRIMARY ) @@ -157,6 +164,7 @@ class ExpenseDetailsDialogCellFactory( ) ) + val currency = expense.currency.toCurrency() val payerUid = expense.paidBy.firstOrNull()?.uid.orEmpty() val debtors = expense.splitBetween.filter { splitMember -> splitMember.uid != payerUid } @@ -167,7 +175,7 @@ class ExpenseDetailsDialogCellFactory( TextCellViewModel( TextCellModel( id = "split_$index", - text = splitMember.name + " owe " + "%.2f".format(debtAmount), + text = splitMember.name + " owe " + debtAmount.formatAsMoney(currency), textSize = TextSize.BODY_LARGE, textColor = TextColor.SECONDARY ) @@ -214,7 +222,8 @@ class ExpenseDetailsDialogCellFactory( MenuCellModel( id = CellId.EDIT_MENU.name, icon = AppIcon.EDIT.vector, - title = resourceProvider.getString(R.string.edit) + title = resourceProvider.getString(R.string.edit), + height = OneLineSmallItemHeight ), eventProvider ), @@ -222,7 +231,8 @@ class ExpenseDetailsDialogCellFactory( MenuCellModel( id = CellId.REMOVE_MENU.name, icon = AppIcon.REMOVE.vector, - title = resourceProvider.getString(R.string.remove) + title = resourceProvider.getString(R.string.remove), + height = OneLineSmallItemHeight ), eventProvider ) diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/menuDialog/MenuDialogViewModel.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/menuDialog/MenuDialogViewModel.kt index 6eb6eac..af07507 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/menuDialog/MenuDialogViewModel.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/menuDialog/MenuDialogViewModel.kt @@ -7,6 +7,7 @@ import com.github.ai.simplesplit.android.presentation.core.compose.cells.model.M import com.github.ai.simplesplit.android.presentation.core.compose.cells.model.MenuCellModel import com.github.ai.simplesplit.android.presentation.core.compose.cells.viewModel.MenuCellViewModel import com.github.ai.simplesplit.android.presentation.core.compose.navigation.Router +import com.github.ai.simplesplit.android.presentation.core.compose.theme.OneLineSmallItemHeight import com.github.ai.simplesplit.android.presentation.core.mvi.CellsMviViewModel import com.github.ai.simplesplit.android.presentation.dialogs.Dialog import com.github.ai.simplesplit.android.presentation.dialogs.menuDialog.model.MenuDialogArgs @@ -63,7 +64,8 @@ class MenuDialogViewModel( val model = MenuCellModel( id = CellId("menu_item", IntPayload(item.actionId)).format(), icon = item.icon.vector, - title = item.text + title = item.text, + height = OneLineSmallItemHeight ) MenuCellViewModel(model, eventProvider) diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/root/BottomSheetRootDialog.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/root/BottomSheetRootDialog.kt index 32413bb..31b7dc0 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/root/BottomSheetRootDialog.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/root/BottomSheetRootDialog.kt @@ -4,7 +4,11 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import arrow.core.getOrElse import com.github.ai.simplesplit.android.R import com.github.ai.simplesplit.android.data.json.JsonSerializer @@ -47,7 +51,11 @@ class BottomSheetRootDialog : BottomSheetDialogFragment() { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { AppTheme(theme = themeProvider.theme) { - dialogComponent.render() + Box( + modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()) + ) { + dialogComponent.render() + } } } } diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/root/RootDialogComponent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/root/RootDialogComponent.kt index 16ddead..6835a02 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/root/RootDialogComponent.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/root/RootDialogComponent.kt @@ -7,6 +7,7 @@ import com.github.ai.simplesplit.android.presentation.dialogs.Dialog import com.github.ai.simplesplit.android.presentation.dialogs.confirmationDialog.ConfirmationDialogComponent import com.github.ai.simplesplit.android.presentation.dialogs.expenseDetails.ExpenseDetailsDialogComponent import com.github.ai.simplesplit.android.presentation.dialogs.menuDialog.MenuDialogComponent +import com.github.ai.simplesplit.android.presentation.dialogs.selectCurrency.SelectCurrencyDialogComponent class RootDialogComponent( private val lifecycle: Lifecycle, @@ -32,6 +33,12 @@ class RootDialogComponent( lifecycle = lifecycle, args = dialog.args ) + + is Dialog.SelectCurrency -> SelectCurrencyDialogComponent( + viewModelStoreOwner = viewModelStoreOwner, + lifecycle = lifecycle, + args = dialog.args + ) } } } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/SelectCurrencyDialogCellFactory.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/SelectCurrencyDialogCellFactory.kt new file mode 100644 index 0000000..05292f0 --- /dev/null +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/SelectCurrencyDialogCellFactory.kt @@ -0,0 +1,129 @@ +package com.github.ai.simplesplit.android.presentation.dialogs.selectCurrency + +import androidx.compose.ui.unit.dp +import com.github.ai.simplesplit.android.data.database.model.CurrencyEntity +import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellEventProvider +import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellViewModel +import com.github.ai.simplesplit.android.presentation.core.compose.cells.model.DividerCellModel +import com.github.ai.simplesplit.android.presentation.core.compose.cells.model.MenuCellModel +import com.github.ai.simplesplit.android.presentation.core.compose.cells.model.SpaceCellModel +import com.github.ai.simplesplit.android.presentation.core.compose.cells.viewModel.DividerCellViewModel +import com.github.ai.simplesplit.android.presentation.core.compose.cells.viewModel.MenuCellViewModel +import com.github.ai.simplesplit.android.presentation.core.compose.cells.viewModel.SpaceCellViewModel +import com.github.ai.simplesplit.android.presentation.core.compose.theme.AppIcon +import com.github.ai.simplesplit.android.presentation.core.compose.theme.HalfMargin +import com.github.ai.simplesplit.android.presentation.core.compose.theme.OneLineMediumItemHeight +import com.github.ai.simplesplit.android.utils.CellId +import com.github.ai.simplesplit.android.utils.CellIdPayload.StringPayload +import com.github.ai.simplesplit.android.utils.format + +class SelectCurrencyDialogCellFactory { + + fun createCells( + currencies: List, + query: String, + selectedIsoCode: String?, + eventProvider: CellEventProvider + ): List { + val cells = mutableListOf() + + val isoCodeToCurrencyMap = currencies.associateBy { currency -> currency.isoCode } + val usdCurrency = isoCodeToCurrencyMap["USD"] + val euroCurrency = isoCodeToCurrencyMap["EUR"] + + cells.add( + SpaceCellViewModel( + SpaceCellModel( + id = "space_top", + height = HalfMargin + ) + ) + ) + + cells.add( + DividerCellViewModel( + DividerCellModel( + id = "divider_before_popular", + padding = 0.dp + ) + ) + ) + + // Popular currencies + if (query.isBlank() && (usdCurrency != null || euroCurrency != null)) { + if (usdCurrency != null) { + cells.add( + createCurrencyCell( + idPrefix = POPULAR_SECTION_PREFIX, + currency = usdCurrency, + selectedIsoCode = selectedIsoCode, + eventProvider = eventProvider + ) + ) + } + + if (euroCurrency != null) { + cells.add( + createCurrencyCell( + idPrefix = POPULAR_SECTION_PREFIX, + currency = euroCurrency, + selectedIsoCode = selectedIsoCode, + eventProvider = eventProvider + ) + ) + } + + cells.add( + DividerCellViewModel( + DividerCellModel( + id = "divider_after_popular", + padding = 0.dp + ) + ) + ) + } + + // All currencies + for (currency in currencies) { + cells.add( + createCurrencyCell( + idPrefix = CURRENCY_SECTION_PREFIX, + currency = currency, + selectedIsoCode = selectedIsoCode, + eventProvider = eventProvider + ) + ) + } + + return cells + } + + private fun createCurrencyCell( + idPrefix: String, + currency: CurrencyEntity, + selectedIsoCode: String?, + eventProvider: CellEventProvider + ): CellViewModel { + val title = "${currency.name} - ${currency.symbol}" + val icon = if (currency.isoCode == selectedIsoCode) { + AppIcon.CHECK.vector + } else { + null + } + + return MenuCellViewModel( + MenuCellModel( + id = CellId(idPrefix, StringPayload(currency.isoCode)).format(), + icon = icon, + title = title, + height = OneLineMediumItemHeight + ), + eventProvider + ) + } + + companion object { + const val CURRENCY_SECTION_PREFIX = "currencies" + const val POPULAR_SECTION_PREFIX = "popular_currencies" + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/SelectCurrencyDialogComponent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/SelectCurrencyDialogComponent.kt new file mode 100644 index 0000000..2d55a9e --- /dev/null +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/SelectCurrencyDialogComponent.kt @@ -0,0 +1,33 @@ +package com.github.ai.simplesplit.android.presentation.dialogs.selectCurrency + +import androidx.compose.runtime.Composable +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStoreOwner +import com.github.ai.simplesplit.android.presentation.core.ViewModelFactory +import com.github.ai.simplesplit.android.presentation.core.compose.navigation.DialogComponent +import com.github.ai.simplesplit.android.presentation.dialogs.selectCurrency.model.SelectCurrencyDialogArgs +import com.github.ai.simplesplit.android.utils.attach + +class SelectCurrencyDialogComponent( + lifecycle: Lifecycle, + viewModelStoreOwner: ViewModelStoreOwner, + args: SelectCurrencyDialogArgs +) : DialogComponent { + + private val viewModel: SelectCurrencyDialogViewModel by lazy { + ViewModelProvider( + owner = viewModelStoreOwner, + factory = ViewModelFactory(args) + )[SelectCurrencyDialogViewModel::class] + } + + init { + lifecycle.attach(viewModel) + } + + @Composable + override fun render() { + SelectCurrencyDialogScreen(viewModel) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/SelectCurrencyDialogScreen.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/SelectCurrencyDialogScreen.kt new file mode 100644 index 0000000..fb9827a --- /dev/null +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/SelectCurrencyDialogScreen.kt @@ -0,0 +1,246 @@ +package com.github.ai.simplesplit.android.presentation.dialogs.selectCurrency + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.github.ai.simplesplit.android.R +import com.github.ai.simplesplit.android.presentation.core.compose.AppTextField +import com.github.ai.simplesplit.android.presentation.core.compose.EmptyState +import com.github.ai.simplesplit.android.presentation.core.compose.ErrorState +import com.github.ai.simplesplit.android.presentation.core.compose.cells.ui.DividerCell +import com.github.ai.simplesplit.android.presentation.core.compose.cells.ui.MenuCell +import com.github.ai.simplesplit.android.presentation.core.compose.cells.ui.SpaceCell +import com.github.ai.simplesplit.android.presentation.core.compose.cells.ui.newDividerCell +import com.github.ai.simplesplit.android.presentation.core.compose.cells.ui.newMenuCell +import com.github.ai.simplesplit.android.presentation.core.compose.cells.ui.newSpaceCell +import com.github.ai.simplesplit.android.presentation.core.compose.cells.viewModel.DividerCellViewModel +import com.github.ai.simplesplit.android.presentation.core.compose.cells.viewModel.MenuCellViewModel +import com.github.ai.simplesplit.android.presentation.core.compose.cells.viewModel.SpaceCellViewModel +import com.github.ai.simplesplit.android.presentation.core.compose.preview.ThemedPreview +import com.github.ai.simplesplit.android.presentation.core.compose.rememberCallback +import com.github.ai.simplesplit.android.presentation.core.compose.rememberOnClickedCallback +import com.github.ai.simplesplit.android.presentation.core.compose.theme.AppIcon +import com.github.ai.simplesplit.android.presentation.core.compose.theme.AppTheme +import com.github.ai.simplesplit.android.presentation.core.compose.theme.DialogCardCornerSize +import com.github.ai.simplesplit.android.presentation.core.compose.theme.ElementMargin +import com.github.ai.simplesplit.android.presentation.core.compose.theme.HalfMargin +import com.github.ai.simplesplit.android.presentation.core.compose.theme.LightTheme +import com.github.ai.simplesplit.android.presentation.dialogs.selectCurrency.model.SelectCurrencyDialogIntent +import com.github.ai.simplesplit.android.presentation.dialogs.selectCurrency.model.SelectCurrencyDialogState + +@Composable +fun SelectCurrencyDialogScreen(viewModel: SelectCurrencyDialogViewModel) { + val state by viewModel.state.collectAsState() + + SelectCurrencyDialogScreen( + state = state, + onIntent = viewModel::sendIntent + ) +} + +@Composable +private fun SelectCurrencyDialogScreen( + state: SelectCurrencyDialogState, + onIntent: (intent: SelectCurrencyDialogIntent) -> Unit +) { + val onQueryTextChange = rememberCallback { query: String -> + onIntent.invoke(SelectCurrencyDialogIntent.OnQueryTextChange(query)) + } + val onResetQueryClick = rememberOnClickedCallback { + onIntent.invoke(SelectCurrencyDialogIntent.OnResetQueryClick) + } + val onCloseIconClick = rememberOnClickedCallback { + onIntent.invoke(SelectCurrencyDialogIntent.Dismiss) + } + + BoxWithConstraints( + modifier = Modifier + ) { + val minContentHeight = maxHeight * 0.7f + + Card( + shape = RoundedCornerShape(DialogCardCornerSize, DialogCardCornerSize, 0.dp, 0.dp), + colors = CardDefaults.cardColors( + containerColor = AppTheme.theme.colors.secondaryBackground + ) + ) { + when (state) { + is SelectCurrencyDialogState.Error -> { + ErrorState(state.message) + } + + is SelectCurrencyDialogState.Data -> { + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + AppTextField( + value = state.query, + label = stringResource(R.string.search_or_select_currency), + isResetIconEnabled = true, + onResetIconClick = onResetQueryClick, + onValueChange = onQueryTextChange, + modifier = Modifier + .weight(weight = 1f) + .padding( + horizontal = ElementMargin, + vertical = ElementMargin + ) + ) + + Box( + modifier = Modifier + .padding(end = HalfMargin) + .size(48.dp) + .clickable(onClick = onCloseIconClick) + ) { + Icon( + imageVector = AppIcon.CLOSE.vector, + tint = AppTheme.theme.colors.primaryIcon, + contentDescription = null, + modifier = Modifier + .align(Alignment.Center) + ) + } + } + + when { + state.isLoading -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .height(height = 200.dp) + ) { + CircularProgressIndicator() + } + } + + state.cellViewModels.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = minContentHeight) + ) { + EmptyState( + text = stringResource(R.string.no_results_found), + modifier = Modifier + .fillMaxWidth() + .padding(top = 200.dp) + ) + } + } + + else -> { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = minContentHeight) + ) { + items(state.cellViewModels) { cellViewModel -> + when (cellViewModel) { + is SpaceCellViewModel -> SpaceCell(cellViewModel) + is DividerCellViewModel -> DividerCell(cellViewModel) + is MenuCellViewModel -> MenuCell(cellViewModel) + else -> throw IllegalArgumentException( + "Unknown cell type: ${cellViewModel::class.simpleName}" + ) + } + } + } + } + } + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun SelectCurrencyDialogScreenPreview() { + ThemedPreview( + theme = LightTheme + ) { + SelectCurrencyDialogScreen( + state = SelectCurrencyDialogState.Data( + query = "", + cellViewModels = listOf( + newSpaceCell( + height = HalfMargin + ), + newDividerCell(), + newSpaceCell( + height = ElementMargin + ), + newMenuCell( + icon = AppIcon.CHECK.vector, + title = "USD - US Dollar" + ), + newSpaceCell( + height = ElementMargin + ), + newMenuCell( + icon = AppIcon.CHECK.vector, + title = "EUR - Euro" + ), + newSpaceCell( + height = ElementMargin + ), + newMenuCell( + icon = AppIcon.CHECK.vector, + title = "GBP - British Pound" + ) + ) + ), + onIntent = {} + ) + } +} + +@Preview +@Composable +private fun SelectCurrencyDialogScreenEmptyPreview() { + ThemedPreview( + theme = LightTheme + ) { + SelectCurrencyDialogScreen( + state = SelectCurrencyDialogState.Data( + query = "", + cellViewModels = emptyList() + ), + onIntent = {} + ) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/SelectCurrencyDialogViewModel.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/SelectCurrencyDialogViewModel.kt new file mode 100644 index 0000000..850dbfc --- /dev/null +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/SelectCurrencyDialogViewModel.kt @@ -0,0 +1,171 @@ +package com.github.ai.simplesplit.android.presentation.dialogs.selectCurrency + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.github.ai.simplesplit.android.data.database.model.CurrencyEntity +import com.github.ai.simplesplit.android.presentation.core.ResourceProvider +import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellEvent +import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellViewModel +import com.github.ai.simplesplit.android.presentation.core.compose.cells.model.MenuCellEvent +import com.github.ai.simplesplit.android.presentation.core.compose.navigation.Router +import com.github.ai.simplesplit.android.presentation.core.mvi.CellsMviViewModel +import com.github.ai.simplesplit.android.presentation.core.mvi.nonStateAction +import com.github.ai.simplesplit.android.presentation.dialogs.Dialog +import com.github.ai.simplesplit.android.presentation.dialogs.selectCurrency.model.SelectCurrencyDialogArgs +import com.github.ai.simplesplit.android.presentation.dialogs.selectCurrency.model.SelectCurrencyDialogIntent +import com.github.ai.simplesplit.android.presentation.dialogs.selectCurrency.model.SelectCurrencyDialogState +import com.github.ai.simplesplit.android.utils.SingleFlow +import com.github.ai.simplesplit.android.utils.StringUtils +import com.github.ai.simplesplit.android.utils.getStringOrNull +import com.github.ai.simplesplit.android.utils.parseCellId +import com.github.ai.simplesplit.android.utils.singleFlowOf +import com.github.ai.simplesplit.android.utils.toErrorMessage +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf + +class SelectCurrencyDialogViewModel( + private val interactor: SelectCurrencyInteractor, + private val cellFactory: SelectCurrencyDialogCellFactory, + private val resources: ResourceProvider, + private val router: Router, + private val args: SelectCurrencyDialogArgs +) : CellsMviViewModel( + initialState = SelectCurrencyDialogState.Data(isLoading = true), + initialIntent = SelectCurrencyDialogIntent.Initialize +) { + + private var allCurrencies by mutableStateOf?>(null) + + override fun handleIntent(intent: SelectCurrencyDialogIntent): Flow { + return when (intent) { + SelectCurrencyDialogIntent.Initialize -> loadData() + SelectCurrencyDialogIntent.Dismiss -> nonStateAction { navigateBack() } + SelectCurrencyDialogIntent.OnResetQueryClick -> onResetQueryClicked() + is SelectCurrencyDialogIntent.OnQueryTextChange -> onQueryTextChanged(intent.query) + is SelectCurrencyDialogIntent.FilterCurrencies -> filterData(intent.query) + + is SelectCurrencyDialogIntent.OnCurrencySelected -> + nonStateAction { onCurrencySelected(intent.currencyIsoCode) } + } + } + + override fun handleCellEvent(event: CellEvent) { + when (event) { + is MenuCellEvent.OnClick -> { + val currencyIsoCode = event.cellId.parseCellId() + ?.payload + ?.getStringOrNull() + + if (currencyIsoCode != null) { + sendIntent(SelectCurrencyDialogIntent.OnCurrencySelected(currencyIsoCode)) + } + } + } + } + + private fun loadData(): Flow { + return flow { + emit(SelectCurrencyDialogState.Data(isLoading = true)) + + interactor.getCurrencies() + .fold( + ifLeft = { error -> + val message = error.toErrorMessage(resources) + emit(SelectCurrencyDialogState.Error(message)) + }, + ifRight = { currencies -> + allCurrencies = currencies + + val state = state.value.asData().copy( + isLoading = false, + cellViewModels = buildCells(currencies, StringUtils.EMPTY) + ) + + emit(state) + } + ) + } + } + + private fun onResetQueryClicked(): Flow { + val currencies = allCurrencies ?: return emptyFlow() + + return flowOf( + state.value.asData().copy( + query = StringUtils.EMPTY, + isLoading = false, + cellViewModels = buildCells(currencies, StringUtils.EMPTY) + ) + ) + } + + private fun onQueryTextChanged(query: String): SingleFlow { + sendIntent(SelectCurrencyDialogIntent.FilterCurrencies(query)) + + return singleFlowOf( + state.value.asData().copy( + isLoading = true, + query = query + ) + ) + } + + private fun filterData(query: String): Flow { + val currencies = allCurrencies ?: return emptyFlow() + val trimmedQuery = query.trim() + + return flow { + delay(300L) + + val filteredCurrencies = currencies.filter { currency -> + currency.name.contains(trimmedQuery, ignoreCase = true) || + currency.isoCode.contains(trimmedQuery, ignoreCase = true) || + currency.symbol.contains(trimmedQuery, ignoreCase = true) + } + + val newState = state.value.asData().copy( + isLoading = false, + cellViewModels = if (filteredCurrencies.isNotEmpty()) { + buildCells(filteredCurrencies, query) + } else { + emptyList() + } + ) + + emit(newState) + } + } + + private fun buildCells( + currencies: List, + query: String + ): List { + return cellFactory.createCells( + currencies = currencies, + query = query, + selectedIsoCode = args.selectedIsoCurrencyCode, + eventProvider = cellEventProvider + ) + } + + private fun navigateBack() { + router.exit() + } + + private fun onCurrencySelected(isoCode: String) { + val currency = allCurrencies + ?.firstOrNull { currency -> currency.isoCode == isoCode } + ?: return + + router.setResult(Dialog.SelectCurrency::class, currency) + router.exit() + } + + private fun SelectCurrencyDialogState.asData(): SelectCurrencyDialogState.Data { + return this as SelectCurrencyDialogState.Data + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/SelectCurrencyInteractor.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/SelectCurrencyInteractor.kt new file mode 100644 index 0000000..c9b3bcd --- /dev/null +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/SelectCurrencyInteractor.kt @@ -0,0 +1,19 @@ +package com.github.ai.simplesplit.android.presentation.dialogs.selectCurrency + +import arrow.core.Either +import com.github.ai.simplesplit.android.data.database.model.CurrencyEntity +import com.github.ai.simplesplit.android.data.repository.CurrencyRepository +import com.github.ai.simplesplit.android.model.exception.AppException + +class SelectCurrencyInteractor( + private val currencyRepository: CurrencyRepository +) { + + fun getCurrencies(): Either> = + currencyRepository.getAllCached() + .map { currencies -> + currencies.sortedBy { currency -> + currency.name + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/model/SelectCurrencyDialogArgs.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/model/SelectCurrencyDialogArgs.kt new file mode 100644 index 0000000..37aa48f --- /dev/null +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/model/SelectCurrencyDialogArgs.kt @@ -0,0 +1,8 @@ +package com.github.ai.simplesplit.android.presentation.dialogs.selectCurrency.model + +import kotlinx.serialization.Serializable + +@Serializable +data class SelectCurrencyDialogArgs( + val selectedIsoCurrencyCode: String? = null +) \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/model/SelectCurrencyDialogIntent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/model/SelectCurrencyDialogIntent.kt new file mode 100644 index 0000000..3546d5f --- /dev/null +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/model/SelectCurrencyDialogIntent.kt @@ -0,0 +1,14 @@ +package com.github.ai.simplesplit.android.presentation.dialogs.selectCurrency.model + +import com.github.ai.simplesplit.android.presentation.core.mvi.MviIntent + +sealed class SelectCurrencyDialogIntent( + override val isImmediate: Boolean = false +) : MviIntent { + data object Initialize : SelectCurrencyDialogIntent() + data object Dismiss : SelectCurrencyDialogIntent() + data object OnResetQueryClick : SelectCurrencyDialogIntent() + data class OnQueryTextChange(val query: String) : SelectCurrencyDialogIntent(isImmediate = true) + data class OnCurrencySelected(val currencyIsoCode: String) : SelectCurrencyDialogIntent() + data class FilterCurrencies(val query: String) : SelectCurrencyDialogIntent() +} \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/model/SelectCurrencyDialogState.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/model/SelectCurrencyDialogState.kt new file mode 100644 index 0000000..5aee9ea --- /dev/null +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/dialogs/selectCurrency/model/SelectCurrencyDialogState.kt @@ -0,0 +1,22 @@ +package com.github.ai.simplesplit.android.presentation.dialogs.selectCurrency.model + +import androidx.compose.runtime.Immutable +import com.github.ai.simplesplit.android.model.ErrorMessage +import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellViewModel +import com.github.ai.simplesplit.android.utils.StringUtils + +@Immutable +sealed interface SelectCurrencyDialogState { + + @Immutable + data class Data( + val isLoading: Boolean = false, + val query: String = StringUtils.EMPTY, + val cellViewModels: List = emptyList() + ) : SelectCurrencyDialogState + + @Immutable + data class Error( + val message: ErrorMessage + ) : SelectCurrencyDialogState +} \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorScreen.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorScreen.kt index 6467aae..2a405ae 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorScreen.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorScreen.kt @@ -155,7 +155,7 @@ private fun RenderDataContent( AppTextField( value = state.amount, error = state.amountError, - label = stringResource(R.string.amount), + label = state.amountHint, onValueChange = { newValue -> onIntent.invoke(ExpenseEditorIntent.OnAmountChanged(newValue)) }, diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorViewModel.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorViewModel.kt index 794457e..be305a6 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorViewModel.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/ExpenseEditorViewModel.kt @@ -1,6 +1,7 @@ package com.github.ai.simplesplit.android.presentation.screens.expenseEditor import com.github.ai.simplesplit.android.R +import com.github.ai.simplesplit.android.data.api.coverters.toCurrency import com.github.ai.simplesplit.android.presentation.core.ResourceProvider import com.github.ai.simplesplit.android.presentation.core.compose.navigation.Router import com.github.ai.simplesplit.android.presentation.core.mvi.MviViewModel @@ -48,10 +49,12 @@ class ExpenseEditorViewModel( emit(ExpenseEditorState.Loading) val memberNames = args.group.members.map { member -> member.name } + val currency = args.group.currency.toCurrency() when (args.mode) { ExpenseEditorMode.NewExpense -> { dataState = dataState.copy( + amountHint = resources.getString(R.string.amount_in_str, currency.symbol), payer = memberNames.first(), availablePayers = memberNames ) @@ -69,6 +72,7 @@ class ExpenseEditorViewModel( dataState = dataState.copy( title = expense?.title.orEmpty(), amount = expense?.amount?.toString().orEmpty(), + amountHint = resources.getString(R.string.amount_in_str, currency.symbol), payer = payer, availablePayers = memberNames ) diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/model/ExpenseEditorState.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/model/ExpenseEditorState.kt index da5f947..f4cc231 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/model/ExpenseEditorState.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/expenseEditor/model/ExpenseEditorState.kt @@ -14,6 +14,7 @@ sealed interface ExpenseEditorState { val payer: String = StringUtils.EMPTY, val title: String = StringUtils.EMPTY, val amount: String = StringUtils.EMPTY, + val amountHint: String = StringUtils.EMPTY, val availablePayers: List = emptyList(), val titleError: String? = null, val amountError: String? = null, diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/cells/GroupDetailsCellFactory.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/cells/GroupDetailsCellFactory.kt index b96c7b0..03c2651 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/cells/GroupDetailsCellFactory.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/cells/GroupDetailsCellFactory.kt @@ -1,6 +1,7 @@ package com.github.ai.simplesplit.android.presentation.screens.groupDetails.cells import com.github.ai.simplesplit.android.R +import com.github.ai.simplesplit.android.data.api.coverters.toCurrency import com.github.ai.simplesplit.android.presentation.core.ResourceProvider import com.github.ai.simplesplit.android.presentation.core.compose.CornersShape import com.github.ai.simplesplit.android.presentation.core.compose.TextSize @@ -32,6 +33,7 @@ import com.github.ai.simplesplit.android.presentation.screens.groupDetails.cells import com.github.ai.simplesplit.android.utils.CellId import com.github.ai.simplesplit.android.utils.CellIdPayload.StringPayload import com.github.ai.simplesplit.android.utils.format +import com.github.ai.simplesplit.android.utils.formatAsMoney import com.github.ai.split.api.GroupDto class GroupDetailsCellFactory( @@ -171,7 +173,7 @@ class GroupDetailsCellFactory( title = expense.title, description = "Paid by $payerName", // TODO: string members = members, - amount = expense.amount.toString(), + amount = expense.amount.formatAsMoney(expense.currency.toCurrency()), // TODO: date should be implemented on server side date = "01 Jan", shape = shape @@ -209,6 +211,7 @@ class GroupDetailsCellFactory( models.addAll(createSettlementHeaderModels()) + val currency = group.currency.toCurrency() val userUidToUserMap = group.members.associateBy { user -> user.uid } val transactions = group.paybackTransactions @@ -236,7 +239,7 @@ class GroupDetailsCellFactory( SettlementCellModel( id = "settlement_$idx", title = "${debtor.name} → ${creditor.name}", - amount = "%.2f".format(transaction.amount), + amount = transaction.amount.formatAsMoney(currency), shape = shape ) ) diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorInteractor.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorInteractor.kt index 965a936..8866c80 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorInteractor.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorInteractor.kt @@ -2,11 +2,14 @@ package com.github.ai.simplesplit.android.presentation.screens.groupEditor import arrow.core.Either import arrow.core.raise.either +import com.github.ai.simplesplit.android.data.database.model.CurrencyEntity import com.github.ai.simplesplit.android.data.database.model.GroupCredentials +import com.github.ai.simplesplit.android.data.repository.CurrencyRepository import com.github.ai.simplesplit.android.data.repository.GroupCredentialsRepository import com.github.ai.simplesplit.android.data.repository.GroupRepository import com.github.ai.simplesplit.android.data.repository.MemberRepository import com.github.ai.simplesplit.android.model.exception.AppException +import com.github.ai.simplesplit.android.presentation.screens.groupEditor.model.GroupEditorData import com.github.ai.split.api.GroupDto import com.github.ai.split.api.UserNameDto import com.github.ai.split.api.request.PostGroupRequest @@ -19,18 +22,32 @@ typealias UserUidAndName = Pair class GroupEditorInteractor( private val groupRepository: GroupRepository, private val memberRepository: MemberRepository, - private val credentialsRepository: GroupCredentialsRepository + private val credentialsRepository: GroupCredentialsRepository, + private val currencyRepository: CurrencyRepository ) { + suspend fun loadCurrencies(): Either> = + currencyRepository.getAllOrDownload() + suspend fun loadGroup( uid: String, password: String - ): Either = groupRepository.getGroup(uid, password) + ): Either = + either { + val currencies = loadCurrencies().bind() + val group = groupRepository.getGroup(uid, password).bind() + + GroupEditorData( + currencies = currencies, + group = group + ) + } suspend fun updateGroup( credentials: GroupCredentials, newTitle: String?, newPassword: String?, + newCurrencyIsoCode: String?, memberUidsToRemove: List, memberNamesToAdd: List, membersToUpdate: List @@ -73,6 +90,7 @@ class GroupEditorInteractor( title = newTitle, password = newPassword, description = null, + currencyIsoCode = newCurrencyIsoCode, members = null ) ).bind().group @@ -89,6 +107,7 @@ class GroupEditorInteractor( suspend fun createGroup( password: String, title: String, + currencyIsoCode: String, members: List ): Either = either { @@ -96,6 +115,7 @@ class GroupEditorInteractor( password = password, title = title, description = null, + currencyIsoCode = currencyIsoCode, members = members.map { UserNameDto(name = it) }, expenses = null ) diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorScreen.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorScreen.kt index ad8455c..790aa75 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorScreen.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.github.ai.simplesplit.android.R +import com.github.ai.simplesplit.android.presentation.core.compose.AppDropdownFieldNoMenu import com.github.ai.simplesplit.android.presentation.core.compose.AppTextField import com.github.ai.simplesplit.android.presentation.core.compose.CenteredBox import com.github.ai.simplesplit.android.presentation.core.compose.ErrorMessageCard @@ -147,6 +148,9 @@ private fun RenderDataContent( val onConfirmPasswordToggleClick = rememberCallback { isVisible: Boolean -> onIntent.invoke(GroupEditorIntent.OnConfirmPasswordToggleClick(isVisible)) } + val onCurrencyClick = rememberOnClickedCallback { + onIntent.invoke(GroupEditorIntent.OnCurrencyClick) + } Column( modifier = Modifier @@ -197,6 +201,13 @@ private fun RenderDataContent( Spacer(modifier = Modifier.height(SmallMargin)) + AppDropdownFieldNoMenu( + label = stringResource(R.string.currency), + value = state.currency, + onClick = onCurrencyClick, + modifier = Modifier.fillMaxWidth() + ) + Row( modifier = Modifier.fillMaxWidth() ) { @@ -326,6 +337,7 @@ fun GroupEditorScreenDataPreview() { private fun newDataState() = GroupEditorState.Data( + currency = "United States Dollar - $", members = listOf( MemberItem("Donald"), MemberItem("Mickey") diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorViewModel.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorViewModel.kt index cd33e0e..f1d6986 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorViewModel.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorViewModel.kt @@ -1,11 +1,17 @@ package com.github.ai.simplesplit.android.presentation.screens.groupEditor +import arrow.core.Either import com.github.ai.simplesplit.android.R +import com.github.ai.simplesplit.android.data.database.model.CurrencyEntity +import com.github.ai.simplesplit.android.model.exception.AppException import com.github.ai.simplesplit.android.presentation.core.ResourceProvider import com.github.ai.simplesplit.android.presentation.core.compose.navigation.Router import com.github.ai.simplesplit.android.presentation.core.mvi.MviViewModel import com.github.ai.simplesplit.android.presentation.core.mvi.nonStateAction +import com.github.ai.simplesplit.android.presentation.dialogs.Dialog +import com.github.ai.simplesplit.android.presentation.dialogs.selectCurrency.model.SelectCurrencyDialogArgs import com.github.ai.simplesplit.android.presentation.screens.groupEditor.model.GroupEditorArgs +import com.github.ai.simplesplit.android.presentation.screens.groupEditor.model.GroupEditorData import com.github.ai.simplesplit.android.presentation.screens.groupEditor.model.GroupEditorIntent import com.github.ai.simplesplit.android.presentation.screens.groupEditor.model.GroupEditorMode import com.github.ai.simplesplit.android.presentation.screens.groupEditor.model.GroupEditorState @@ -34,7 +40,9 @@ class GroupEditorViewModel( ) { private var dataState by mutableStateFlow(GroupEditorState.Data()) - private var data by mutableStateFlow(null) + private var currentGroup by mutableStateFlow(null) + private var allCurrencies by mutableStateFlow?>(null) + private var selectedCurrency by mutableStateFlow(null) private var selectedMember by mutableStateFlow(null) override fun handleIntent(intent: GroupEditorIntent): Flow { @@ -44,6 +52,9 @@ class GroupEditorViewModel( GroupEditorIntent.OnAddMemberClick -> onAddMemberClicked() GroupEditorIntent.OnDoneClick -> onDoneClicked() GroupEditorIntent.OnCloseErrorClick -> onCloseErrorClicked() + GroupEditorIntent.OnCurrencyClick -> + nonStateAction { showSelectCurrencyDialog() } + is GroupEditorIntent.OnTitleChanged -> onTitleChanged(intent) is GroupEditorIntent.OnPasswordChanged -> onPasswordChanged(intent) is GroupEditorIntent.OnConfirmPasswordChanged -> onConfirmPasswordChanged(intent) @@ -56,6 +67,7 @@ class GroupEditorViewModel( GroupEditorIntent.OnCancelMemberEditClick -> onCancelMemberEditClicked() GroupEditorIntent.OnApplyMemberEditClick -> onApplyMemberEditClicked() is GroupEditorIntent.OnEditMemberClick -> onEditMemberClicked(intent.memberIndex) + is GroupEditorIntent.OnCurrencySelected -> onCurrencySelected(intent.currency) } } @@ -244,6 +256,7 @@ class GroupEditorViewModel( interactor.createGroup( password = dataState.password.trim(), title = dataState.title.trim(), + currencyIsoCode = selectedCurrency?.isoCode ?: StringUtils.EMPTY, members = dataState.members.map { member -> member.name } ) } @@ -257,6 +270,7 @@ class GroupEditorViewModel( credentials = args.mode.credentials, newTitle = dataState.title.ifBlank { null }, newPassword = dataState.password.ifBlank { null }, + newCurrencyIsoCode = selectedCurrency?.isoCode?.ifBlank { null }, memberUidsToRemove = membersToDelete, memberNamesToAdd = membersToAdd.map { it.name }, membersToUpdate = membersToUpdate.map { Pair(it.uid ?: "", it.name) } @@ -281,52 +295,87 @@ class GroupEditorViewModel( private fun loadData(): Flow { return when (args.mode) { - is GroupEditorMode.NewGroup -> { - dataState = dataState.copy( - isAddButtonVisible = true - ) + is GroupEditorMode.NewGroup -> flow { + emit(GroupEditorState.Loading) - flowOf( - dataState - ) + val getCurrenciesResult = interactor.loadCurrencies() + emit(onNewGroupDataLoaded(getCurrenciesResult)) } is GroupEditorMode.EditGroup -> flow { emit(GroupEditorState.Loading) - interactor.loadGroup( + val loadDataResult = interactor.loadGroup( uid = args.mode.credentials.groupUid, password = args.mode.credentials.password - ).fold( - ifLeft = { error -> - emit(GroupEditorState.Error(error.toErrorMessage(resources))) - }, - ifRight = { group -> - data = group - dataState = GroupEditorState.Data( - title = group.title, - isAddButtonVisible = true, - members = group.members.map { member -> - MemberItem( - name = member.name, - uid = member.uid - ) - } - ) - emit(dataState) - } ) + emit(onGroupDataLoaded(loadDataResult)) }.flowOn(Dispatchers.IO) } } + private fun onNewGroupDataLoaded( + result: Either> + ): GroupEditorState { + return result.fold( + ifLeft = { error -> + GroupEditorState.Error(error.toErrorMessage(resources)) + }, + ifRight = { data -> + val currency = determineDefaultCurrency(data) + + allCurrencies = data + selectedCurrency = currency + + dataState = dataState.copy( + currency = formatCurrency(currency), + isAddButtonVisible = true + ) + + dataState + } + ) + } + + private fun onGroupDataLoaded(result: Either): GroupEditorState { + return result.fold( + ifLeft = { error -> + GroupEditorState.Error(error.toErrorMessage(resources)) + }, + ifRight = { data -> + allCurrencies = data.currencies + currentGroup = data.group + + val currency = data.currencies + .firstOrNull { currency -> currency.isoCode == data.group.currency.isoCode } + ?: determineDefaultCurrency(data.currencies) + + selectedCurrency = currency + + dataState = GroupEditorState.Data( + title = data.group.title, + currency = formatCurrency(currency), + isAddButtonVisible = true, + members = data.group.members.map { member -> + MemberItem( + name = member.name, + uid = member.uid + ) + } + ) + + dataState + } + ) + } + private fun getMembersToAdd(): List { return dataState.members .filter { member -> member.uid == null } } private fun getMemberUidsToDelete(): List { - val currentMember = data?.members ?: return emptyList() + val currentMember = currentGroup?.members ?: return emptyList() val oldMemberUidToNameMap = currentMember.associateBy { member -> member.uid } @@ -341,7 +390,7 @@ class GroupEditorViewModel( } private fun getMembersToUpdate(): List { - val oldMembers = data?.members ?: return emptyList() + val oldMembers = currentGroup?.members ?: return emptyList() val oldMemberUidToNameMap = oldMembers .map { member -> member.uid to member.name } @@ -365,6 +414,45 @@ class GroupEditorViewModel( return flowOf(dataState) } + private fun showSelectCurrencyDialog() { + router.showDialog( + Dialog.SelectCurrency( + SelectCurrencyDialogArgs( + selectedIsoCurrencyCode = selectedCurrency?.isoCode + ) + ) + ) + router.setResultListener(Dialog.SelectCurrency::class) { currency -> + if (currency is CurrencyEntity) { + sendIntent(GroupEditorIntent.OnCurrencySelected(currency)) + } + } + } + + private fun onCurrencySelected(currency: CurrencyEntity): Flow { + selectedCurrency = currency + + dataState = dataState.copy( + currency = formatCurrency(currency) + ) + + return flowOf(dataState) + } + + private fun determineDefaultCurrency(currencies: List): CurrencyEntity { + return currencies.first { currency -> + currency.isoCode == "EUR" + } + } + + private fun formatCurrency(currency: CurrencyEntity): String { + return if (currency.symbol.isNotBlank()) { + currency.name + " - " + currency.symbol + } else { + currency.name + } + } + private fun validateEditGroupData(state: GroupEditorState.Data): ValidationResult { val title = state.title val password = state.password diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorData.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorData.kt new file mode 100644 index 0000000..ce5cb7c --- /dev/null +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorData.kt @@ -0,0 +1,9 @@ +package com.github.ai.simplesplit.android.presentation.screens.groupEditor.model + +import com.github.ai.simplesplit.android.data.database.model.CurrencyEntity +import com.github.ai.split.api.GroupDto + +data class GroupEditorData( + val currencies: List, + val group: GroupDto +) \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorIntent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorIntent.kt index 1f962d3..7d8d4e0 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorIntent.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorIntent.kt @@ -1,5 +1,6 @@ package com.github.ai.simplesplit.android.presentation.screens.groupEditor.model +import com.github.ai.simplesplit.android.data.database.model.CurrencyEntity import com.github.ai.simplesplit.android.presentation.core.mvi.MviIntent sealed class GroupEditorIntent( @@ -13,6 +14,7 @@ sealed class GroupEditorIntent( data object OnAddMemberClick : GroupEditorIntent() data object OnCancelMemberEditClick : GroupEditorIntent() data object OnApplyMemberEditClick : GroupEditorIntent() + data object OnCurrencyClick : GroupEditorIntent() data class OnTitleChanged(val title: String) : GroupEditorIntent(isImmediate = true) data class OnPasswordChanged(val password: String) : GroupEditorIntent(isImmediate = true) data class OnConfirmPasswordChanged( @@ -23,4 +25,5 @@ sealed class GroupEditorIntent( data class OnEditMemberClick(val memberIndex: Int) : GroupEditorIntent() data class OnPasswordToggleClick(val isVisible: Boolean) : GroupEditorIntent() data class OnConfirmPasswordToggleClick(val isVisible: Boolean) : GroupEditorIntent() + data class OnCurrencySelected(val currency: CurrencyEntity) : GroupEditorIntent() } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorState.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorState.kt index 93a0e02..c9bdd49 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorState.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorState.kt @@ -14,6 +14,8 @@ sealed interface GroupEditorState { val title: String = StringUtils.EMPTY, val password: String = StringUtils.EMPTY, val confirmPassword: String = StringUtils.EMPTY, + val currency: String = StringUtils.EMPTY, + val isCurrencyExpanded: Boolean = false, val member: String = StringUtils.EMPTY, val members: List = emptyList(), val titleError: String? = null, diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/GroupsInteractor.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/GroupsInteractor.kt index 3e0ef73..1a8f395 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/GroupsInteractor.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/GroupsInteractor.kt @@ -3,6 +3,7 @@ package com.github.ai.simplesplit.android.presentation.screens.groups import arrow.core.Either import arrow.core.raise.either import com.github.ai.simplesplit.android.data.database.model.GroupCredentials +import com.github.ai.simplesplit.android.data.repository.CurrencyRepository import com.github.ai.simplesplit.android.data.repository.GroupCredentialsRepository import com.github.ai.simplesplit.android.data.repository.GroupRepository import com.github.ai.simplesplit.android.domain.usecase.CreateExportUrlUseCase @@ -14,6 +15,7 @@ import kotlinx.coroutines.flow.Flow class GroupsInteractor( private val groupRepository: GroupRepository, private val credentialsRepository: GroupCredentialsRepository, + private val currencyRepository: CurrencyRepository, private val exportUrlUseCase: CreateExportUrlUseCase, private val groupUrlUseCase: CreateGroupUrlUseCase ) { @@ -21,6 +23,7 @@ class GroupsInteractor( suspend fun loadData(): Either = either { val credentials = credentialsRepository.getAll() + val currencies = currencyRepository.getAllOrDownload().bind() val groups = if (credentials.isNotEmpty()) { val (uids, passwords) = credentials @@ -39,7 +42,8 @@ class GroupsInteractor( GroupsData( groups = groups, - requestedCredentials = credentials + requestedCredentials = credentials, + currencies = currencies ) } diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/cells/CellFactory.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/cells/CellFactory.kt index 0a7825f..e4af520 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/cells/CellFactory.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/cells/CellFactory.kt @@ -1,6 +1,7 @@ package com.github.ai.simplesplit.android.presentation.screens.groups.cells import androidx.compose.ui.unit.dp +import com.github.ai.simplesplit.android.data.api.coverters.toCurrency import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellEventProvider import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellViewModel import com.github.ai.simplesplit.android.presentation.core.compose.cells.model.SpaceCellModel @@ -11,6 +12,7 @@ import com.github.ai.simplesplit.android.presentation.screens.groups.model.Group import com.github.ai.simplesplit.android.utils.CellId import com.github.ai.simplesplit.android.utils.CellIdPayload.StringPayload import com.github.ai.simplesplit.android.utils.format +import com.github.ai.simplesplit.android.utils.formatAsMoney class CellFactory { @@ -34,7 +36,7 @@ class CellFactory { title = group.title, description = group.description, members = members, - amount = "%.2f".format(sum) + amount = sum.formatAsMoney(group.currency.toCurrency()) ), eventProvider ) diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/model/GroupsData.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/model/GroupsData.kt index d737f8d..f4501ab 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/model/GroupsData.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/model/GroupsData.kt @@ -1,9 +1,11 @@ package com.github.ai.simplesplit.android.presentation.screens.groups.model +import com.github.ai.simplesplit.android.data.database.model.CurrencyEntity import com.github.ai.simplesplit.android.data.database.model.GroupCredentials import com.github.ai.split.api.GroupDto data class GroupsData( - val groups: List = emptyList(), - val requestedCredentials: List = emptyList() + val groups: List, + val requestedCredentials: List, + val currencies: List ) \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/utils/ArrowEtensions.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/utils/ArrowEtensions.kt index fe5e099..bafa70f 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/utils/ArrowEtensions.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/utils/ArrowEtensions.kt @@ -6,11 +6,7 @@ import com.github.ai.simplesplit.android.model.ErrorMessage import com.github.ai.simplesplit.android.model.exception.AppException import com.github.ai.simplesplit.android.presentation.core.ResourceProvider -fun Either.getMessage(): String { - // TODO: get root cause - return leftOrNull()?.message ?: "Error has been occurred" -} - +@Deprecated("Refactor or remove") fun Either.toErrorMessage(resources: ResourceProvider): ErrorMessage { val message = leftOrNull()?.formatReadableMessage(resources) ?: resources.getString(R.string.unknown_error_message) diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/utils/CurrencyFormatter.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/utils/CurrencyFormatter.kt new file mode 100644 index 0000000..6528eff --- /dev/null +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/utils/CurrencyFormatter.kt @@ -0,0 +1,23 @@ +package com.github.ai.simplesplit.android.utils + +import com.github.ai.simplesplit.android.data.database.model.CurrencyEntity + +fun Double.formatAsMoney(currency: CurrencyEntity?): String { + val formatted = "%.2f".format(this) + + val whole = formatted.substringBefore('.') + val fraction = formatted.substringAfter('.') + val fractionValue = fraction.toIntOrNull() + + val symbol = if (!currency?.symbol.isNullOrBlank()) { + currency?.symbol + } else { + currency?.isoCode ?: StringUtils.EMPTY + } + + return if (fractionValue == 0) { + "$symbol$whole" + } else { + "$symbol$formatted" + } +} \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 45f4544..0179419 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -23,6 +23,7 @@ No groups Some groups are missing in the server response. Potentially their password could be changed or they were removed Forget them + Not selected Settings @@ -42,6 +43,7 @@ Passwords don\'t match Add at least 2 members Non-unique member name + Currency No expenses yet in this group. Add the first expense to get started! @@ -54,6 +56,7 @@ Payer Expense Title Amount + Amount in %s Select a payer Enter expense title Enter amount @@ -64,4 +67,8 @@ Add group by URL Group URL + + Search or select a currency" + No results found + \ No newline at end of file