Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
8c7197a
Add Most Popular Time Insights card
adalpari Mar 12, 2026
ded6513
Clean up MostPopularTimeViewModel: locale-aware hour formatting and r…
adalpari Mar 12, 2026
0f04583
Add tests for MostPopularTimeViewModel and StatsInsightsUseCase
adalpari Mar 12, 2026
63e37bb
Fix day-of-week mapping, NoData condition, and add bounds check
adalpari Mar 12, 2026
50150d2
Fix detekt: suppress LongMethod and remove unused import
adalpari Mar 12, 2026
9da3f06
Centralize Insights data fetching in InsightsViewModel
adalpari Mar 12, 2026
8adaafc
Fix race condition, consistent onRetry pattern, and remove unused siteId
adalpari Mar 12, 2026
2d6f6cd
Add formatHour bounds check, remove duplicate string, clean up import
adalpari Mar 12, 2026
5f8bec6
Fix detekt LongMethod: extract fetchSummary and fetchInsights
adalpari Mar 12, 2026
45f255d
Reduce duplication in MostPopularTimeCard using shared components
adalpari Mar 12, 2026
0556980
Rename views percent string resource and add NoData preview
adalpari Mar 12, 2026
06648cd
Trigger PR checks
adalpari Mar 12, 2026
1e4fac6
Use device 24h/12h setting for hour formatting
adalpari Mar 12, 2026
676dfc2
Fix thread safety, CancellationException handling, and lambda allocation
adalpari Mar 12, 2026
710b5ce
Add Tags & Categories insights card
adalpari Mar 13, 2026
86895ef
Add expand/collapse for multi-tag groups in detail screen
adalpari Mar 13, 2026
cee4040
Add tests for Tags & Categories feature
adalpari Mar 13, 2026
f7b2548
Simplify Tags & Categories by reusing shared components
adalpari Mar 13, 2026
8cf8402
Remove unused stubUnknownError from ViewModel test
adalpari Mar 13, 2026
6e76a52
Fix review issues: error recovery, conditional refresh, loading state
adalpari Mar 13, 2026
7f6203b
Merge branch 'feat/CMM-1936-create-insights-tab' into adalpari/insigh…
adalpari Mar 13, 2026
9f6c8c5
Address review feedback: deduplicate row composable, remove unused li…
adalpari Mar 13, 2026
717c3cf
Fix concurrent refresh, process death, isExpandable duplication, and …
adalpari Mar 13, 2026
af05442
Update configuration tests to include TAGS_AND_CATEGORIES card type
adalpari Mar 13, 2026
e4fe2c9
Fetch tags data independently in detail screen instead of using stati…
adalpari Mar 13, 2026
2d75c8e
Extract shared mapper, add detail VM tests, guard double calls, show …
adalpari Mar 13, 2026
7d56a07
Skip data fetching for hidden Insights cards
adalpari Mar 13, 2026
88b7d9c
Early return in fetchData when no endpoints are needed
adalpari Mar 13, 2026
fa9a9bd
Address code review findings: reduce duplication and improve tests
adalpari Mar 13, 2026
545a9f0
Suppress LargeClass detekt warning on InsightsViewModelTest
adalpari Mar 13, 2026
6c5eb45
Fix process-death restore and add caching to StatsTagsUseCase
adalpari Mar 13, 2026
887bba3
Extract BaseTagsAndCategoriesViewModel and use localized error messages
adalpari Mar 13, 2026
b439e22
Fix thread safety and error handling in ViewModels
adalpari Mar 13, 2026
e4b26af
Extract isCacheHit method to fix detekt ComplexCondition
adalpari Mar 13, 2026
ae608d8
Cancel in-flight fetch job before refreshing in InsightsViewModel
adalpari Mar 13, 2026
a6a0fac
Merge branch 'adalpari/insights-tags-card' into fix/CMM-1952-new-stat…
adalpari Mar 17, 2026
42f4c67
Merge branch 'feat/CMM-1936-create-insights-tab' into fix/CMM-1952-ne…
adalpari Mar 17, 2026
0d1b580
Fix compilation errors after trunk merge
adalpari Mar 17, 2026
a30b5bd
Address code review findings and add base ViewModel tests
adalpari Mar 17, 2026
4788f02
Fix detekt findings: suppress ReturnCount, remove unused composable
adalpari Mar 17, 2026
038a6a9
Fix TOCTOU race, config fetch trigger, and detail empty state
adalpari Mar 17, 2026
95cdee9
Deduplicate detail VM tests, fix Mockito import, add cancellation test
adalpari Mar 17, 2026
6dc12ec
Clear stats caches on screen open to prevent stale data
adalpari Mar 17, 2026
5acc29b
Update wordpress-rs to trunk-262a778ead5f163f3450d62adfac21fb32048714
adalpari Mar 18, 2026
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 @@ -3,6 +3,7 @@ package org.wordpress.android.ui.newstats
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
Expand All @@ -17,8 +18,10 @@ import org.wordpress.android.ui.newstats.repository.InsightsResult
import org.wordpress.android.ui.newstats.repository.StatsSummaryResult
import org.wordpress.android.ui.newstats.repository.StatsSummaryUseCase
import org.wordpress.android.ui.newstats.repository.StatsInsightsUseCase
import org.wordpress.android.ui.newstats.repository.StatsTagsUseCase
import org.wordpress.android.util.AppLog
import org.wordpress.android.util.NetworkUtilsWrapper
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.cancellation.CancellationException
import javax.inject.Inject

Expand All @@ -30,7 +33,8 @@ class InsightsViewModel @Inject constructor(
InsightsCardsConfigurationRepository,
private val networkUtilsWrapper: NetworkUtilsWrapper,
private val statsSummaryUseCase: StatsSummaryUseCase,
private val statsInsightsUseCase: StatsInsightsUseCase
private val statsInsightsUseCase: StatsInsightsUseCase,
private val statsTagsUseCase: StatsTagsUseCase
) : ViewModel() {
private val _visibleCards =
MutableStateFlow<List<InsightsCardType>>(
Expand Down Expand Up @@ -68,13 +72,20 @@ class InsightsViewModel @Inject constructor(
val isDataRefreshing: StateFlow<Boolean> =
_isDataRefreshing.asStateFlow()

@Volatile
private var isDataLoaded = false

@Volatile
private var isDataLoading = false
private val isDataLoaded = AtomicBoolean(false)
private val isDataLoading = AtomicBoolean(false)
private val summaryFetched = AtomicBoolean(false)
private val insightsFetched = AtomicBoolean(false)
// Main-thread-confined: only accessed from
// viewModelScope (Dispatchers.Main).
private var fetchJob: Job? = null

init {
viewModelScope.launch {
statsSummaryUseCase.clearCache()
statsInsightsUseCase.clearCache()
statsTagsUseCase.clearCache()
}
checkNetworkStatus()
loadConfiguration()
observeConfigurationChanges()
Expand All @@ -90,30 +101,45 @@ class InsightsViewModel @Inject constructor(
// region Data fetching

fun loadDataIfNeeded() {
if (isDataLoaded || isDataLoading) return
isDataLoading = true
if (isDataLoaded.get() ||
!isDataLoading.compareAndSet(false, true)
) return
fetchData()
}

fun fetchData(forceRefresh: Boolean = false) {
val siteId = resolvedSiteId() ?: run {
isDataLoading = false
isDataLoading.set(false)
_isDataRefreshing.value = false
return
}
viewModelScope.launch {
val cards = _cardsToLoad.value
val shouldFetchSummary = cards.needsSummary()
val shouldFetchInsights = cards.needsInsights()
if (!shouldFetchSummary && !shouldFetchInsights) {
isDataLoading.set(false)
_isDataRefreshing.value = false
return
}
fetchJob = viewModelScope.launch {
try {
coroutineScope {
launch {
fetchSummary(siteId, forceRefresh)
if (shouldFetchSummary) {
launch {
fetchSummary(siteId, forceRefresh)
}
}
launch {
fetchInsights(siteId, forceRefresh)
if (shouldFetchInsights) {
launch {
fetchInsights(
siteId, forceRefresh
)
}
}
}
isDataLoaded = true
isDataLoaded.set(true)
} finally {
isDataLoading = false
isDataLoading.set(false)
_isDataRefreshing.value = false
}
}
Expand All @@ -131,6 +157,9 @@ class InsightsViewModel @Inject constructor(
val result = statsSummaryUseCase(
siteId, forceRefresh
)
if (result is StatsSummaryResult.Success) {
summaryFetched.set(true)
}
_summaryResult.emit(result)
} catch (e: Exception) {
if (e is CancellationException) throw e
Expand Down Expand Up @@ -160,6 +189,9 @@ class InsightsViewModel @Inject constructor(
val result = statsInsightsUseCase(
siteId, forceRefresh
)
if (result is InsightsResult.Success) {
insightsFetched.set(true)
}
_insightsResult.emit(result)
} catch (e: Exception) {
if (e is CancellationException) throw e
Expand All @@ -178,8 +210,11 @@ class InsightsViewModel @Inject constructor(
}

fun refreshData() {
isDataLoaded = false
isDataLoading = true
fetchJob?.cancel()
isDataLoaded.set(false)
summaryFetched.set(false)
insightsFetched.set(false)
isDataLoading.set(true)
_isDataRefreshing.value = true
fetchData(forceRefresh = true)
}
Expand Down Expand Up @@ -224,7 +259,17 @@ class InsightsViewModel @Inject constructor(
) {
_visibleCards.value = config.visibleCards
_hiddenCards.value = config.computeHiddenCards()
val cards = config.visibleCards
val needsNewFetch =
(cards.needsSummary() &&
!summaryFetched.get()) ||
(cards.needsInsights() &&
!insightsFetched.get())
_cardsToLoad.value = config.visibleCards
if (needsNewFetch) {
isDataLoaded.set(false)
loadDataIfNeeded()
}
}

fun removeCard(cardType: InsightsCardType) {
Expand Down Expand Up @@ -287,4 +332,22 @@ class InsightsViewModel @Inject constructor(
null
}
}

companion object {
// TAGS_AND_CATEGORIES is intentionally absent
// from both checks: it has its own dedicated
// fetch path via StatsTagsUseCase in
// TagsAndCategoriesViewModel.
private fun List<InsightsCardType>.needsSummary():
Boolean = any {
it == InsightsCardType.ALL_TIME_STATS ||
it == InsightsCardType.MOST_POPULAR_DAY
}

private fun List<InsightsCardType>.needsInsights():
Boolean = any {
it == InsightsCardType.YEAR_IN_REVIEW ||
it == InsightsCardType.MOST_POPULAR_TIME
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,6 @@ private fun StatsTabContent(
)
StatsTab.INSIGHTS -> InsightsTabContent()
StatsTab.SUBSCRIBERS -> SubscribersTabContent()
else -> PlaceholderTabContent(tab)
}
}

Expand Down Expand Up @@ -1227,14 +1226,8 @@ private fun InsightsTabContent(
uiState =
tagsAndCategoriesUiState,
onShowAllClick = {
val items =
tagsAndCategoriesViewModel
.getDetailData()
TagsAndCategoriesDetailActivity
.start(
context,
items
)
.start(context)
},
onRemoveCard = {
insightsViewModel
Expand Down Expand Up @@ -1357,65 +1350,6 @@ private fun AddInsightsCardBottomSheet(
}
}

@Composable
private fun NoConnectionContent(
onRetry: () -> Unit
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 60.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
painter = painterResource(R.drawable.ic_wifi_off_24px),
contentDescription = null,
modifier = Modifier
.size(48.dp)
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = CircleShape
)
.padding(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.no_connection_error_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.no_connection_error_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = onRetry) {
Text(stringResource(R.string.retry))
}
}
}
}

@Composable
private fun PlaceholderTabContent(tab: StatsTab) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(text = "${stringResource(id = tab.titleResId)} - Coming Soon")
}
}

@Composable
private fun StatsPeriodMenu(
expanded: Boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,8 @@ class StatsInsightsUseCase @Inject constructor(
result
}
}

suspend fun clearCache() {
mutex.withLock { cachedInsights = null }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,8 @@ class StatsSummaryUseCase @Inject constructor(
result
}
}

suspend fun clearCache() {
mutex.withLock { cachedSummary = null }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package org.wordpress.android.ui.newstats.repository

import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.wordpress.android.fluxc.store.AccountStore
import org.wordpress.android.ui.newstats.datasource.StatsTagsData
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class StatsTagsUseCase @Inject constructor(
private val statsRepository: StatsRepository,
private val accountStore: AccountStore
) {
private val mutex = Mutex()
private var cachedTags:
Triple<Long, Int, StatsTagsData>? = null

// In-flight request keyed by (siteId, max).
// Concurrent callers with the same params join
// the existing request instead of duplicating it.
private var inFlight:
Pair<Pair<Long, Int>,
CompletableDeferred<TagsResult>>? = null

@Suppress("ReturnCount")
suspend operator fun invoke(
siteId: Long,
max: Int = DEFAULT_MAX_ITEMS,
forceRefresh: Boolean = false
): TagsResult {
val token = accountStore.accessToken
if (token.isNullOrEmpty()) {
return TagsResult.Error("No access token")
}
statsRepository.init(token)

val key = siteId to max

// Under lock: check cache, then check/create
// an in-flight deferred. The actual network
// call runs outside the lock so concurrent
// callers with different params aren't blocked.
val (deferred, isOwner) = mutex.withLock {
val cached = cachedTags
if (!forceRefresh &&
isCacheHit(cached, siteId, max)
) {
return TagsResult.Success(
cached!!.third
)
}

val existing = inFlight
if (!forceRefresh &&
existing != null &&
existing.first == key
) {
return@withLock existing.second to false
}

val newDeferred =
CompletableDeferred<TagsResult>()
inFlight = key to newDeferred
newDeferred to true
}

if (isOwner) {
val result = statsRepository.fetchTags(
siteId = siteId,
max = max
)
mutex.withLock {
if (result is TagsResult.Success) {
cachedTags =
Triple(siteId, max, result.data)
}
inFlight = null
}
deferred.complete(result)
}

return deferred.await()
}

suspend fun clearCache() {
mutex.withLock {
cachedTags = null
inFlight = null
}
}

private fun isCacheHit(
cached: Triple<Long, Int, StatsTagsData>?,
siteId: Long,
max: Int
): Boolean = cached != null &&
cached.first == siteId &&
cached.second == max

companion object {
private const val DEFAULT_MAX_ITEMS = 10
}
}
Loading
Loading