mirror of
https://github.com/ReVanced/revanced-manager.git
synced 2026-03-13 08:41:57 +08:00
feat: Add announcements (#2948)
Co-authored-by: Ushie <ushiekane@gmail.com> Co-authored-by: Ax333l <main@axelen.xyz>
This commit is contained in:
committed by
GitHub
parent
6f4219c01b
commit
813df46847
@@ -22,6 +22,8 @@ import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.navigation
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.toRoute
|
||||
import app.revanced.manager.ui.model.navigation.Announcement
|
||||
import app.revanced.manager.ui.model.navigation.Announcements
|
||||
import app.revanced.manager.ui.model.navigation.AppSelector
|
||||
import app.revanced.manager.ui.model.navigation.ComplexParameter
|
||||
import app.revanced.manager.ui.model.navigation.Dashboard
|
||||
@@ -30,6 +32,8 @@ import app.revanced.manager.ui.model.navigation.Patcher
|
||||
import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo
|
||||
import app.revanced.manager.ui.model.navigation.Settings
|
||||
import app.revanced.manager.ui.model.navigation.Update
|
||||
import app.revanced.manager.ui.screen.AnnouncementScreen
|
||||
import app.revanced.manager.ui.screen.AnnouncementsScreen
|
||||
import app.revanced.manager.ui.screen.AppSelectorScreen
|
||||
import app.revanced.manager.ui.screen.DashboardScreen
|
||||
import app.revanced.manager.ui.screen.InstalledAppInfoScreen
|
||||
@@ -104,7 +108,7 @@ private fun ReVancedManager(vm: MainViewModel) {
|
||||
enterTransition = { slideInHorizontally(initialOffsetX = { it }) },
|
||||
exitTransition = { slideOutHorizontally(targetOffsetX = { -it / 3 }) },
|
||||
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it / 3 }) },
|
||||
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) },
|
||||
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) }
|
||||
) {
|
||||
composable<Dashboard> {
|
||||
DashboardScreen(
|
||||
@@ -120,6 +124,12 @@ private fun ReVancedManager(vm: MainViewModel) {
|
||||
},
|
||||
onAppClick = { packageName ->
|
||||
navController.navigate(InstalledApplicationInfo(packageName))
|
||||
},
|
||||
onAnnouncementsClick = {
|
||||
navController.navigate(Announcements)
|
||||
},
|
||||
onAnnouncementClick = { announcement ->
|
||||
navController.navigateComplex(Announcement, announcement)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -165,6 +175,22 @@ private fun ReVancedManager(vm: MainViewModel) {
|
||||
)
|
||||
}
|
||||
|
||||
composable<Announcements> {
|
||||
AnnouncementsScreen(
|
||||
onBackClick = navController::popBackStack,
|
||||
onAnnouncementClick = { announcement ->
|
||||
navController.navigateComplex(Announcement, announcement)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<Announcement> {
|
||||
AnnouncementScreen(
|
||||
onBackClick = navController::popBackStack,
|
||||
announcement = it.getComplexArg()
|
||||
)
|
||||
}
|
||||
|
||||
navigation<SelectedApplicationInfo>(startDestination = SelectedApplicationInfo.Main) {
|
||||
composable<SelectedApplicationInfo.Main> {
|
||||
val parentBackStackEntry = navController.navGraphEntry(it)
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.koin.dsl.module
|
||||
|
||||
val repositoryModule = module {
|
||||
singleOf(::ReVancedAPI)
|
||||
singleOf(::AnnouncementRepository)
|
||||
singleOf(::Filesystem) {
|
||||
createdAtStart()
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ val viewModelModule = module {
|
||||
viewModelOf(::AppSelectorViewModel)
|
||||
viewModelOf(::PatcherViewModel)
|
||||
viewModelOf(::UpdateViewModel)
|
||||
viewModelOf(::AnnouncementsViewModel)
|
||||
viewModelOf(::ChangelogsViewModel)
|
||||
viewModelOf(::ImportExportViewModel)
|
||||
viewModelOf(::AboutViewModel)
|
||||
|
||||
@@ -20,6 +20,8 @@ class PreferencesManager(
|
||||
val keystoreAlias = stringPreference("keystore_alias", KeystoreManager.DEFAULT)
|
||||
val keystorePass = stringPreference("keystore_pass", KeystoreManager.DEFAULT)
|
||||
|
||||
val readAnnouncements = longSetPreference("read_announcements", emptySet())
|
||||
val selectedAnnouncementTags = stringSetPreference("selected_announcement_tags", setOf("revanced", "manager"))
|
||||
val firstLaunch = booleanPreference("first_launch", true)
|
||||
val managerAutoUpdates = booleanPreference("manager_auto_updates", false)
|
||||
val showManagerUpdateDialogOnLaunch = booleanPreference("show_manager_update_dialog_on_launch", true)
|
||||
|
||||
@@ -29,6 +29,9 @@ abstract class BasePreferencesManager(private val context: Context, name: String
|
||||
protected fun stringSetPreference(key: String, default: Set<String>) =
|
||||
StringSetPreference(dataStore, key, default)
|
||||
|
||||
protected fun longSetPreference(key: String, default: Set<Long>) =
|
||||
LongSetPreference(dataStore, key, default)
|
||||
|
||||
protected fun booleanPreference(key: String, default: Boolean) =
|
||||
BooleanPreference(dataStore, key, default)
|
||||
|
||||
@@ -56,9 +59,13 @@ class EditorContext(private val prefs: MutablePreferences) {
|
||||
get() = prefs.run { read() }
|
||||
set(value) = prefs.run { write(value) }
|
||||
|
||||
operator fun Preference<Set<String>>.plusAssign(value: String) = prefs.run {
|
||||
operator fun <T> Preference<Set<T>>.plusAssign(value: T) = prefs.run {
|
||||
write(read() + value)
|
||||
}
|
||||
|
||||
operator fun <T> Preference<Set<T>>.minusAssign(value: T) = prefs.run {
|
||||
write(read() subtract setOf(value))
|
||||
}
|
||||
}
|
||||
|
||||
abstract class Preference<T>(
|
||||
@@ -125,6 +132,19 @@ class StringSetPreference(
|
||||
override val key = stringSetPreferencesKey(key)
|
||||
}
|
||||
|
||||
class LongSetPreference(
|
||||
dataStore: DataStore<Preferences>,
|
||||
key: String,
|
||||
default: Set<Long>
|
||||
) : Preference<Set<Long>>(dataStore, default) {
|
||||
private val key = stringSetPreferencesKey(key)
|
||||
|
||||
override fun Preferences.read() = this[key]?.mapTo(mutableSetOf()) { it.toLong() } ?: default
|
||||
override fun MutablePreferences.write(value: Set<Long>) {
|
||||
this[key] = value.mapTo(mutableSetOf()) { it.toString() }
|
||||
}
|
||||
}
|
||||
|
||||
class BooleanPreference(
|
||||
dataStore: DataStore<Preferences>,
|
||||
key: String,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package app.revanced.manager.domain.repository
|
||||
|
||||
import app.revanced.manager.network.api.ReVancedAPI
|
||||
import app.revanced.manager.network.dto.ReVancedAnnouncement
|
||||
import app.revanced.manager.network.dto.ReVancedAnnouncementTag
|
||||
import app.revanced.manager.network.utils.getOrNull
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
class AnnouncementRepository(
|
||||
private val api: ReVancedAPI
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
private var cachedAnnouncements: List<ReVancedAnnouncement>? = null
|
||||
private var cachedTags: List<ReVancedAnnouncementTag>? = null
|
||||
|
||||
suspend fun getAnnouncements(forceRefresh: Boolean = false): List<ReVancedAnnouncement>? {
|
||||
mutex.withLock {
|
||||
if (cachedAnnouncements == null || forceRefresh) {
|
||||
cachedAnnouncements = api.getAnnouncements().getOrNull()
|
||||
}
|
||||
return cachedAnnouncements
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getTags(forceRefresh: Boolean = false): List<ReVancedAnnouncementTag>? {
|
||||
mutex.withLock {
|
||||
if (cachedTags == null || forceRefresh) {
|
||||
cachedTags = api.getAnnouncementTags().getOrNull()
|
||||
}
|
||||
return cachedTags
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package app.revanced.manager.network.api
|
||||
|
||||
import app.revanced.manager.BuildConfig
|
||||
import app.revanced.manager.domain.manager.PreferencesManager
|
||||
import app.revanced.manager.network.dto.ReVancedAnnouncement
|
||||
import app.revanced.manager.network.dto.ReVancedAnnouncementTag
|
||||
import app.revanced.manager.network.dto.ReVancedAsset
|
||||
import app.revanced.manager.network.dto.ReVancedGitRepository
|
||||
import app.revanced.manager.network.dto.ReVancedInfo
|
||||
@@ -29,6 +31,10 @@ class ReVancedAPI(
|
||||
|
||||
private suspend inline fun <reified T> request(route: String) = request<T>(apiUrl(), route)
|
||||
|
||||
suspend fun getAnnouncements() = request<List<ReVancedAnnouncement>>("announcements")
|
||||
|
||||
suspend fun getAnnouncementTags() = request<List<ReVancedAnnouncementTag>>("announcements/tags")
|
||||
|
||||
suspend fun getAppUpdate() =
|
||||
getLatestAppInfo().getOrThrow().takeIf { it.version.removePrefix("v") != BuildConfig.VERSION_NAME }
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package app.revanced.manager.network.dto
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class ReVancedAnnouncement(
|
||||
val id: Long,
|
||||
val author: String,
|
||||
val title: String,
|
||||
val content: String,
|
||||
val tags: List<String>,
|
||||
val attachments: List<String>,
|
||||
@SerialName("created_at")
|
||||
val createdAt: LocalDateTime,
|
||||
@SerialName("archived_at")
|
||||
val archivedAt: LocalDateTime,
|
||||
val level: Int,
|
||||
) : Parcelable
|
||||
@@ -0,0 +1,8 @@
|
||||
package app.revanced.manager.network.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ReVancedAnnouncementTag(
|
||||
val name: String
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
package app.revanced.manager.ui.model.navigation
|
||||
|
||||
import android.os.Parcelable
|
||||
import app.revanced.manager.network.dto.ReVancedAnnouncement
|
||||
import app.revanced.manager.ui.model.SelectedApp
|
||||
import app.revanced.manager.util.Options
|
||||
import app.revanced.manager.util.PatchSelection
|
||||
@@ -22,6 +23,12 @@ data class InstalledApplicationInfo(val packageName: String)
|
||||
@Serializable
|
||||
data class Update(val downloadOnScreenEntry: Boolean = false)
|
||||
|
||||
@Serializable
|
||||
data object Announcements
|
||||
|
||||
@Serializable
|
||||
data object Announcement : ComplexParameter<ReVancedAnnouncement>
|
||||
|
||||
@Serializable
|
||||
data object SelectedApplicationInfo : ComplexParameter<SelectedApplicationInfo.ViewModelParams> {
|
||||
@Parcelize
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package app.revanced.manager.ui.screen
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.MotionEvent
|
||||
import android.webkit.WebView
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TwoRowsTopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.view.children
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.network.dto.ReVancedAnnouncement
|
||||
import app.revanced.manager.util.relativeTime
|
||||
import org.intellij.lang.annotations.Language
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun AnnouncementScreen(
|
||||
onBackClick: () -> Unit,
|
||||
announcement: ReVancedAnnouncement
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(
|
||||
canScroll = {
|
||||
scrollState.canScrollBackward || scrollState.canScrollForward
|
||||
}
|
||||
)
|
||||
val textColor = MaterialTheme.colorScheme.onSurface
|
||||
val linkColor = MaterialTheme.colorScheme.primary
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TwoRowsTopAppBar(
|
||||
title = { expanded ->
|
||||
Text(
|
||||
text = announcement.title,
|
||||
style = if (expanded) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.titleMedium
|
||||
)
|
||||
},
|
||||
subtitle = {
|
||||
val createDate = announcement.createdAt.relativeTime(LocalContext.current)
|
||||
Text("$createDate · ${announcement.author}")
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClick) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back)
|
||||
)
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
.verticalScroll(scrollState)
|
||||
.padding(horizontal = 10.dp),
|
||||
factory = {
|
||||
val webView = WebView(it).apply {
|
||||
setBackgroundColor(0)
|
||||
isVerticalScrollBarEnabled = false
|
||||
isHorizontalScrollBarEnabled = false
|
||||
isLongClickable = false
|
||||
setOnLongClickListener { true }
|
||||
isHapticFeedbackEnabled = false
|
||||
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
|
||||
|
||||
// Disable WebView's internal scrolling
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
setOnTouchListener { _, event ->
|
||||
event.action == MotionEvent.ACTION_MOVE
|
||||
}
|
||||
}
|
||||
FrameLayout(it).apply {
|
||||
addView(webView)
|
||||
}
|
||||
},
|
||||
update = {
|
||||
val webView = it.children.first() as WebView
|
||||
@Language("HTML")
|
||||
val style = """
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
body {
|
||||
color: ${textColor.toCss()};
|
||||
}
|
||||
a {
|
||||
color: ${linkColor.toCss()};
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${announcement.content}
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
webView.loadData(style, "text/html", "UTF-8")
|
||||
},
|
||||
onRelease = {
|
||||
val webView = it.children.first() as WebView
|
||||
webView.destroy()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Color.toCss(): String {
|
||||
return "rgba(${red * 255f}, ${green * 255f}, ${blue * 255f}, $alpha)"
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package app.revanced.manager.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.outlined.FilterAlt
|
||||
import androidx.compose.material.icons.outlined.Inventory2
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.network.dto.ReVancedAnnouncement
|
||||
import app.revanced.manager.ui.component.AppTopBar
|
||||
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
|
||||
import app.revanced.manager.ui.component.LoadingIndicator
|
||||
import app.revanced.manager.ui.viewmodel.AnnouncementsViewModel
|
||||
import app.revanced.manager.util.relativeTime
|
||||
import app.revanced.manager.util.transparentListItemColors
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import kotlin.time.Clock
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AnnouncementsScreen(
|
||||
onBackClick: () -> Unit,
|
||||
onAnnouncementClick: (ReVancedAnnouncement) -> Unit,
|
||||
vm: AnnouncementsViewModel = koinViewModel(),
|
||||
) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
var showFilterSheet by rememberSaveable { mutableStateOf(false) }
|
||||
val tags by vm.tags.collectAsStateWithLifecycle()
|
||||
val selectedTags by vm.selectedTags.getAsState()
|
||||
val showArchived by vm.showArchived.collectAsStateWithLifecycle()
|
||||
val announcements by vm.announcements.collectAsStateWithLifecycle(emptyList())
|
||||
|
||||
if (showFilterSheet) {
|
||||
FilterBottomSheet(
|
||||
onDismissRequest = { showFilterSheet = false },
|
||||
tags = tags.orEmpty(),
|
||||
selectedTags = selectedTags,
|
||||
showArchived = showArchived,
|
||||
onShowArchivedChange = { vm.showArchived.value = it },
|
||||
onReset = vm::resetTagSelection,
|
||||
changeSelection = vm::changeTagSelection
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = { Text(stringResource(R.string.announcements)) },
|
||||
onBackClick = onBackClick,
|
||||
actions = {
|
||||
if (tags != null) {
|
||||
IconButton(onClick = { showFilterSheet = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.FilterAlt,
|
||||
contentDescription = stringResource(R.string.announcements_filter_tag)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
val readAnnouncements by vm.readAnnouncements.getAsState()
|
||||
LazyColumnWithScrollbar(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
verticalArrangement = if (announcements.isNullOrEmpty()) Arrangement.Center else Arrangement.Top,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
announcements?.let { announcements ->
|
||||
if (announcements.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.no_announcements_found),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
}
|
||||
} else {
|
||||
itemsIndexed(
|
||||
items = announcements,
|
||||
key = { _, announcement ->
|
||||
announcement.id
|
||||
}
|
||||
) { i, announcement ->
|
||||
if (i != 0) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
AnnouncementCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
onClick = {
|
||||
vm.markAnnouncementRead(announcement.id)
|
||||
onAnnouncementClick(announcement)
|
||||
},
|
||||
title = announcement.title,
|
||||
date = announcement.createdAt.relativeTime(LocalContext.current),
|
||||
author = announcement.author,
|
||||
content = announcement.content,
|
||||
unread = announcement.id !in readAnnouncements,
|
||||
archived = announcement.archivedAt.toInstant(TimeZone.UTC) < Clock.System.now()
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
LoadingIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun FilterBottomSheet(
|
||||
onDismissRequest: () -> Unit,
|
||||
tags: List<String>,
|
||||
selectedTags: Set<String>,
|
||||
showArchived: Boolean,
|
||||
onShowArchivedChange: (Boolean) -> Unit,
|
||||
onReset: () -> Unit,
|
||||
changeSelection: (String) -> Unit
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = onDismissRequest) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.announcements_filter_tag),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
tags.forEach { tag ->
|
||||
FilterChip(
|
||||
selected = tag in selectedTags,
|
||||
onClick = {
|
||||
changeSelection(tag)
|
||||
},
|
||||
label = { Text(tag) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = { onShowArchivedChange(!showArchived) }),
|
||||
headlineContent = { Text(text = stringResource(R.string.announcements_show_archived)) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = showArchived,
|
||||
onCheckedChange = onShowArchivedChange
|
||||
)
|
||||
},
|
||||
colors = transparentListItemColors
|
||||
)
|
||||
|
||||
TextButton(modifier = Modifier.align(Alignment.End), onClick = onReset) {
|
||||
Text(stringResource(R.string.reset))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AnnouncementCard(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
title: String,
|
||||
date: String,
|
||||
author: String,
|
||||
content: String,
|
||||
unread: Boolean,
|
||||
archived: Boolean
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clickable(onClick = onClick)
|
||||
.background(if (unread) MaterialTheme.colorScheme.surfaceContainerLow else MaterialTheme.colorScheme.surface)
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = if (unread) FontWeight.ExtraBold else null
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "$date • $author",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = if (unread) FontWeight.ExtraBold else null
|
||||
)
|
||||
if (archived) {
|
||||
Icon(
|
||||
Icons.Outlined.Inventory2,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
}
|
||||
if (unread) {
|
||||
Badge(modifier = Modifier.size(6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
Icon(Icons.Default.ChevronRight, contentDescription = null)
|
||||
}
|
||||
// TODO add announcement summary
|
||||
// val textColor = MaterialTheme.colorScheme.onSurface
|
||||
// val linkColor = MaterialTheme.colorScheme.primary
|
||||
// AndroidView(
|
||||
// factory = {
|
||||
// WebView(it).apply {
|
||||
// setBackgroundColor(0)
|
||||
// isVerticalScrollBarEnabled = false
|
||||
// isHorizontalScrollBarEnabled = false
|
||||
// isLongClickable = false
|
||||
// setOnLongClickListener { true }
|
||||
// isHapticFeedbackEnabled = false
|
||||
//
|
||||
// // Disable WebView's internal scrolling
|
||||
// @SuppressLint("ClickableViewAccessibility")
|
||||
// setOnTouchListener { _, event ->
|
||||
// event.action == MotionEvent.ACTION_MOVE
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// update = {
|
||||
// @Language("HTML")
|
||||
// val body = """
|
||||
// <html>
|
||||
// <head>
|
||||
// <meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
// <style>
|
||||
// * {
|
||||
// font-size: 12px;
|
||||
// font-weight: normal;
|
||||
// }
|
||||
// body {
|
||||
// margin: 0;
|
||||
// padding: 0;
|
||||
// color: ${textColor.toCss()};
|
||||
// overflow: hidden;
|
||||
// display: -webkit-box;
|
||||
// -webkit-box-orient: vertical;
|
||||
// -webkit-line-clamp: 3;
|
||||
// text-overflow: ellipsis;
|
||||
// }
|
||||
// a {
|
||||
// color: ${linkColor.toCss()};
|
||||
// }
|
||||
// </style>
|
||||
// </head>
|
||||
// <body>
|
||||
// $content
|
||||
// </body>
|
||||
// </html>
|
||||
// """.trimIndent()
|
||||
//
|
||||
// it.loadData(body, "text/html", "UTF-8")
|
||||
// },
|
||||
// onReset = {},
|
||||
// onRelease = { it.destroy() }
|
||||
// )
|
||||
}
|
||||
}
|
||||
|
||||
//private fun Color.toCss(): String {
|
||||
// return "rgba(${red * 255f}, ${green * 255f}, ${blue * 255f}, $alpha)"
|
||||
//}
|
||||
@@ -19,11 +19,13 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.BatteryAlert
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.outlined.Apps
|
||||
import androidx.compose.material.icons.outlined.BugReport
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.DeleteOutline
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material.icons.outlined.Notifications
|
||||
import androidx.compose.material.icons.outlined.Refresh
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.Source
|
||||
@@ -58,6 +60,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.network.dto.ReVancedAnnouncement
|
||||
import app.revanced.manager.patcher.aapt.Aapt
|
||||
import app.revanced.manager.ui.component.AlertDialogExtended
|
||||
import app.revanced.manager.ui.component.AppTopBar
|
||||
@@ -91,6 +94,8 @@ fun DashboardScreen(
|
||||
onAppSelectorClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
onUpdateClick: () -> Unit,
|
||||
onAnnouncementsClick: () -> Unit,
|
||||
onAnnouncementClick: (ReVancedAnnouncement) -> Unit,
|
||||
onDownloaderClick: () -> Unit,
|
||||
onAppClick: (String) -> Unit
|
||||
) {
|
||||
@@ -219,6 +224,20 @@ fun DashboardScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onAnnouncementsClick) {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (vm.unreadAnnouncement != null) {
|
||||
Badge(modifier = Modifier.size(6.dp))
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Notifications,
|
||||
stringResource(R.string.announcements)
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onSettingsClick) {
|
||||
Icon(Icons.Outlined.Settings, stringResource(R.string.settings))
|
||||
}
|
||||
@@ -321,7 +340,29 @@ fun DashboardScreen(
|
||||
}
|
||||
)
|
||||
}
|
||||
} else null
|
||||
} else null,
|
||||
vm.unreadAnnouncement?.let { announcement ->
|
||||
{
|
||||
NotificationCard(
|
||||
text = stringResource(R.string.new_announcement, announcement.title),
|
||||
icon = Icons.Filled.Notifications,
|
||||
actions = {
|
||||
TextButton(onClick = vm::markUnreadAnnouncementRead) {
|
||||
Text(stringResource(R.string.dismiss))
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
vm.markUnreadAnnouncementRead()
|
||||
onAnnouncementClick(announcement)
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.view_announcement))
|
||||
}
|
||||
},
|
||||
isWarning = announcement.level > 0
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalPager(
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package app.revanced.manager.ui.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.revanced.manager.data.platform.NetworkInfo
|
||||
import app.revanced.manager.domain.manager.PreferencesManager
|
||||
import app.revanced.manager.domain.repository.AnnouncementRepository
|
||||
import app.revanced.manager.network.dto.ReVancedAnnouncement
|
||||
import kotlin.time.Clock
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
|
||||
class AnnouncementsViewModel(
|
||||
private val announcementRepository: AnnouncementRepository,
|
||||
private val network: NetworkInfo,
|
||||
private val preferences: PreferencesManager
|
||||
) : ViewModel() {
|
||||
private val allAnnouncements = MutableStateFlow<List<ReVancedAnnouncement>?>(null)
|
||||
|
||||
private val _tags = MutableStateFlow<List<String>?>(null)
|
||||
val tags get() = _tags.asStateFlow()
|
||||
val selectedTags = preferences.selectedAnnouncementTags
|
||||
val readAnnouncements = preferences.readAnnouncements
|
||||
val showArchived = MutableStateFlow(false)
|
||||
|
||||
val announcements = combine(
|
||||
allAnnouncements,
|
||||
_tags,
|
||||
selectedTags.flow,
|
||||
showArchived
|
||||
) { source, tags, selectedTags, showArchived ->
|
||||
if (source == null) return@combine null
|
||||
// Only filter by tags that actually exist
|
||||
val availableTags = tags.orEmpty().toSet()
|
||||
val validSelected = selectedTags.intersect(availableTags)
|
||||
|
||||
val visibleAnnouncements = if (showArchived) {
|
||||
source
|
||||
} else {
|
||||
source.filter { announcement ->
|
||||
announcement.archivedAt.toInstant(TimeZone.UTC) > Clock.System.now()
|
||||
}
|
||||
}
|
||||
|
||||
if (validSelected.isEmpty()) {
|
||||
visibleAnnouncements
|
||||
} else {
|
||||
visibleAnnouncements.filter { announcement ->
|
||||
announcement.tags.any(validSelected::contains)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
loadData()
|
||||
}
|
||||
|
||||
fun markAnnouncementRead(id: Long) {
|
||||
viewModelScope.launch {
|
||||
preferences.edit {
|
||||
preferences.readAnnouncements += id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun changeTagSelection(tag: String) = viewModelScope.launch {
|
||||
preferences.edit {
|
||||
if (tag in selectedTags.value) selectedTags -= tag
|
||||
else selectedTags += tag
|
||||
}
|
||||
}
|
||||
|
||||
fun resetTagSelection() = viewModelScope.launch {
|
||||
selectedTags.update(preferences.selectedAnnouncementTags.default)
|
||||
}
|
||||
|
||||
private fun loadData() {
|
||||
viewModelScope.launch {
|
||||
if (!network.isConnected()) {
|
||||
allAnnouncements.value = emptyList()
|
||||
_tags.value = emptyList()
|
||||
return@launch
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
announcementRepository.getAnnouncements()?.let {
|
||||
allAnnouncements.value = it
|
||||
}
|
||||
announcementRepository.getTags()?.let {
|
||||
_tags.value = it.map { tag -> tag.name }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,21 +16,29 @@ import app.revanced.manager.R
|
||||
import app.revanced.manager.data.platform.NetworkInfo
|
||||
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
|
||||
import app.revanced.manager.domain.manager.PreferencesManager
|
||||
import app.revanced.manager.domain.repository.AnnouncementRepository
|
||||
import app.revanced.manager.domain.repository.DownloaderRepository
|
||||
import app.revanced.manager.domain.repository.PatchBundleRepository
|
||||
import app.revanced.manager.network.api.ReVancedAPI
|
||||
import app.revanced.manager.network.dto.ReVancedAnnouncement
|
||||
import app.revanced.manager.util.PM
|
||||
import app.revanced.manager.util.uiSafe
|
||||
import kotlin.time.Clock
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class DashboardViewModel(
|
||||
private val app: Application,
|
||||
private val patchBundleRepository: PatchBundleRepository,
|
||||
private val downloaderRepository: DownloaderRepository,
|
||||
private val announcementRepository: AnnouncementRepository,
|
||||
private val reVancedAPI: ReVancedAPI,
|
||||
private val networkInfo: NetworkInfo,
|
||||
val prefs: PreferencesManager,
|
||||
@@ -57,12 +65,16 @@ class DashboardViewModel(
|
||||
var showBatteryOptimizationsWarning by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var unreadAnnouncement by mutableStateOf<ReVancedAnnouncement?>(null)
|
||||
private set
|
||||
|
||||
private val bundleListEventsChannel = Channel<BundleListViewModel.Event>()
|
||||
val bundleListEventsFlow = bundleListEventsChannel.receiveAsFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
checkForManagerUpdates()
|
||||
checkForAnnouncements()
|
||||
updateBatteryOptimizationsWarning()
|
||||
}
|
||||
}
|
||||
@@ -79,6 +91,44 @@ class DashboardViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkForAnnouncements() {
|
||||
uiSafe(app, R.string.failed_to_check_updates, "Failed to check for announcements") {
|
||||
val announcements = withContext(Dispatchers.IO) {
|
||||
announcementRepository.getAnnouncements()
|
||||
} ?: throw IllegalStateException("Announcements could not be retrieved")
|
||||
|
||||
val readAnnouncements = prefs.readAnnouncements.get()
|
||||
if (readAnnouncements.isEmpty()) {
|
||||
val announcementIds = announcements.mapTo(mutableSetOf()) { it.id }
|
||||
prefs.readAnnouncements.update(announcementIds)
|
||||
return@uiSafe
|
||||
}
|
||||
|
||||
unreadAnnouncement = announcements.firstOrNull { announcement ->
|
||||
val isNotArchived =
|
||||
announcement.archivedAt.toInstant(TimeZone.UTC) > Clock.System.now()
|
||||
|
||||
val hasRelevantTag = "revanced" in announcement.tags ||
|
||||
"manager" in announcement.tags
|
||||
|
||||
val isUnread = announcement.id !in readAnnouncements
|
||||
|
||||
isNotArchived && hasRelevantTag && isUnread
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun markUnreadAnnouncementRead() {
|
||||
viewModelScope.launch {
|
||||
unreadAnnouncement?.let {
|
||||
prefs.edit {
|
||||
prefs.readAnnouncements += it.id
|
||||
}
|
||||
}
|
||||
unreadAnnouncement = null
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBatteryOptimizationsWarning() {
|
||||
showBatteryOptimizationsWarning =
|
||||
!powerManager.isIgnoringBatteryOptimizations(app.packageName)
|
||||
|
||||
@@ -82,6 +82,9 @@ Second \"item\" text"</string>
|
||||
<string name="auto_updates_dialog_patches">ReVanced Patches</string>
|
||||
<string name="auto_updates_dialog_note">These settings can be changed later.</string>
|
||||
|
||||
<string name="announcements_filter_tag">Filter by tag</string>
|
||||
<string name="announcements_show_archived">Show archived</string>
|
||||
|
||||
<string name="general">General</string>
|
||||
<string name="general_description">Language, theme, dynamic color</string>
|
||||
<string name="updates">Updates</string>
|
||||
@@ -380,6 +383,7 @@ It is only compatible with the following version(s): %2$s"</string>
|
||||
<string name="less">Less</string>
|
||||
<string name="continue_">Continue</string>
|
||||
<string name="dismiss">Dismiss</string>
|
||||
<string name="view_announcement">View announcement</string>
|
||||
<string name="permanent_dismiss">Do not show this again</string>
|
||||
<string name="donate">Donate</string>
|
||||
<string name="website">Website</string>
|
||||
@@ -398,6 +402,8 @@ It is only compatible with the following version(s): %2$s"</string>
|
||||
<string name="patches_delete_single_dialog_description">Are you sure you want to delete \"%s\"?</string>
|
||||
<string name="patches_delete_multiple_dialog_description">Are you sure you want to delete the selected patches?</string>
|
||||
|
||||
<string name="announcements">Announcements</string>
|
||||
<string name="archive">Archive</string>
|
||||
<string name="about_revanced_manager">About ReVanced Manager</string>
|
||||
<string name="revanced_manager_description">ReVanced Manager is an Android application that uses ReVanced Patcher to patch Android apps. It allows you to download and patch apps with custom patches, and manage the patching process.</string>
|
||||
<string name="developer_options_taps">%d taps remaining</string>
|
||||
@@ -443,6 +449,7 @@ ReVanced Manager will close when updating."</string>
|
||||
|
||||
<string name="failed_to_check_updates">Failed to check for updates: %s</string>
|
||||
<string name="no_update_available">No update available</string>
|
||||
<string name="no_announcements_found">No announcements found</string>
|
||||
<string name="update_check">Checking for updates…</string>
|
||||
<string name="dismiss_temporary">Not now</string>
|
||||
<string name="update_available_dialog_description">A new version of ReVanced Manager (%s) is available.</string>
|
||||
@@ -499,4 +506,5 @@ Click on the patches to see more details."</string>
|
||||
<string name="failed_to_import_keystore">Failed to import keystore</string>
|
||||
<string name="export">Export</string>
|
||||
<string name="confirm">Confirm</string>
|
||||
<string name="new_announcement">New announcement:\n%s</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
ktx = "1.17.0"
|
||||
material3 = "1.4.0"
|
||||
material3 = "1.5.0-alpha14"
|
||||
ui-tooling = "1.10.0"
|
||||
viewmodel-lifecycle = "2.10.0"
|
||||
splash-screen = "1.2.0"
|
||||
@@ -9,7 +9,7 @@ appcompat = "1.7.1"
|
||||
preferences-datastore = "1.2.0"
|
||||
work-runtime = "2.11.0"
|
||||
compose-bom = "2025.12.01"
|
||||
navigation = "2.9.6"
|
||||
navigation = "2.9.7"
|
||||
accompanist = "0.37.3"
|
||||
placeholder = "1.0.12"
|
||||
reorderable = "3.0.0"
|
||||
|
||||
Reference in New Issue
Block a user