Skip to content

Commit 0483383

Browse files
committed
feat: check update, add built in and community tab, add onboarding screen
1 parent 2c271d6 commit 0483383

24 files changed

Lines changed: 678 additions & 75 deletions

File tree

app/src/main/java/org/nqmgaming/aneko/core/data/ApiService.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package org.nqmgaming.aneko.core.data
22

33
import kotlinx.coroutines.flow.Flow
44
import org.nqmgaming.aneko.core.networking.ApiResult
5+
import org.nqmgaming.aneko.data.GitHubRelease
56
import org.nqmgaming.aneko.data.SkinCollection
67

78
interface ApiService {
89
suspend fun getSkinCollection(): Flow<ApiResult<List<SkinCollection>>>
10+
suspend fun getLatestRelease(): Flow<ApiResult<GitHubRelease>>
911
}

app/src/main/java/org/nqmgaming/aneko/core/data/ApiServiceImpl.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ package org.nqmgaming.aneko.core.data
33
import io.ktor.client.HttpClient
44
import io.ktor.client.call.body
55
import io.ktor.client.request.get
6+
import io.ktor.client.request.header
7+
import io.ktor.http.HttpHeaders
68
import kotlinx.coroutines.flow.Flow
79
import kotlinx.coroutines.flow.flow
810
import org.nqmgaming.aneko.core.networking.ApiResult
911
import org.nqmgaming.aneko.core.util.Constants
12+
import org.nqmgaming.aneko.data.GitHubRelease
1013
import org.nqmgaming.aneko.data.SkinCollection
1114
import timber.log.Timber
1215

@@ -24,4 +27,18 @@ class ApiServiceImpl(
2427
emit(ApiResult.Error(e.message ?: "Unknown error occurred"))
2528
}
2629
}
30+
31+
override suspend fun getLatestRelease(): Flow<ApiResult<GitHubRelease>> = flow {
32+
emit(ApiResult.Loading())
33+
try {
34+
val response = httpClient.get(Constants.GITHUB_LATEST_RELEASE_URL) {
35+
header(HttpHeaders.Accept, "application/vnd.github+json")
36+
}
37+
Timber.d("GitHub Release: ${response.status}")
38+
emit(ApiResult.Success(response.body()))
39+
} catch (e: Exception) {
40+
Timber.e(e)
41+
emit(ApiResult.Error(e.message ?: "Unknown error occurred"))
42+
}
43+
}
2744
}

app/src/main/java/org/nqmgaming/aneko/core/util/Constants.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@ package org.nqmgaming.aneko.core.util
22

33
object Constants {
44
const val SKIN_COLLECTION_URL = "https://pass-with-high-score.github.io/Aneko-skin/skin.json"
5+
const val GITHUB_LATEST_RELEASE_URL =
6+
"https://api.github.com/repos/pass-with-high-score/ANeko/releases/latest"
57
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package org.nqmgaming.aneko.data
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
@Serializable
7+
data class GitHubRelease(
8+
@SerialName("tag_name") val tagName: String,
9+
val name: String? = null,
10+
val body: String? = null,
11+
@SerialName("html_url") val htmlUrl: String,
12+
)

app/src/main/java/org/nqmgaming/aneko/data/SkinCollection.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,11 @@ data class SkinCollection(
1212
val author: String? = null,
1313
val image: String,
1414
val url: String,
15-
)
15+
) {
16+
val isBuiltIn: Boolean
17+
get() = author?.equals(OFFICIAL_AUTHOR, ignoreCase = true) == true
18+
19+
companion object {
20+
const val OFFICIAL_AUTHOR = "nqmgaming"
21+
}
22+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package org.nqmgaming.aneko.presentation
22

33
import org.nqmgaming.aneko.core.data.entity.SkinEntity
4+
import org.nqmgaming.aneko.data.GitHubRelease
45
import org.nqmgaming.aneko.data.SkinCollection
56

67
data class ANekoState (
78
val skins: List<SkinEntity> = emptyList(),
89
val isLoading: Boolean = false,
910
val isRefreshing: Boolean = false,
1011
val skinCollections: List<SkinCollection>? = emptyList(),
12+
val updateInfo: GitHubRelease? = null,
13+
val isCheckingUpdate: Boolean = false,
1114
)

app/src/main/java/org/nqmgaming/aneko/presentation/AnekoViewModel.kt

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ class AnekoViewModel @Inject constructor(
9191
val isDarkTheme: StateFlow<Boolean> = _isDarkTheme.asStateFlow()
9292

9393
private val _isFirstLaunch =
94-
MutableStateFlow(prefs.getBoolean("is_first_launch_share_skin", true))
94+
MutableStateFlow(prefs.getBoolean("is_first_launch_share_skin_fix_ar", true))
9595
val isFirstLaunch: StateFlow<Boolean> = _isFirstLaunch.asStateFlow()
9696

9797
fun setFirstLaunchDone() {
@@ -479,4 +479,54 @@ class AnekoViewModel @Inject constructor(
479479
putBoolean(PREF_KEY_FINISHED_SETUP, true)
480480
}
481481
}
482+
483+
fun checkForUpdate() {
484+
viewModelScope.launch {
485+
apiService.getLatestRelease().collect { result ->
486+
when (result) {
487+
is ApiResult.Loading -> {
488+
_uiState.update { it.copy(isCheckingUpdate = true) }
489+
}
490+
491+
is ApiResult.Error -> {
492+
_uiState.update { it.copy(isCheckingUpdate = false) }
493+
}
494+
495+
is ApiResult.Success -> {
496+
val release = result.data
497+
val latestVersion = release?.tagName
498+
?.removePrefix("v")
499+
?.trim()
500+
?: ""
501+
val currentVersion = org.nqmgaming.aneko.BuildConfig.VERSION_NAME
502+
503+
val isNewer = compareVersions(latestVersion, currentVersion) > 0
504+
505+
_uiState.update {
506+
it.copy(
507+
updateInfo = if (isNewer) release else null,
508+
isCheckingUpdate = false
509+
)
510+
}
511+
}
512+
}
513+
}
514+
}
515+
}
516+
517+
fun dismissUpdate() {
518+
_uiState.update { it.copy(updateInfo = null) }
519+
}
520+
521+
private fun compareVersions(v1: String, v2: String): Int {
522+
val parts1 = v1.split(".").map { it.toIntOrNull() ?: 0 }
523+
val parts2 = v2.split(".").map { it.toIntOrNull() ?: 0 }
524+
val maxLen = maxOf(parts1.size, parts2.size)
525+
for (i in 0 until maxLen) {
526+
val p1 = parts1.getOrElse(i) { 0 }
527+
val p2 = parts2.getOrElse(i) { 0 }
528+
if (p1 != p2) return p1.compareTo(p2)
529+
}
530+
return 0
531+
}
482532
}

app/src/main/java/org/nqmgaming/aneko/presentation/explore/ExploreSkinScreen.kt

Lines changed: 123 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,23 @@ import androidx.compose.foundation.layout.fillMaxSize
1212
import androidx.compose.foundation.layout.height
1313
import androidx.compose.foundation.layout.padding
1414
import androidx.compose.foundation.lazy.LazyColumn
15+
import androidx.compose.foundation.pager.HorizontalPager
16+
import androidx.compose.foundation.pager.rememberPagerState
1517
import androidx.compose.material.icons.Icons
1618
import androidx.compose.material.icons.filled.Add
1719
import androidx.compose.material.icons.filled.Info
1820
import androidx.compose.material.icons.filled.Share
21+
import androidx.compose.material.icons.outlined.Inventory2
22+
import androidx.compose.material.icons.outlined.People
1923
import androidx.compose.material3.Button
2024
import androidx.compose.material3.CenterAlignedTopAppBar
2125
import androidx.compose.material3.ExperimentalMaterial3Api
2226
import androidx.compose.material3.Icon
2327
import androidx.compose.material3.IconButton
2428
import androidx.compose.material3.MaterialTheme
29+
import androidx.compose.material3.PrimaryTabRow
2530
import androidx.compose.material3.Scaffold
31+
import androidx.compose.material3.Tab
2632
import androidx.compose.material3.Text
2733
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
2834
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator
@@ -160,6 +166,7 @@ fun ExploreSkin(
160166
) {
161167
val context = LocalContext.current
162168
val state = rememberPullToRefreshState()
169+
val scope = rememberCoroutineScope()
163170

164171
val statusMap by SkinDownloadQueue.status.collectAsState()
165172

@@ -185,6 +192,26 @@ fun ExploreSkin(
185192

186193
var isShowInfoDialog by remember { mutableStateOf(false) }
187194

195+
// Tab definitions
196+
val tabTitles = listOf(
197+
stringResource(R.string.tab_built_in),
198+
stringResource(R.string.tab_community),
199+
)
200+
val tabIcons = listOf(
201+
Icons.Outlined.Inventory2,
202+
Icons.Outlined.People,
203+
)
204+
205+
val pagerState = rememberPagerState(pageCount = { tabTitles.size })
206+
207+
// Filter skins based on selected tab
208+
val builtInSkins = remember(skinCollection) {
209+
skinCollection?.filter { it.isBuiltIn } ?: emptyList()
210+
}
211+
val communitySkins = remember(skinCollection) {
212+
skinCollection?.filter { !it.isBuiltIn } ?: emptyList()
213+
}
214+
188215
Scaffold(
189216
topBar = {
190217
CenterAlignedTopAppBar(
@@ -225,66 +252,111 @@ fun ExploreSkin(
225252
)
226253
}
227254
) { innerPadding ->
228-
PullToRefreshBox(
229-
isRefreshing = isRefreshing,
230-
onRefresh = onRefresh,
231-
state = state,
232-
indicator = {
233-
Indicator(
234-
modifier = Modifier.align(Alignment.TopCenter),
235-
isRefreshing = isRefreshing,
236-
containerColor = MaterialTheme.colorScheme.surface,
237-
color = MaterialTheme.colorScheme.primary,
238-
state = state
239-
)
240-
},
255+
Column(
241256
modifier = Modifier
242257
.padding(innerPadding)
243258
.fillMaxSize()
244259
) {
245-
skinCollection?.let {
246-
LazyColumn(modifier = modifier) {
247-
if (it.isEmpty() && !isLoading) {
248-
item {
249-
Column(
250-
modifier = Modifier
251-
.fillMaxSize()
252-
.padding(16.dp),
253-
horizontalAlignment = Alignment.CenterHorizontally,
254-
verticalArrangement = Arrangement.Center,
255-
) {
256-
Text(
257-
text = stringResource(R.string.hi_there_is_no_skin_collection_for_now),
258-
modifier = Modifier.padding(16.dp),
259-
style = MaterialTheme.typography.bodyLarge
260-
)
261-
Button(onClick = {
262-
filePickerLauncher.launch(
263-
arrayOf("application/zip", "application/x-zip-compressed")
264-
)
265-
}) {
266-
Text(
267-
text = stringResource(R.string.try_to_import_from_zip),
268-
style = MaterialTheme.typography.bodyLarge
269-
)
260+
// Tab Row
261+
PrimaryTabRow(
262+
selectedTabIndex = pagerState.currentPage,
263+
) {
264+
tabTitles.forEachIndexed { index, title ->
265+
Tab(
266+
selected = pagerState.currentPage == index,
267+
onClick = {
268+
scope.launch { pagerState.animateScrollToPage(index) }
269+
},
270+
text = {
271+
Text(
272+
text = title,
273+
style = MaterialTheme.typography.labelLarge,
274+
fontWeight = if (pagerState.currentPage == index)
275+
FontWeight.Bold else FontWeight.Normal,
276+
)
277+
},
278+
icon = {
279+
Icon(
280+
imageVector = tabIcons[index],
281+
contentDescription = title,
282+
)
283+
},
284+
)
285+
}
286+
}
287+
288+
// Pager content
289+
PullToRefreshBox(
290+
isRefreshing = isRefreshing,
291+
onRefresh = onRefresh,
292+
state = state,
293+
indicator = {
294+
Indicator(
295+
modifier = Modifier.align(Alignment.TopCenter),
296+
isRefreshing = isRefreshing,
297+
containerColor = MaterialTheme.colorScheme.surface,
298+
color = MaterialTheme.colorScheme.primary,
299+
state = state
300+
)
301+
},
302+
modifier = Modifier.fillMaxSize()
303+
) {
304+
HorizontalPager(
305+
state = pagerState,
306+
modifier = Modifier.fillMaxSize(),
307+
) { page ->
308+
val currentSkins = if (page == 0) builtInSkins else communitySkins
309+
310+
if (skinCollection != null) {
311+
LazyColumn(modifier = modifier.fillMaxSize()) {
312+
if (currentSkins.isEmpty() && !isLoading) {
313+
item {
314+
Column(
315+
modifier = Modifier
316+
.fillMaxSize()
317+
.padding(16.dp),
318+
horizontalAlignment = Alignment.CenterHorizontally,
319+
verticalArrangement = Arrangement.Center,
320+
) {
321+
Text(
322+
text = stringResource(R.string.hi_there_is_no_skin_collection_for_now),
323+
modifier = Modifier.padding(16.dp),
324+
style = MaterialTheme.typography.bodyLarge
325+
)
326+
Button(onClick = {
327+
filePickerLauncher.launch(
328+
arrayOf(
329+
"application/zip",
330+
"application/x-zip-compressed"
331+
)
332+
)
333+
}) {
334+
Text(
335+
text = stringResource(R.string.try_to_import_from_zip),
336+
style = MaterialTheme.typography.bodyLarge
337+
)
338+
}
339+
}
270340
}
271341
}
272-
}
273-
}
274342

275-
items(it.size) { index ->
276-
val collection = it[index]
277-
val st = statusMap[collection.packageName] ?: DownloadStatus.Idle
278-
val queuePos = SkinDownloadQueue.queuePositionOf(collection.packageName)
343+
items(currentSkins.size) { index ->
344+
val collection = currentSkins[index]
345+
val st =
346+
statusMap[collection.packageName] ?: DownloadStatus.Idle
347+
val queuePos =
348+
SkinDownloadQueue.queuePositionOf(collection.packageName)
279349

280-
ExploreItem(
281-
collection = collection,
282-
isInstalled = isInstalled(collection.packageName),
283-
st = st,
284-
queuePos = queuePos,
285-
)
350+
ExploreItem(
351+
collection = collection,
352+
isInstalled = isInstalled(collection.packageName),
353+
st = st,
354+
queuePos = queuePos,
355+
)
356+
}
357+
item { Spacer(modifier = Modifier.height(90.dp)) }
358+
}
286359
}
287-
item { Spacer(modifier = Modifier.height(90.dp)) }
288360
}
289361
}
290362
}

0 commit comments

Comments
 (0)