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 ae84d5f..56b9a2b 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 @@ -10,6 +10,7 @@ import com.github.ai.simplesplit.android.data.repository.GroupRepository import com.github.ai.simplesplit.android.data.repository.MemberRepository import com.github.ai.simplesplit.android.data.settings.Settings import com.github.ai.simplesplit.android.data.settings.SettingsImpl +import com.github.ai.simplesplit.android.domain.TimestampFormatter import com.github.ai.simplesplit.android.domain.usecase.CreateExportUrlUseCase import com.github.ai.simplesplit.android.domain.usecase.CreateGroupUrlUseCase import com.github.ai.simplesplit.android.domain.usecase.ParseGroupUrlUseCase @@ -60,6 +61,7 @@ object AndroidAppModule { singleOf(::ThemeProviderImpl).bind(ThemeProvider::class) singleOf(::ResourceProviderImpl).bind(ResourceProvider::class) singleOf(::SettingsImpl).bind(Settings::class) + singleOf(::TimestampFormatter) // Database single { AppDatabase.buildDatabase(get()) } diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/domain/TimestampFormatter.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/domain/TimestampFormatter.kt new file mode 100644 index 0000000..ef93679 --- /dev/null +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/domain/TimestampFormatter.kt @@ -0,0 +1,27 @@ +package com.github.ai.simplesplit.android.domain + +import android.content.Context +import java.text.DateFormat +import java.util.Date +import java.util.Locale + +class TimestampFormatter( + private val context: Context +) { + + fun formatShortDate(timestampMillis: Long): String { + val formatter = DateFormat.getDateInstance(DateFormat.MEDIUM, getSystemLocale(context)) + return formatter.format(Date(timestampMillis)) + } + + fun formatDateAndTime(timestampMillis: Long): String { + val dateFormatter = DateFormat.getDateInstance(DateFormat.LONG, getSystemLocale(context)) + val timeFormatter = DateFormat.getTimeInstance() + val date = Date(timestampMillis) + return dateFormatter.format(date) + " " + timeFormatter.format(date) + } + + private fun getSystemLocale(context: Context): Locale { + return context.resources.configuration.locales[0] + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/ui/EmptyMessageCell.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/ui/EmptyMessageCell.kt index b3432d8..2d64c18 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/ui/EmptyMessageCell.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/core/compose/cells/ui/EmptyMessageCell.kt @@ -34,7 +34,7 @@ fun EmptyMessageCell(viewModel: EmptyMessageCellViewModel) { Text( text = model.message, textAlign = TextAlign.Center, - style = TextSize.BODY_MEDIUM.toTextStyle(), + style = TextSize.TITLE_MEDIUM.toTextStyle(), color = AppTheme.theme.colors.secondaryText ) } 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 d22341d..841b842 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 @@ -3,6 +3,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.domain.TimestampFormatter 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 @@ -30,7 +31,8 @@ import com.github.ai.split.api.ExpenseDto class ExpenseDetailsDialogCellFactory( private val themeProvider: ThemeProvider, - private val resourceProvider: ResourceProvider + private val resources: ResourceProvider, + private val timestampFormatter: TimestampFormatter ) { fun createCells( @@ -89,11 +91,31 @@ class ExpenseDetailsDialogCellFactory( ) ) + val created = expense.created.timestampSeconds * 1000L + val modified = expense.modified.timestampSeconds * 1000L + + cells.add( + TextCellViewModel( + TextCellModel( + id = "created_date", + text = resources.getString( + R.string.create_on_with_str, + timestampFormatter.formatDateAndTime(created) + ), + textSize = TextSize.BODY_LARGE, + textColor = TextColor.SECONDARY + ) + ) + ) + cells.add( TextCellViewModel( TextCellModel( - id = "added_date", - text = "added on 01 Jan 2025", // TODO: handle date + id = "modified_date", + text = resources.getString( + R.string.modified_on_with_str, + timestampFormatter.formatDateAndTime(modified) + ), textSize = TextSize.BODY_LARGE, textColor = TextColor.SECONDARY ) @@ -222,7 +244,7 @@ class ExpenseDetailsDialogCellFactory( MenuCellModel( id = CellId.EDIT_MENU.name, icon = AppIcon.EDIT.vector, - title = resourceProvider.getString(R.string.edit), + title = resources.getString(R.string.edit), height = OneLineSmallItemHeight ), eventProvider @@ -231,7 +253,7 @@ class ExpenseDetailsDialogCellFactory( MenuCellModel( id = CellId.REMOVE_MENU.name, icon = AppIcon.REMOVE.vector, - title = resourceProvider.getString(R.string.remove), + title = resources.getString(R.string.remove), height = OneLineSmallItemHeight ), eventProvider diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/GroupDetailsScreen.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/GroupDetailsScreen.kt index 0047235..a4c5a9b 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/GroupDetailsScreen.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/GroupDetailsScreen.kt @@ -16,10 +16,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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.CenteredBox +import com.github.ai.simplesplit.android.presentation.core.compose.CornersShape import com.github.ai.simplesplit.android.presentation.core.compose.ErrorMessageCard import com.github.ai.simplesplit.android.presentation.core.compose.ErrorState +import com.github.ai.simplesplit.android.presentation.core.compose.TextSize import com.github.ai.simplesplit.android.presentation.core.compose.TopBar import com.github.ai.simplesplit.android.presentation.core.compose.TopBarMenuItem import com.github.ai.simplesplit.android.presentation.core.compose.cells.CellViewModel @@ -29,6 +33,9 @@ import com.github.ai.simplesplit.android.presentation.core.compose.cells.ui.Head import com.github.ai.simplesplit.android.presentation.core.compose.cells.ui.ShapedSpaceCell import com.github.ai.simplesplit.android.presentation.core.compose.cells.ui.ShapedTextCell import com.github.ai.simplesplit.android.presentation.core.compose.cells.ui.SpaceCell +import com.github.ai.simplesplit.android.presentation.core.compose.cells.ui.newShapedSpaceCellViewModel +import com.github.ai.simplesplit.android.presentation.core.compose.cells.ui.newShapedTextCell +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.EmptyMessageCellViewModel import com.github.ai.simplesplit.android.presentation.core.compose.cells.viewModel.HeaderCellViewModel @@ -39,6 +46,9 @@ import com.github.ai.simplesplit.android.presentation.core.compose.preview.Theme 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.AppTheme +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.LightTheme import com.github.ai.simplesplit.android.presentation.screens.groupDetails.cells.ui.ExpenseCell import com.github.ai.simplesplit.android.presentation.screens.groupDetails.cells.ui.SettlementCell @@ -167,7 +177,58 @@ fun GroupDetailsDataPreview() { } } +@Composable private fun newDataState() = GroupDetailsState.Data( - cellViewModels = listOf() + cellViewModels = listOf( + newSpaceCell( + height = HalfMargin + ), + newShapedSpaceCellViewModel( + height = GroupMargin, + shape = CornersShape.TOP + ), + newShapedTextCell( + text = "Trip to Disney Land", + textColor = AppTheme.theme.colors.primaryText, + textSize = TextSize.TITLE_LARGE, + shape = CornersShape.NONE + ), + newShapedSpaceCellViewModel( + height = ElementMargin, + shape = CornersShape.NONE + ), + newShapedTextCell( + text = stringResource(R.string.members_with_str, 5), + textColor = AppTheme.theme.colors.primaryText, + textSize = TextSize.TITLE_MEDIUM, + shape = CornersShape.NONE + ), + newShapedTextCell( + text = "Mickey Mouse - Donald Duck - Goofy - Minnie Mouse - Shlof", + textColor = AppTheme.theme.colors.secondaryText, + textSize = TextSize.BODY_LARGE, + shape = CornersShape.NONE + ), + newShapedSpaceCellViewModel( + height = ElementMargin, + shape = CornersShape.NONE + ), + newShapedTextCell( + text = stringResource(R.string.total_spending), + textColor = AppTheme.theme.colors.primaryText, + textSize = TextSize.TITLE_MEDIUM, + shape = CornersShape.NONE + ), + newShapedTextCell( + text = "$300", + textColor = AppTheme.theme.colors.secondaryText, + textSize = TextSize.BODY_LARGE, + shape = CornersShape.NONE + ), + newShapedSpaceCellViewModel( + height = GroupMargin, + shape = CornersShape.BOTTOM + ) + ) ) \ No newline at end of file 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 03c2651..10a0e78 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 @@ -2,6 +2,7 @@ package com.github.ai.simplesplit.android.presentation.screens.groupDetails.cell import com.github.ai.simplesplit.android.R import com.github.ai.simplesplit.android.data.api.coverters.toCurrency +import com.github.ai.simplesplit.android.domain.TimestampFormatter 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 @@ -38,7 +39,8 @@ import com.github.ai.split.api.GroupDto class GroupDetailsCellFactory( private val themeProvider: ThemeProvider, - private val resourceProvider: ResourceProvider + private val resources: ResourceProvider, + private val timestampFormatter: TimestampFormatter ) { fun createCells( @@ -76,7 +78,9 @@ class GroupDetailsCellFactory( val members = group.members.map { member -> member.name } .joinToString(" - ") - return listOf( + val totalSpending = group.expenses.sumOf { expense -> expense.amount } + + val models = mutableListOf( SpaceCellModel( id = "top_space", height = HalfMargin @@ -95,7 +99,14 @@ class GroupDetailsCellFactory( ), ShapedSpaceCellModel( id = "title_middle_space", - height = HalfMargin, + height = ElementMargin, + shape = CornersShape.NONE + ), + ShapedTextCellModel( + id = "members_title", + text = resources.getString(R.string.members_with_str, group.members.size), + textColor = themeProvider.theme.colors.primaryText, + textSize = TextSize.TITLE_MEDIUM, shape = CornersShape.NONE ), ShapedTextCellModel( @@ -104,13 +115,44 @@ class GroupDetailsCellFactory( textColor = themeProvider.theme.colors.secondaryText, textSize = TextSize.BODY_LARGE, shape = CornersShape.NONE - ), + ) + ) + + if (group.expenses.isNotEmpty()) { + models.addAll( + listOf( + ShapedSpaceCellModel( + id = "total_spending_space", + height = ElementMargin, + shape = CornersShape.NONE + ), + ShapedTextCellModel( + id = "total_spending_title", + text = resources.getString(R.string.total_spending), + textColor = themeProvider.theme.colors.primaryText, + textSize = TextSize.TITLE_MEDIUM, + shape = CornersShape.NONE + ), + ShapedTextCellModel( + id = "total_spending", + text = totalSpending.formatAsMoney(group.currency.toCurrency()), + textColor = themeProvider.theme.colors.secondaryText, + textSize = TextSize.BODY_LARGE, + shape = CornersShape.NONE + ) + ) + ) + } + + models.add( ShapedSpaceCellModel( id = "title_bottom_space", height = GroupMargin, shape = CornersShape.BOTTOM ) ) + + return models } private fun createExpenseHeaderModels(): List { @@ -121,7 +163,7 @@ class GroupDetailsCellFactory( ), HeaderCellModel( id = "expense_header", - text = resourceProvider.getString(R.string.expenses), + text = resources.getString(R.string.expenses), textSize = TextSize.TITLE_MEDIUM, textColor = themeProvider.theme.colors.primaryText ) @@ -132,7 +174,7 @@ class GroupDetailsCellFactory( return listOf( EmptyMessageCellModel( id = "empty_message", - message = resourceProvider.getString(R.string.no_expenses_message), + message = resources.getString(R.string.no_expenses_message), height = EmptyMessageItemHeight ) ) @@ -167,15 +209,18 @@ class GroupDetailsCellFactory( ) } + val date = timestampFormatter.formatShortDate( + expense.modified.timestampSeconds * 1000L + ) + models.add( ExpenseCellModel( id = CellId("expense", StringPayload(expense.uid)).format(), title = expense.title, - description = "Paid by $payerName", // TODO: string + description = resources.getString(R.string.paid_by_with_str, payerName), members = members, amount = expense.amount.formatAsMoney(expense.currency.toCurrency()), - // TODO: date should be implemented on server side - date = "01 Jan", + date = date, shape = shape ) ) @@ -192,7 +237,7 @@ class GroupDetailsCellFactory( ), HeaderCellModel( id = "settlement_header", - text = "How to Settle Debts", // TODO: string + text = resources.getString(R.string.how_to_settle_debts), textSize = TextSize.TITLE_MEDIUM, textColor = themeProvider.theme.colors.primaryText ) @@ -238,7 +283,7 @@ class GroupDetailsCellFactory( models.add( SettlementCellModel( id = "settlement_$idx", - title = "${debtor.name} → ${creditor.name}", + title = "${debtor.name} > ${creditor.name}", amount = transaction.amount.formatAsMoney(currency), shape = shape ) @@ -249,7 +294,7 @@ class GroupDetailsCellFactory( models.add( EmptyMessageCellModel( id = "settlement_empty_message", - message = resourceProvider.getString(R.string.no_debts_yet), + message = resources.getString(R.string.no_debts_yet), height = HugeMargin ) ) diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/cells/ui/SettlementCell.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/cells/ui/SettlementCell.kt index 2d014d4..1d025df 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/cells/ui/SettlementCell.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupDetails/cells/ui/SettlementCell.kt @@ -86,7 +86,6 @@ fun SettlementCellPreview() { DividerCell(newDividerCell()) SettlementCell( newSettlementCell( - text = "JohnJohnJohnJohn → JaneJaneJaneJaneJaneJane", shape = CornersShape.BOTTOM ) ) 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 1a8f395..132ef97 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 @@ -10,6 +10,7 @@ import com.github.ai.simplesplit.android.domain.usecase.CreateExportUrlUseCase import com.github.ai.simplesplit.android.domain.usecase.CreateGroupUrlUseCase import com.github.ai.simplesplit.android.model.exception.AppException import com.github.ai.simplesplit.android.presentation.screens.groups.model.GroupsData +import com.github.ai.split.api.GroupDto import kotlinx.coroutines.flow.Flow class GroupsInteractor( @@ -41,7 +42,7 @@ class GroupsInteractor( } GroupsData( - groups = groups, + groups = sortGroups(groups), requestedCredentials = credentials, currencies = currencies ) @@ -66,4 +67,23 @@ class GroupsInteractor( fun createShareUrl(credentials: GroupCredentials): String = groupUrlUseCase.createUrl(credentials) + + private fun sortGroups(groups: List): List { + val groupsAndTimestamps = groups.map { group -> + val lastModified = if (group.expenses.isNotEmpty()) { + val lastTimestamp = + group.expenses.maxOf { expense -> expense.modified.timestampSeconds } + + lastTimestamp + } else { + group.modified.timestampSeconds + } + + group to lastModified + } + + return groupsAndTimestamps + .sortedByDescending { (_, timestamp) -> timestamp } + .map { (group, _) -> group } + } } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/GroupsViewModel.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/GroupsViewModel.kt index 6e4b6be..9724daf 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/GroupsViewModel.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groups/GroupsViewModel.kt @@ -31,13 +31,11 @@ import com.github.ai.simplesplit.android.utils.getStringOrNull import com.github.ai.simplesplit.android.utils.mutableStateFlow import com.github.ai.simplesplit.android.utils.parseCellId import com.github.ai.simplesplit.android.utils.toErrorMessage -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch class GroupsViewModel( @@ -141,7 +139,7 @@ class GroupsViewModel( emit(createScreenState(data)) } ) - }.flowOn(Dispatchers.IO) + } } private fun createScreenState(data: GroupsData): GroupsState { @@ -178,7 +176,7 @@ class GroupsViewModel( interactor.removeGroup(groupUid) emitAll(loadData()) - }.flowOn(Dispatchers.IO) + } } private fun navigateToGroupDetails(groupUid: String) { @@ -373,7 +371,7 @@ class GroupsViewModel( emit(GroupsState.Error(message)) return@flow } - }.flowOn(Dispatchers.IO) + } } private fun getGroupUidFromCellId(cellId: String): String? { diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 0179419..2b05402 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -50,6 +50,12 @@ Expenses No debts yet Export as CSV file + created on %s + modified on %s + %s members: + Total spending: + Paid by %s + How to Settle Debts Add Expense 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 b5d6f70..ededd6e 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 @@ -10,5 +10,7 @@ data class ExpenseDto( val amount: Double, val currency: CurrencyDto, val paidBy: List, - val splitBetween: List + val splitBetween: List, + val created: TimestampDto, + val modified: TimestampDto ) \ 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 2644a05..b684a50 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 @@ -10,5 +10,7 @@ data class GroupDto( val currency: CurrencyDto, val members: List, val expenses: List, - val paybackTransactions: List + val paybackTransactions: List, + val created: TimestampDto, + val modified: TimestampDto ) \ No newline at end of file diff --git a/android/backend-api/src/main/kotlin/com/github/ai/split/api/TimestampDto.kt b/android/backend-api/src/main/kotlin/com/github/ai/split/api/TimestampDto.kt new file mode 100644 index 0000000..39a7ad0 --- /dev/null +++ b/android/backend-api/src/main/kotlin/com/github/ai/split/api/TimestampDto.kt @@ -0,0 +1,9 @@ +package com.github.ai.split.api + +import kotlinx.serialization.Serializable + +@Serializable +data class TimestampDto( + val timestampSeconds: Long, + val formatted: String +) \ No newline at end of file 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 5efcebc..5c4ad63 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 @@ -9,7 +9,9 @@ case class ExpenseDto( amount: Double, currency: CurrencyDto, paidBy: List[MemberDto], - splitBetween: List[MemberDto] + splitBetween: List[MemberDto], + created: TimestampDto, + modified: TimestampDto ) object ExpenseDto { 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 3059ce1..73195eb 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 @@ -9,7 +9,9 @@ case class GroupDto( currency: CurrencyDto, members: List[MemberDto], expenses: List[ExpenseDto], - paybackTransactions: List[TransactionDto] + paybackTransactions: List[TransactionDto], + created: TimestampDto, + modified: TimestampDto ) object GroupDto { diff --git a/backend/api/src/main/scala/com/github/ai/split/api/TimestampDto.scala b/backend/api/src/main/scala/com/github/ai/split/api/TimestampDto.scala new file mode 100644 index 0000000..b84801a --- /dev/null +++ b/backend/api/src/main/scala/com/github/ai/split/api/TimestampDto.scala @@ -0,0 +1,13 @@ +package com.github.ai.split.api + +import zio.json.{DeriveJsonDecoder, DeriveJsonEncoder, JsonDecoder, JsonEncoder} + +case class TimestampDto( + timestampSeconds: Long, + formatted: String +) + +object TimestampDto { + implicit val encoder: JsonEncoder[TimestampDto] = DeriveJsonEncoder.gen[TimestampDto] + implicit val decoder: JsonDecoder[TimestampDto] = DeriveJsonDecoder.gen[TimestampDto] +} diff --git a/backend/app/src/main/resources/init.sql b/backend/app/src/main/resources/init.sql index ec94a01..bf0f808 100644 --- a/backend/app/src/main/resources/init.sql +++ b/backend/app/src/main/resources/init.sql @@ -8,7 +8,9 @@ CREATE TABLE IF NOT EXISTS groups ( title VARCHAR(255) NOT NULL, description VARCHAR(255) NOT NULL, password_hash VARCHAR(255), - currency_iso_code VARCHAR(3) NOT NULL + currency_iso_code VARCHAR(3) NOT NULL, + created TIMESTAMP NOT NULL, + modified TIMESTAMP NOT NULl ); CREATE TABLE IF NOT EXISTS group_members ( @@ -23,7 +25,9 @@ CREATE TABLE IF NOT EXISTS expenses ( title VARCHAR(255) NOT NULL, description VARCHAR(255) NOT NULL, amount DOUBLE NOT NULL, - is_split_between_all BOOLEAN DEFAULT FALSE + is_split_between_all BOOLEAN DEFAULT FALSE, + created TIMESTAMP NOT NULL, + modified TIMESTAMP NOT NULl ); CREATE TABLE IF NOT EXISTS paid_by ( diff --git a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AddExpenseUseCase.scala b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AddExpenseUseCase.scala index 1e9c213..582f3cd 100644 --- a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AddExpenseUseCase.scala +++ b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AddExpenseUseCase.scala @@ -28,6 +28,7 @@ import com.github.ai.split.domain.usecases.{ResolveUserReferencesUseCase, Valida import zio.* import zio.direct.* +import java.time.{LocalDateTime, ZoneOffset} import java.util.UUID class AddExpenseUseCase( @@ -92,6 +93,8 @@ class AddExpenseUseCase( ) } + val time = LocalDateTime.now(ZoneOffset.UTC) + val expense = expenseRepository .add( ExpenseWithRelations( @@ -101,7 +104,9 @@ class AddExpenseUseCase( title = newExpense.title, description = newExpense.description, amount = newExpense.amount, - isSplitBetweenAll = newExpense.split == SplitBetweenAll + isSplitBetweenAll = newExpense.split == SplitBetweenAll, + created = time, + modified = time ), paidBy = paidBy, splitBetween = splitBetween 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 4adafe1..674c0d4 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 @@ -1,15 +1,15 @@ package com.github.ai.split.domain.usecases -import com.github.ai.split.entity.db.{GroupEntity, GroupMemberEntity, GroupUid, MemberUid} +import com.github.ai.split.entity.db.{GroupEntity, GroupUid} 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.NewGroup import com.github.ai.split.entity.exception.DomainError import com.github.ai.split.utils.some import zio.* import zio.direct.* +import java.time.{LocalDateTime, ZoneOffset} import java.util.UUID class AddGroupUseCase( @@ -30,6 +30,7 @@ class AddGroupUseCase( validateData(newGroup).run val groupUid = GroupUid(UUID.randomUUID()) + val created = LocalDateTime.now(ZoneOffset.UTC) val group = groupDao .add( @@ -42,7 +43,9 @@ class AddGroupUseCase( } else { None }, - currencyIsoCode = newGroup.currencyIsoCode + currencyIsoCode = newGroup.currencyIsoCode, + created = created, + modified = created ) ) .run 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 049c271..3c07339 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 @@ -27,6 +27,7 @@ import com.github.ai.split.entity.exception.DomainError import com.github.ai.split.utils.some import zio.{IO, ZIO} +import java.time.{LocalDateTime, ZoneOffset} import java.util.UUID import java.util.concurrent.atomic.{AtomicInteger, AtomicLong, AtomicReference} @@ -98,6 +99,7 @@ class FillTestDataUseCase( } private def insertGroup(group: Group): IO[DomainError, Unit] = { + val time = LocalDateTime.now(ZoneOffset.UTC) for { _ <- groupDao.add( GroupEntity( @@ -105,7 +107,9 @@ class FillTestDataUseCase( title = group.title, description = group.description, passwordHash = Some(passwordService.hashPassword(group.password)), - currencyIsoCode = "EUR" + currencyIsoCode = "EUR", + created = time, + modified = time ) ) @@ -136,6 +140,8 @@ class FillTestDataUseCase( groupUid: GroupUid, expense: Expense ): IO[DomainError, Unit] = { + val time = LocalDateTime.now(ZoneOffset.UTC) + for { members <- groupRepository.getMembers(groupUid) @@ -152,7 +158,9 @@ class FillTestDataUseCase( isSplitBetweenAll = expense.split match { case SplitBetweenAll => true case SplitBetweenMembers(_) => false - } + }, + created = time, + modified = time ) ) diff --git a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/UpdateExpenseUseCase.scala b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/UpdateExpenseUseCase.scala index 584436d..7488bd6 100644 --- a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/UpdateExpenseUseCase.scala +++ b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/UpdateExpenseUseCase.scala @@ -4,11 +4,11 @@ import com.github.ai.split.data.db.dao.{GroupMemberEntityDao, PaidByEntityDao, S import com.github.ai.split.data.db.repository.{ExpenseRepository, GroupRepository} import com.github.ai.split.entity.{ ExpenseWithRelations, + Member, Split, SplitBetweenAll, SplitBetweenMembers, - UserReference, - Member + UserReference } import com.github.ai.split.entity.db.{ExpenseEntity, ExpenseUid, GroupUid, MemberUid, PaidByEntity, SplitBetweenEntity} import com.github.ai.split.entity.exception.DomainError @@ -16,6 +16,8 @@ import com.github.ai.split.utils.some import com.github.ai.split.domain.usecases.ResolveUserReferencesUseCase import zio.* +import java.time.{LocalDateTime, ZoneOffset} + class UpdateExpenseUseCase( private val groupRepository: GroupRepository, private val expenseRepository: ExpenseRepository, @@ -98,6 +100,8 @@ class UpdateExpenseUseCase( expense.entity.isSplitBetweenAll } + val modified = LocalDateTime.now(ZoneOffset.UTC) + val newExpense = ExpenseWithRelations( entity = ExpenseEntity( uid = expense.entity.uid, @@ -105,7 +109,9 @@ class UpdateExpenseUseCase( title = newTitle.getOrElse(expense.entity.title), description = newDescription.getOrElse(expense.entity.description), amount = newAmount.getOrElse(expense.entity.amount), - isSplitBetweenAll = isSplitBetweenAll + isSplitBetweenAll = isSplitBetweenAll, + created = expense.entity.created, + modified = modified ), paidBy = paidBy, splitBetween = splitBetween 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 24bbd6f..5099388 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 @@ -14,6 +14,7 @@ import com.github.ai.split.entity.exception.DomainError import zio.* import zio.direct.* +import java.time.{LocalDateTime, ZoneOffset} import java.util.UUID class UpdateGroupUseCase( @@ -66,19 +67,25 @@ class UpdateGroupUseCase( _ <- updateMembers(groupUid = groupUid, newMembersOption = newMemberUids) - _ <- groupDao.update( - GroupEntity( - uid = groupUid, - title = newTitle.getOrElse(group.title), - description = newDescription.getOrElse(group.description), - passwordHash = if (newPassword.isDefined) { - Some(passwordService.hashPassword(newPassword.get)) - } else { - group.passwordHash - }, - currencyIsoCode = newCurrencyIsoCode.getOrElse(group.currencyIsoCode) + _ <- { + val modified = LocalDateTime.now(ZoneOffset.UTC) + + groupDao.update( + GroupEntity( + uid = groupUid, + title = newTitle.getOrElse(group.title), + description = newDescription.getOrElse(group.description), + passwordHash = if (newPassword.isDefined) { + Some(passwordService.hashPassword(newPassword.get)) + } else { + group.passwordHash + }, + currencyIsoCode = newCurrencyIsoCode.getOrElse(group.currencyIsoCode), + created = group.created, + modified = modified + ) ) - ) + } } yield groupUid } diff --git a/backend/app/src/main/scala/com/github/ai/split/entity/db/ExpenseEntity.scala b/backend/app/src/main/scala/com/github/ai/split/entity/db/ExpenseEntity.scala index 1ce4f5e..79f27b5 100644 --- a/backend/app/src/main/scala/com/github/ai/split/entity/db/ExpenseEntity.scala +++ b/backend/app/src/main/scala/com/github/ai/split/entity/db/ExpenseEntity.scala @@ -1,10 +1,14 @@ package com.github.ai.split.entity.db +import java.time.LocalDateTime + case class ExpenseEntity( uid: ExpenseUid, groupUid: GroupUid, title: String, description: String, amount: Double, - isSplitBetweenAll: Boolean + isSplitBetweenAll: Boolean, + created: LocalDateTime, + modified: LocalDateTime ) 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 124b64d..1b6f562 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,11 +1,15 @@ package com.github.ai.split.entity.db +import java.time.LocalDateTime + case class GroupEntity( uid: GroupUid, title: String, description: String, passwordHash: Option[String], - currencyIsoCode: String + currencyIsoCode: String, + created: LocalDateTime, + modified: LocalDateTime ) object GroupEntity { 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 59886f2..3c6e6d1 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,7 +1,7 @@ package com.github.ai.split.utils import com.github.ai.split.entity.{ExpenseWithRelations, Transaction} -import com.github.ai.split.api.{CurrencyDto, ExpenseDto, GroupDto, MemberDto, TransactionDto} +import com.github.ai.split.api.{CurrencyDto, ExpenseDto, GroupDto, MemberDto, TimestampDto, TransactionDto} import com.github.ai.split.entity.db.{ CurrencyEntity, ExpenseEntity, @@ -16,9 +16,9 @@ import com.github.ai.split.entity.db.{ } import com.github.ai.split.entity.exception.DomainError import zio.* -import zio.direct.* -import java.util.UUID +import java.time.format.DateTimeFormatter +import java.time.{LocalDateTime, ZoneOffset} def toExpenseDto( expense: ExpenseWithRelations, @@ -74,7 +74,9 @@ def toExpenseDto( amount = expense.amount, currency = toCurrencyDto(currency), paidBy = paidByUsers, - splitBetween = splitBetweenUsers + splitBetween = splitBetweenUsers, + created = toTimestampDto(expense.created), + modified = toTimestampDto(expense.modified) ) } @@ -162,7 +164,9 @@ def toGroupDto( currency = toCurrencyDto(currency), members = memberDtos, expenses = transformedExpenses, - paybackTransactions = paybackTransactions.map(transaction => toTransactionDto(transaction)) + paybackTransactions = paybackTransactions.map(transaction => toTransactionDto(transaction)), + created = toTimestampDto(group.created), + modified = toTimestampDto(group.modified) ) } @@ -183,3 +187,13 @@ def toCurrencyDto( name = currency.name, symbol = currency.symbol ) + +def toTimestampDto( + dateTime: LocalDateTime +): TimestampDto = + TimestampDto( + timestampSeconds = dateTime.toEpochSecond(ZoneOffset.UTC), + formatted = dateTime.format(TIMESTAMP_FORMAT) + ) + +private val TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")