feat: redesign navigation and screen layout (#11)

* feat: redesign navigation and screen layout

* chore: remove ksp
This commit is contained in:
Ushie 2022-08-07 07:25:09 +03:00 • committed by GitHub
parent 4a0027c638
commit 306ac35635
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 709 additions and 587 deletions

View file

@ -5,7 +5,7 @@ val ktorVersion = "2.0.3"
plugins {
id("com.android.application")
id("kotlin-android")
id("com.google.devtools.ksp") version "1.7.10-1.0.6"
id("kotlin-parcelize")
kotlin("plugin.serialization") version "1.7.10"
}
@ -61,31 +61,34 @@ dependencies {
implementation("androidx.appcompat:appcompat:1.4.2")
implementation("androidx.core:core-ktx:1.8.0")
implementation("androidx.core:core-splashscreen:1.0.0")
implementation("com.android:zipflinger:7.4.0-alpha08")
implementation("com.android:zipflinger:7.4.0-alpha09")
// implementation("com.github.Aliucord:libzip:1.0.0")
// androidX activity
implementation("androidx.activity:activity-compose:1.6.0-alpha05")
// compose dependencies
implementation("androidx.compose.ui:ui:${composeVersion}")
implementation("androidx.compose.ui:ui-tooling:${composeVersion}")
implementation("androidx.compose.material:material-icons-extended:${composeVersion}")
implementation("androidx.compose.material3:material3:1.0.0-alpha15")
implementation("androidx.paging:paging-compose:1.0.0-alpha15")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0-alpha01")
// accompanist dependencies
implementation("com.google.accompanist:accompanist-systemuicontroller:${accompanistVersion}")
implementation("com.google.accompanist:accompanist-permissions:${accompanistVersion}")
// compose destinations
implementation("io.github.raamcosta.compose-destinations:core:1.7.15-beta")
ksp("io.github.raamcosta.compose-destinations:ksp:1.7.15-beta")
// other dependencies
implementation("io.coil-kt:coil-compose:2.1.0")
implementation("org.bouncycastle:bcpkix-jdk15on:1.70")
implementation("de.upb.cs.swt:axml:2.1.2") // 2.1.2 is broken btw
implementation("com.android.tools.build:apksig:7.2.1")
implementation("com.android.tools.build:apksig:7.2.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3")
// taxi
implementation("com.github.X1nto:Taxi:1.0.0")
// ktor
implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("io.ktor:ktor-client-android:$ktorVersion")

View file

@ -11,17 +11,25 @@ import android.content.Intent
import android.os.*
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.animation.*
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.aliucord.manager.preferences.Prefs
import com.aliucord.manager.preferences.sharedPreferences
import com.aliucord.manager.ui.component.ManagerScaffold
import com.aliucord.manager.ui.navigation.AppDestination
import com.aliucord.manager.ui.screen.*
import com.aliucord.manager.ui.theme.ManagerTheme
import com.aliucord.manager.ui.theme.Theme
import com.xinto.taxi.Taxi
import com.xinto.taxi.rememberBackstackNavigator
class MainActivity : ComponentActivity() {
@OptIn(ExperimentalAnimationApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
@ -45,7 +53,38 @@ class MainActivity : ComponentActivity() {
theme == Theme.SYSTEM && isSystemInDarkTheme() || theme == Theme.DARK
}
) {
ManagerScaffold()
val navigator = rememberBackstackNavigator<AppDestination>(AppDestination.Home)
BackHandler {
navigator.pop()
}
Taxi(
modifier = Modifier.fillMaxSize(),
navigator = navigator,
transitionSpec = { fadeIn() with fadeOut() }
) { destination ->
when (destination) {
is AppDestination.Home -> MainRootScreen(
navigator = navigator
)
is AppDestination.Plugins -> PluginsScreen(
navigator = navigator
)
is AppDestination.Install -> InstallerScreen(
onClickBack = navigator::pop,
)
is AppDestination.Settings -> SettingsScreen(
onClickBack = navigator::pop
)
is AppDestination.About -> AboutScreen(
onClickBack = navigator::pop
)
is AppDestination.Store -> StoreScreen(
onClickBack = navigator::pop
)
}
}
}
}
}

View file

@ -1,137 +0,0 @@
/*
* Copyright (c) 2022 Juby210 & zt
* Licensed under the Open Software License version 3.0
*/
package com.aliucord.manager.ui.component
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState
import com.aliucord.manager.BuildConfig
import com.aliucord.manager.R
import com.aliucord.manager.ui.screen.appDestination
import com.aliucord.manager.ui.screen.destinations.*
import com.ramcosta.composedestinations.navigation.navigate
import com.ramcosta.composedestinations.navigation.popUpTo
@get:StringRes
private val Destination.title
get() = when (this) {
HomeScreenDestination -> R.string.home
InstallerScreenDestination -> R.string.installer
CommitsScreenDestination -> R.string.commits
StoreScreenDestination -> R.string.store
SettingsScreenDestination -> R.string.settings
AboutScreenDestination -> R.string.about
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppBar(navController: NavController) {
val destination = navController.currentBackStackEntryAsState().value?.appDestination() ?: HomeScreenDestination
var isMenuExpanded by remember { mutableStateOf(false) }
MediumTopAppBar(
navigationIcon = {
if (destination != HomeScreenDestination) {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.Default.ArrowBack, stringResource(R.string.back), tint = MaterialTheme.colorScheme.onBackground)
}
}
},
title = {
Text(stringResource(destination.title))
},
actions = {
val localUriHandler = LocalUriHandler.current
IconButton(
onClick = {
navController.navigate(StoreScreenDestination) {
popUpTo(HomeScreenDestination) { saveState = true }
}
},
) {
Icon(
painter = painterResource(R.drawable.ic_plugin_24dp),
contentDescription = stringResource(R.string.plugins),
tint = MaterialTheme.colorScheme.onBackground
)
}
IconButton(
onClick = {
localUriHandler.openUri("https://discord.gg/${BuildConfig.SUPPORT_SERVER}")
},
) {
Icon(
painter = painterResource(R.drawable.ic_discord),
contentDescription = stringResource(R.string.support_server),
tint = MaterialTheme.colorScheme.onBackground
)
}
Box(
modifier = Modifier.wrapContentSize(Alignment.TopEnd)
) {
IconButton(
onClick = { isMenuExpanded = !isMenuExpanded }
) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.show_menu),
tint = MaterialTheme.colorScheme.onBackground
)
}
DropdownMenu(
offset = DpOffset(x = 12.dp, y = 0.dp),
expanded = isMenuExpanded,
onDismissRequest = { isMenuExpanded = false }
) {
DropdownMenuItem(
onClick = {
isMenuExpanded = false
navController.navigate(AboutScreenDestination)
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Info,
contentDescription = "About"
)
},
text = { Text(stringResource(AboutScreenDestination.title)) }
)
DropdownMenuItem(
onClick = {
isMenuExpanded = false
navController.navigate(SettingsScreenDestination)
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Settings"
)
},
text = { Text(stringResource(SettingsScreenDestination.title)) }
)
}
}
}
)
}

View file

@ -1,48 +0,0 @@
/*
* Copyright (c) 2022 Juby210 & zt
* Licensed under the Open Software License version 3.0
*/
package com.aliucord.manager.ui.component
import android.Manifest
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.rememberNavController
import com.aliucord.manager.ui.screen.NavGraphs
import com.google.accompanist.permissions.*
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.rememberNavHostEngine
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalPermissionsApi::class
)
@Composable
fun ManagerScaffold() {
val navController = rememberNavController()
val storagePermissionState = rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE)
when (storagePermissionState.status) {
is PermissionStatus.Denied -> GrantPermission(storagePermissionState)
PermissionStatus.Granted -> {
Scaffold(
topBar = { AppBar(navController) }
) { paddingValues ->
DestinationsNavHost(
modifier = Modifier
.padding(paddingValues)
.padding(horizontal = 12.dp, vertical = 14.dp),
navGraph = NavGraphs.root,
navController = navController,
engine = rememberNavHostEngine()
)
}
}
}
}

View file

@ -1,113 +0,0 @@
/*
* Copyright (c) 2022 Juby210 & zt
* Licensed under the Open Software License version 3.0
*/
package com.aliucord.manager.ui.component
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.aliucord.manager.R
import com.aliucord.manager.model.Plugin
import com.aliucord.manager.ui.component.plugins.Changelog
import com.aliucord.manager.ui.component.plugins.PluginCard
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PluginsList() = Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
var plugins by remember { mutableStateOf(Plugin.loadAll()) }
val focusManager = LocalFocusManager.current
var search by remember { mutableStateOf("") }
if (plugins.isNotEmpty()) {
OutlinedTextField(
maxLines = 1,
value = search,
onValueChange = { search = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.search)) },
trailingIcon = { Icon(Icons.Default.Search, stringResource(R.string.search_plugins)) },
keyboardOptions = KeyboardOptions(autoCorrect = false, imeAction = ImeAction.Search),
keyboardActions = KeyboardActions { focusManager.clearFocus() },
)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(plugins.filter { plugin ->
plugin.manifest.run {
name.contains(search, true)
|| description.contains(search, true)
|| authors.any { a -> a.name.contains(search, true) }
}
}) { plugin ->
var showDeleteDialog by remember { mutableStateOf(false) }
var showChangelogDialog by remember { mutableStateOf(false) }
if (showDeleteDialog) AlertDialog(
onDismissRequest = { showDeleteDialog = false },
icon = {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.uninstall_plugin, plugin.manifest.name)
)
},
title = { Text(stringResource(R.string.uninstall_plugin, plugin.manifest.name)) },
text = { Text(stringResource(R.string.delete_plugin_body)) },
confirmButton = {
Button(
onClick = {
showDeleteDialog = false
plugin.file.delete()
plugins = plugins.filter { it != plugin }
}
) {
Text(stringResource(android.R.string.ok))
}
},
dismissButton = {
Button(
onClick = { showDeleteDialog = false },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
)
) {
Text(stringResource(android.R.string.cancel))
}
}
)
if (showChangelogDialog) Dialog(
onDismissRequest = { showChangelogDialog = false },
content = { Changelog(plugin, onDismiss = { showChangelogDialog = false }) }
)
PluginCard(
plugin = plugin,
onDelete = { showDeleteDialog = true },
onShowChangelog = { showChangelogDialog = true }
)
}
}
} else {
Text(stringResource(R.string.no_plugins_installed))
}
}

View file

@ -116,7 +116,7 @@ fun PluginCard(
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
FilledTonalButton(
IconButton(
onClick = {
uriHandler.openUri(
plugin.manifest.updateUrl
@ -132,7 +132,7 @@ fun PluginCard(
}
if (plugin.manifest.changelog != null) {
FilledTonalButton(onClick = onShowChangelog) {
IconButton(onClick = onShowChangelog) {
Icon(
painter = painterResource(R.drawable.ic_history_white_24dp),
contentDescription = stringResource(R.string.view_plugin_changelog, plugin.manifest.name)
@ -142,10 +142,13 @@ fun PluginCard(
Spacer(Modifier.weight(1f, true))
Button(onClick = onDelete) {
IconButton(
onClick = onDelete
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.uninstall_plugin, plugin.manifest.name)
contentDescription = stringResource(R.string.uninstall_plugin, plugin.manifest.name),
tint = MaterialTheme.colorScheme.error
)
}
}

View file

@ -0,0 +1,44 @@
package com.aliucord.manager.ui.navigation
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.outlined.Extension
import androidx.compose.material.icons.outlined.Home
import androidx.compose.ui.graphics.vector.ImageVector
import com.aliucord.manager.R
import com.xinto.taxi.Destination
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.RawValue
@Parcelize
sealed interface AppDestination : Destination {
@Parcelize
object Home : AppDestination
@Parcelize
object Plugins : AppDestination
@Parcelize
object Install : AppDestination
@Parcelize
object Store : AppDestination
@Parcelize
object About : AppDestination
@Parcelize
object Settings : AppDestination
}
@Parcelize
enum class HomeDestination(
val selectedIcon: @RawValue ImageVector,
val unselectedIcon: @RawValue ImageVector,
@StringRes val label: Int
) : Destination {
HOME(Icons.Default.Home, Icons.Outlined.Home, R.string.home),
PLUGINS(Icons.Default.Extension, Icons.Outlined.Extension, R.string.plugins)
}

View file

@ -6,6 +6,8 @@
package com.aliucord.manager.ui.screen
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NavigateBefore
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@ -18,32 +20,49 @@ import com.aliucord.manager.R
import com.aliucord.manager.ui.component.about.ContributorsCard
import com.aliucord.manager.ui.component.about.TeamCard
import com.aliucord.manager.ui.viewmodel.AboutViewModel
import com.ramcosta.composedestinations.annotation.Destination
@Destination
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen() {
val uriHandler = LocalUriHandler.current
val viewModel: AboutViewModel = viewModel()
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
TeamCard()
ContributorsCard(viewModel.contributors)
Spacer(Modifier.weight(1f, true))
Button(
modifier = Modifier.fillMaxWidth(),
onClick = { uriHandler.openUri("https://github.com/Aliucord/Aliucord") }
) {
Icon(
painter = painterResource(R.drawable.ic_account_github_white_24dp),
contentDescription = "GitHub"
fun AboutScreen(onClickBack: () -> Unit) {
Scaffold(
topBar = {
MediumTopAppBar(
title = { Text(stringResource(R.string.about)) },
navigationIcon = {
IconButton(onClick = onClickBack) {
Icon(
imageVector = Icons.Default.NavigateBefore,
contentDescription = stringResource(R.string.back)
)
}
}
)
Spacer(Modifier.padding(4.dp))
Text(stringResource(R.string.github))
}
) { paddingValues ->
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.padding(paddingValues)
.padding(horizontal = 12.dp, vertical = 14.dp)
) {
val uriHandler = LocalUriHandler.current
val viewModel: AboutViewModel = viewModel()
TeamCard()
ContributorsCard(viewModel.contributors)
Spacer(Modifier.weight(1f, true))
Button(
modifier = Modifier.fillMaxWidth(),
onClick = { uriHandler.openUri("https://github.com/Aliucord/Aliucord") }
) {
Icon(
painter = painterResource(R.drawable.ic_account_github_white_24dp),
contentDescription = "GitHub"
)
Spacer(Modifier.padding(4.dp))
Text(stringResource(R.string.github))
}
}
}
}

View file

@ -1,96 +0,0 @@
/*
* Copyright (c) 2022 Juby210 & zt
* Licensed under the Open Software License version 3.0
*/
package com.aliucord.manager.ui.screen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.paging.*
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.items
import com.aliucord.manager.R
import com.aliucord.manager.model.github.Commit
import com.aliucord.manager.util.Github
import com.ramcosta.composedestinations.annotation.Destination
@OptIn(ExperimentalMaterial3Api::class)
@Destination
@Composable
fun CommitsScreen() {
val pager = remember {
Pager(
PagingConfig(
pageSize = 30,
enablePlaceholders = true,
maxSize = 200
)
) {
object : PagingSource<Int, Commit>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Commit> {
val pageNumber = params.key ?: 0
val response = Github.getCommits("page" to pageNumber.toString())
val prevKey = if (pageNumber > 0) pageNumber - 1 else null
val nextKey = if (response.isNotEmpty()) pageNumber + 1 else null
return LoadResult.Page(
data = response,
prevKey = prevKey,
nextKey = nextKey
)
}
override fun getRefreshKey(state: PagingState<Int, Commit>) = state.anchorPosition?.let {
state.closestPageToPosition(it)?.prevKey?.plus(1) ?: state.closestPageToPosition(it)?.nextKey?.minus(1)
}
}
}
}
val lazyPagingItems = pager.flow.collectAsLazyPagingItems()
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
if (lazyPagingItems.loadState.refresh == LoadState.Loading) item {
Text(
text = stringResource(R.string.paging_initial_load),
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally)
)
}
items(lazyPagingItems) { commitData ->
if (commitData == null) return@items
val localUriHandler = LocalUriHandler.current
ListItem(
modifier = Modifier.clickable { localUriHandler.openUri(commitData.htmlUrl) },
overlineText = { Text(commitData.sha.substring(0, 7)) },
headlineText = { Text("${commitData.commit.message.split("\n").first()} - ${commitData.author.name}") }
)
}
if (lazyPagingItems.loadState.append == LoadState.Loading) item {
CircularProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally)
)
}
}
}

View file

@ -0,0 +1,171 @@
package com.aliucord.manager.ui.screen
import android.content.res.Configuration
import androidx.compose.animation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.aliucord.manager.BuildConfig
import com.aliucord.manager.R
import com.aliucord.manager.ui.navigation.AppDestination
import com.aliucord.manager.ui.navigation.HomeDestination
import com.xinto.taxi.*
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable
fun MainRootScreen(navigator: BackstackNavigator<AppDestination>) {
val mainRootNavigator = rememberNavigator(HomeDestination.HOME)
val currentDestination = mainRootNavigator.currentDestination
val orientation = LocalConfiguration.current.orientation
var isMenuExpanded by remember { mutableStateOf(false) }
Scaffold(
modifier = Modifier
.fillMaxSize(),
topBar = {
MediumTopAppBar(
title = {
Text(stringResource(mainRootNavigator.currentDestination.label))
},
actions = {
val localUriHandler = LocalUriHandler.current
IconButton(
onClick = {
localUriHandler.openUri("https://discord.gg/${BuildConfig.SUPPORT_SERVER}")
},
) {
Icon(
painter = painterResource(R.drawable.ic_discord),
contentDescription = stringResource(R.string.support_server),
tint = MaterialTheme.colorScheme.onBackground
)
}
Box(
modifier = Modifier.wrapContentSize(Alignment.TopEnd)
) {
IconButton(
onClick = { isMenuExpanded = !isMenuExpanded }
) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.show_menu),
tint = MaterialTheme.colorScheme.onBackground
)
}
DropdownMenu(
offset = DpOffset(x = 12.dp, y = 0.dp),
expanded = isMenuExpanded,
onDismissRequest = { isMenuExpanded = false }
) {
DropdownMenuItem(
onClick = {
isMenuExpanded = false
navigator.push(AppDestination.About)
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Info,
contentDescription = "About"
)
},
text = { Text(stringResource(R.string.about)) }
)
DropdownMenuItem(
onClick = {
isMenuExpanded = false
navigator.push(AppDestination.Settings)
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Settings"
)
},
text = { Text(stringResource(R.string.settings)) }
)
}
}
}
)
},
bottomBar = {
if (orientation == Configuration.ORIENTATION_PORTRAIT) {
NavigationBar {
HomeDestination.values().forEach { destination ->
NavigationBarItem(
selected = currentDestination == destination,
icon = {
if (currentDestination == destination) Icon(
destination.selectedIcon,
stringResource(destination.label)
) else Icon(destination.unselectedIcon, stringResource(destination.label))
},
label = { Text(stringResource(destination.label)) },
onClick = { mainRootNavigator.replace(destination) }
)
}
}
}
}
) { paddingValues ->
Row(
modifier = Modifier
.padding(paddingValues)
.padding(horizontal = 12.dp, vertical = 14.dp)
) {
if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
NavRail(
currentDestination = currentDestination,
onClickDestination = mainRootNavigator::replace
)
}
Taxi(
modifier = Modifier.weight(1f, true),
navigator = mainRootNavigator,
transitionSpec = { fadeIn() with fadeOut() }
) { destination ->
when (destination) {
HomeDestination.HOME -> HomeScreen(navigator = navigator)
HomeDestination.PLUGINS -> PluginsScreen(navigator = navigator)
}
}
}
}
}
@Composable
fun NavRail(
currentDestination: HomeDestination,
onClickDestination: (HomeDestination) -> Unit
) = NavigationRail {
HomeDestination.values().forEach { destination ->
NavigationRailItem(
selected = currentDestination == destination,
icon = {
if (currentDestination == destination) Icon(
destination.selectedIcon,
stringResource(destination.label)
) else Icon(destination.unselectedIcon, stringResource(destination.label))
},
label = { Text(stringResource(destination.label)) },
onClick = { onClickDestination(destination) }
)
}
}

View file

@ -8,36 +8,38 @@ package com.aliucord.manager.ui.screen
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.paging.*
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.items
import com.aliucord.manager.R
import com.aliucord.manager.model.github.Commit
import com.aliucord.manager.preferences.Prefs
import com.aliucord.manager.ui.component.PluginsList
import com.aliucord.manager.ui.component.installer.DownloadMethod
import com.aliucord.manager.ui.component.installer.InstallerDialog
import com.aliucord.manager.ui.screen.destinations.CommitsScreenDestination
import com.aliucord.manager.ui.screen.destinations.InstallerScreenDestination
import com.aliucord.manager.ui.navigation.AppDestination
import com.aliucord.manager.ui.viewmodel.HomeViewModel
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.aliucord.manager.util.Github
import com.xinto.taxi.BackstackNavigator
@OptIn(ExperimentalMaterial3Api::class)
@RootNavGraph(start = true)
@Destination
@Composable
fun HomeScreen(navigator: DestinationsNavigator) {
fun HomeScreen(navigator: BackstackNavigator<AppDestination>) {
val viewModel: HomeViewModel = viewModel()
var showOptionsDialog by remember { mutableStateOf(false) }
@ -46,14 +48,14 @@ fun HomeScreen(navigator: DestinationsNavigator) {
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
navigator.navigate(InstallerScreenDestination)
navigator.push(AppDestination.Install)
}
InstallerDialog(
onDismissRequest = { showOptionsDialog = false },
onConfirm = { method ->
if (method == DownloadMethod.DOWNLOAD) {
navigator.navigate(InstallerScreenDestination())
navigator.push(AppDestination.Install)
} else {
filePicker.launch(arrayOf("application/octet-stream"))
}
@ -75,7 +77,7 @@ fun HomeScreen(navigator: DestinationsNavigator) {
) {
Column {
Text(
"Aliucord${Prefs.packageName.get().let { if (it != "com.aliucord") " ($it)" else ""}}",
"Aliucord${Prefs.packageName.get().let { if (it != "com.aliucord") " ($it)" else "" }}",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
@ -114,7 +116,7 @@ fun HomeScreen(navigator: DestinationsNavigator) {
if (Prefs.devMode.get()) {
showOptionsDialog = true
} else {
navigator.navigate(InstallerScreenDestination())
navigator.push(AppDestination.Install)
}
}
) {
@ -127,19 +129,6 @@ fun HomeScreen(navigator: DestinationsNavigator) {
Text(stringResource(description))
}
if (viewModel.installedVersion == "-") Button(
modifier = Modifier.wrapContentSize(),
onClick = { navigator.navigate(CommitsScreenDestination) },
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer)
) {
Icon(
modifier = Modifier.padding(8.dp),
painter = painterResource(R.drawable.ic_update_24dp),
contentDescription = "Commits",
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
if (viewModel.installedVersion != "-") {
@ -149,7 +138,7 @@ fun HomeScreen(navigator: DestinationsNavigator) {
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
CompositionLocalProvider(
LocalContentColor provides MaterialTheme.colorScheme.secondaryContainer
LocalContentColor provides MaterialTheme.colorScheme.secondaryContainer
) {
FilledTonalButton(
modifier = Modifier.weight(1f),
@ -174,23 +163,41 @@ fun HomeScreen(navigator: DestinationsNavigator) {
contentDescription = "Launch"
)
}
FilledTonalButton(
modifier = Modifier.weight(1f),
onClick = { navigator.navigate(CommitsScreenDestination) }
) {
Icon(
modifier = Modifier.padding(8.dp),
painter = painterResource(R.drawable.ic_update_24dp),
contentDescription = "Commits"
)
}
}
}
}
}
}
}
val pager = remember {
Pager(
PagingConfig(
pageSize = 30,
enablePlaceholders = true,
maxSize = 200
)
) {
object : PagingSource<Int, Commit>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Commit> {
val pageNumber = params.key ?: 0
val response = Github.getCommits("page" to pageNumber.toString())
val prevKey = if (pageNumber > 0) pageNumber - 1 else null
val nextKey = if (response.isNotEmpty()) pageNumber + 1 else null
return LoadResult.Page(
data = response,
prevKey = prevKey,
nextKey = nextKey
)
}
override fun getRefreshKey(state: PagingState<Int, Commit>) = state.anchorPosition?.let {
state.closestPageToPosition(it)?.prevKey?.plus(1) ?: state.closestPageToPosition(it)?.nextKey?.minus(1)
}
}
}
}
ElevatedCard(
Modifier
@ -198,22 +205,55 @@ fun HomeScreen(navigator: DestinationsNavigator) {
.weight(1f)
) {
Column(
modifier = Modifier.padding(16.dp),
modifier = Modifier.padding(16.dp, 16.dp, 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Plugins",
text = stringResource(R.string.commits),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.weight(1f, true))
}
val lazyPagingItems = pager.flow.collectAsLazyPagingItems()
LazyColumn(
modifier = Modifier
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
if (lazyPagingItems.loadState.refresh == LoadState.Loading) item {
Text(
text = stringResource(R.string.paging_initial_load),
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally)
)
}
PluginsList()
items(lazyPagingItems) { commitData ->
if (commitData == null) return@items
val localUriHandler = LocalUriHandler.current
ListItem(
modifier = Modifier.clickable { localUriHandler.openUri(commitData.htmlUrl) },
overlineText = { Text(commitData.sha.substring(0, 7)) },
headlineText = { Text("${commitData.commit.message.split("\n").first()} - ${commitData.author.name}") }
)
}
if (lazyPagingItems.loadState.append == LoadState.Loading) item {
CircularProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally)
)
}
}
}
}
}

View file

@ -5,47 +5,69 @@
package com.aliucord.manager.ui.screen
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NavigateBefore
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.aliucord.manager.ui.screen.destinations.HomeScreenDestination
import com.aliucord.manager.ui.viewmodel.InstallViewModel
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@OptIn(ExperimentalFoundationApi::class)
@Destination
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun InstallerScreen(navigator: DestinationsNavigator) {
fun InstallerScreen(
onClickBack: () -> Unit
) {
val viewModel: InstallViewModel = viewModel()
val navigateMain by viewModel.returnToHome.collectAsState(initial = false)
if (navigateMain) navigator.navigate(HomeScreenDestination)
if (navigateMain) onClickBack
LaunchedEffect(Unit) {
viewModel.startInstallation()
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
stickyHeader {
LinearProgressIndicator(
modifier = Modifier.fillParentMaxWidth()
Scaffold(
topBar = {
MediumTopAppBar(
title = { Text("Installer") },
navigationIcon = {
IconButton(onClick = onClickBack) {
Icon(
imageVector = Icons.Default.NavigateBefore,
contentDescription = "Back"
)
}
}
)
}
item {
Text(
text = viewModel.log,
modifier = Modifier.padding(8.dp)
)
) { paddingValues ->
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(paddingValues)
.padding(horizontal = 12.dp, vertical = 14.dp)
) {
stickyHeader {
LinearProgressIndicator(
modifier = Modifier.fillParentMaxWidth()
)
}
item {
Text(
text = viewModel.log,
modifier = Modifier.padding(8.dp)
)
}
}
}
}

View file

@ -0,0 +1,130 @@
/*
* Copyright (c) 2022 Juby210 & zt
* Licensed under the Open Software License version 3.0
*/
package com.aliucord.manager.ui.screen
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.aliucord.manager.R
import com.aliucord.manager.model.Plugin
import com.aliucord.manager.ui.component.plugins.Changelog
import com.aliucord.manager.ui.component.plugins.PluginCard
import com.aliucord.manager.ui.navigation.AppDestination
import com.xinto.taxi.BackstackNavigator
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PluginsScreen(navigator: BackstackNavigator<AppDestination>) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
var plugins by remember { mutableStateOf(Plugin.loadAll()) }
val focusManager = LocalFocusManager.current
var search by remember { mutableStateOf("") }
if (plugins.isNotEmpty()) {
OutlinedTextField(
maxLines = 1,
value = search,
onValueChange = { search = it },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
label = { Text(stringResource(R.string.search)) },
trailingIcon = {
if (search.isNotBlank()) {
IconButton(onClick = { search = "" }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.clear)
)
}
} else {
Icon(Icons.Default.Search, stringResource(R.string.search_plugins))
}
},
keyboardOptions = KeyboardOptions(autoCorrect = false, imeAction = ImeAction.Search),
keyboardActions = KeyboardActions { focusManager.clearFocus() },
)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(plugins.filter { plugin ->
plugin.manifest.run {
name.contains(search, true)
|| description.contains(search, true)
|| authors.any { a -> a.name.contains(search, true) }
}
}) { plugin ->
var showDeleteDialog by remember { mutableStateOf(false) }
var showChangelogDialog by remember { mutableStateOf(false) }
if (showDeleteDialog) AlertDialog(
onDismissRequest = { showDeleteDialog = false },
icon = {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.uninstall_plugin, plugin.manifest.name)
)
},
title = { Text(stringResource(R.string.uninstall_plugin, plugin.manifest.name)) },
text = { Text(stringResource(R.string.delete_plugin_body)) },
confirmButton = {
Button(
onClick = {
showDeleteDialog = false
plugin.file.delete()
plugins = plugins.filter { it != plugin }
}
) {
Text(stringResource(android.R.string.ok))
}
},
dismissButton = {
Button(
onClick = { showDeleteDialog = false },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
)
) {
Text(stringResource(android.R.string.cancel))
}
}
)
if (showChangelogDialog) Dialog(
onDismissRequest = { showChangelogDialog = false },
content = { Changelog(plugin, onDismiss = { showChangelogDialog = false }) }
)
PluginCard(
plugin = plugin,
onDelete = { showDeleteDialog = true },
onShowChangelog = { showChangelogDialog = true }
)
}
}
} else {
Text(stringResource(R.string.no_plugins_installed))
}
}
}

View file

@ -10,6 +10,8 @@ import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NavigateBefore
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
@ -21,91 +23,110 @@ import com.aliucord.manager.R
import com.aliucord.manager.preferences.Prefs
import com.aliucord.manager.ui.component.settings.*
import com.aliucord.manager.ui.viewmodel.SettingsViewModel
import com.ramcosta.composedestinations.annotation.Destination
@OptIn(ExperimentalMaterial3Api::class)
@Destination
@Composable
fun SettingsScreen() = Column(
modifier = Modifier.verticalScroll(state = rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
val viewModifier: SettingsViewModel = viewModel()
var showThemeDialog by remember { mutableStateOf(false) }
if (showThemeDialog) {
ThemeDialog(
onDismissRequest = { showThemeDialog = false },
onConfirm = { Prefs.theme.set(it.ordinal) }
)
}
ThemeSetting(onClick = { showThemeDialog = true })
PreferenceItem(
title = { Text(stringResource(R.string.use_black)) },
description = { Text(stringResource(R.string.use_black_description)) },
preference = Prefs.useBlack
)
Divider()
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp),
value = Prefs.appName.get(),
onValueChange = Prefs.appName::set,
label = { Text(stringResource(R.string.app_name_setting)) },
placeholder = { Text("Aliucord") },
singleLine = true
)
PreferenceItem(
title = { Text(stringResource(R.string.replace_bg)) },
preference = Prefs.replaceBg
)
val devModeOn = Prefs.devMode.get()
PreferenceItem(
title = { Text(stringResource(R.string.developer_mode)) },
preference = Prefs.devMode
)
AnimatedVisibility(
visible = devModeOn,
exit = shrinkVertically()
) {
fun SettingsScreen(onClickBack: () -> Unit) {
Scaffold(
topBar = {
MediumTopAppBar(
title = { Text(stringResource(R.string.settings)) },
navigationIcon = {
IconButton(onClick = onClickBack) {
Icon(
imageVector = Icons.Default.NavigateBefore,
contentDescription = stringResource(R.string.back)
)
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.padding(horizontal = 12.dp, vertical = 14.dp)
.verticalScroll(state = rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
val viewModifier: SettingsViewModel = viewModel()
var showThemeDialog by remember { mutableStateOf(false) }
if (showThemeDialog) {
ThemeDialog(
onDismissRequest = { showThemeDialog = false },
onConfirm = { Prefs.theme.set(it.ordinal) }
)
}
ThemeSetting(onClick = { showThemeDialog = true })
PreferenceItem(
title = { Text(stringResource(R.string.use_black)) },
description = { Text(stringResource(R.string.use_black_description)) },
preference = Prefs.useBlack
)
Divider()
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
value = Prefs.packageName.get(),
onValueChange = Prefs.packageName::set,
label = { Text(stringResource(R.string.package_name)) },
placeholder = { Text("com.aliucord") },
.padding(horizontal = 10.dp),
value = Prefs.appName.get(),
onValueChange = Prefs.appName::set,
label = { Text(stringResource(R.string.app_name_setting)) },
placeholder = { Text("Aliucord") },
singleLine = true
)
PreferenceItem(
title = { Text(stringResource(R.string.debuggable)) },
description = { Text(stringResource(R.string.debuggable_description)) },
preference = Prefs.debuggable
title = { Text(stringResource(R.string.replace_bg)) },
preference = Prefs.replaceBg
)
val devModeOn = Prefs.devMode.get()
PreferenceItem(
title = { Text(stringResource(R.string.developer_mode)) },
preference = Prefs.devMode
)
AnimatedVisibility(
visible = devModeOn,
exit = shrinkVertically()
) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
value = Prefs.packageName.get(),
onValueChange = Prefs.packageName::set,
label = { Text(stringResource(R.string.package_name)) },
placeholder = { Text("com.aliucord") },
singleLine = true
)
PreferenceItem(
title = { Text(stringResource(R.string.debuggable)) },
description = { Text(stringResource(R.string.debuggable_description)) },
preference = Prefs.debuggable
)
}
}
Button(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
onClick = viewModifier::clearCacheDir
) {
Text(stringResource(R.string.clear_files_cache), textAlign = TextAlign.Center)
}
}
}
Button(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
onClick = viewModifier::clearCacheDir
) {
Text(stringResource(R.string.clear_files_cache), textAlign = TextAlign.Center)
}
}

View file

@ -8,13 +8,16 @@ package com.aliucord.manager.ui.screen
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NavigateBefore
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.aliucord.manager.R
import com.aliucord.manager.util.httpClient
import com.ramcosta.composedestinations.annotation.Destination
import io.ktor.client.call.*
import io.ktor.client.request.*
import kotlinx.coroutines.Dispatchers
@ -32,33 +35,53 @@ private data class Tree(
)
}
@Destination
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StoreScreen() {
fun StoreScreen(onClickBack: () -> Unit) {
val plugins = remember { mutableStateListOf<Tree.File>() }
// TODO: Display cards with option to install plugin
Column {
if (plugins.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(12.dp),
contentAlignment = Alignment.Center,
content = { CircularProgressIndicator() }
)
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
val content = httpClient.get("https://api.github.com/repos/DiamondMiner88/AllACPlugins/git/trees/master") {
header("accept", "application/json")
}.body<Tree>()
plugins.addAll(content.tree.filter { it.mode == "160000" })
Scaffold(
topBar = {
MediumTopAppBar(
title = { Text(stringResource(R.string.store)) },
navigationIcon = {
IconButton(onClick = onClickBack) {
Icon(
imageVector = Icons.Default.NavigateBefore,
contentDescription = stringResource(R.string.back)
)
}
}
}
} else {
LazyColumn {
items(plugins) { item ->
Text("SHA: ${item.sha}", style = MaterialTheme.typography.labelSmall)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
) {
if (plugins.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(12.dp),
contentAlignment = Alignment.Center,
content = { CircularProgressIndicator() }
)
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
val content = httpClient.get("https://api.github.com/repos/DiamondMiner88/AllACPlugins/git/trees/master") {
header("accept", "application/json")
}.body<Tree>()
plugins.addAll(content.tree.filter { it.mode == "160000" })
}
}
} else {
LazyColumn {
items(plugins) { item ->
Text("SHA: ${item.sha}", style = MaterialTheme.typography.labelSmall)
}
}
}
}

View file

@ -48,4 +48,5 @@
<string name="changelog_media">Changelog Media</string>
<string name="app_name_setting">App Name</string>
<string name="package_name">Package Name</string>
<string name="clear">Clear</string>
</resources>