-
-
Notifications
You must be signed in to change notification settings - Fork 800
Expand file tree
/
Copy pathActivityTabViewModel.kt
More file actions
327 lines (292 loc) · 13.7 KB
/
ActivityTabViewModel.kt
File metadata and controls
327 lines (292 loc) · 13.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
package org.wikipedia.activitytab
import android.text.format.DateUtils
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import androidx.paging.insertSeparators
import androidx.paging.map
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.wikipedia.Constants
import org.wikipedia.WikipediaApp
import org.wikipedia.activitytab.timeline.HistoryEntryPagingSource
import org.wikipedia.activitytab.timeline.ReadingListPagingSource
import org.wikipedia.activitytab.timeline.TimelineItem
import org.wikipedia.activitytab.timeline.TimelinePagingSource
import org.wikipedia.activitytab.timeline.TimelineSource
import org.wikipedia.activitytab.timeline.UserContribPagingSource
import org.wikipedia.auth.AccountUtil
import org.wikipedia.categories.db.Category
import org.wikipedia.database.AppDatabase
import org.wikipedia.dataclient.Service
import org.wikipedia.dataclient.ServiceFactory
import org.wikipedia.dataclient.WikiSite
import org.wikipedia.dataclient.growthtasks.GrowthUserImpact
import org.wikipedia.extensions.toLocalDate
import org.wikipedia.games.onthisday.OnThisDayGameViewModel
import org.wikipedia.json.JsonUtil
import org.wikipedia.page.PageTitle
import org.wikipedia.readinglist.database.ReadingListPage
import org.wikipedia.settings.Prefs
import org.wikipedia.util.UiState
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.Date
import java.util.concurrent.TimeUnit
import kotlin.math.abs
class ActivityTabViewModel() : ViewModel() {
private val _readingHistoryState = MutableStateFlow<UiState<ReadingHistory>>(UiState.Loading)
val readingHistoryState: StateFlow<UiState<ReadingHistory>> = _readingHistoryState.asStateFlow()
private val _donationUiState = MutableStateFlow<UiState<String?>>(UiState.Loading)
val donationUiState: StateFlow<UiState<String?>> = _donationUiState.asStateFlow()
private val _wikiGamesUiState = MutableStateFlow<UiState<OnThisDayGameViewModel.GameStatistics?>>(UiState.Loading)
val wikiGamesUiState: StateFlow<UiState<OnThisDayGameViewModel.GameStatistics?>> = _wikiGamesUiState.asStateFlow()
private var currentTimelinePagingSource: TimelinePagingSource? = null
val wikiSiteForTimeline get(): WikiSite {
val langCode = Prefs.userContribFilterLangCode
return when (langCode) {
Constants.WIKI_CODE_COMMONS -> WikiSite(Service.COMMONS_URL)
Constants.WIKI_CODE_WIKIDATA -> WikiSite(Service.WIKIDATA_URL)
else -> WikiSite.forLanguageCode(langCode)
}
}
var shouldRefreshTimelineSilently = false
val timelineFlow = Pager(
config = PagingConfig(
pageSize = 150,
prefetchDistance = 20
),
pagingSourceFactory = { TimelinePagingSource(
createTimelineSources()
).also {
currentTimelinePagingSource = it
} }
).flow.cachedIn(viewModelScope)
.map { pagingData ->
pagingData.insertSeparators { before, after ->
if (before == null && after != null) TimelineDisplayItem.DateSeparator(after.timestamp)
else if (before != null && after != null && before.timestamp.toLocalDate() != after.timestamp.toLocalDate()) {
TimelineDisplayItem.DateSeparator(after.timestamp)
} else null
}.map { item ->
when (item) {
is TimelineItem -> TimelineDisplayItem.TimelineEntry(item)
else -> item as TimelineDisplayItem
}
}
}
private val _impactUiState = MutableStateFlow<UiState<GrowthUserImpact>>(UiState.Loading)
val impactUiState: StateFlow<UiState<GrowthUserImpact>> = _impactUiState.asStateFlow()
val allDataLoaded = combine(
readingHistoryState,
donationUiState,
wikiGamesUiState,
impactUiState
) { reading, donation, games, impact ->
reading !is UiState.Loading &&
donation !is UiState.Loading &&
games !is UiState.Loading &&
impact !is UiState.Loading
}.stateIn(viewModelScope, SharingStarted.Lazily, false)
init {
loadAll()
}
fun loadAll() {
loadReadingHistory()
if (!AccountUtil.isLoggedIn) {
return
}
loadDonationResults()
loadWikiGamesStats()
loadImpact()
refreshTimeline()
}
fun refreshTimeline() {
currentTimelinePagingSource?.invalidate()
}
fun loadReadingHistory() {
viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
_readingHistoryState.value = UiState.Error(throwable)
}) {
_readingHistoryState.value = UiState.Loading
delay(500)
val now = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
val weekInMillis = TimeUnit.DAYS.toMillis(7)
var weekAgo = now - weekInMillis
val totalTimeSpent = AppDatabase.instance.historyEntryWithImageDao().getTimeSpentSinceTimeStamp(weekAgo)
val thirtyDaysAgo = now - TimeUnit.DAYS.toMillis(30)
val articlesReadThisMonth = AppDatabase.instance.historyEntryDao().getDistinctEntriesSince(thirtyDaysAgo) ?: 0
val articlesReadByWeek = mutableListOf<Int>()
articlesReadByWeek.add(AppDatabase.instance.historyEntryDao().getDistinctEntriesSince(weekAgo) ?: 0)
for (i in 1..3) {
weekAgo -= weekInMillis
val articlesRead = AppDatabase.instance.historyEntryDao().getDistinctEntriesBetween(weekAgo, weekAgo + weekInMillis)
articlesReadByWeek.add(articlesRead)
}
val mostRecentReadTime = AppDatabase.instance.historyEntryDao().getMostRecentEntry()?.timestamp?.toInstant()?.atZone(ZoneId.systemDefault())?.toLocalDateTime()
val articlesSavedThisMonth = AppDatabase.instance.readingListPageDao().getTotalLocallySavedPagesSince(thirtyDaysAgo) ?: 0
val articlesSaved = AppDatabase.instance.readingListPageDao().getLocallySavedPagesSince(thirtyDaysAgo, 4)
.map { ReadingListPage.toPageTitle(it) }
val mostRecentSaveTime = AppDatabase.instance.readingListPageDao().getMostRecentLocallySavedPage()?.atime?.let { Instant.ofEpochMilli(it) }?.atZone(ZoneId.systemDefault())?.toLocalDateTime()
val currentDate = LocalDate.now()
val topCategories = AppDatabase.instance.categoryDao().getTopCategoriesByMonth(currentDate.year, currentDate.monthValue)
_readingHistoryState.value = UiState.Success(ReadingHistory(
timeSpentThisWeek = totalTimeSpent,
articlesReadThisMonth = articlesReadThisMonth,
lastArticleReadTime = mostRecentReadTime,
articlesReadByWeek = articlesReadByWeek,
articlesSavedThisMonth = articlesSavedThisMonth,
lastArticleSavedTime = mostRecentSaveTime,
articlesSaved = articlesSaved,
topCategories.take(3))
)
}
}
fun loadDonationResults() {
val lastDonationTime = Prefs.donationResults.lastOrNull()?.dateTime?.let {
val timestampInLong = LocalDateTime.parse(it).toInstant(ZoneOffset.UTC).epochSecond
val relativeTime = DateUtils.getRelativeTimeSpanString(
timestampInLong * 1000, // Convert seconds to milliseconds
System.currentTimeMillis(),
0L
)
return@let relativeTime.toString()
}
_donationUiState.value = UiState.Success(lastDonationTime)
}
fun loadWikiGamesStats() {
viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
_wikiGamesUiState.value = UiState.Error(throwable)
}) {
_wikiGamesUiState.value = UiState.Loading
delay(500)
val lastGameHistory = AppDatabase.instance.dailyGameHistoryDao().findLastGameHistory()
if (lastGameHistory == null) {
_wikiGamesUiState.value = UiState.Success(null)
return@launch
}
val gamesStats =
OnThisDayGameViewModel.getGameStatistics(WikipediaApp.instance.wikiSite.languageCode)
_wikiGamesUiState.value = UiState.Success(gamesStats)
}
}
fun loadImpact() {
if (!AccountUtil.isLoggedIn) {
return
}
viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
_impactUiState.value = UiState.Error(throwable)
}) {
_impactUiState.value = UiState.Loading
// The impact API is rate limited, so we cache it manually.
val wikiSite = WikipediaApp.instance.wikiSite
val now = Instant.now().epochSecond
val impact: GrowthUserImpact
val impactLastResponseBodyMap = Prefs.impactLastResponseBody.toMutableMap()
val impactResponse = impactLastResponseBodyMap[wikiSite.languageCode]
if (impactResponse.isNullOrEmpty() || abs(now - Prefs.impactLastQueryTime) > TimeUnit.HOURS.toSeconds(12)) {
val userId = ServiceFactory.get(wikiSite).getUserInfo().query?.userInfo?.id!!
impact = ServiceFactory.getCoreRest(wikiSite).getUserImpact(userId)
impactLastResponseBodyMap[wikiSite.languageCode] = JsonUtil.encodeToString(impact).orEmpty()
Prefs.impactLastResponseBody = impactLastResponseBodyMap
Prefs.impactLastQueryTime = now
} else {
impact = JsonUtil.decodeFromString(impactResponse)!!
}
val pagesResponse = ServiceFactory.get(wikiSite).getInfoByPageIdsOrTitles(
titles = impact.topViewedArticles.keys.joinToString(separator = "|")
)
// Transform the response to a map of PageTitle to ArticleViews
val pageMap = pagesResponse.query?.pages?.associate { page ->
val pageTitle = PageTitle(
text = page.title,
wiki = wikiSite,
thumbUrl = page.thumbUrl(),
description = page.description,
displayText = page.displayTitle(wikiSite.languageCode)
)
pageTitle to impact.topViewedArticles[pageTitle.text]!!
} ?: emptyMap()
impact.topViewedArticlesWithPageTitle = pageMap
_impactUiState.value = UiState.Success(impact)
}
}
fun createPageTitleForCategory(category: Category): PageTitle {
return PageTitle(title = category.title, wiki = WikiSite.forLanguageCode(category.lang))
}
private fun createTimelineSources(): List<TimelineSource> {
val historyEntryPagingSource = HistoryEntryPagingSource(AppDatabase.instance.historyEntryWithImageDao())
val userContribPagingSource = UserContribPagingSource(wikiSiteForTimeline, AccountUtil.userName, AppDatabase.instance.historyEntryWithImageDao())
val readingListPagingSource = ReadingListPagingSource(AppDatabase.instance.readingListPageDao())
return listOf(historyEntryPagingSource, readingListPagingSource, userContribPagingSource)
}
fun getTotalEditsCount(): Int {
return when (val currentState = _impactUiState.value) {
is UiState.Success -> currentState.data.totalEditsCount
else -> 0
}
}
fun hasNoDonationData(): Boolean {
return when (val currentState = _donationUiState.value) {
is UiState.Success -> currentState.data == null
else -> true
}
}
fun hasNoReadingHistoryData(): Boolean {
return when (val currentState = _readingHistoryState.value) {
is UiState.Success -> {
val data = currentState.data
data.timeSpentThisWeek <= 0 && data.articlesReadThisMonth <= 0 && data.articlesSavedThisMonth <= 0 && data.topCategories.isEmpty()
}
else -> true
}
}
fun hasNoImpactData(): Boolean {
return when (val currentState = _impactUiState.value) {
is UiState.Success -> {
val data = currentState.data
data.totalEditsCount <= 0 && data.receivedThanksCount <= 0 && data.totalPageviewsCount <= 0
}
else -> true
}
}
fun hasNoGameStats(): Boolean {
return when (val currentState = _wikiGamesUiState.value) {
is UiState.Success -> {
val data = currentState.data ?: return true
data.totalGamesPlayed <= 0
}
else -> true
}
}
class ReadingHistory(
val timeSpentThisWeek: Long,
val articlesReadThisMonth: Int,
val lastArticleReadTime: LocalDateTime?,
val articlesReadByWeek: List<Int>,
val articlesSavedThisMonth: Int,
val lastArticleSavedTime: LocalDateTime?,
val articlesSaved: List<PageTitle>,
val topCategories: List<Category>
)
companion object {
const val CAMPAIGN_ID = "appmenu_activity"
}
}
sealed class TimelineDisplayItem {
data class DateSeparator(val date: Date) : TimelineDisplayItem()
data class TimelineEntry(val item: TimelineItem) : TimelineDisplayItem()
}