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 ) }