Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,22 @@ import com.mindera.alfie.core.navigation.arguments.productlist.ProductListType
import com.mindera.alfie.repository.productlist.model.ProductListQuerySource

/**
* The BFF query source backing a [ProductListType].
* Maps each [ProductListType] to the BFF query source that backs the list.
*
* [ProductListType.Search] threads its term straight through to `searchProducts`. Every other
* type currently falls back to [collectionHandle] because the BFF does not yet expose a query to
* resolve a collection handle from a category/brand slug or id — when it does, this is the single
* place that mapping needs to change.
* type uses its own slug/id directly as the collection handle for the `productList` query.
*/
internal fun ProductListType.toQuerySource(collectionHandle: String): ProductListQuerySource =
internal fun ProductListType.toQuerySource(): ProductListQuerySource =
when (this) {
is ProductListType.Search -> ProductListQuerySource.Search(term = query)
is ProductListType.Category.Slug,
is ProductListType.Category.Id,
is ProductListType.Brand.Slug,
is ProductListType.Brand.Id -> ProductListQuerySource.Collection(handle = collectionHandle)
is ProductListType.Category.Slug -> ProductListQuerySource.Collection(handle = slug)
is ProductListType.Category.Id -> ProductListQuerySource.Collection(handle = id)
is ProductListType.Brand.Slug -> ProductListQuerySource.Collection(handle = slug)
is ProductListType.Brand.Id -> ProductListQuerySource.Collection(handle = id)
}

/**
* Human-readable title for a [ProductListType], shown in the top bar.
*
* Note: for non-search lists this may not match the products shown while the collection handle is
* still hardcoded (see [toQuerySource]).
*/
internal val ProductListType.displayTitle: String
get() = when (this) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,6 @@ internal class ProductListViewModel @Inject constructor(
private const val PREVIEW_DEBOUNCE_MS = 300L
private const val PREVIEW_PAGE_SIZE = 1

// Search is backed directly by the BFF `searchProducts` query (the search term comes
// straight from the nav args). For Category/Brand, the BFF does not yet expose a
// navigation/category lookup, so the collection handle falls back to "frontpage" as a
// placeholder until the BFF can resolve a handle from category slug/id or brand. The
// displayed title still uses nav args (see collectionTitle), so for non-search lists the
// title may not match the products shown — a known trade-off until that BFF work lands.
private const val COLLECTION_HANDLE = "frontpage"

private val initialPagerLoadState = LoadStates(
refresh = LoadState.Loading,
append = LoadState.NotLoading(false),
Expand All @@ -102,8 +94,7 @@ internal class ProductListViewModel @Inject constructor(
*/
val collectionTitle: String = navArgs.type.displayTitle

/** The BFF query backing this list (see [toQuerySource]). */
private val querySource: ProductListQuerySource = navArgs.type.toQuerySource(COLLECTION_HANDLE)
private val querySource: ProductListQuerySource = navArgs.type.toQuerySource()

/** Non-null only in search mode; drives the search-specific no-results copy. */
val searchQuery: String? = (querySource as? ProductListQuerySource.Search)?.term
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ class ProductListPreviewCountTest {
fun `WHEN filters change THEN count is emitted only after the debounce`() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
coEvery {
getProductListUseCase(after = null, source = ProductListQuerySource.Collection("frontpage"), filters = previewFilters, sort = any(), limit = 1)
getProductListUseCase(after = null, source = ProductListQuerySource.Collection("women"), filters = previewFilters, sort = any(), limit = 1)
} returns UseCaseResult.Success(
ProductList(products = emptyList(), pagination = CursorPagination(endCursor = null, hasNextPage = false, totalCount = 42))
)
Expand Down Expand Up @@ -161,8 +161,8 @@ class ProductListPreviewCountTest {
advanceUntilIdle()

assertEquals(7, viewModel.state.value.previewResultCount)
coVerify(exactly = 1) { getProductListUseCase(null, ProductListQuerySource.Collection("frontpage"), previewFilters, any(), 1) }
coVerify(exactly = 0) { getProductListUseCase(null, ProductListQuerySource.Collection("frontpage"), firstFilters, any(), 1) }
coVerify(exactly = 1) { getProductListUseCase(null, ProductListQuerySource.Collection("women"), previewFilters, any(), 1) }
coVerify(exactly = 0) { getProductListUseCase(null, ProductListQuerySource.Collection("women"), firstFilters, any(), 1) }
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,11 @@ class ProductListViewModelTest {
}

@Test
fun `init - THEN uses hardcoded frontpage collection handle`() = runTest {
fun `init - THEN uses category slug as collection handle`() = runTest {
buildViewModel()

@Suppress("UnusedFlow")
verify { getPaginatedProductListUseCase(ProductListQuerySource.Collection("frontpage"), any(), any(), any(), any()) }
verify { getPaginatedProductListUseCase(ProductListQuerySource.Collection("women"), any(), any(), any(), any()) }
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -1,53 +1,29 @@
package com.mindera.alfie.feature.shop

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.mindera.alfie.core.commons.string.StringResource
import com.mindera.alfie.core.deeplink.DeeplinkHandler
import com.mindera.alfie.core.navigation.DirectionProvider
import com.mindera.alfie.core.navigation.Screen
import com.mindera.alfie.core.navigation.arguments.shop.ShopNavArgs
import com.mindera.alfie.core.navigation.arguments.shop.ShopTab
import com.mindera.alfie.core.navigation.arguments.wishlist.wishlistNavArgs
import com.mindera.alfie.core.ui.event.ClickEventOneArg
import com.mindera.alfie.core.ui.test.SERVICES_PAGE
import com.mindera.alfie.designsystem.component.bottombar.BottomBarState
import com.mindera.alfie.designsystem.component.dialog.error.ErrorType
import com.mindera.alfie.designsystem.component.segmented.SegmentedItem
import com.mindera.alfie.designsystem.component.segmented.SegmentedPage
import com.mindera.alfie.designsystem.component.snackbar.SnackbarCustomHostState
import com.mindera.alfie.designsystem.component.topbar.TopBarState
import com.mindera.alfie.designsystem.component.topbar.action.TopBarAction
import com.mindera.alfie.designsystem.theme.Theme
import com.mindera.alfie.feature.shop.brand.ShopBrandsScreen
import com.mindera.alfie.feature.shop.category.ShopCategoriesScreen
import com.mindera.alfie.feature.shop.model.ShopUI
import com.mindera.alfie.feature.shop.model.ShopUIState
import com.mindera.alfie.feature.shop.ui.ShopErrorScreen
import com.mindera.alfie.feature.uievent.UIEvent
import com.mindera.alfie.feature.uievent.handleUIEvent
import com.mindera.alfie.feature.uievent.handleUIEvents
import com.mindera.alfie.feature.webview.WebViewContent
import com.mindera.alfie.feature.webview.WebViewEvent
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
import com.mindera.alfie.feature.R as FeatureR

@Destination(navArgsDelegate = ShopNavArgs::class)
@Composable
Expand All @@ -57,8 +33,7 @@ internal fun ShopScreen(
directionProvider: DirectionProvider,
snackbarHostState: SnackbarCustomHostState,
topBarState: TopBarState,
bottomBarState: BottomBarState,
shopNavArgs: ShopNavArgs
bottomBarState: BottomBarState
) {
Comment thread
hoangnhatdrk marked this conversation as resolved.
val viewModel: ShopViewModel = hiltViewModel()
val state by viewModel.state.collectAsStateWithLifecycle()
Expand Down Expand Up @@ -86,9 +61,7 @@ internal fun ShopScreen(

when (state) {
is ShopUIState.Data -> {
ShopScreenContent(
state = state as ShopUIState.Data,
initialTab = shopNavArgs.tab,
ShopCategoriesScreen(
onUiEvent = { uiEvent ->
coroutineScope.launch {
uiEvent.handleUIEvent(
Expand All @@ -98,9 +71,7 @@ internal fun ShopScreen(
snackbarHostState = snackbarHostState
)
}
},
deeplinkHandler = viewModel.deeplinkHandler,
onWebViewEvent = { viewModel.run { handleWebViewEvent(it) } }
}
)
}
is ShopUIState.Error -> {
Expand All @@ -110,68 +81,3 @@ internal fun ShopScreen(
}
}
}

@Composable
private fun ShopScreenContent(
state: ShopUIState.Data,
initialTab: ShopTab,
onUiEvent: ClickEventOneArg<UIEvent>,
deeplinkHandler: DeeplinkHandler,
onWebViewEvent: ClickEventOneArg<WebViewEvent>
) {
var selectedSegment by rememberSaveable { mutableIntStateOf(ShopTab.entries.indexOf(initialTab)) }
val segments: ImmutableList<Pair<SegmentedItem, @Composable () -> Unit>> = persistentListOf(
SegmentedItem(label = StringResource.fromId(R.string.shop_segment_categories)) to {
ShopCategoriesScreen(
onUiEvent = onUiEvent
)
},
SegmentedItem(label = StringResource.fromId(R.string.shop_segment_brands)) to {
ShopBrandsScreen(
onUiEvent = onUiEvent
)
},
SegmentedItem(label = StringResource.fromId(R.string.shop_segment_services)) to {
WebViewContent(
url = state.shopUI.servicesUrl,
queryParameters = emptyMap(),
headers = emptyMap(),
deeplinkHandler = deeplinkHandler,
onEvent = onWebViewEvent,
isBackHandlerEnabled = false,
modifier = Modifier.testTag(SERVICES_PAGE),
errorType = ErrorType(
message = stringResource(FeatureR.string.error_failed_to_load_page),
buttonLabel = stringResource(FeatureR.string.retry)
)
)
}
)

SegmentedPage(
segments = segments.map { it.first }.toImmutableList(),
selectedSegment = selectedSegment,
onSegmentClick = { selectedSegment = it },
modifier = Modifier.fillMaxSize()
) { page ->
segments[page].second()
}
}

@Preview(showBackground = true)
@Composable
private fun ShopScreenPreview() {
Theme {
ShopScreenContent(
state = ShopUIState.Data(
shopUI = ShopUI(
servicesUrl = "servicesUrl"
)
),
initialTab = ShopTab.Categories,
onUiEvent = { },
deeplinkHandler = DeeplinkHandler(emptySet()),
onWebViewEvent = { }
)
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,7 @@
package com.mindera.alfie.feature.shop.category

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mindera.alfie.core.analytics.AnalyticsManager
import com.mindera.alfie.core.analytics.params.EmptyParams
import com.mindera.alfie.core.commons.string.StringResource
import com.mindera.alfie.domain.doOnResult
import com.mindera.alfie.domain.usecase.navigation.GetRootNavEntriesUseCase
import com.mindera.alfie.feature.mappers.toApiErrorType
import com.mindera.alfie.feature.mappers.toEventErrorValue
import com.mindera.alfie.feature.shop.category.factory.CategoryUIStateFactory
import com.mindera.alfie.feature.shop.category.model.CategoryEntryUI
import com.mindera.alfie.feature.shop.category.model.CategoryEvent
import com.mindera.alfie.feature.shop.category.model.CategoryUIState
Expand All @@ -18,60 +10,31 @@ import com.mindera.alfie.feature.shop.delegate.NavigateToEntryDelegate
import com.mindera.alfie.feature.uievent.UIEventEmitter
import com.mindera.alfie.feature.uievent.UIEventEmitterDelegate
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
internal class CategoryViewModel @Inject constructor(
private val getRootNavEntriesUseCase: GetRootNavEntriesUseCase,
private val uiFactory: CategoryUIStateFactory,
private val analyticsManager: AnalyticsManager,
navigateToEntryDelegate: NavigateToEntryDelegate,
uiEventEmitterDelegate: UIEventEmitterDelegate
) : ViewModel(),
NavigateToEntry by navigateToEntryDelegate,
UIEventEmitter by uiEventEmitterDelegate {

private val _state = MutableStateFlow<CategoryUIState>(CategoryUIStateFactory.PLACEHOLDER)
private val _state = MutableStateFlow<CategoryUIState>(STATIC_STATE)
val state: StateFlow<CategoryUIState> = _state.asStateFlow()

init {
loadCategories()
}

fun retry() = loadCategories()
fun retry() = Unit

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F4 — retry() is now a no-op.

fun retry() = Unit does nothing, and the CategoryUIState.Error branch in ShopCategoriesScreen (wired to viewModel::retry) is unreachable since the VM only ever emits Data (STATIC_STATE). Either drop the dead retry() + Error handling, or keep real error handling if the static state is expected to become fallible later. As-is it's a confusing stub.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentionally keeping retry() + the Error branch for now. Once the static list is replaced with real (fallible) data, the VM will emit Error again and this wiring becomes live — pulling it out now just means re-adding it shortly. Happy to revisit if you would rather see it gone in the interim.


fun handleEvent(event: CategoryEvent) {
when (event) {
is CategoryEvent.OnEntryClickEvent -> navigateToCategoryEntry(event.entry)
}
}

private fun loadCategories() {
viewModelScope.launch {
getRootNavEntriesUseCase().doOnResult(
onSuccess = {
_state.value = uiFactory(
title = StringResource.EMPTY,
navEntries = it
)
},
onError = {
analyticsManager.trackError(
screenName = SCREEN_NAME,
eventName = EVENT_LOAD_ERROR,
eventErrorValue = it.type.toEventErrorValue(),
params = EmptyParams()
)
_state.value = CategoryUIState.Error(it.type.toApiErrorType())
}
)
}
}

private fun navigateToCategoryEntry(entry: CategoryEntryUI) {
val state = _state.value
if (state is CategoryUIState.Data && entry.path.isNotEmpty()) {
Expand All @@ -80,7 +43,22 @@ internal class CategoryViewModel @Inject constructor(
}

companion object {
private const val SCREEN_NAME = "shop_category"
private const val EVENT_LOAD_ERROR = "load_error"
private val STATIC_ENTRIES = listOf(
CategoryEntryUI(id = 0, title = StringResource.fromText("Women"), path = "women"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F3 — category titles should be string resources.

These 9 titles are hardcoded English via StringResource.fromText("Women"…). The old code sourced titles from the BFF (potentially server-localized); this module already uses strings.xml for its other labels (shop_segment_*). Hardcoding literals breaks that convention and blocks localization.

Suggest StringResource.fromId(R.string.shop_category_women) etc. with entries added to strings.xml. Not a hard blocker today (no values-* translations exist yet), but cheap to do now while the list is small — or a tracked follow-up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the convention. Deferring to a tracked follow-up since no values-* translations exist yet and this static list is temporary — it will be replaced when we move to the BFF-supported solution, at which point titles come from the server. Keeping it on the list rather than folding it in here.

CategoryEntryUI(id = 1, title = StringResource.fromText("Men"), path = "men"),
CategoryEntryUI(id = 2, title = StringResource.fromText("Featured"), path = "frontpage"),
CategoryEntryUI(id = 3, title = StringResource.fromText("Tops"), path = "womens-tops"),
CategoryEntryUI(id = 4, title = StringResource.fromText("Beauty"), path = "spring-summer"),
Comment thread
amccall-mindera marked this conversation as resolved.
CategoryEntryUI(id = 5, title = StringResource.fromText("Bags"), path = "womens-bags"),
CategoryEntryUI(id = 6, title = StringResource.fromText("Dresses"), path = "dresses"),
CategoryEntryUI(id = 7, title = StringResource.fromText("Jackets"), path = "womens-jackets"),
CategoryEntryUI(id = 8, title = StringResource.fromText("Jeans"), path = "womens-jeans")
).toImmutableList()
Comment thread
hoangnhatdrk marked this conversation as resolved.

private val STATIC_STATE = CategoryUIState.Data(
title = StringResource.EMPTY,
entries = STATIC_ENTRIES,
isLoading = false
)
}
}
Loading