diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index 19a5e5a8..85a24343 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -14,6 +14,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/categoriesApi/src/commonMain/kotlin/com/spendoo/categories/api/Routes.kt b/categoriesApi/src/commonMain/kotlin/com/spendoo/categories/api/Routes.kt
index 35dd0a13..0f334ac6 100644
--- a/categoriesApi/src/commonMain/kotlin/com/spendoo/categories/api/Routes.kt
+++ b/categoriesApi/src/commonMain/kotlin/com/spendoo/categories/api/Routes.kt
@@ -25,7 +25,7 @@ data class FinancialActionRoute(val tile: String, val body: String, val actionId
data object TopSpendingCategoriesRoute : NavKey
@Serializable
-data class CategoryOffersRoute(val categoryId: String) : NavKey
+data class CategoryOffersRoute(val categoryId: String?) : NavKey
@Serializable
data class EditTransactionRoute(val transactionId: String) : NavKey
\ No newline at end of file
diff --git a/categoriesPresentation/src/commonMain/kotlin/com/spendoo/categories/presentation/di/categoriesPresentationModule.kt b/categoriesPresentation/src/commonMain/kotlin/com/spendoo/categories/presentation/di/categoriesPresentationModule.kt
index c5fda2f7..b3052794 100644
--- a/categoriesPresentation/src/commonMain/kotlin/com/spendoo/categories/presentation/di/categoriesPresentationModule.kt
+++ b/categoriesPresentation/src/commonMain/kotlin/com/spendoo/categories/presentation/di/categoriesPresentationModule.kt
@@ -4,15 +4,16 @@ import com.spendoo.categories.presentation.screen.addCategoryBottomSheet.AddCate
import com.spendoo.categories.presentation.screen.addScheduledPaymentBottomSheet.AddScheduledPaymentViewModel
import com.spendoo.categories.presentation.screen.addTransactionBottomSheet.AddTransactionViewModel
import com.spendoo.categories.presentation.screen.categories.CategoriesViewModel
+import com.spendoo.categories.presentation.screen.categoryOffers.CategoryOffersViewModel
import com.spendoo.categories.presentation.screen.categorySelectionSheet.CategorySelectionViewModel
+import com.spendoo.categories.presentation.screen.editTransactionBottomSheet.EditTransactionViewModel
import com.spendoo.categories.presentation.screen.financialActionScreen.FinancialActionViewModel
import com.spendoo.categories.presentation.screen.inputVoiceBottomSheet.InputVoiceViewModel
import com.spendoo.categories.presentation.screen.scheduledPaymentDetails.ScheduledPaymentDetailsViewModel
import com.spendoo.categories.presentation.screen.scheduledPayments.ScheduledPaymentsViewModel
-import com.spendoo.categories.presentation.screen.transactionDetails.TransactionDetailsViewModel
import com.spendoo.categories.presentation.screen.topSpendingCategories.TopSpendingCategoriesViewModel
-import com.spendoo.categories.presentation.screen.categoryOffers.CategoryOffersViewModel
-import com.spendoo.categories.presentation.screen.editTransactionBottomSheet.EditTransactionViewModel
+import com.spendoo.categories.presentation.screen.transactionDetails.TransactionDetailsViewModel
+import org.koin.core.module.dsl.viewModel
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
@@ -28,6 +29,13 @@ val categoriesPresentationModule = module {
viewModelOf(::TransactionDetailsViewModel)
viewModelOf(::FinancialActionViewModel)
viewModelOf(::TopSpendingCategoriesViewModel)
- viewModelOf(::CategoryOffersViewModel)
viewModelOf(::EditTransactionViewModel)
+ viewModel { params ->
+ val (categoryId: String?) = params
+ CategoryOffersViewModel(
+ categoryId = categoryId,
+ transactionsRepository = get(),
+ offersRepository = get()
+ )
+ }
}
diff --git a/categoriesPresentation/src/commonMain/kotlin/com/spendoo/categories/presentation/screen/categoryOffers/CategoryOffersScreen.kt b/categoriesPresentation/src/commonMain/kotlin/com/spendoo/categories/presentation/screen/categoryOffers/CategoryOffersScreen.kt
index 55bed671..d4de72f4 100644
--- a/categoriesPresentation/src/commonMain/kotlin/com/spendoo/categories/presentation/screen/categoryOffers/CategoryOffersScreen.kt
+++ b/categoriesPresentation/src/commonMain/kotlin/com/spendoo/categories/presentation/screen/categoryOffers/CategoryOffersScreen.kt
@@ -32,7 +32,7 @@ import spendoo.designsystem.generated.resources.no_offers_found
@Composable
fun CategoryOffersScreen(
- categoryId: String,
+ categoryId: String?,
viewModel: CategoryOffersViewModel = koinViewModel(parameters = { parametersOf(categoryId) })
) {
val state by viewModel.state.collectAsStateWithLifecycle()
diff --git a/categoriesPresentation/src/commonMain/kotlin/com/spendoo/categories/presentation/screen/categoryOffers/CategoryOffersUiState.kt b/categoriesPresentation/src/commonMain/kotlin/com/spendoo/categories/presentation/screen/categoryOffers/CategoryOffersUiState.kt
index 2934f48c..6dbc9d07 100644
--- a/categoriesPresentation/src/commonMain/kotlin/com/spendoo/categories/presentation/screen/categoryOffers/CategoryOffersUiState.kt
+++ b/categoriesPresentation/src/commonMain/kotlin/com/spendoo/categories/presentation/screen/categoryOffers/CategoryOffersUiState.kt
@@ -13,7 +13,7 @@ data class CategoryOffersItemUiState(
)
data class CategoryOffersUiState(
- val categoryId: String = "",
+ val categoryId: String? = null,
val categoryName: String = "",
val isLoading: Boolean = false,
val topItemsWithOffers: List = emptyList()
diff --git a/categoriesPresentation/src/commonMain/kotlin/com/spendoo/categories/presentation/screen/categoryOffers/CategoryOffersViewModel.kt b/categoriesPresentation/src/commonMain/kotlin/com/spendoo/categories/presentation/screen/categoryOffers/CategoryOffersViewModel.kt
index 6b83e0d1..10011ce8 100644
--- a/categoriesPresentation/src/commonMain/kotlin/com/spendoo/categories/presentation/screen/categoryOffers/CategoryOffersViewModel.kt
+++ b/categoriesPresentation/src/commonMain/kotlin/com/spendoo/categories/presentation/screen/categoryOffers/CategoryOffersViewModel.kt
@@ -13,7 +13,7 @@ import spendoo.designsystem.generated.resources.an_error_occurred
import kotlin.math.absoluteValue
class CategoryOffersViewModel(
- private val categoryId: String,
+ private val categoryId: String?,
private val transactionsRepository: TransactionsRepository,
private val offersRepository: OffersRepository
) : BaseViewModel(CategoryOffersUiState(categoryId = categoryId)),
@@ -28,7 +28,7 @@ class CategoryOffersViewModel(
tryToCall(
block = {
transactionsRepository.getTopFrequencyItems(
- categoryId = categoryId.ifEmpty { null },
+ categoryId = categoryId?.ifEmpty { null },
pageQuery = PageQuery(page = 0, size = 3)
)
},
diff --git a/designSystem/src/commonMain/composeResources/values-ar/strings.xml b/designSystem/src/commonMain/composeResources/values-ar/strings.xml
index 5dc953bd..bef2a186 100644
--- a/designSystem/src/commonMain/composeResources/values-ar/strings.xml
+++ b/designSystem/src/commonMain/composeResources/values-ar/strings.xml
@@ -55,6 +55,9 @@
يجب أن يتكون رمز التحقق من 5 أرقام
حدث خطأ
خطأ غير معروف
+ تتبع إنفاقك
+ لا تنسَ تسجيل نفقاتك اليوم للحفاظ على ميزانيتك في المسار الصحيح!
+ التنبيهات الدورية
منخفض
متوسط
مرتفع
@@ -377,5 +380,6 @@
اختر الوقت
تسجيل الخروج
مطلوب الدفع
+ عروض الصفحة الرئيسية
diff --git a/designSystem/src/commonMain/composeResources/values/strings.xml b/designSystem/src/commonMain/composeResources/values/strings.xml
index 8fa381ea..86fe8365 100644
--- a/designSystem/src/commonMain/composeResources/values/strings.xml
+++ b/designSystem/src/commonMain/composeResources/values/strings.xml
@@ -55,6 +55,9 @@
OTP must be 5 digits
An error occurred
Unknown error
+ Track your spending
+ Don't forget to record your expenses today to keep your budget on track!
+ Periodic Reminder
Low
Medium
High
@@ -377,4 +380,5 @@
Select Time
Logout
Payment Required
+ Home Offers
diff --git a/designSystem/src/commonMain/kotlin/com/spendoo/designsystem/components/button/ProfileSettingItem.kt b/designSystem/src/commonMain/kotlin/com/spendoo/designsystem/components/button/ProfileSettingItem.kt
index 3966de4f..8a404cfd 100644
--- a/designSystem/src/commonMain/kotlin/com/spendoo/designsystem/components/button/ProfileSettingItem.kt
+++ b/designSystem/src/commonMain/kotlin/com/spendoo/designsystem/components/button/ProfileSettingItem.kt
@@ -17,6 +17,7 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.spendoo.designsystem.components.icon.CategoryIcon
import com.spendoo.designsystem.components.icon.Icon
+import com.spendoo.designsystem.components.switch.Switch
import com.spendoo.designsystem.components.text.Text
import com.spendoo.designsystem.modifier.clickableNoRipple
import com.spendoo.designsystem.theme.theme.SpendooTheme
@@ -63,6 +64,40 @@ fun ProfileSettingItem(
}
}
+@Composable
+fun ProfileSettingItem(
+ icon: DrawableResource,
+ title: String,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+ backgroundColor: Color = Theme.colorScheme.background.secondary,
+ shape: Shape = RoundedCornerShape(16.dp)
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(backgroundColor, shape)
+ .border(1.dp, Theme.colorScheme.border.primary, shape)
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ CategoryIcon(icon)
+ Text(
+ modifier = Modifier.padding(start = 8.dp).weight(1f),
+ text = title,
+ style = Theme.typography.body.medium,
+ color = Theme.colorScheme.text.title,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1
+ )
+ Switch(
+ checked = checked,
+ onCheckedChange = onCheckedChange
+ )
+ }
+}
+
@PreviewLightDark
@Composable
private fun ProfileSettingItemPreview() = SpendooTheme {
diff --git a/designSystem/src/commonMain/kotlin/com/spendoo/designsystem/components/switch/Switch.kt b/designSystem/src/commonMain/kotlin/com/spendoo/designsystem/components/switch/Switch.kt
index 83886090..7ca7e88a 100644
--- a/designSystem/src/commonMain/kotlin/com/spendoo/designsystem/components/switch/Switch.kt
+++ b/designSystem/src/commonMain/kotlin/com/spendoo/designsystem/components/switch/Switch.kt
@@ -1,10 +1,11 @@
package com.spendoo.designsystem.components.switch
-import androidx.compose.material3.Switch as Material3Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import com.spendoo.designsystem.theme.theme.Theme
+import androidx.compose.material3.Switch as Material3Switch
@Composable
fun Switch(
@@ -17,10 +18,10 @@ fun Switch(
onCheckedChange = onCheckedChange,
modifier = modifier,
colors = SwitchDefaults.colors(
- checkedThumbColor = Theme.colorScheme.brand.primary,
- checkedTrackColor = Theme.colorScheme.button.secondary,
+ checkedThumbColor = Theme.colorScheme.brand.onPrimary,
+ checkedTrackColor = Theme.colorScheme.icon.primary,
uncheckedThumbColor = Theme.colorScheme.text.body,
- uncheckedTrackColor = Theme.colorScheme.background.secondary
+ uncheckedTrackColor = Color.Unspecified
)
)
}
diff --git a/designSystem/src/commonMain/kotlin/com/spendoo/designsystem/modifier/clickableNoRipple.kt b/designSystem/src/commonMain/kotlin/com/spendoo/designsystem/modifier/clickableNoRipple.kt
index faa74972..0949dbf3 100644
--- a/designSystem/src/commonMain/kotlin/com/spendoo/designsystem/modifier/clickableNoRipple.kt
+++ b/designSystem/src/commonMain/kotlin/com/spendoo/designsystem/modifier/clickableNoRipple.kt
@@ -6,22 +6,29 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
import androidx.compose.ui.semantics.Role
@Composable
fun Modifier.clickableNoRipple(
enabled: Boolean = true,
- interactionSource: MutableInteractionSource? = MutableInteractionSource(),
+ interactionSource: MutableInteractionSource? = null,
indication: Indication? = null,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit,
-) = this
- .clickable(
+): Modifier = composed {
+
+ val finalInteractionSource = remember(interactionSource) {
+ interactionSource ?: MutableInteractionSource()
+ }
+
+ this.clickable(
enabled = enabled,
- interactionSource = remember { interactionSource },
+ interactionSource = finalInteractionSource,
indication = indication,
onClickLabel = onClickLabel,
role = role,
onClick = onClick
- )
\ No newline at end of file
+ )
+}
\ No newline at end of file
diff --git a/homePresentation/src/commonMain/kotlin/com/spendoo/home/presentation/screen/HomeScreen.kt b/homePresentation/src/commonMain/kotlin/com/spendoo/home/presentation/screen/HomeScreen.kt
index 13a038d7..2c5bb14f 100644
--- a/homePresentation/src/commonMain/kotlin/com/spendoo/home/presentation/screen/HomeScreen.kt
+++ b/homePresentation/src/commonMain/kotlin/com/spendoo/home/presentation/screen/HomeScreen.kt
@@ -73,19 +73,21 @@ private fun HomeContent(
isLoading = state.isBalanceLoading
)
- item {
- OffersSection(
- modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
- offers = state.offers,
- isLoading = state.isOffersLoading,
- onViewAll = viewModel::onViewAllOffersClicked,
- onOfferClicked = { link ->
- link?.takeIf { it.isNotBlank() }?.let { url ->
- val formattedUrl = if (url.startsWith("http://") || url.startsWith("https://")) url else "https://$url" //TODO: format url function
- uriHandler.openUri(formattedUrl)
+ if (state.isHomeOffersEnabled) {
+ item {
+ OffersSection(
+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
+ offers = state.offers,
+ isLoading = state.isOffersLoading,
+ onViewAll = viewModel::onViewAllOffersClicked,
+ onOfferClicked = { link ->
+ link?.takeIf { it.isNotBlank() }?.let { url ->
+ val formattedUrl = if (url.startsWith("http://") || url.startsWith("https://")) url else "https://$url" //TODO: format url function
+ uriHandler.openUri(formattedUrl)
+ }
}
- }
- )
+ )
+ }
}
item {
diff --git a/homePresentation/src/commonMain/kotlin/com/spendoo/home/presentation/screen/HomeUiState.kt b/homePresentation/src/commonMain/kotlin/com/spendoo/home/presentation/screen/HomeUiState.kt
index 849ecd67..9b1a35de 100644
--- a/homePresentation/src/commonMain/kotlin/com/spendoo/home/presentation/screen/HomeUiState.kt
+++ b/homePresentation/src/commonMain/kotlin/com/spendoo/home/presentation/screen/HomeUiState.kt
@@ -33,6 +33,7 @@ data class HomeUiState(
val isGoalsLoading: Boolean = true,
val isTopSpendingLoading: Boolean = true,
val isNotificationsLoading: Boolean = true,
+ val isHomeOffersEnabled: Boolean = true,
val balanceSummary: BalanceSummaryUiState = BalanceSummaryUiState(),
val offers: List = emptyList(),
val goals: List = emptyList(),
@@ -62,6 +63,10 @@ data class OfferUiState(
val id: String,
val discountPercent: Int?,
val imageUrl: String?,
+ val title: String?,
+ val price: Double?,
+ val currency: String?,
+ val rating: Double?,
val link: String? = null
)
@@ -70,6 +75,10 @@ fun Offer.toUiState(): OfferUiState {
id = id,
discountPercent = discountPercent,
imageUrl = imageUrl,
+ title = title,
+ price = price,
+ currency = currency,
+ rating = rating,
link = link
)
}
diff --git a/homePresentation/src/commonMain/kotlin/com/spendoo/home/presentation/screen/HomeViewModel.kt b/homePresentation/src/commonMain/kotlin/com/spendoo/home/presentation/screen/HomeViewModel.kt
index d986553e..d555da0e 100644
--- a/homePresentation/src/commonMain/kotlin/com/spendoo/home/presentation/screen/HomeViewModel.kt
+++ b/homePresentation/src/commonMain/kotlin/com/spendoo/home/presentation/screen/HomeViewModel.kt
@@ -12,6 +12,7 @@ import com.spendoo.goals.domain.repository.GoalsRepository
import com.spendoo.home.api.NotificationsRoute
import com.spendoo.identity.api.ProfileRoute
import com.spendoo.identity.domain.repository.ProfileRepository
+import com.spendoo.identity.domain.repository.SettingsRepository
import com.spendoo.notifications.domain.repository.NotificationRepository
import com.spendoo.offers.domain.repository.OffersRepository
import com.spendoo.shared.domain.utils.PageQuery
@@ -28,11 +29,13 @@ class HomeViewModel(
private val categoriesRepository: CategoriesRepository,
private val profileRepository: ProfileRepository,
private val notificationRepository: NotificationRepository,
- private val goalsRepository: GoalsRepository
+ private val goalsRepository: GoalsRepository,
+ private val settingsRepository: SettingsRepository
) : BaseViewModel(HomeUiState()), HomeInteractionListener {
init {
listenToResetSignal()
+ observeHomeOffersEnabledSetting()
getHomeData()
}
@@ -199,7 +202,11 @@ class HomeViewModel(
onStart = { updateState { copy(isTopSpendingLoading = true) } },
onSuccess = { spending ->
updateState { copy(topSpending = spending.data.map { it.toUiState() }) }
- loadOffers()
+ if (settingsRepository.isHomeOffersEnabled()) {
+ loadOffers()
+ } else {
+ updateState { copy(isOffersLoading = false) }
+ }
},
onError = { error ->
error.message?.let {
@@ -237,7 +244,9 @@ class HomeViewModel(
override fun onSpendingClicked(categoryId: String) {
navigate(CategoryOffersRoute(categoryId = categoryId))
}
- override fun onViewAllOffersClicked() {}
+ override fun onViewAllOffersClicked() {
+ navigate(CategoryOffersRoute(categoryId = null))
+ }
override fun onViewAllGoalsClicked() {
navigate(GoalsRoute)
}
@@ -251,4 +260,21 @@ class HomeViewModel(
override fun onProfileClicked() {
navigate(ProfileRoute)
}
+
+ private fun observeHomeOffersEnabledSetting() {
+ tryToCollect(
+ block = { settingsRepository.observeHomeOffersEnabled() },
+ onEach = { isEnabled ->
+ updateState { copy(isHomeOffersEnabled = isEnabled) }
+ if (isEnabled) {
+ if (state.value.offers.isEmpty() && !state.value.isOffersLoading) {
+ loadOffers()
+ }
+ } else {
+ updateState { copy(offers = emptyList(), isOffersLoading = false) }
+ }
+ },
+ onError = {}
+ )
+ }
}
diff --git a/homePresentation/src/commonMain/kotlin/com/spendoo/home/presentation/screen/components/OffersSection.kt b/homePresentation/src/commonMain/kotlin/com/spendoo/home/presentation/screen/components/OffersSection.kt
index fa14345a..7143d789 100644
--- a/homePresentation/src/commonMain/kotlin/com/spendoo/home/presentation/screen/components/OffersSection.kt
+++ b/homePresentation/src/commonMain/kotlin/com/spendoo/home/presentation/screen/components/OffersSection.kt
@@ -5,7 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -17,20 +17,22 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.spendoo.designsystem.components.text.Text
import com.spendoo.designsystem.modifier.clickableNoRipple
import com.spendoo.designsystem.modifier.shimmerEffect
import com.spendoo.designsystem.theme.theme.Theme
+import com.spendoo.designsystem.utils.formatMoney
import com.spendoo.home.presentation.screen.OfferUiState
import org.jetbrains.compose.resources.stringResource
import spendoo.designsystem.generated.resources.Res
import spendoo.designsystem.generated.resources.no_offers_yet
import spendoo.designsystem.generated.resources.offer_image
-import spendoo.designsystem.generated.resources.offer_off
import spendoo.designsystem.generated.resources.offers
@Composable
@@ -107,28 +109,88 @@ private fun OfferCard(offer: OfferUiState, onClick: () -> Unit) {
contentScale = ContentScale.Fit,
modifier = Modifier.matchParentSize()
)
+
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .background(
+ brush = Brush.verticalGradient(
+ colors = listOf(
+ Color.Transparent,
+ Color.Transparent,
+ Color.Black.copy(alpha = 0.6f)
+ )
+ )
+ )
+ )
+
offer.discountPercent?.let { discount ->
Box(
modifier = Modifier
- .align(Alignment.CenterEnd)
- .fillMaxHeight()
- .width(115.dp)
- .background(Theme.colorScheme.background.octonary.copy(alpha = 0.7f))
- .padding(8.dp),
- contentAlignment = Alignment.Center
+ .align(Alignment.TopStart)
+ .padding(16.dp)
+ .background(Theme.colorScheme.additional.error, RoundedCornerShape(8.dp))
+ .padding(horizontal = 8.dp, vertical = 4.dp)
) {
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Text(
- text = "$discount %",
- style = Theme.typography.heading.large,
- color = Theme.colorScheme.border.primary
- )
+ Text(
+ text = "-$discount%",
+ style = Theme.typography.label.medium.small,
+ color = Theme.colorScheme.additional.onError
+ )
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ offer.title?.let { title ->
+ Text(
+ text = title,
+ style = Theme.typography.body.medium,
+ color = Color.White,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ val priceText = if (offer.price != null) {
+ val formattedPrice = formatMoney(offer.price)
+ "$formattedPrice ${offer.currency.orEmpty()}"
+ } else null
+
+ priceText?.let { price ->
Text(
- text = stringResource(Res.string.offer_off),
+ text = price,
style = Theme.typography.label.medium.medium,
- color = Theme.colorScheme.border.primary
+ color = Color.White
)
}
+
+ offer.rating?.let { rating ->
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Text(
+ text = "★",
+ color = Theme.colorScheme.additional.golden,
+ style = Theme.typography.label.medium.medium
+ )
+ Text(
+ text = rating.toString(),
+ style = Theme.typography.label.medium.medium,
+ color = Color.White
+ )
+ }
+ }
}
}
}
diff --git a/identityData/src/commonMain/kotlin/com/spendoo/identity/data/dataSource/local/setting/settings.kt b/identityData/src/commonMain/kotlin/com/spendoo/identity/data/dataSource/local/setting/settings.kt
index fea1ccf1..5b09008c 100644
--- a/identityData/src/commonMain/kotlin/com/spendoo/identity/data/dataSource/local/setting/settings.kt
+++ b/identityData/src/commonMain/kotlin/com/spendoo/identity/data/dataSource/local/setting/settings.kt
@@ -23,8 +23,18 @@ internal var Settings.onBoardingCompleted: Boolean
get() = getBoolean(OnBOARDING_COMPLETED, false)
set(value) = putBoolean(OnBOARDING_COMPLETED, value)
+internal var Settings.reminderEnabled: Boolean
+ get() = getBoolean(REMINDER_ENABLED, true)
+ set(value) = putBoolean(REMINDER_ENABLED, value)
+
+internal var Settings.homeOffersEnabled: Boolean
+ get() = getBoolean(HOME_OFFERS_ENABLED, true)
+ set(value) = putBoolean(HOME_OFFERS_ENABLED, value)
+
const val ACCESS_TOKEN = "access_token"
const val REFRESH_TOKEN = "refresh_token"
const val APP_LANGUAGE = "app_language"
const val APP_THEME = "app_theme"
const val OnBOARDING_COMPLETED = "onboarding_completed"
+const val REMINDER_ENABLED = "reminder_enabled"
+const val HOME_OFFERS_ENABLED = "home_offers_enabled"
diff --git a/identityData/src/commonMain/kotlin/com/spendoo/identity/data/repository/SettingsRepositoryImpl.kt b/identityData/src/commonMain/kotlin/com/spendoo/identity/data/repository/SettingsRepositoryImpl.kt
index a41059a2..805d9976 100644
--- a/identityData/src/commonMain/kotlin/com/spendoo/identity/data/repository/SettingsRepositoryImpl.kt
+++ b/identityData/src/commonMain/kotlin/com/spendoo/identity/data/repository/SettingsRepositoryImpl.kt
@@ -4,6 +4,8 @@ import com.russhwolf.settings.Settings
import com.spendoo.identity.data.dataSource.local.setting.appLanguage
import com.spendoo.identity.data.dataSource.local.setting.appTheme
import com.spendoo.identity.data.dataSource.local.setting.onBoardingCompleted
+import com.spendoo.identity.data.dataSource.local.setting.reminderEnabled
+import com.spendoo.identity.data.dataSource.local.setting.homeOffersEnabled
import com.spendoo.identity.domain.repository.SettingsRepository
import com.spendoo.identity.domain.util.AppLanguage
import com.spendoo.identity.domain.util.AppTheme
@@ -16,6 +18,8 @@ class SettingsRepositoryImpl(
private val _appLanguageFlow = MutableStateFlow(settings.appLanguage.toAppLanguage())
private val _appThemeFlow = MutableStateFlow(settings.appTheme.toAppTheme())
private val _onBoardingFlow = MutableStateFlow(settings.onBoardingCompleted)
+ private val _reminderEnabledFlow = MutableStateFlow(settings.reminderEnabled)
+ private val _homeOffersEnabledFlow = MutableStateFlow(settings.homeOffersEnabled)
override suspend fun applyLanguage(appLanguage: AppLanguage) {
@@ -59,4 +63,22 @@ class SettingsRepositoryImpl(
settings.onBoardingCompleted = value
_onBoardingFlow.value = value
}
+
+ override fun isReminderEnabled(): Boolean = settings.reminderEnabled
+
+ override fun observeReminderEnabled(): StateFlow = _reminderEnabledFlow
+
+ override suspend fun setReminderEnabled(value: Boolean) {
+ settings.reminderEnabled = value
+ _reminderEnabledFlow.value = value
+ }
+
+ override fun isHomeOffersEnabled(): Boolean = settings.homeOffersEnabled
+
+ override fun observeHomeOffersEnabled(): StateFlow = _homeOffersEnabledFlow
+
+ override suspend fun setHomeOffersEnabled(value: Boolean) {
+ settings.homeOffersEnabled = value
+ _homeOffersEnabledFlow.value = value
+ }
}
\ No newline at end of file
diff --git a/identityDomain/src/commonMain/kotlin/com/spendoo/identity/domain/repository/SettingsRepository.kt b/identityDomain/src/commonMain/kotlin/com/spendoo/identity/domain/repository/SettingsRepository.kt
index e12301b1..f3e24997 100644
--- a/identityDomain/src/commonMain/kotlin/com/spendoo/identity/domain/repository/SettingsRepository.kt
+++ b/identityDomain/src/commonMain/kotlin/com/spendoo/identity/domain/repository/SettingsRepository.kt
@@ -14,4 +14,10 @@ interface SettingsRepository {
fun isOnboardingComplete(): Boolean
fun observeOnBoardingCompleted(): StateFlow
fun setOnboardingCompleted(value: Boolean)
+ fun isReminderEnabled(): Boolean
+ fun observeReminderEnabled(): StateFlow
+ suspend fun setReminderEnabled(value: Boolean)
+ fun isHomeOffersEnabled(): Boolean
+ fun observeHomeOffersEnabled(): StateFlow
+ suspend fun setHomeOffersEnabled(value: Boolean)
}
\ No newline at end of file
diff --git a/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/ProfileInteractionListener.kt b/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/ProfileInteractionListener.kt
index 0d991541..2d3cfb6f 100644
--- a/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/ProfileInteractionListener.kt
+++ b/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/ProfileInteractionListener.kt
@@ -19,6 +19,8 @@ interface ProfileInteractionListener {
fun onSelectLanguage(language: AppLanguage)
fun onDismissLanguageBottomSheet()
fun onToggleTheme(isDark: Boolean)
+ fun onToggleReminder(isEnabled: Boolean)
+ fun onToggleHomeOffers(isEnabled: Boolean)
fun onReload()
fun onClickLogout()
fun onClickUser(userId: String, userName: String, imageUrl: String?)
diff --git a/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/ProfileScreen.kt b/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/ProfileScreen.kt
index 175124f0..ef2b8a10 100644
--- a/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/ProfileScreen.kt
+++ b/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/ProfileScreen.kt
@@ -101,10 +101,15 @@ fun ProfileContent(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
- .padding(horizontal = 16.dp, vertical = 8.dp),
+ .padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
- ProfileHeader(state = state, onCopyFollowCode = onCopyFollowCode, onRegenerateCode = listener::onClickRegenerateFollowCode)
+ ProfileHeader(
+ state = state,
+ onCopyFollowCode = onCopyFollowCode,
+ onRegenerateCode = listener::onClickRegenerateFollowCode,
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
Spacer(modifier = Modifier.height(24.dp))
@@ -116,7 +121,12 @@ fun ProfileContent(
Spacer(modifier = Modifier.height(24.dp))
- ProfileSettingsList(listener = listener)
+ ProfileSettingsList(
+ isReminderEnabled = state.isReminderEnabled,
+ isHomeOffersEnabled = state.isHomeOffersEnabled,
+ listener = listener,
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
Spacer(modifier = Modifier.height(32.dp))
}
@@ -158,6 +168,8 @@ private fun ProfileScreenPreview() {
override fun onSelectLanguage(language: AppLanguage) {}
override fun onDismissLanguageBottomSheet() {}
override fun onToggleTheme(isDark: Boolean) {}
+ override fun onToggleReminder(isEnabled: Boolean) {}
+ override fun onToggleHomeOffers(isEnabled: Boolean) {}
override fun onReload() {}
override fun onClickLogout() {}
override fun onClickUser(userId: String, userName: String, imageUrl: String?) {}
diff --git a/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/ProfileUiState.kt b/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/ProfileUiState.kt
index 6e5b9aad..d110fb70 100644
--- a/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/ProfileUiState.kt
+++ b/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/ProfileUiState.kt
@@ -11,8 +11,12 @@ data class ProfileUiState(
val followCode: String = "",
val followings: List = emptyList(),
val followers: List = emptyList(),
+ val isFollowingsLoading: Boolean = false,
+ val isFollowersLoading: Boolean = false,
val isAddFollowerSheetVisible: Boolean = false,
val selectedLanguage: AppLanguage = AppLanguage.ENGLISH,
val isLanguageBottomSheetVisible: Boolean = false,
val isRefreshing: Boolean = false,
+ val isReminderEnabled: Boolean = true,
+ val isHomeOffersEnabled: Boolean = true,
)
diff --git a/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/ProfileViewModel.kt b/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/ProfileViewModel.kt
index ad63b396..552d2220 100644
--- a/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/ProfileViewModel.kt
+++ b/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/ProfileViewModel.kt
@@ -59,18 +59,26 @@ class ProfileViewModel(
tryToCall(
block = { followRepository.getFollowing(PageQuery(page = 0, size = 10)) },
+ onStart = { updateState { copy(isFollowingsLoading = true) } },
onSuccess = { pagedData ->
- updateState { copy(followings = pagedData.data) }
+ updateState { copy(followings = pagedData.data, isFollowingsLoading = false) }
},
- onError = { handleError(it) }
+ onError = {
+ updateState { copy(isFollowingsLoading = false) }
+ handleError(it)
+ }
)
tryToCall(
block = { followRepository.getFollowers(PageQuery(page = 0, size = 10)) },
+ onStart = { updateState { copy(isFollowersLoading = true) } },
onSuccess = { pagedData ->
- updateState { copy(followers = pagedData.data) }
+ updateState { copy(followers = pagedData.data, isFollowersLoading = false) }
},
- onError = { handleError(it) }
+ onError = {
+ updateState { copy(isFollowersLoading = false) }
+ handleError(it)
+ }
)
}
@@ -92,6 +100,22 @@ class ProfileViewModel(
},
onError = { handleError(it) }
)
+
+ tryToCollect(
+ block = { settingsRepository.observeReminderEnabled() },
+ onEach = { isEnabled ->
+ updateState { copy(isReminderEnabled = isEnabled) }
+ },
+ onError = { handleError(it) }
+ )
+
+ tryToCollect(
+ block = { settingsRepository.observeHomeOffersEnabled() },
+ onEach = { isEnabled ->
+ updateState { copy(isHomeOffersEnabled = isEnabled) }
+ },
+ onError = { handleError(it) }
+ )
}
private fun handleError(throwable: Throwable) {
@@ -184,6 +208,22 @@ class ProfileViewModel(
)
}
+ override fun onToggleReminder(isEnabled: Boolean) {
+ tryToCall(
+ block = { settingsRepository.setReminderEnabled(isEnabled) },
+ onSuccess = { },
+ onError = { handleError(it) }
+ )
+ }
+
+ override fun onToggleHomeOffers(isEnabled: Boolean) {
+ tryToCall(
+ block = { settingsRepository.setHomeOffersEnabled(isEnabled) },
+ onSuccess = { },
+ onError = { handleError(it) }
+ )
+ }
+
override fun onClickLogout() {
tryToCall(
block = { authenticationRepository.logout() },
diff --git a/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/components/FollowersSection.kt b/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/components/FollowersSection.kt
index f74ce59a..f5928c60 100644
--- a/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/components/FollowersSection.kt
+++ b/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/components/FollowersSection.kt
@@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
+import androidx.compose.foundation.layout.padding
import com.spendoo.designsystem.components.text.Text
import com.spendoo.designsystem.modifier.clickableNoRipple
import com.spendoo.designsystem.theme.theme.Theme
@@ -29,7 +30,7 @@ fun FollowersSection(
listener: ProfileInteractionListener
) {
Row(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -48,8 +49,9 @@ fun FollowersSection(
Spacer(modifier = Modifier.height(8.dp))
- if (state.followers.isEmpty()) {
+ if (state.followers.isEmpty() && !state.isFollowersLoading) {
Text(
+ modifier = Modifier.padding(horizontal = 16.dp),
text = stringResource(Res.string.no_followers),
style = Theme.typography.body.medium,
color = Theme.colorScheme.text.title
@@ -59,10 +61,16 @@ fun FollowersSection(
LazyRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
- contentPadding = PaddingValues(vertical = 4.dp)
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp)
) {
- items(state.followers) { user ->
- UserAvatarChip(user = user)
+ if (state.isFollowersLoading) {
+ items(5) {
+ UserAvatarShimmer()
+ }
+ } else {
+ items(state.followers) { user ->
+ UserAvatarChip(user = user)
+ }
}
}
}
diff --git a/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/components/FollowingSection.kt b/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/components/FollowingSection.kt
index 420a7300..d683ea93 100644
--- a/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/components/FollowingSection.kt
+++ b/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/components/FollowingSection.kt
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
@@ -39,7 +40,7 @@ fun FollowingSection(
listener: ProfileInteractionListener
) {
Row(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -61,7 +62,7 @@ fun FollowingSection(
LazyRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
- contentPadding = PaddingValues(vertical = 4.dp)
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp)
) {
item {
Column(
@@ -93,8 +94,14 @@ fun FollowingSection(
}
}
- items(state.followings) { user ->
- UserAvatarChip(user = user, modifier = Modifier.clickableNoRipple { listener.onClickUser(user.userId, user.fullName, user.imageUrl) })
+ if (state.isFollowingsLoading) {
+ items(5) {
+ UserAvatarShimmer()
+ }
+ } else {
+ items(state.followings) { user ->
+ UserAvatarChip(user = user, modifier = Modifier.clickableNoRipple { listener.onClickUser(user.userId, user.fullName, user.imageUrl) })
+ }
}
}
}
diff --git a/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/components/ProfileHeader.kt b/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/components/ProfileHeader.kt
index d4510b59..d710f17d 100644
--- a/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/components/ProfileHeader.kt
+++ b/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/components/ProfileHeader.kt
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
@@ -36,80 +37,86 @@ import spendoo.designsystem.generated.resources.id_prefix
fun ProfileHeader(
state: ProfileUiState,
onCopyFollowCode: () -> Unit,
- onRegenerateCode: () -> Unit
+ onRegenerateCode: () -> Unit,
+ modifier: Modifier = Modifier
) {
- if (!state.imageUrl.isNullOrBlank()) {
- AsyncImage(
- model = state.imageUrl,
- contentDescription = null,
- contentScale = ContentScale.Crop,
- modifier = Modifier
- .size(114.dp)
- .clip(CircleShape)
- .border(1.dp, Theme.colorScheme.border.primary, CircleShape)
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ if (!state.imageUrl.isNullOrBlank()) {
+ AsyncImage(
+ model = state.imageUrl,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .size(114.dp)
+ .clip(CircleShape)
+ .border(1.dp, Theme.colorScheme.border.primary, CircleShape)
+ )
+ } else {
+ Box(
+ modifier = Modifier
+ .size(114.dp)
+ .clip(CircleShape)
+ .background(Theme.colorScheme.background.secondary)
+ .border(1.dp, Theme.colorScheme.border.primary, CircleShape),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ modifier = Modifier.size(56.dp),
+ painter = Res.drawable.ic_profile_details.painter(),
+ tint = Theme.colorScheme.icon.secondary,
+ contentDescription = null
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Text(
+ text = state.fullName.ifBlank { "" },
+ style = Theme.typography.heading.large,
+ color = Theme.colorScheme.text.title,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
)
- } else {
- Box(
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
- .size(114.dp)
- .clip(CircleShape)
- .background(Theme.colorScheme.background.secondary)
- .border(1.dp, Theme.colorScheme.border.primary, CircleShape),
- contentAlignment = Alignment.Center
+ .background(Theme.colorScheme.background.secondary, RoundedCornerShape(12.dp))
+ .padding(horizontal = 12.dp, vertical = 6.dp)
) {
+ Text(
+ text = stringResource(Res.string.id_prefix),
+ style = Theme.typography.label.medium.small,
+ color = Theme.colorScheme.text.body
+ )
+ Text(
+ text = state.followCode.ifBlank { state.userId },
+ style = Theme.typography.label.medium.medium,
+ color = Theme.colorScheme.text.titleSmall
+ )
+ Icon(
+ modifier = Modifier
+ .size(16.dp)
+ .clickableNoRipple(onClick = onCopyFollowCode),
+ painter = Res.drawable.ic_copy.painter(),
+ tint = Theme.colorScheme.icon.secondary,
+ contentDescription = null
+ )
Icon(
- modifier = Modifier.size(56.dp),
- painter = Res.drawable.ic_profile_details.painter(),
+ modifier = Modifier
+ .size(16.dp)
+ .clickableNoRipple(onClick = onRegenerateCode),
+ painter = Res.drawable.ic_repeat.painter(),
tint = Theme.colorScheme.icon.secondary,
contentDescription = null
)
}
}
-
- Spacer(modifier = Modifier.height(12.dp))
-
- Text(
- text = state.fullName.ifBlank { "" },
- style = Theme.typography.heading.large,
- color = Theme.colorScheme.text.title,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
-
- Spacer(modifier = Modifier.height(8.dp))
-
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier
- .background(Theme.colorScheme.background.secondary, RoundedCornerShape(12.dp))
- .padding(horizontal = 12.dp, vertical = 6.dp)
- ) {
- Text(
- text = stringResource(Res.string.id_prefix),
- style = Theme.typography.label.medium.small,
- color = Theme.colorScheme.text.body
- )
- Text(
- text = state.followCode.ifBlank { state.userId },
- style = Theme.typography.label.medium.medium,
- color = Theme.colorScheme.text.titleSmall
- )
- Icon(
- modifier = Modifier
- .size(16.dp)
- .clickableNoRipple(onClick = onCopyFollowCode),
- painter = Res.drawable.ic_copy.painter(),
- tint = Theme.colorScheme.icon.secondary,
- contentDescription = null
- )
- Icon(
- modifier = Modifier
- .size(16.dp)
- .clickableNoRipple(onClick = onRegenerateCode),
- painter = Res.drawable.ic_repeat.painter(),
- tint = Theme.colorScheme.icon.secondary,
- contentDescription = null
- )
- }
}
diff --git a/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/components/ProfileSettingsList.kt b/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/components/ProfileSettingsList.kt
index c2b33298..acaaadfe 100644
--- a/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/components/ProfileSettingsList.kt
+++ b/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/components/ProfileSettingsList.kt
@@ -1,27 +1,13 @@
package com.spendoo.identity.presentation.screen.profile.components
-import androidx.compose.foundation.background
-import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.spendoo.designsystem.components.button.ProfileSettingItem
-import com.spendoo.designsystem.components.icon.Icon
-import com.spendoo.designsystem.components.switch.Switch
-import com.spendoo.designsystem.components.text.Text
import com.spendoo.designsystem.theme.theme.Theme
-import com.spendoo.designsystem.utils.extentions.painter
import com.spendoo.identity.presentation.screen.profile.ProfileInteractionListener
import org.jetbrains.compose.resources.stringResource
import spendoo.designsystem.generated.resources.Res
@@ -29,26 +15,32 @@ import spendoo.designsystem.generated.resources.achievements
import spendoo.designsystem.generated.resources.categories
import spendoo.designsystem.generated.resources.change_language
import spendoo.designsystem.generated.resources.change_theme
+import spendoo.designsystem.generated.resources.home_offers
import spendoo.designsystem.generated.resources.ic_bell
import spendoo.designsystem.generated.resources.ic_categories
import spendoo.designsystem.generated.resources.ic_logout
import spendoo.designsystem.generated.resources.ic_mobile
+import spendoo.designsystem.generated.resources.ic_money_in_offer
import spendoo.designsystem.generated.resources.ic_profile_details
import spendoo.designsystem.generated.resources.ic_repeat
import spendoo.designsystem.generated.resources.ic_stats
import spendoo.designsystem.generated.resources.ic_win_cup
import spendoo.designsystem.generated.resources.logout
import spendoo.designsystem.generated.resources.notifications
+import spendoo.designsystem.generated.resources.periodic_reminder
import spendoo.designsystem.generated.resources.profile_details
import spendoo.designsystem.generated.resources.scheduled_payments
@Composable
fun ProfileSettingsList(
- listener: ProfileInteractionListener
+ isReminderEnabled: Boolean,
+ isHomeOffersEnabled: Boolean,
+ listener: ProfileInteractionListener,
+ modifier: Modifier = Modifier
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier.fillMaxWidth()
+ modifier = modifier.fillMaxWidth()
) {
ProfileSettingItem(
icon = Res.drawable.ic_profile_details,
@@ -81,44 +73,27 @@ fun ProfileSettingsList(
onClickArrow = { listener.onClickChangeLanguage() }
)
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .background(Theme.colorScheme.background.secondary, RoundedCornerShape(16.dp))
- .border(1.dp, Theme.colorScheme.border.primary, RoundedCornerShape(16.dp))
- .padding(horizontal = 8.dp, vertical = 4.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Box(
- modifier = Modifier
- .size(40.dp)
- .clip(RoundedCornerShape(12.dp))
- .background(Theme.colorScheme.button.secondary),
- contentAlignment = Alignment.Center
- ) {
- Icon(
- modifier = Modifier.size(24.dp),
- painter = Res.drawable.ic_stats.painter(),
- tint = Theme.colorScheme.button.primary,
- contentDescription = null
- )
- }
- Text(
- modifier = Modifier
- .padding(start = 8.dp)
- .weight(1f),
- text = stringResource(Res.string.change_theme),
- style = Theme.typography.body.medium,
- color = Theme.colorScheme.text.title,
- overflow = TextOverflow.Ellipsis,
- maxLines = 1
- )
- val isDark = Theme.isDarkTheme
- Switch(
- checked = isDark,
- onCheckedChange = { listener.onToggleTheme(!isDark) }
- )
- }
+ val isDark = Theme.isDarkTheme
+ ProfileSettingItem(
+ icon = Res.drawable.ic_stats,
+ title = stringResource(Res.string.change_theme),
+ checked = isDark,
+ onCheckedChange = { listener.onToggleTheme(!isDark) }
+ )
+
+ ProfileSettingItem(
+ icon = Res.drawable.ic_bell,
+ title = stringResource(Res.string.periodic_reminder),
+ checked = isReminderEnabled,
+ onCheckedChange = { listener.onToggleReminder(it) }
+ )
+
+ ProfileSettingItem(
+ icon = Res.drawable.ic_money_in_offer,
+ title = stringResource(Res.string.home_offers),
+ checked = isHomeOffersEnabled,
+ onCheckedChange = { listener.onToggleHomeOffers(it) }
+ )
ProfileSettingItem(
icon = Res.drawable.ic_logout,
diff --git a/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/components/UserAvatarChip.kt b/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/components/UserAvatarChip.kt
index a33a2345..0229bf81 100644
--- a/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/components/UserAvatarChip.kt
+++ b/identityPresentation/src/commonMain/kotlin/com/spendoo/identity/presentation/screen/profile/components/UserAvatarChip.kt
@@ -21,6 +21,8 @@ import com.spendoo.designsystem.components.icon.Icon
import com.spendoo.designsystem.components.text.Text
import com.spendoo.designsystem.theme.theme.Theme
import com.spendoo.designsystem.utils.extentions.painter
+import androidx.compose.foundation.shape.RoundedCornerShape
+import com.spendoo.designsystem.modifier.shimmerEffect
import com.spendoo.identity.domain.model.UserSearch
import spendoo.designsystem.generated.resources.Res
import spendoo.designsystem.generated.resources.ic_profile_details
@@ -71,3 +73,28 @@ fun UserAvatarChip(
)
}
}
+
+@Composable
+fun UserAvatarShimmer(
+ modifier: Modifier = Modifier
+) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = modifier.width(70.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .size(56.dp)
+ .clip(CircleShape)
+ .shimmerEffect()
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Box(
+ modifier = Modifier
+ .height(12.dp)
+ .width(48.dp)
+ .clip(RoundedCornerShape(4.dp))
+ .shimmerEffect()
+ )
+ }
+}
diff --git a/notificationsData/build.gradle.kts b/notificationsData/build.gradle.kts
index 2a1669b6..f13c81f4 100644
--- a/notificationsData/build.gradle.kts
+++ b/notificationsData/build.gradle.kts
@@ -12,6 +12,7 @@ kotlin {
dependencies {
implementation(projects.notificationsDomain)
implementation(projects.sharedData)
+ implementation(libs.kmpnotifier)
}
}
}
diff --git a/notificationsData/src/androidMain/kotlin/com/spendoo/notifications/data/di/notificationsDataModule.android.kt b/notificationsData/src/androidMain/kotlin/com/spendoo/notifications/data/di/notificationsDataModule.android.kt
new file mode 100644
index 00000000..9e4586f9
--- /dev/null
+++ b/notificationsData/src/androidMain/kotlin/com/spendoo/notifications/data/di/notificationsDataModule.android.kt
@@ -0,0 +1,8 @@
+package com.spendoo.notifications.data.di
+
+import com.spendoo.notifications.domain.scheduler.LocalNotificationScheduler
+import com.spendoo.notifications.data.scheduler.LocalNotificationSchedulerImpl
+
+actual fun getLocalNotificationScheduler(): LocalNotificationScheduler {
+ return LocalNotificationSchedulerImpl()
+}
diff --git a/notificationsData/src/androidMain/kotlin/com/spendoo/notifications/data/scheduler/LocalNotificationSchedulerImpl.kt b/notificationsData/src/androidMain/kotlin/com/spendoo/notifications/data/scheduler/LocalNotificationSchedulerImpl.kt
new file mode 100644
index 00000000..b108b78d
--- /dev/null
+++ b/notificationsData/src/androidMain/kotlin/com/spendoo/notifications/data/scheduler/LocalNotificationSchedulerImpl.kt
@@ -0,0 +1,59 @@
+package com.spendoo.notifications.data.scheduler
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import com.mmk.kmpnotifier.KMPNotifier
+import com.mmk.kmpnotifier.local.localNotifier
+import com.spendoo.notifications.domain.entity.NotificationType
+import com.spendoo.notifications.domain.scheduler.LocalNotificationScheduler
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+
+class LocalNotificationSchedulerImpl : LocalNotificationScheduler, KoinComponent {
+
+ private val context: Context by inject()
+
+ override fun schedule(
+ id: Int,
+ title: String,
+ body: String,
+ delayMs: Long,
+ notificationType: NotificationType
+ ) {
+ val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+
+ val intent = Intent(context, ReminderDelayReceiver::class.java).apply {
+ putExtra("id", id)
+ putExtra("title", title)
+ putExtra("body", body)
+ putExtra("type", notificationType.name)
+ }
+
+ val flags =
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+
+ val pendingIntent = PendingIntent.getBroadcast(context, id, intent, flags)
+ val triggerAtMillis = System.currentTimeMillis() + delayMs
+
+ alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
+ }
+
+ override fun cancelAll(startId: Int, endId: Int) {
+ val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+
+ val flags =
+ PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
+
+ for (id in startId..endId) {
+ val intent = Intent(context, ReminderDelayReceiver::class.java)
+ val pendingIntent = PendingIntent.getBroadcast(context, id, intent, flags)
+ if (pendingIntent != null) {
+ alarmManager.cancel(pendingIntent)
+ pendingIntent.cancel()
+ }
+ KMPNotifier.localNotifier.remove(id)
+ }
+ }
+}
diff --git a/notificationsData/src/androidMain/kotlin/com/spendoo/notifications/data/scheduler/ReminderDelayReceiver.kt b/notificationsData/src/androidMain/kotlin/com/spendoo/notifications/data/scheduler/ReminderDelayReceiver.kt
new file mode 100644
index 00000000..7b9c1653
--- /dev/null
+++ b/notificationsData/src/androidMain/kotlin/com/spendoo/notifications/data/scheduler/ReminderDelayReceiver.kt
@@ -0,0 +1,24 @@
+package com.spendoo.notifications.data.scheduler
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.mmk.kmpnotifier.KMPNotifier
+import com.mmk.kmpnotifier.local.localNotifier
+import com.spendoo.notifications.domain.entity.NotificationType
+
+class ReminderDelayReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ val id = intent?.getIntExtra("id", 1000) ?: 1000
+ val title = intent?.getStringExtra("title") ?: "Track your spending"
+ val body = intent?.getStringExtra("body") ?: "Don't forget to record your expenses today to keep your budget on track!"
+ val type = intent?.getStringExtra("type") ?: NotificationType.TRACKING_REMINDER.name
+
+ KMPNotifier.localNotifier.notify {
+ this.id = id
+ this.title = title
+ this.body = body
+ this.payloadData = mapOf("type" to type)
+ }
+ }
+}
diff --git a/notificationsData/src/commonMain/kotlin/com/spendoo/notifications/data/di/notificationsDataModule.kt b/notificationsData/src/commonMain/kotlin/com/spendoo/notifications/data/di/notificationsDataModule.kt
index d1d1c055..762aa626 100644
--- a/notificationsData/src/commonMain/kotlin/com/spendoo/notifications/data/di/notificationsDataModule.kt
+++ b/notificationsData/src/commonMain/kotlin/com/spendoo/notifications/data/di/notificationsDataModule.kt
@@ -2,10 +2,16 @@ package com.spendoo.notifications.data.di
import com.spendoo.notifications.data.repository.NotificationRepositoryImpl
import com.spendoo.notifications.domain.repository.NotificationRepository
+import com.spendoo.notifications.domain.scheduler.LocalNotificationScheduler
+import com.spendoo.notifications.domain.scheduler.NotificationReminderManager
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
+expect fun getLocalNotificationScheduler(): LocalNotificationScheduler
+
val notificationsDataModule = module {
singleOf(::NotificationRepositoryImpl) bind NotificationRepository::class
+ single { getLocalNotificationScheduler() }
+ singleOf(::NotificationReminderManager)
}
diff --git a/notificationsData/src/iosMain/kotlin/com/spendoo/notifications/data/di/notificationsDataModule.ios.kt b/notificationsData/src/iosMain/kotlin/com/spendoo/notifications/data/di/notificationsDataModule.ios.kt
new file mode 100644
index 00000000..9e4586f9
--- /dev/null
+++ b/notificationsData/src/iosMain/kotlin/com/spendoo/notifications/data/di/notificationsDataModule.ios.kt
@@ -0,0 +1,8 @@
+package com.spendoo.notifications.data.di
+
+import com.spendoo.notifications.domain.scheduler.LocalNotificationScheduler
+import com.spendoo.notifications.data.scheduler.LocalNotificationSchedulerImpl
+
+actual fun getLocalNotificationScheduler(): LocalNotificationScheduler {
+ return LocalNotificationSchedulerImpl()
+}
diff --git a/notificationsData/src/iosMain/kotlin/com/spendoo/notifications/data/scheduler/LocalNotificationSchedulerImpl.kt b/notificationsData/src/iosMain/kotlin/com/spendoo/notifications/data/scheduler/LocalNotificationSchedulerImpl.kt
new file mode 100644
index 00000000..4e174d4e
--- /dev/null
+++ b/notificationsData/src/iosMain/kotlin/com/spendoo/notifications/data/scheduler/LocalNotificationSchedulerImpl.kt
@@ -0,0 +1,35 @@
+package com.spendoo.notifications.data.scheduler
+
+import com.mmk.kmpnotifier.KMPNotifier
+import com.mmk.kmpnotifier.local.localNotifier
+import com.spendoo.notifications.domain.entity.NotificationType
+import com.spendoo.notifications.domain.scheduler.LocalNotificationScheduler
+import kotlin.time.Clock
+import kotlin.time.ExperimentalTime
+
+class LocalNotificationSchedulerImpl : LocalNotificationScheduler {
+
+ @OptIn(ExperimentalTime::class)
+ override fun schedule(
+ id: Int,
+ title: String,
+ body: String,
+ delayMs: Long,
+ notificationType: NotificationType
+ ) {
+ val nowMs = Clock.System.now().toEpochMilliseconds()
+ KMPNotifier.localNotifier.notify {
+ this.id = id
+ this.title = title
+ this.body = body
+ this.payloadData = mapOf("type" to notificationType.name)
+ this.scheduledAt = nowMs + delayMs
+ }
+ }
+
+ override fun cancelAll(startId: Int, endId: Int) {
+ for (id in startId..endId) {
+ KMPNotifier.localNotifier.remove(id)
+ }
+ }
+}
diff --git a/notificationsDomain/src/commonMain/kotlin/com/spendoo/notifications/domain/entity/NotificationType.kt b/notificationsDomain/src/commonMain/kotlin/com/spendoo/notifications/domain/entity/NotificationType.kt
index 8199a81a..37707fdf 100644
--- a/notificationsDomain/src/commonMain/kotlin/com/spendoo/notifications/domain/entity/NotificationType.kt
+++ b/notificationsDomain/src/commonMain/kotlin/com/spendoo/notifications/domain/entity/NotificationType.kt
@@ -6,7 +6,8 @@ enum class NotificationType {
USER_FOLLOW,
PAYMENT_REMINDER,
ALERT,
- SYSTEM;
+ SYSTEM,
+ TRACKING_REMINDER;
companion object {
fun fromStringOrDefault(value: String?): NotificationType {
diff --git a/notificationsDomain/src/commonMain/kotlin/com/spendoo/notifications/domain/scheduler/LocalNotificationScheduler.kt b/notificationsDomain/src/commonMain/kotlin/com/spendoo/notifications/domain/scheduler/LocalNotificationScheduler.kt
new file mode 100644
index 00000000..7007159b
--- /dev/null
+++ b/notificationsDomain/src/commonMain/kotlin/com/spendoo/notifications/domain/scheduler/LocalNotificationScheduler.kt
@@ -0,0 +1,14 @@
+package com.spendoo.notifications.domain.scheduler
+
+import com.spendoo.notifications.domain.entity.NotificationType
+
+interface LocalNotificationScheduler {
+ fun schedule(
+ id: Int,
+ title: String,
+ body: String,
+ delayMs: Long,
+ notificationType: NotificationType
+ )
+ fun cancelAll(startId: Int, endId: Int)
+}
diff --git a/notificationsDomain/src/commonMain/kotlin/com/spendoo/notifications/domain/scheduler/NotificationReminderManager.kt b/notificationsDomain/src/commonMain/kotlin/com/spendoo/notifications/domain/scheduler/NotificationReminderManager.kt
new file mode 100644
index 00000000..4ab48f06
--- /dev/null
+++ b/notificationsDomain/src/commonMain/kotlin/com/spendoo/notifications/domain/scheduler/NotificationReminderManager.kt
@@ -0,0 +1,39 @@
+package com.spendoo.notifications.domain.scheduler
+
+import com.spendoo.notifications.domain.entity.NotificationType
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
+import kotlinx.coroutines.launch
+
+class NotificationReminderManager(
+ private val scheduler: LocalNotificationScheduler
+) {
+ companion object {
+ const val START_ID = 1000
+ const val END_ID = 1024
+ var reminderIntervalMs: Long = 6 * 60 * 60 * 1000L //6 hours
+ }
+
+ fun startReminderCycle(
+ coroutineScope: CoroutineScope,
+ title: String,
+ body: String,
+ isReminderEnabled: Boolean
+ ) {
+ coroutineScope.launch(Dispatchers.IO) {
+ scheduler.cancelAll(START_ID, END_ID)
+
+ if (!isReminderEnabled) {
+ return@launch
+ }
+
+ val totalReminders = END_ID - START_ID + 1
+ for (i in 0 until totalReminders) {
+ val reminderId = START_ID + i
+ val delayMs = (i + 1) * reminderIntervalMs
+ scheduler.schedule(reminderId, title, body, delayMs, NotificationType.TRACKING_REMINDER)
+ }
+ }
+ }
+}
diff --git a/offersData/schemas/com.spendoo.offers.data.local.OffersDatabase/3.json b/offersData/schemas/com.spendoo.offers.data.local.OffersDatabase/3.json
new file mode 100644
index 00000000..043ecf65
--- /dev/null
+++ b/offersData/schemas/com.spendoo.offers.data.local.OffersDatabase/3.json
@@ -0,0 +1,109 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 3,
+ "identityHash": "c8bdd5d5d1f7e745ab46560a78755411",
+ "entities": [
+ {
+ "tableName": "offers",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `keyword` TEXT NOT NULL, `language` TEXT NOT NULL, `discountPercent` INTEGER, `imageUrl` TEXT, `title` TEXT, `price` REAL, `currency` TEXT, `rating` REAL, `link` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "keyword",
+ "columnName": "keyword",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "language",
+ "columnName": "language",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "discountPercent",
+ "columnName": "discountPercent",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "imageUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "price",
+ "columnName": "price",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "currency",
+ "columnName": "currency",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "rating",
+ "columnName": "rating",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "link",
+ "columnName": "link",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "keyword_cache",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`keyword` TEXT NOT NULL, `language` TEXT NOT NULL, `lastFetchTime` INTEGER NOT NULL, PRIMARY KEY(`keyword`, `language`))",
+ "fields": [
+ {
+ "fieldPath": "keyword",
+ "columnName": "keyword",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "language",
+ "columnName": "language",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastFetchTime",
+ "columnName": "lastFetchTime",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "keyword",
+ "language"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c8bdd5d5d1f7e745ab46560a78755411')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/offersData/src/commonMain/kotlin/com/spendoo/offers/data/local/OffersDatabase.kt b/offersData/src/commonMain/kotlin/com/spendoo/offers/data/local/OffersDatabase.kt
index 780c2e07..9793f9e8 100644
--- a/offersData/src/commonMain/kotlin/com/spendoo/offers/data/local/OffersDatabase.kt
+++ b/offersData/src/commonMain/kotlin/com/spendoo/offers/data/local/OffersDatabase.kt
@@ -8,7 +8,7 @@ import com.spendoo.offers.data.local.dao.OffersDao
import com.spendoo.offers.data.local.entity.KeywordCacheEntity
import com.spendoo.offers.data.local.entity.OfferEntity
-@Database(entities = [OfferEntity::class, KeywordCacheEntity::class], version = 2)
+@Database(entities = [OfferEntity::class, KeywordCacheEntity::class], version = 3)
@ConstructedBy(OffersDatabaseConstructor::class)
abstract class OffersDatabase : RoomDatabase() {
abstract fun offersDao(): OffersDao
diff --git a/offersData/src/commonMain/kotlin/com/spendoo/offers/data/local/dao/OffersDao.kt b/offersData/src/commonMain/kotlin/com/spendoo/offers/data/local/dao/OffersDao.kt
index a7ce32e9..9ca6f5b6 100644
--- a/offersData/src/commonMain/kotlin/com/spendoo/offers/data/local/dao/OffersDao.kt
+++ b/offersData/src/commonMain/kotlin/com/spendoo/offers/data/local/dao/OffersDao.kt
@@ -9,7 +9,20 @@ import com.spendoo.offers.data.local.entity.OfferEntity
@Dao
interface OffersDao {
- @Query("SELECT * FROM offers WHERE keyword IN (:keywords) AND language = :language ORDER BY price ASC LIMIT :limit OFFSET :offset")
+ @Query(
+ """
+ SELECT *
+ FROM offers
+ WHERE keyword IN (:keywords)
+ AND language = :language
+ ORDER BY
+ CASE WHEN discountPercent IS NULL THEN 0 ELSE 1 END DESC,
+ discountPercent DESC,
+ price ASC
+ LIMIT :limit
+ OFFSET :offset
+ """
+ )
suspend fun getOffersForKeywords(
keywords: List,
language: String,
@@ -22,7 +35,10 @@ interface OffersDao {
@Query("SELECT * FROM keyword_cache WHERE keyword IN (:keywords) AND language = :language")
- suspend fun getCacheStatusForKeywords(keywords: List, language: String): List
+ suspend fun getCacheStatusForKeywords(
+ keywords: List,
+ language: String
+ ): List
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOffers(offers: List)
diff --git a/offersData/src/commonMain/kotlin/com/spendoo/offers/data/local/entity/OfferEntity.kt b/offersData/src/commonMain/kotlin/com/spendoo/offers/data/local/entity/OfferEntity.kt
index 90ab604b..a2db6c80 100644
--- a/offersData/src/commonMain/kotlin/com/spendoo/offers/data/local/entity/OfferEntity.kt
+++ b/offersData/src/commonMain/kotlin/com/spendoo/offers/data/local/entity/OfferEntity.kt
@@ -13,5 +13,6 @@ data class OfferEntity(
val title: String?,
val price: Double?,
val currency: String?,
+ val rating: Double?,
val link: String?
)
diff --git a/offersData/src/commonMain/kotlin/com/spendoo/offers/data/repository/OffersRepositoryImpl.kt b/offersData/src/commonMain/kotlin/com/spendoo/offers/data/repository/OffersRepositoryImpl.kt
index 840469ae..182117cc 100644
--- a/offersData/src/commonMain/kotlin/com/spendoo/offers/data/repository/OffersRepositoryImpl.kt
+++ b/offersData/src/commonMain/kotlin/com/spendoo/offers/data/repository/OffersRepositoryImpl.kt
@@ -8,13 +8,12 @@ import com.spendoo.offers.domain.entity.Offer
import com.spendoo.offers.domain.repository.OffersRepository
import com.spendoo.shared.domain.utils.PageQuery
import com.spendoo.shared.domain.utils.PagedData
-import org.spendoo.scraper.domain.repository.AmazonScraperRepository
-import kotlin.time.Clock
-import kotlin.time.Duration.Companion.days
-
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
+import org.spendoo.scraper.domain.repository.AmazonScraperRepository
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.days
class OffersRepositoryImpl(
private val offersDao: OffersDao,
@@ -63,11 +62,12 @@ class OffersRepositoryImpl(
id = product.link ?: "${product.title.hashCode()}_$index",
keyword = kw,
language = language,
- discountPercent = null,
+ discountPercent = product.discountPercent,
imageUrl = product.imageUrl,
title = product.title,
price = product.price,
currency = product.currency,
+ rating = product.rating,
link = product.link
)
}
@@ -75,7 +75,7 @@ class OffersRepositoryImpl(
}
}.awaitAll()
- for ((kw, entities) in results) {
+ results.forEach { (kw, entities) ->
offersDao.insertOffers(entities)
offersDao.insertKeywordCacheStatus(listOf(KeywordCacheEntity(kw, language, currentTime)))
}
@@ -98,6 +98,7 @@ class OffersRepositoryImpl(
title = entity.title,
price = entity.price,
currency = entity.currency,
+ rating = entity.rating,
link = entity.link
)
}
diff --git a/offersDomain/src/commonMain/kotlin/com/spendoo/offers/domain/entity/Offer.kt b/offersDomain/src/commonMain/kotlin/com/spendoo/offers/domain/entity/Offer.kt
index b2d0060f..49167d3a 100644
--- a/offersDomain/src/commonMain/kotlin/com/spendoo/offers/domain/entity/Offer.kt
+++ b/offersDomain/src/commonMain/kotlin/com/spendoo/offers/domain/entity/Offer.kt
@@ -7,5 +7,6 @@ data class Offer(
val title: String? = null,
val price: Double? = null,
val currency: String? = null,
+ val rating: Double? = null,
val link: String? = null
)
\ No newline at end of file
diff --git a/scraperData/src/commonMain/kotlin/org/spendoo/scraper/data/remote/AmazonScraper.kt b/scraperData/src/commonMain/kotlin/org/spendoo/scraper/data/remote/AmazonScraper.kt
index 0aa56d47..fb13672f 100644
--- a/scraperData/src/commonMain/kotlin/org/spendoo/scraper/data/remote/AmazonScraper.kt
+++ b/scraperData/src/commonMain/kotlin/org/spendoo/scraper/data/remote/AmazonScraper.kt
@@ -21,7 +21,7 @@ class AmazonScraper(private val fetcher: KmpHtmlFetcher = KtorAmazonFetcher()) {
}
if (request.offersOnly) {
- params["f"] = "p_n_deal_type%3A2356606011"
+ params["f"] = "p_n_deal_type:2356606011"
}
if (request.minPrice != null || request.maxPrice != null) {
@@ -106,6 +106,27 @@ class AmazonScraper(private val fetcher: KmpHtmlFetcher = KtorAmazonFetcher()) {
currency = AmazonConstants.DEFAULT_CURRENCIES[request.country]
}
+ // --- Discount Percent Extraction ---
+ val listPriceEl = card.selectFirst("span.a-price.a-text-price span.a-offscreen")
+ ?: card.selectFirst("span.a-price[data-a-color='secondary'] span.a-offscreen")
+ var discountPercent: Int? = null
+ if (listPriceEl != null && price != null) {
+ val parsedListPrice = KmpAmazonParserUtils.parsePrice(listPriceEl.text()).first
+ if (parsedListPrice != null && parsedListPrice > price) {
+ discountPercent = (((parsedListPrice - price) / parsedListPrice) * 100).toInt()
+ }
+ }
+ if (discountPercent == null) {
+ val percentageEl = card.selectFirst("span[class*='savings'], span[class*='percent'], span.a-badge-label-inner")
+ if (percentageEl != null) {
+ val text = percentageEl.text()
+ val match = Regex("""(\d+)%""").find(text)
+ if (match != null) {
+ discountPercent = match.groupValues[1].toInt()
+ }
+ }
+ }
+
// --- Rating Extraction ---
val ratingEl = card.selectFirst("i.a-icon-star span.a-icon-alt") ?: card.selectFirst("span.a-icon-alt")
val rating = KmpAmazonParserUtils.parseRating(ratingEl?.text())
@@ -150,7 +171,8 @@ class AmazonScraper(private val fetcher: KmpHtmlFetcher = KtorAmazonFetcher()) {
rating = rating,
reviews = reviews,
link = link,
- imageUrl = imageUrl
+ imageUrl = imageUrl,
+ discountPercent = discountPercent
)
)
val maxItems = request.maxItems
diff --git a/scraperDomain/src/commonMain/kotlin/org/spendoo/scraper/domain/entity/AmazonProduct.kt b/scraperDomain/src/commonMain/kotlin/org/spendoo/scraper/domain/entity/AmazonProduct.kt
index ab16a4e2..62c571aa 100644
--- a/scraperDomain/src/commonMain/kotlin/org/spendoo/scraper/domain/entity/AmazonProduct.kt
+++ b/scraperDomain/src/commonMain/kotlin/org/spendoo/scraper/domain/entity/AmazonProduct.kt
@@ -8,5 +8,6 @@ data class AmazonProduct(
val rating: Double?,
val reviews: Int?,
val link: String?,
- val imageUrl: String?
+ val imageUrl: String?,
+ val discountPercent: Int?
)
diff --git a/spendooApp/build.gradle.kts b/spendooApp/build.gradle.kts
index 7a9677dc..f2e80598 100644
--- a/spendooApp/build.gradle.kts
+++ b/spendooApp/build.gradle.kts
@@ -13,47 +13,53 @@ kotlin {
sourceSets {
- commonMain.dependencies {
- implementation(libs.androidx.navigation3.ui)
- implementation(libs.androidx.lifecycle.viewmodel.navigation3)
- implementation(projects.designSystem)
- implementation(projects.identityData)
- implementation(projects.identityDomain)
- implementation(projects.identityPresentation)
- implementation(projects.identityApi)
- implementation(projects.homeApi)
- implementation(projects.homePresentation)
- implementation(projects.offersData)
- implementation(projects.scraperData)
- implementation(projects.goalsData)
- implementation(projects.categoriesDomain)
- implementation(projects.categoriesData)
- implementation(projects.categoriesApi)
- implementation(projects.categoriesPresentation)
- implementation(projects.statisticsApi)
- implementation(projects.statisticsPresentation)
- implementation(projects.statisticsDomain)
- implementation(projects.statisticsData)
- implementation(projects.goalsApi)
- implementation(projects.goalsPresentation)
- implementation(projects.chatbotApi)
- implementation(projects.chatbotPresentation)
- implementation(projects.chatbotDomain)
- implementation(projects.chatbotData)
- implementation(projects.notificationsDomain)
- implementation(projects.notificationsData)
- api(projects.logging)
- implementation(projects.sharedDomain)
- implementation(projects.sharedData)
- api(libs.kmpnotifier)
-
+ commonMain {
+ dependencies {
+ implementation(libs.androidx.navigation3.ui)
+ implementation(libs.androidx.lifecycle.viewmodel.navigation3)
+ implementation(projects.designSystem)
+ implementation(projects.identityData)
+ implementation(projects.identityDomain)
+ implementation(projects.identityPresentation)
+ implementation(projects.identityApi)
+ implementation(projects.homeApi)
+ implementation(projects.homePresentation)
+ implementation(projects.offersData)
+ implementation(projects.scraperData)
+ implementation(projects.goalsData)
+ implementation(projects.categoriesDomain)
+ implementation(projects.categoriesData)
+ implementation(projects.categoriesApi)
+ implementation(projects.categoriesPresentation)
+ implementation(projects.statisticsApi)
+ implementation(projects.statisticsPresentation)
+ implementation(projects.statisticsDomain)
+ implementation(projects.statisticsData)
+ implementation(projects.goalsApi)
+ implementation(projects.goalsPresentation)
+ implementation(projects.chatbotApi)
+ implementation(projects.chatbotPresentation)
+ implementation(projects.chatbotDomain)
+ implementation(projects.chatbotData)
+ implementation(projects.notificationsDomain)
+ implementation(projects.notificationsData)
+ api(projects.logging)
+ implementation(projects.sharedDomain)
+ implementation(projects.sharedData)
+ api(libs.kmpnotifier)
+ implementation(libs.kotlinx.datetime)
+ }
}
- commonTest.dependencies {
- implementation(libs.kotlin.test)
+ commonTest {
+ dependencies {
+ implementation(libs.kotlin.test)
+ }
}
- androidMain.dependencies {
- implementation(libs.androidx.poolingcontainer)
- implementation(libs.androidx.core.ktx)
+ androidMain {
+ dependencies {
+ implementation(libs.androidx.poolingcontainer)
+ implementation(libs.androidx.core.ktx)
+ }
}
}
diff --git a/spendooApp/src/commonMain/kotlin/com/spendoo/App.kt b/spendooApp/src/commonMain/kotlin/com/spendoo/App.kt
index 476e30c7..1a4f0c00 100644
--- a/spendooApp/src/commonMain/kotlin/com/spendoo/App.kt
+++ b/spendooApp/src/commonMain/kotlin/com/spendoo/App.kt
@@ -2,10 +2,14 @@ package com.spendoo
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.tooling.preview.Preview
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.mmk.kmpnotifier.KMPNotifier
import com.mmk.kmpnotifier.local.localNotifier
@@ -13,6 +17,7 @@ import com.mmk.kmpnotifier.notification.PayloadData
import com.mmk.kmpnotifier.push.PushListener
import com.mmk.kmpnotifier.push.firebase.addPushListener
import com.spendoo.appEntryPoint.EntryPoint
+import com.spendoo.categories.api.AddTransactionRoute
import com.spendoo.categories.api.FinancialActionRoute
import com.spendoo.categories.api.ScheduledPaymentsRoute
import com.spendoo.designsystem.navigation.effector.Effector
@@ -27,13 +32,18 @@ import com.spendoo.identity.domain.util.AppLanguage
import com.spendoo.identity.domain.util.AppLocalizer
import com.spendoo.identity.domain.util.AppTheme
import com.spendoo.notifications.domain.entity.NotificationType
+import com.spendoo.notifications.domain.scheduler.NotificationReminderManager
import com.spendoo.statistics.api.StatisticsRoute
import com.spendoo.util.SetSystemBarsAppearance
import com.spendoo.util.toStringMap
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.getString
import org.koin.compose.koinInject
+import spendoo.designsystem.generated.resources.Res
+import spendoo.designsystem.generated.resources.tracking_reminder_body
+import spendoo.designsystem.generated.resources.tracking_reminder_title
@Preview
@Composable
@@ -42,6 +52,7 @@ fun App(
settingsRepository: SettingsRepository = koinInject(),
appLocalizer: AppLocalizer = koinInject(),
authenticationRepository: AuthenticationRepository = koinInject(),
+ reminderManager: NotificationReminderManager = koinInject(),
effector: Effector = koinInject()
) {
val currentTheme by settingsRepository.observeAppTheme().collectAsStateWithLifecycle()
@@ -56,6 +67,43 @@ fun App(
val coroutineScope = rememberCoroutineScope()
val exceptionHandler = CoroutineExceptionHandler { _, _ -> }
+ val isReminderEnabled by settingsRepository.observeReminderEnabled().collectAsStateWithLifecycle()
+
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner, isReminderEnabled) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_START) {
+ coroutineScope.launch {
+ val titleText = getString(Res.string.tracking_reminder_title)
+ val bodyText = getString(Res.string.tracking_reminder_body)
+ reminderManager.startReminderCycle(
+ coroutineScope = coroutineScope,
+ title = titleText,
+ body = bodyText,
+ isReminderEnabled = isReminderEnabled
+ )
+ }
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose {
+ lifecycleOwner.lifecycle.removeObserver(observer)
+ }
+ }
+
+ LaunchedEffect(isReminderEnabled) {
+ val titleText = getString(Res.string.tracking_reminder_title)
+
+ val bodyText = getString(Res.string.tracking_reminder_body)
+
+ reminderManager.startReminderCycle(
+ coroutineScope = coroutineScope,
+ title = titleText,
+ body = bodyText,
+ isReminderEnabled = isReminderEnabled
+ )
+ }
+
LaunchedEffect(Unit) {
KMPNotifier.addPushListener(object : PushListener {
override fun onNewToken(token: String) {
@@ -148,6 +196,7 @@ private fun handleNotificationClick(
NotificationType.PAYMENT_REMINDER -> ScheduledPaymentsRoute
NotificationType.ALERT -> StatisticsRoute()
NotificationType.SYSTEM -> NotificationsRoute
+ NotificationType.TRACKING_REMINDER -> AddTransactionRoute
}, forceNavigate = true
)
}