Большая реструктуризация проекта
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
package com.github.nullptroma.wallenc.ui
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.github.nullptroma.wallenc.ui.test", appContext.packageName)
|
||||
}
|
||||
}
|
||||
4
ui/src/main/AndroidManifest.xml
Normal file
4
ui/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.github.nullptroma.wallenc.ui
|
||||
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
abstract class ViewModelBase<TState>(initState: TState) : ViewModel() {
|
||||
private val _state = MutableStateFlow(initState)
|
||||
|
||||
val state: StateFlow<TState>
|
||||
get() = _state
|
||||
|
||||
protected fun updateState(newState: TState) {
|
||||
_state.value = newState
|
||||
}
|
||||
}
|
||||
142
ui/src/main/java/com/github/nullptroma/wallenc/ui/WallencUi.kt
Normal file
142
ui/src/main/java/com/github/nullptroma/wallenc/ui/WallencUi.kt
Normal file
@@ -0,0 +1,142 @@
|
||||
package com.github.nullptroma.wallenc.ui
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.List
|
||||
import androidx.compose.material.icons.rounded.Menu
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import com.github.nullptroma.wallenc.ui.navigation.NavBarItemData
|
||||
import com.github.nullptroma.wallenc.ui.navigation.rememberNavigationState
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.MainRoute
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.MainScreen
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.MainViewModel
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.screens.tasks.TaskPipelineRoute
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.screens.tasks.TaskPipelineScreen
|
||||
import com.github.nullptroma.wallenc.ui.screens.settings.SettingsRoute
|
||||
import com.github.nullptroma.wallenc.ui.screens.settings.SettingsScreen
|
||||
import com.github.nullptroma.wallenc.ui.screens.settings.SettingsViewModel
|
||||
import com.github.nullptroma.wallenc.ui.theme.WallencTheme
|
||||
|
||||
|
||||
@Composable
|
||||
fun WallencUi() {
|
||||
WallencTheme {
|
||||
Surface {
|
||||
WallencNavRoot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun WallencNavRoot(viewModel: WallencViewModel = hiltViewModel()) {
|
||||
val navState = rememberNavigationState()
|
||||
val mainNavState = rememberNavigationState()
|
||||
|
||||
val mainViewModel: MainViewModel = hiltViewModel()
|
||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||
|
||||
val topLevelRoutes = viewModel.routes
|
||||
|
||||
val topLevelNavBarItems = remember {
|
||||
mapOf(
|
||||
MainRoute::class.qualifiedName!! to NavBarItemData(
|
||||
R.string.nav_label_main, MainRoute::class.qualifiedName!!, Icons.Rounded.Menu
|
||||
),
|
||||
TaskPipelineRoute::class.qualifiedName!! to NavBarItemData(
|
||||
R.string.task_pipeline_title,
|
||||
TaskPipelineRoute::class.qualifiedName!!,
|
||||
Icons.AutoMirrored.Rounded.List
|
||||
),
|
||||
SettingsRoute::class.qualifiedName!! to NavBarItemData(
|
||||
R.string.nav_label_settings,
|
||||
SettingsRoute::class.qualifiedName!!,
|
||||
Icons.Rounded.Settings
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Scaffold(bottomBar = {
|
||||
NavigationBar(modifier = Modifier.wrapContentHeight()) {
|
||||
val navBackStackEntry by navState.navHostController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
topLevelNavBarItems.forEach {
|
||||
val routeClassName = it.key
|
||||
val navBarItemData = it.value
|
||||
NavigationBarItem(
|
||||
modifier = Modifier.wrapContentHeight(),
|
||||
icon = {
|
||||
if (navBarItemData.icon != null) Icon(
|
||||
navBarItemData.icon,
|
||||
contentDescription = stringResource(navBarItemData.nameStringResourceId)
|
||||
)
|
||||
},
|
||||
label = { Text(stringResource(navBarItemData.nameStringResourceId)) },
|
||||
selected = currentRoute?.startsWith(routeClassName) == true,
|
||||
onClick = {
|
||||
val route = topLevelRoutes[navBarItemData.screenRouteClass]
|
||||
if (route == null)
|
||||
throw NullPointerException("Route $route not found")
|
||||
if (currentRoute?.startsWith(routeClassName) != true) navState.changeTop(
|
||||
route
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}) { innerPaddings ->
|
||||
NavHost(
|
||||
navState.navHostController,
|
||||
startDestination = topLevelRoutes[MainRoute::class.qualifiedName]!!
|
||||
) {
|
||||
composable<MainRoute>(enterTransition = {
|
||||
fadeIn(tween(200))
|
||||
}, exitTransition = {
|
||||
fadeOut(tween(200))
|
||||
}) {
|
||||
MainScreen(
|
||||
modifier = Modifier.padding(innerPaddings),
|
||||
navState = mainNavState,
|
||||
viewModel = mainViewModel
|
||||
)
|
||||
}
|
||||
composable<SettingsRoute>(enterTransition = {
|
||||
fadeIn(tween(200))
|
||||
}, exitTransition = {
|
||||
fadeOut(tween(200))
|
||||
}) {
|
||||
SettingsScreen(Modifier.padding(innerPaddings), settingsViewModel)
|
||||
}
|
||||
composable<TaskPipelineRoute>(enterTransition = {
|
||||
fadeIn(tween(200))
|
||||
}, exitTransition = {
|
||||
fadeOut(tween(200))
|
||||
}) {
|
||||
TaskPipelineScreen(
|
||||
modifier = Modifier.padding(innerPaddings)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.github.nullptroma.wallenc.ui
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
|
||||
import androidx.lifecycle.viewmodel.compose.saveable
|
||||
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.MainRoute
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.screens.tasks.TaskPipelineRoute
|
||||
import com.github.nullptroma.wallenc.ui.screens.settings.SettingsRoute
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlin.collections.set
|
||||
|
||||
@HiltViewModel
|
||||
class WallencViewModel @javax.inject.Inject constructor(savedStateHandle: SavedStateHandle) :
|
||||
ViewModelBase<Unit>(Unit) {
|
||||
@OptIn(SavedStateHandleSaveableApi::class)
|
||||
var routes by savedStateHandle.saveable {
|
||||
mutableStateOf(
|
||||
mapOf(
|
||||
MainRoute::class.qualifiedName!! to MainRoute(),
|
||||
TaskPipelineRoute::class.qualifiedName!! to TaskPipelineRoute(),
|
||||
SettingsRoute::class.qualifiedName!! to SettingsRoute()
|
||||
)
|
||||
)
|
||||
}
|
||||
private set
|
||||
|
||||
fun updateRoute(qualifiedName: String, route: ScreenRoute) {
|
||||
routes = routes.toMutableMap().apply {
|
||||
this[qualifiedName] = route
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package com.github.nullptroma.wallenc.ui.elements
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.BasicAlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TextEditCancelOkDialog(onDismiss: () -> Unit, onConfirmation: (String) -> Unit, title: String, startString: String = "") {
|
||||
var name by remember { mutableStateOf(startString) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
BasicAlertDialog(
|
||||
onDismissRequest = { onDismiss() }
|
||||
) {
|
||||
Card {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge)
|
||||
TextField(modifier = Modifier.focusRequester(focusRequester), value = name, onValueChange = {
|
||||
name = it
|
||||
})
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
Button(modifier = Modifier.weight(1f), onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Button(modifier = Modifier.weight(1f), onClick = {
|
||||
onConfirmation(name)
|
||||
}) {
|
||||
Text("Ok")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConfirmationCancelOkDialog(onDismiss: () -> Unit, onConfirmation: () -> Unit, title: String) {
|
||||
BasicAlertDialog(
|
||||
onDismissRequest = { onDismiss() }
|
||||
) {
|
||||
Card {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
Button(modifier = Modifier.weight(1f), onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Button(modifier = Modifier.weight(1f), onClick = {
|
||||
onConfirmation()
|
||||
}) {
|
||||
Text("Ok")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EncryptionSetupDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirmation: (password: String, encryptPath: Boolean) -> Unit,
|
||||
) {
|
||||
var password by remember { mutableStateOf("") }
|
||||
var encryptPath by remember { mutableStateOf(false) }
|
||||
BasicAlertDialog(onDismissRequest = onDismiss) {
|
||||
Card {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text("Enable encryption", style = MaterialTheme.typography.titleLarge)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
TextField(value = password, onValueChange = { password = it }, label = { Text("Password") })
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = encryptPath, onCheckedChange = { encryptPath = it })
|
||||
Text("Encrypt paths")
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
Button(modifier = Modifier.weight(1f), onClick = onDismiss) { Text("Cancel") }
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { onConfirmation(password, encryptPath) },
|
||||
enabled = password.isNotEmpty()
|
||||
) { Text("Apply") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OpenEncryptedStorageDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirmation: (password: String, rememberPassword: Boolean) -> Unit,
|
||||
) {
|
||||
var password by remember { mutableStateOf("") }
|
||||
var rememberPassword by remember { mutableStateOf(false) }
|
||||
BasicAlertDialog(onDismissRequest = onDismiss) {
|
||||
Card {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text("Open encrypted storage", style = MaterialTheme.typography.titleLarge)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
TextField(value = password, onValueChange = { password = it }, label = { Text("Password") })
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = rememberPassword, onCheckedChange = { rememberPassword = it })
|
||||
Text("Remember password")
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
Button(modifier = Modifier.weight(1f), onClick = onDismiss) { Text("Cancel") }
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { onConfirmation(password, rememberPassword) },
|
||||
enabled = password.isNotEmpty()
|
||||
) { Text("Open") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StorageEncryptionActionsDialog(
|
||||
onDismiss: () -> Unit,
|
||||
title: String,
|
||||
isOpened: Boolean,
|
||||
onOpen: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
onDisable: () -> Unit,
|
||||
) {
|
||||
BasicAlertDialog(onDismissRequest = onDismiss) {
|
||||
Card {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
if (isOpened) {
|
||||
Button(onClick = onClose, modifier = Modifier.fillMaxWidth()) { Text("Close") }
|
||||
} else {
|
||||
Button(onClick = onOpen, modifier = Modifier.fillMaxWidth()) { Text("Open") }
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(onClick = onDisable, modifier = Modifier.fillMaxWidth()) { Text("Disable encryption") }
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { Text("Done") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package com.github.nullptroma.wallenc.ui.elements
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.LockOpen
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.PlatformTextStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.github.nullptroma.wallenc.domain.datatypes.Tree
|
||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
|
||||
import com.github.nullptroma.wallenc.ui.R
|
||||
import com.github.nullptroma.wallenc.ui.utils.debouncedLambda
|
||||
|
||||
@Composable
|
||||
fun StorageTree(
|
||||
modifier: Modifier,
|
||||
tree: Tree<IStorageInfo>,
|
||||
onClick: (Tree<IStorageInfo>) -> Unit,
|
||||
onRename: (Tree<IStorageInfo>, String) -> Unit,
|
||||
onRemove: (Tree<IStorageInfo>) -> Unit,
|
||||
onEncrypt: (Tree<IStorageInfo>, String, Boolean) -> Unit,
|
||||
onOpenEncrypted: (Tree<IStorageInfo>, String, Boolean) -> Unit,
|
||||
onCloseEncrypted: (Tree<IStorageInfo>) -> Unit,
|
||||
onDisableEncryption: (Tree<IStorageInfo>) -> Unit,
|
||||
getStatusText: (Tree<IStorageInfo>) -> String,
|
||||
isEncryptionOpened: (Tree<IStorageInfo>) -> Boolean,
|
||||
) {
|
||||
val cur = tree.value
|
||||
val available by cur.isAvailable.collectAsStateWithLifecycle()
|
||||
val numOfFiles by cur.numberOfFiles.collectAsStateWithLifecycle()
|
||||
val size by cur.size.collectAsStateWithLifecycle()
|
||||
val metaInfo by cur.metaInfo.collectAsStateWithLifecycle()
|
||||
val isAvailable by cur.isAvailable.collectAsStateWithLifecycle()
|
||||
val isEncrypted = metaInfo.encInfo != null
|
||||
val isOpened = isEncryptionOpened(tree)
|
||||
val borderColor =
|
||||
if (cur.isVirtualStorage) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.primary
|
||||
Column(modifier) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(IntrinsicSize.Min)
|
||||
.zIndex(100f)
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(
|
||||
CardDefaults.shape
|
||||
)
|
||||
.padding(0.dp, 0.dp, 16.dp, 0.dp)
|
||||
.fillMaxSize()
|
||||
.background(borderColor)
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = ripple(),
|
||||
enabled = false,
|
||||
onClick = { }
|
||||
)
|
||||
)
|
||||
Card(
|
||||
interactionSource = interactionSource,
|
||||
modifier = Modifier
|
||||
.padding(8.dp, 0.dp, 0.dp, 0.dp)
|
||||
.fillMaxWidth(),
|
||||
elevation = CardDefaults.cardElevation(
|
||||
defaultElevation = 4.dp
|
||||
),
|
||||
onClick = debouncedLambda(debounceMs = 500) {
|
||||
onClick(tree)
|
||||
}
|
||||
) {
|
||||
|
||||
|
||||
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
Text(metaInfo.name ?: stringResource(R.string.no_name))
|
||||
Text(
|
||||
text = "IsAvailable: $available"
|
||||
)
|
||||
Text("Files: $numOfFiles")
|
||||
Text("Size: $size")
|
||||
Text("IsVirtual: ${cur.isVirtualStorage}")
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier,
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var showRenameDialog by remember { mutableStateOf(false) }
|
||||
var showRemoveConfirmDialog by remember { mutableStateOf(false) }
|
||||
var showLockDialog by remember { mutableStateOf(false) }
|
||||
var showSetupEncryptionDialog by remember { mutableStateOf(false) }
|
||||
var showOpenEncryptionDialog by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp)) {
|
||||
IconButton(onClick = { expanded = !expanded }) {
|
||||
Icon(
|
||||
Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.show_storage_item_menu)
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
expanded = false
|
||||
showRenameDialog = true
|
||||
},
|
||||
text = { Text(stringResource(R.string.rename)) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
expanded = false
|
||||
showRemoveConfirmDialog = true
|
||||
},
|
||||
text = { Text(stringResource(R.string.remove)) }
|
||||
)
|
||||
if (!isEncrypted) {
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
expanded = false
|
||||
showSetupEncryptionDialog = true
|
||||
},
|
||||
text = { Text(stringResource(R.string.encrypt)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showRenameDialog) {
|
||||
TextEditCancelOkDialog(
|
||||
onDismiss = { showRenameDialog = false },
|
||||
onConfirmation = { newName ->
|
||||
showRenameDialog = false
|
||||
onRename(tree, newName)
|
||||
},
|
||||
title = stringResource(R.string.new_name_title),
|
||||
startString = metaInfo.name ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
if (showRemoveConfirmDialog) {
|
||||
ConfirmationCancelOkDialog(
|
||||
onDismiss = { showRemoveConfirmDialog = false },
|
||||
title = stringResource(
|
||||
R.string.remove_confirmation_dialog,
|
||||
metaInfo.name ?: "<noname>"
|
||||
),
|
||||
onConfirmation = {
|
||||
showRemoveConfirmDialog = false
|
||||
onRemove(tree)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showLockDialog) {
|
||||
StorageEncryptionActionsDialog(
|
||||
onDismiss = { showLockDialog = false },
|
||||
title = metaInfo.name ?: stringResource(R.string.no_name),
|
||||
isOpened = isOpened,
|
||||
onOpen = {
|
||||
showLockDialog = false
|
||||
showOpenEncryptionDialog = true
|
||||
},
|
||||
onClose = {
|
||||
showLockDialog = false
|
||||
onCloseEncrypted(tree)
|
||||
},
|
||||
onDisable = {
|
||||
showLockDialog = false
|
||||
onDisableEncryption(tree)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showSetupEncryptionDialog) {
|
||||
EncryptionSetupDialog(
|
||||
onDismiss = { showSetupEncryptionDialog = false },
|
||||
onConfirmation = { password, encryptPath ->
|
||||
showSetupEncryptionDialog = false
|
||||
onEncrypt(tree, password, encryptPath)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showOpenEncryptionDialog) {
|
||||
OpenEncryptedStorageDialog(
|
||||
onDismiss = { showOpenEncryptionDialog = false },
|
||||
onConfirmation = { password, rememberPassword ->
|
||||
showOpenEncryptionDialog = false
|
||||
onOpenEncrypted(tree, password, rememberPassword)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (isEncrypted) {
|
||||
IconButton(onClick = { showLockDialog = true }) {
|
||||
Icon(
|
||||
if (isOpened) Icons.Default.LockOpen else Icons.Default.Lock,
|
||||
contentDescription = stringResource(R.string.storage_lock_actions)
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(0.dp, 0.dp, 12.dp, 0.dp)
|
||||
.align(Alignment.End),
|
||||
text = getStatusText(tree),
|
||||
textAlign = TextAlign.End,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(0.dp, 0.dp, 12.dp, 8.dp)
|
||||
.align(Alignment.End),
|
||||
text = cur.uuid.toString(),
|
||||
textAlign = TextAlign.End,
|
||||
fontSize = 8.sp,
|
||||
style = LocalTextStyle.current.copy(
|
||||
platformStyle = PlatformTextStyle(
|
||||
includeFontPadding = true
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!isAvailable) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(
|
||||
CardDefaults.shape
|
||||
)
|
||||
.fillMaxSize()
|
||||
.alpha(0.5f)
|
||||
.background(Color.Black)
|
||||
)
|
||||
}
|
||||
}
|
||||
for (i in tree.children ?: listOf()) {
|
||||
StorageTree(
|
||||
Modifier
|
||||
.padding(16.dp, 0.dp, 0.dp, 0.dp)
|
||||
.offset(y = (-4).dp),
|
||||
i,
|
||||
onClick,
|
||||
onRename,
|
||||
onRemove,
|
||||
onEncrypt,
|
||||
onOpenEncrypted,
|
||||
onCloseEncrypted,
|
||||
onDisableEncryption,
|
||||
getStatusText,
|
||||
isEncryptionOpened
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.github.nullptroma.wallenc.ui.elements.indication
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.IndicationNodeFactory
|
||||
import androidx.compose.foundation.interaction.InteractionSource
|
||||
import androidx.compose.foundation.interaction.PressInteraction
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.scale
|
||||
import androidx.compose.ui.node.DelegatableNode
|
||||
import androidx.compose.ui.node.DrawModifierNode
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private class ScaleNode(private val interactionSource: InteractionSource) :
|
||||
Modifier.Node(), DrawModifierNode {
|
||||
|
||||
var currentPressPosition: Offset = Offset.Zero
|
||||
val animatedScalePercent = Animatable(1f)
|
||||
|
||||
private suspend fun animateToPressed(pressPosition: Offset) {
|
||||
currentPressPosition = pressPosition
|
||||
animatedScalePercent.animateTo(0.9f, spring())
|
||||
}
|
||||
|
||||
private suspend fun animateToResting() {
|
||||
animatedScalePercent.animateTo(1f, spring())
|
||||
}
|
||||
|
||||
override fun onAttach() {
|
||||
coroutineScope.launch {
|
||||
interactionSource.interactions.collectLatest { interaction ->
|
||||
when (interaction) {
|
||||
is PressInteraction.Press -> animateToPressed(interaction.pressPosition)
|
||||
is PressInteraction.Release -> animateToResting()
|
||||
is PressInteraction.Cancel -> animateToResting()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun ContentDrawScope.draw() {
|
||||
scale(
|
||||
scale = animatedScalePercent.value,
|
||||
pivot = currentPressPosition
|
||||
) {
|
||||
this@draw.drawContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object ScaleIndication : IndicationNodeFactory {
|
||||
override fun create(interactionSource: InteractionSource): DelegatableNode {
|
||||
return ScaleNode(interactionSource)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean = other === ScaleIndication
|
||||
override fun hashCode() = 100
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.github.nullptroma.wallenc.ui.extensions
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
fun Modifier.ignoreHorizontalParentPadding(horizontal: Dp): Modifier {
|
||||
return this.layout { measurable, constraints ->
|
||||
val overrideWidth = constraints.maxWidth + 2 * horizontal.roundToPx()
|
||||
val placeable = measurable.measure(constraints.copy(maxWidth = overrideWidth))
|
||||
layout(placeable.width, placeable.height) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.ignoreVerticalParentPadding(vertical: Dp): Modifier {
|
||||
return this.layout { measurable, constraints ->
|
||||
val overrideHeight = constraints.maxHeight + 2 * vertical.roundToPx()
|
||||
val placeable = measurable.measure(constraints.copy(maxHeight = overrideHeight))
|
||||
layout(placeable.width, placeable.height) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.gesturesDisabled(disabled: Boolean = true) =
|
||||
if (disabled) {
|
||||
pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
// we should wait for all new pointer events
|
||||
while (true) {
|
||||
awaitPointerEvent(pass = PointerEventPass.Initial)
|
||||
.changes
|
||||
.forEach(PointerInputChange::consume)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.github.nullptroma.wallenc.ui.extensions
|
||||
|
||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
|
||||
|
||||
fun IStorageInfo.toPrintable(): String {
|
||||
return "{ uuid: $uuid, enc: ${metaInfo.value.encInfo} }"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.github.nullptroma.wallenc.ui.navigation
|
||||
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
data class NavBarItemData(val nameStringResourceId: Int, val screenRouteClass: String, val icon: ImageVector?)
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.github.nullptroma.wallenc.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
|
||||
|
||||
|
||||
class NavigationState(
|
||||
val navHostController: NavHostController
|
||||
) {
|
||||
fun changeTop(route: ScreenRoute) {
|
||||
navHostController.navigate(route) {
|
||||
popUpTo(navHostController.graph.findStartDestination().id)
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
|
||||
fun push(route: ScreenRoute) {
|
||||
navHostController.navigate(route) {
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberNavigationState(
|
||||
navHostController: NavHostController? = null
|
||||
): NavigationState {
|
||||
val controller = navHostController ?: rememberNavController()
|
||||
return remember { NavigationState(controller) }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
abstract class ScreenRoute : Parcelable
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.main
|
||||
|
||||
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
open class MainRoute: ScreenRoute()
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.main
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.toRoute
|
||||
import com.github.nullptroma.wallenc.ui.R
|
||||
import com.github.nullptroma.wallenc.ui.navigation.NavBarItemData
|
||||
import com.github.nullptroma.wallenc.ui.navigation.NavigationState
|
||||
import com.github.nullptroma.wallenc.ui.navigation.rememberNavigationState
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsRoute
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsScreen
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsViewModel
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.LocalVaultRoute
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.LocalVaultScreen
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.LocalVaultViewModel
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.RemoteVaultViewModel
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.VaultBrowserRoute
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.VaultBrowserScreen
|
||||
import com.github.nullptroma.wallenc.ui.screens.shared.TextEditRoute
|
||||
import com.github.nullptroma.wallenc.ui.screens.shared.TextEditScreen
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@androidx.compose.runtime.Composable
|
||||
fun MainScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MainViewModel = hiltViewModel(),
|
||||
navState: NavigationState = rememberNavigationState(),
|
||||
) {
|
||||
val routes = viewModel.routes
|
||||
val localVaultViewModel: LocalVaultViewModel = hiltViewModel()
|
||||
val remoteVaultsViewModel: RemoteVaultsViewModel = hiltViewModel()
|
||||
|
||||
val topLevelNavBarItems = remember {
|
||||
mapOf(
|
||||
LocalVaultRoute::class.qualifiedName!! to NavBarItemData(
|
||||
R.string.nav_label_local_vault, LocalVaultRoute::class.qualifiedName!!, null
|
||||
),
|
||||
RemoteVaultsRoute::class.qualifiedName!! to NavBarItemData(
|
||||
R.string.nav_label_remote_vaults, RemoteVaultsRoute::class.qualifiedName!!, null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(modifier = modifier, contentWindowInsets = WindowInsets(0.dp), bottomBar = {
|
||||
Column {
|
||||
NavigationBar(windowInsets = WindowInsets(0), modifier = Modifier.height(48.dp)) {
|
||||
val navBackStackEntry by navState.navHostController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
topLevelNavBarItems.forEach {
|
||||
val routeClassName = it.key
|
||||
val navBarItemData = it.value
|
||||
NavigationBarItem(modifier = Modifier
|
||||
.weight(1f),
|
||||
icon = { Text(stringResource(navBarItemData.nameStringResourceId)) },
|
||||
selected = currentRoute?.startsWith(routeClassName) == true,
|
||||
onClick = {
|
||||
val route = routes[navBarItemData.screenRouteClass]
|
||||
?: throw NullPointerException("Route ${navBarItemData.screenRouteClass} not found")
|
||||
if (currentRoute?.startsWith(routeClassName) != true)
|
||||
navState.changeTop(
|
||||
route
|
||||
)
|
||||
},
|
||||
label = null
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}) { innerPaddings ->
|
||||
NavHost(
|
||||
navState.navHostController,
|
||||
startDestination = routes[LocalVaultRoute::class.qualifiedName]!!
|
||||
) {
|
||||
composable<LocalVaultRoute>(enterTransition = {
|
||||
fadeIn(tween(200))
|
||||
}, exitTransition = {
|
||||
fadeOut(tween(200))
|
||||
}) {
|
||||
LocalVaultScreen(
|
||||
modifier = Modifier.padding(innerPaddings),
|
||||
viewModel = localVaultViewModel,
|
||||
openTextEdit = { text ->
|
||||
navState.push(TextEditRoute(text))
|
||||
},
|
||||
)
|
||||
}
|
||||
composable<RemoteVaultsRoute>(enterTransition = {
|
||||
fadeIn(tween(200))
|
||||
}, exitTransition = {
|
||||
fadeOut(tween(200))
|
||||
}) {
|
||||
RemoteVaultsScreen(
|
||||
modifier = Modifier.padding(innerPaddings),
|
||||
viewModel = remoteVaultsViewModel,
|
||||
onOpenVault = { item ->
|
||||
navState.push(VaultBrowserRoute(item.uuid.toString()))
|
||||
},
|
||||
)
|
||||
}
|
||||
composable<VaultBrowserRoute>(enterTransition = {
|
||||
fadeIn(tween(200))
|
||||
}, exitTransition = {
|
||||
fadeOut(tween(200))
|
||||
}) { entry ->
|
||||
val remoteVaultViewModel: RemoteVaultViewModel = hiltViewModel(entry)
|
||||
VaultBrowserScreen(
|
||||
modifier = Modifier.padding(innerPaddings),
|
||||
viewModel = remoteVaultViewModel,
|
||||
openTextEdit = { text ->
|
||||
navState.push(TextEditRoute(text))
|
||||
},
|
||||
)
|
||||
}
|
||||
composable<TextEditRoute> {
|
||||
val route: TextEditRoute = it.toRoute()
|
||||
TextEditScreen(route.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.main
|
||||
|
||||
class MainScreenState
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.main
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
|
||||
import androidx.lifecycle.viewmodel.compose.saveable
|
||||
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsRoute
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.LocalVaultRoute
|
||||
import com.github.nullptroma.wallenc.ui.ViewModelBase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
|
||||
@HiltViewModel
|
||||
class MainViewModel @javax.inject.Inject constructor(savedStateHandle: SavedStateHandle) :
|
||||
ViewModelBase<MainScreenState>(MainScreenState()) {
|
||||
|
||||
@OptIn(SavedStateHandleSaveableApi::class)
|
||||
var routes by savedStateHandle.saveable {
|
||||
mutableStateOf(
|
||||
mapOf<String, ScreenRoute>(
|
||||
LocalVaultRoute::class.qualifiedName!! to LocalVaultRoute(),
|
||||
RemoteVaultsRoute::class.qualifiedName!! to RemoteVaultsRoute()
|
||||
)
|
||||
)
|
||||
}
|
||||
private set
|
||||
|
||||
fun updateRoute(qualifiedName: String, route: ScreenRoute) {
|
||||
routes = routes.toMutableMap().apply {
|
||||
this[qualifiedName] = route
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.main.screens.remotes
|
||||
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.MainRoute
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
class RemoteVaultsRoute : MainRoute()
|
||||
@@ -0,0 +1,247 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.main.screens.remotes
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.github.nullptroma.wallenc.ui.R
|
||||
import com.github.nullptroma.wallenc.vault.contract.CloudBrand
|
||||
import com.github.nullptroma.wallenc.vault.contract.VaultLinkOutcome
|
||||
|
||||
@Composable
|
||||
fun RemoteVaultsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: RemoteVaultsViewModel = hiltViewModel(),
|
||||
onOpenVault: (RemoteVaultListItem) -> Unit,
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
Box {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
contentWindowInsets = WindowInsets(0.dp),
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
if (!uiState.isBusy) viewModel.setAddChoiceVisible(true)
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Add,
|
||||
contentDescription = stringResource(R.string.remote_vaults_add_cd),
|
||||
)
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
if (uiState.vaults.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.remote_vaults_empty_hint),
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.padding(24.dp),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(uiState.vaults, key = { it.uuid }) { item ->
|
||||
ElevatedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
enabled = !uiState.isBusy,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) { onOpenVault(item) },
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = when (item.brand) {
|
||||
CloudBrand.YANDEX ->
|
||||
stringResource(R.string.remote_vault_type_yandex)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { viewModel.requestDeleteVault(item) },
|
||||
enabled = !uiState.isBusy,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Delete,
|
||||
contentDescription = stringResource(R.string.remote_vault_delete_cd),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.isBusy) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.addChoiceVisible) {
|
||||
Dialog(
|
||||
onDismissRequest = { if (!uiState.isBusy) viewModel.setAddChoiceVisible(false) },
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
tonalElevation = 3.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(24.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.remote_vaults_add_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
viewModel.setAddChoiceVisible(false)
|
||||
viewModel.remoteAuthenticator.beginLink(CloudBrand.YANDEX) { outcome ->
|
||||
when (outcome) {
|
||||
is VaultLinkOutcome.Success ->
|
||||
viewModel.onLinkSucceeded(outcome.registration)
|
||||
is VaultLinkOutcome.Failed ->
|
||||
Toast.makeText(context, outcome.message, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
VaultLinkOutcome.Cancelled -> { }
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !uiState.isBusy,
|
||||
) {
|
||||
Text(stringResource(R.string.remote_vaults_provider_yandex))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.remote_vaults_add_cancel),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
enabled = !uiState.isBusy,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) { viewModel.setAddChoiceVisible(false) }
|
||||
.padding(vertical = 8.dp, horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uiState.vaultPendingDelete?.let { pending ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!uiState.isBusy) viewModel.dismissDeleteVault() },
|
||||
title = {
|
||||
Text(stringResource(R.string.remote_vault_remove_title))
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(R.string.remote_vault_remove_message, pending.label))
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { viewModel.confirmDeleteVault() },
|
||||
enabled = !uiState.isBusy,
|
||||
) {
|
||||
Text(stringResource(R.string.remove))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { viewModel.dismissDeleteVault() },
|
||||
enabled = !uiState.isBusy,
|
||||
) {
|
||||
Text(stringResource(R.string.remote_vaults_add_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.main.screens.remotes
|
||||
|
||||
import com.github.nullptroma.wallenc.vault.contract.CloudBrand
|
||||
import java.util.UUID
|
||||
|
||||
data class RemoteVaultListItem(
|
||||
val uuid: UUID,
|
||||
val brand: CloudBrand,
|
||||
val label: String,
|
||||
)
|
||||
|
||||
data class RemoteVaultsScreenState(
|
||||
val vaults: List<RemoteVaultListItem> = emptyList(),
|
||||
val isBusy: Boolean = false,
|
||||
val addChoiceVisible: Boolean = false,
|
||||
val vaultPendingDelete: RemoteVaultListItem? = null,
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.main.screens.remotes
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
|
||||
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
|
||||
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
|
||||
import com.github.nullptroma.wallenc.ui.ViewModelBase
|
||||
import com.github.nullptroma.wallenc.vault.contract.RemoteVaultAuthenticator
|
||||
import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor
|
||||
import com.github.nullptroma.wallenc.vault.contract.VaultRegistrar
|
||||
import com.github.nullptroma.wallenc.vault.contract.VaultRegistration
|
||||
import com.github.nullptroma.wallenc.vault.contract.described
|
||||
import com.github.nullptroma.wallenc.vault.contract.remotes
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class RemoteVaultsViewModel @Inject constructor(
|
||||
private val vaultsManager: IVaultsManager,
|
||||
private val vaultRegistrar: VaultRegistrar,
|
||||
val remoteAuthenticator: RemoteVaultAuthenticator,
|
||||
private val taskOrchestrator: ITaskOrchestrator,
|
||||
) : ViewModelBase<RemoteVaultsScreenState>(RemoteVaultsScreenState()) {
|
||||
|
||||
val uiState = combine(
|
||||
vaultsManager.vaults,
|
||||
state,
|
||||
) { all, base ->
|
||||
base.copy(
|
||||
vaults = all.described().remotes.mapNotNull { v ->
|
||||
val descriptor = v.descriptor as? VaultDescriptor.LinkedRemote ?: return@mapNotNull null
|
||||
RemoteVaultListItem(
|
||||
uuid = descriptor.uuid,
|
||||
brand = descriptor.brand,
|
||||
label = descriptor.accountDisplayName,
|
||||
)
|
||||
},
|
||||
)
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
RemoteVaultsScreenState(),
|
||||
)
|
||||
|
||||
fun setAddChoiceVisible(visible: Boolean) {
|
||||
updateState(state.value.copy(addChoiceVisible = visible))
|
||||
}
|
||||
|
||||
fun setBusy(busy: Boolean) {
|
||||
updateState(state.value.copy(isBusy = busy))
|
||||
}
|
||||
|
||||
fun onLinkSucceeded(registration: VaultRegistration) {
|
||||
setBusy(true)
|
||||
taskOrchestrator.enqueue(
|
||||
title = "Add remote vault",
|
||||
dispatcher = Dispatchers.IO,
|
||||
work = { ctx ->
|
||||
try {
|
||||
ctx.log(TaskLogLevel.Info, "Adding vault…")
|
||||
vaultRegistrar.register(registration)
|
||||
ctx.log(TaskLogLevel.Info, "Vault added")
|
||||
} catch (e: Exception) {
|
||||
ctx.log(TaskLogLevel.Error, e.message ?: "Failed to add vault")
|
||||
} finally {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
setBusy(false)
|
||||
setAddChoiceVisible(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun requestDeleteVault(item: RemoteVaultListItem) {
|
||||
updateState(state.value.copy(vaultPendingDelete = item))
|
||||
}
|
||||
|
||||
fun dismissDeleteVault() {
|
||||
updateState(state.value.copy(vaultPendingDelete = null))
|
||||
}
|
||||
|
||||
fun confirmDeleteVault() {
|
||||
val pending = state.value.vaultPendingDelete ?: return
|
||||
val uuid = pending.uuid
|
||||
setBusy(true)
|
||||
taskOrchestrator.enqueue(
|
||||
title = "Remove remote vault",
|
||||
dispatcher = Dispatchers.IO,
|
||||
work = { ctx ->
|
||||
try {
|
||||
ctx.log(TaskLogLevel.Info, "Removing remote vault…")
|
||||
vaultRegistrar.unregister(uuid)
|
||||
ctx.log(TaskLogLevel.Info, "Remote vault removed")
|
||||
} catch (e: Exception) {
|
||||
ctx.log(TaskLogLevel.Error, e.message ?: "Failed to remove vault")
|
||||
} finally {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
setBusy(false)
|
||||
dismissDeleteVault()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.main.screens.tasks
|
||||
|
||||
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
class TaskPipelineRoute : ScreenRoute()
|
||||
@@ -0,0 +1,209 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.main.screens.tasks
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
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.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.github.nullptroma.wallenc.domain.tasks.PipelineTask
|
||||
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
|
||||
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
|
||||
import com.github.nullptroma.wallenc.ui.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TaskPipelineScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: TaskPipelineViewModel = hiltViewModel(),
|
||||
) {
|
||||
val pipeline by viewModel.orchestrator.pipelineState.collectAsStateWithLifecycle()
|
||||
val logs by viewModel.orchestrator.logLines.collectAsStateWithLifecycle()
|
||||
val hasAnyTask = pipeline.tasks.isNotEmpty()
|
||||
val runningTaskIds = pipeline.runningTaskIds
|
||||
var showTestDialog by remember { mutableStateOf(false) }
|
||||
var testDurationSec by remember { mutableFloatStateOf(10f) }
|
||||
var testInfinity by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
windowInsets = WindowInsets(0.dp),
|
||||
title = { Text(stringResource(R.string.task_pipeline_title)) },
|
||||
)
|
||||
},
|
||||
) { inner ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(inner)
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Button(onClick = { showTestDialog = true }) {
|
||||
Text(stringResource(R.string.task_pipeline_run_test))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.orchestrator.cancelAll() },
|
||||
enabled = hasAnyTask,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.task_pipeline_cancel_all))
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.task_pipeline_jobs),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(pipeline.tasks, key = { it.id.uuid }) { task ->
|
||||
TaskRow(task = task, isRunning = task.id in runningTaskIds)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.task_pipeline_log),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(logs.size) { i ->
|
||||
val line = logs[i]
|
||||
val prefix = when (line.level) {
|
||||
TaskLogLevel.Debug -> "D"
|
||||
TaskLogLevel.Info -> "I"
|
||||
TaskLogLevel.Warn -> "W"
|
||||
TaskLogLevel.Error -> "E"
|
||||
}
|
||||
Text(
|
||||
"[$prefix] ${line.message}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showTestDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showTestDialog = false },
|
||||
title = { Text(stringResource(R.string.task_pipeline_test_dialog_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.task_pipeline_test_dialog_duration,
|
||||
testDurationSec.toInt(),
|
||||
)
|
||||
)
|
||||
Slider(
|
||||
value = testDurationSec,
|
||||
onValueChange = { testDurationSec = it },
|
||||
valueRange = 0f..60f,
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Checkbox(
|
||||
checked = testInfinity,
|
||||
onCheckedChange = { testInfinity = it },
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.task_pipeline_test_dialog_infinity),
|
||||
modifier = Modifier
|
||||
.clickable { testInfinity = !testInfinity }
|
||||
.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.startTestTask(
|
||||
testDurationSec.toInt(),
|
||||
testInfinity,
|
||||
)
|
||||
showTestDialog = false
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.task_pipeline_test_dialog_start))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Button(onClick = { showTestDialog = false }) {
|
||||
Text(stringResource(R.string.task_pipeline_test_dialog_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskRow(task: PipelineTask, isRunning: Boolean) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
task.title,
|
||||
style = if (isRunning) MaterialTheme.typography.titleSmall
|
||||
else MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
val stateLabel = when (val s = task.state) {
|
||||
TaskRunState.Queued -> stringResource(R.string.task_state_queued)
|
||||
is TaskRunState.Running -> stringResource(R.string.task_state_running)
|
||||
TaskRunState.Completed -> stringResource(R.string.task_state_completed)
|
||||
TaskRunState.Cancelled -> stringResource(R.string.task_state_cancelled)
|
||||
is TaskRunState.Failed -> stringResource(R.string.task_state_failed, s.message)
|
||||
}
|
||||
Text(stateLabel, style = MaterialTheme.typography.bodySmall)
|
||||
if (task.state is TaskRunState.Running) {
|
||||
val frac = (task.state as TaskRunState.Running).progress?.fraction
|
||||
if (frac != null) {
|
||||
LinearProgressIndicator(
|
||||
progress = { frac },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.main.screens.tasks
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
|
||||
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class TaskPipelineViewModel @Inject constructor(
|
||||
val orchestrator: ITaskOrchestrator,
|
||||
) : ViewModel() {
|
||||
|
||||
fun startTestTask(durationSec: Int, infinityIndeterminateProgress: Boolean) {
|
||||
val safeDurationSec = durationSec.coerceIn(0, 60)
|
||||
val title =
|
||||
if (infinityIndeterminateProgress) "Test task (${safeDurationSec}s, ∞)"
|
||||
else "Test task (${safeDurationSec}s)"
|
||||
orchestrator.enqueue(
|
||||
title = title,
|
||||
dispatcher = Dispatchers.Default,
|
||||
work = { ctx ->
|
||||
val steps = if (safeDurationSec == 0) 1 else safeDurationSec * 10
|
||||
ctx.log(TaskLogLevel.Info, "Test task started for ${safeDurationSec}s")
|
||||
for (step in 0..steps) {
|
||||
val fraction = step.toFloat() / steps.toFloat()
|
||||
val elapsedMs = (fraction * safeDurationSec * 1000).toInt()
|
||||
ctx.reportProgress(
|
||||
fraction = if (infinityIndeterminateProgress) null else fraction,
|
||||
label = "Elapsed: ${elapsedMs / 1000}s / ${safeDurationSec}s",
|
||||
)
|
||||
if (step < steps) delay(100)
|
||||
}
|
||||
ctx.log(TaskLogLevel.Info, "Test task finished")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
|
||||
import com.github.nullptroma.wallenc.domain.datatypes.Tree
|
||||
import com.github.nullptroma.wallenc.domain.interfaces.IDirectory
|
||||
import com.github.nullptroma.wallenc.domain.interfaces.IFile
|
||||
import com.github.nullptroma.wallenc.domain.interfaces.ILogger
|
||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
|
||||
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
|
||||
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
|
||||
import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase
|
||||
import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase
|
||||
import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase
|
||||
import com.github.nullptroma.wallenc.usecases.RemoveStorageUseCase
|
||||
import com.github.nullptroma.wallenc.usecases.RenameStorageUseCase
|
||||
import com.github.nullptroma.wallenc.usecases.StorageFileManagementUseCase
|
||||
import com.github.nullptroma.wallenc.ui.ViewModelBase
|
||||
import com.github.nullptroma.wallenc.ui.extensions.toPrintable
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
/**
|
||||
* Общая логика дерева storages для локального и удалённого vault (presentation).
|
||||
*/
|
||||
abstract class AbstractVaultBrowserViewModel(
|
||||
storagesFlow: Flow<List<IStorage>>,
|
||||
private val vaultAvailabilityFlow: Flow<Boolean>,
|
||||
private val resolveCreateVaultUuid: () -> UUID?,
|
||||
private val removeStorageUseCase: RemoveStorageUseCase,
|
||||
private val getOpenedStoragesUseCase: GetOpenedStoragesUseCase,
|
||||
private val storageFileManagementUseCase: StorageFileManagementUseCase,
|
||||
private val manageStoragesEncryptionUseCase: ManageStoragesEncryptionUseCase,
|
||||
private val renameStorageUseCase: RenameStorageUseCase,
|
||||
private val manageVaultUseCase: ManageVaultUseCase,
|
||||
private val taskOrchestrator: ITaskOrchestrator,
|
||||
private val logger: ILogger,
|
||||
) : ViewModelBase<VaultBrowserScreenState>(
|
||||
VaultBrowserScreenState(storagesList = emptyList(), isLoading = true, addStorageFabEnabled = false),
|
||||
) {
|
||||
|
||||
private val _messages = MutableSharedFlow<String>()
|
||||
val messages: SharedFlow<String> = _messages
|
||||
|
||||
private var taskCount: Int = 0
|
||||
set(value) {
|
||||
field = value
|
||||
updateStateLoading()
|
||||
}
|
||||
|
||||
private var storagesLoading: Boolean = false
|
||||
set(value) {
|
||||
field = value
|
||||
updateStateLoading()
|
||||
}
|
||||
|
||||
init {
|
||||
collectFlows(storagesFlow)
|
||||
viewModelScope.launch {
|
||||
vaultAvailabilityFlow
|
||||
.distinctUntilChanged()
|
||||
.collect { available ->
|
||||
updateState(state.value.copy(addStorageFabEnabled = available))
|
||||
logger.debug(TAG, "vault availability → add FAB enabled=$available")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateStateLoading() {
|
||||
updateState(
|
||||
state.value.copy(
|
||||
isLoading = storagesLoading || taskCount > 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun collectFlows(storagesFlow: Flow<List<IStorage>>) {
|
||||
viewModelScope.launch {
|
||||
storagesFlow.combine(getOpenedStoragesUseCase.openedStorages) { storages, opened ->
|
||||
val list = mutableListOf<Tree<IStorageInfo>>()
|
||||
for (storage in storages) {
|
||||
var tree = Tree<IStorageInfo>(storage)
|
||||
list.add(tree)
|
||||
while (opened.containsKey(tree.value.uuid)) {
|
||||
val child = opened.getValue(tree.value.uuid)
|
||||
val nextTree = Tree<IStorageInfo>(child)
|
||||
tree.children = listOf(nextTree)
|
||||
tree = nextTree
|
||||
}
|
||||
}
|
||||
list
|
||||
}.collect { trees ->
|
||||
storagesLoading = false
|
||||
updateState(state.value.copy(storagesList = trees))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun printStorageInfoToLog(storage: IStorageInfo) {
|
||||
taskOrchestrator.enqueue(
|
||||
title = "Dump storage to log",
|
||||
dispatcher = Dispatchers.IO,
|
||||
work = { ctx ->
|
||||
storageFileManagementUseCase.setStorage(storage)
|
||||
ctx.log(TaskLogLevel.Info, "Enumerating files and directories…")
|
||||
val files: List<IFile>
|
||||
val dirs: List<IDirectory>
|
||||
val time = measureTimeMillis {
|
||||
files = storageFileManagementUseCase.getAllFiles()
|
||||
dirs = storageFileManagementUseCase.getAllDirs()
|
||||
}
|
||||
for (file in files) {
|
||||
logger.debug("Files", file.metaInfo.toString())
|
||||
}
|
||||
for (dir in dirs) {
|
||||
logger.debug("Dirs", dir.metaInfo.toString())
|
||||
}
|
||||
logger.debug("Time", "Time: $time ms")
|
||||
logger.debug("Storage", storage.toPrintable())
|
||||
ctx.log(
|
||||
TaskLogLevel.Info,
|
||||
"Done: ${files.size} files, ${dirs.size} dirs in ${time}ms (see app log for lines)",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun createStorage() {
|
||||
if (!state.value.addStorageFabEnabled) {
|
||||
logger.debug(TAG, "createStorage ignored (vault unavailable or FAB disabled)")
|
||||
return
|
||||
}
|
||||
logger.debug(TAG, "createStorage: enqueue task")
|
||||
taskOrchestrator.enqueue(
|
||||
title = "Create storage",
|
||||
dispatcher = Dispatchers.IO,
|
||||
work = { ctx ->
|
||||
try {
|
||||
ctx.log(TaskLogLevel.Info, "Creating storage…")
|
||||
val uuid = resolveCreateVaultUuid()
|
||||
?: throw IllegalStateException("Vault is not available")
|
||||
logger.debug(TAG, "createStorage: vaultUuid=$uuid")
|
||||
val storage = manageVaultUseCase.createStorage(uuid)
|
||||
ctx.log(TaskLogLevel.Info, "Storage created")
|
||||
logger.debug(TAG, "createStorage: done storageUuid=${storage.uuid}")
|
||||
} catch (e: Exception) {
|
||||
logger.debug(TAG, "createStorage failed: ${e.stackTraceToString()}")
|
||||
ctx.log(TaskLogLevel.Error, e.message ?: e.toString())
|
||||
throw e
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "VaultBrowser"
|
||||
}
|
||||
|
||||
private val storageOpMutex = Any()
|
||||
private val runningStorages = mutableSetOf<UUID>()
|
||||
|
||||
fun enableEncryption(storage: IStorageInfo, password: String, encryptPath: Boolean) {
|
||||
val id = storage.uuid
|
||||
synchronized(storageOpMutex) {
|
||||
if (runningStorages.contains(id)) return
|
||||
runningStorages.add(id)
|
||||
taskCount++
|
||||
}
|
||||
val key = EncryptKey(password)
|
||||
taskOrchestrator.enqueue(
|
||||
title = "Enable encryption",
|
||||
dispatcher = Dispatchers.IO,
|
||||
work = { ctx ->
|
||||
try {
|
||||
ctx.log(TaskLogLevel.Info, "Checking storage…")
|
||||
when (manageStoragesEncryptionUseCase.canEncrypt(storage)) {
|
||||
ManageStoragesEncryptionUseCase.CanEncryptResult.Allowed -> {
|
||||
ctx.log(TaskLogLevel.Info, "Encrypting…")
|
||||
manageStoragesEncryptionUseCase.enableEncryption(storage, key, encryptPath)
|
||||
manageStoragesEncryptionUseCase.openStorage(storage, key, true)
|
||||
ctx.log(TaskLogLevel.Info, "Encryption enabled")
|
||||
_messages.emit("Encryption enabled")
|
||||
}
|
||||
ManageStoragesEncryptionUseCase.CanEncryptResult.AlreadyEncrypted -> {
|
||||
ctx.log(TaskLogLevel.Info, "Storage is already encrypted")
|
||||
_messages.emit("Storage is already encrypted")
|
||||
}
|
||||
ManageStoragesEncryptionUseCase.CanEncryptResult.StorageIsNotEmpty -> {
|
||||
ctx.log(TaskLogLevel.Info, "Storage is not empty")
|
||||
_messages.emit("Storage is not empty")
|
||||
}
|
||||
ManageStoragesEncryptionUseCase.CanEncryptResult.StorageStateUnknown -> {
|
||||
ctx.log(TaskLogLevel.Info, "Cannot determine whether storage is empty")
|
||||
_messages.emit("Cannot determine whether storage is empty")
|
||||
}
|
||||
ManageStoragesEncryptionUseCase.CanEncryptResult.UnsupportedStorageType -> {
|
||||
ctx.log(TaskLogLevel.Info, "Unsupported storage type")
|
||||
_messages.emit("Unsupported storage type")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ctx.log(TaskLogLevel.Error, e.message ?: "Failed to enable encryption")
|
||||
_messages.emit(e.message ?: "Failed to enable encryption")
|
||||
} finally {
|
||||
synchronized(storageOpMutex) {
|
||||
runningStorages.remove(id)
|
||||
taskCount--
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun openEncryptedStorage(storage: IStorageInfo, password: String, rememberPassword: Boolean) {
|
||||
val id = storage.uuid
|
||||
synchronized(storageOpMutex) {
|
||||
if (runningStorages.contains(id)) return
|
||||
runningStorages.add(id)
|
||||
taskCount++
|
||||
}
|
||||
val key = EncryptKey(password)
|
||||
taskOrchestrator.enqueue(
|
||||
title = "Open encrypted storage",
|
||||
dispatcher = Dispatchers.IO,
|
||||
work = { ctx ->
|
||||
try {
|
||||
ctx.log(TaskLogLevel.Info, "Opening storage…")
|
||||
manageStoragesEncryptionUseCase.openStorage(storage, key, rememberPassword)
|
||||
ctx.log(TaskLogLevel.Info, "Storage opened")
|
||||
} catch (e: Exception) {
|
||||
ctx.log(TaskLogLevel.Error, e.message ?: "Failed to open encrypted storage")
|
||||
_messages.emit(e.message ?: "Failed to open encrypted storage")
|
||||
} finally {
|
||||
synchronized(storageOpMutex) {
|
||||
runningStorages.remove(id)
|
||||
taskCount--
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun closeEncryptedStorage(storage: IStorageInfo) {
|
||||
taskOrchestrator.enqueue(
|
||||
title = "Close encrypted storage",
|
||||
dispatcher = Dispatchers.IO,
|
||||
work = { ctx ->
|
||||
try {
|
||||
ctx.log(TaskLogLevel.Info, "Closing storage…")
|
||||
manageStoragesEncryptionUseCase.closeStorage(storage)
|
||||
ctx.log(TaskLogLevel.Info, "Storage closed")
|
||||
} catch (e: Exception) {
|
||||
ctx.log(TaskLogLevel.Error, e.message ?: "Failed to close encrypted storage")
|
||||
_messages.emit(e.message ?: "Failed to close encrypted storage")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun disableEncryption(storage: IStorageInfo) {
|
||||
taskOrchestrator.enqueue(
|
||||
title = "Disable encryption",
|
||||
dispatcher = Dispatchers.IO,
|
||||
work = { ctx ->
|
||||
try {
|
||||
ctx.log(TaskLogLevel.Info, "Disabling encryption…")
|
||||
manageStoragesEncryptionUseCase.clearAndDisableEncryption(storage) { p ->
|
||||
ctx.reportProgress(p)
|
||||
}
|
||||
ctx.log(TaskLogLevel.Info, "Encryption disabled")
|
||||
_messages.emit("Encryption disabled")
|
||||
} catch (e: Exception) {
|
||||
ctx.log(TaskLogLevel.Error, e.message ?: "Failed")
|
||||
_messages.emit(e.message ?: "Failed to disable encryption")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun rename(storage: IStorageInfo, newName: String) {
|
||||
taskOrchestrator.enqueue(
|
||||
title = "Rename storage",
|
||||
dispatcher = Dispatchers.IO,
|
||||
work = { ctx ->
|
||||
try {
|
||||
ctx.log(TaskLogLevel.Info, "Renaming…")
|
||||
renameStorageUseCase.rename(storage, newName)
|
||||
ctx.log(TaskLogLevel.Info, "Renamed")
|
||||
} catch (e: Exception) {
|
||||
ctx.log(TaskLogLevel.Error, e.message ?: "Rename failed")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun remove(storage: IStorageInfo) {
|
||||
taskOrchestrator.enqueue(
|
||||
title = "Remove storage",
|
||||
dispatcher = Dispatchers.IO,
|
||||
work = { ctx ->
|
||||
try {
|
||||
ctx.log(TaskLogLevel.Info, "Removing storage…")
|
||||
removeStorageUseCase.remove(storage)
|
||||
ctx.log(TaskLogLevel.Info, "Removed")
|
||||
} catch (e: Exception) {
|
||||
ctx.log(TaskLogLevel.Error, e.message ?: "Remove failed")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun getStorageStatus(storage: IStorageInfo): String {
|
||||
val encrypted = storage.metaInfo.value.encInfo != null
|
||||
if (!encrypted) return "Not encrypted"
|
||||
val opened = isEncryptionSessionOpen(storage)
|
||||
return if (opened) "Encrypted (opened)" else "Encrypted (closed)"
|
||||
}
|
||||
|
||||
fun isEncryptionSessionOpen(storage: IStorageInfo): Boolean {
|
||||
val openedMap = getOpenedStoragesUseCase.openedStorages.value
|
||||
return openedMap.containsKey(storage.uuid)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
|
||||
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.MainRoute
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
class LocalVaultRoute : MainRoute()
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
|
||||
@Composable
|
||||
fun LocalVaultScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: LocalVaultViewModel = hiltViewModel(),
|
||||
openTextEdit: (String) -> Unit,
|
||||
) {
|
||||
VaultBrowserScreen(
|
||||
modifier = modifier,
|
||||
viewModel = viewModel,
|
||||
openTextEdit = openTextEdit,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
|
||||
|
||||
import com.github.nullptroma.wallenc.domain.interfaces.ILogger
|
||||
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
|
||||
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
|
||||
import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase
|
||||
import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase
|
||||
import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase
|
||||
import com.github.nullptroma.wallenc.usecases.RemoveStorageUseCase
|
||||
import com.github.nullptroma.wallenc.usecases.RenameStorageUseCase
|
||||
import com.github.nullptroma.wallenc.usecases.StorageFileManagementUseCase
|
||||
import com.github.nullptroma.wallenc.vault.contract.described
|
||||
import com.github.nullptroma.wallenc.vault.contract.locals
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class LocalVaultViewModel @Inject constructor(
|
||||
vaultsManager: IVaultsManager,
|
||||
manageVaultUseCase: ManageVaultUseCase,
|
||||
removeStorageUseCase: RemoveStorageUseCase,
|
||||
getOpenedStoragesUseCase: GetOpenedStoragesUseCase,
|
||||
storageFileManagementUseCase: StorageFileManagementUseCase,
|
||||
manageStoragesEncryptionUseCase: ManageStoragesEncryptionUseCase,
|
||||
renameStorageUseCase: RenameStorageUseCase,
|
||||
taskOrchestrator: ITaskOrchestrator,
|
||||
logger: ILogger,
|
||||
) : AbstractVaultBrowserViewModel(
|
||||
storagesFlow = vaultsManager.vaults
|
||||
.map { vaults -> vaults.described().locals.firstOrNull() }
|
||||
.flatMapLatest { v -> v?.storages ?: flowOf(emptyList()) },
|
||||
vaultAvailabilityFlow = vaultsManager.vaults
|
||||
.map { vaults -> vaults.described().locals.firstOrNull() }
|
||||
.flatMapLatest { v -> v?.isAvailable ?: flowOf(false) },
|
||||
resolveCreateVaultUuid = { vaultsManager.vaults.value.described().locals.firstOrNull()?.uuid },
|
||||
removeStorageUseCase = removeStorageUseCase,
|
||||
getOpenedStoragesUseCase = getOpenedStoragesUseCase,
|
||||
storageFileManagementUseCase = storageFileManagementUseCase,
|
||||
manageStoragesEncryptionUseCase = manageStoragesEncryptionUseCase,
|
||||
renameStorageUseCase = renameStorageUseCase,
|
||||
manageVaultUseCase = manageVaultUseCase,
|
||||
taskOrchestrator = taskOrchestrator,
|
||||
logger = logger,
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.github.nullptroma.wallenc.domain.interfaces.ILogger
|
||||
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
|
||||
import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase
|
||||
import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase
|
||||
import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase
|
||||
import com.github.nullptroma.wallenc.usecases.RemoveStorageUseCase
|
||||
import com.github.nullptroma.wallenc.usecases.RenameStorageUseCase
|
||||
import com.github.nullptroma.wallenc.usecases.StorageFileManagementUseCase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class RemoteVaultViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
manageVaultUseCase: ManageVaultUseCase,
|
||||
removeStorageUseCase: RemoveStorageUseCase,
|
||||
getOpenedStoragesUseCase: GetOpenedStoragesUseCase,
|
||||
storageFileManagementUseCase: StorageFileManagementUseCase,
|
||||
manageStoragesEncryptionUseCase: ManageStoragesEncryptionUseCase,
|
||||
renameStorageUseCase: RenameStorageUseCase,
|
||||
taskOrchestrator: ITaskOrchestrator,
|
||||
logger: ILogger,
|
||||
) : AbstractVaultBrowserViewModel(
|
||||
storagesFlow = manageVaultUseCase.storagesOf(savedStateHandle.requireVaultUuid()),
|
||||
vaultAvailabilityFlow = manageVaultUseCase.observe(savedStateHandle.requireVaultUuid())
|
||||
.flatMapLatest { v -> v?.isAvailable ?: flowOf(false) },
|
||||
resolveCreateVaultUuid = { savedStateHandle.requireVaultUuid() },
|
||||
removeStorageUseCase = removeStorageUseCase,
|
||||
getOpenedStoragesUseCase = getOpenedStoragesUseCase,
|
||||
storageFileManagementUseCase = storageFileManagementUseCase,
|
||||
manageStoragesEncryptionUseCase = manageStoragesEncryptionUseCase,
|
||||
renameStorageUseCase = renameStorageUseCase,
|
||||
manageVaultUseCase = manageVaultUseCase,
|
||||
taskOrchestrator = taskOrchestrator,
|
||||
logger = logger,
|
||||
)
|
||||
|
||||
private fun SavedStateHandle.requireVaultUuid(): UUID {
|
||||
val raw = get<String>("vaultUuid") ?: error("Missing vault UUID in navigation arguments")
|
||||
return UUID.fromString(raw)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
|
||||
|
||||
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class VaultBrowserRoute(val vaultUuid: String) : ScreenRoute()
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.github.nullptroma.wallenc.ui.elements.StorageTree
|
||||
import com.github.nullptroma.wallenc.ui.extensions.gesturesDisabled
|
||||
|
||||
@Composable
|
||||
fun VaultBrowserScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AbstractVaultBrowserViewModel,
|
||||
openTextEdit: (String) -> Unit,
|
||||
) {
|
||||
val uiState by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.messages.collect { message ->
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
contentWindowInsets = WindowInsets(0.dp),
|
||||
floatingActionButton = {
|
||||
val fabEnabled = uiState.addStorageFabEnabled
|
||||
FloatingActionButton(
|
||||
onClick = { if (fabEnabled) viewModel.createStorage() },
|
||||
containerColor = if (fabEnabled) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
contentColor = if (fabEnabled) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f)
|
||||
},
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.gesturesDisabled(uiState.isLoading),
|
||||
) {
|
||||
items(uiState.storagesList) { listItem ->
|
||||
StorageTree(
|
||||
modifier = Modifier.padding(8.dp, 8.dp, 8.dp, 0.dp),
|
||||
tree = listItem,
|
||||
onClick = { openTextEdit(it.value.uuid.toString()) },
|
||||
onRename = { tree, newName -> viewModel.rename(tree.value, newName) },
|
||||
onRemove = { tree -> viewModel.remove(tree.value) },
|
||||
onEncrypt = { tree, password, encryptPath ->
|
||||
viewModel.enableEncryption(tree.value, password, encryptPath)
|
||||
},
|
||||
onOpenEncrypted = { tree, password, remember ->
|
||||
viewModel.openEncryptedStorage(tree.value, password, remember)
|
||||
},
|
||||
onCloseEncrypted = { tree -> viewModel.closeEncryptedStorage(tree.value) },
|
||||
onDisableEncryption = { tree -> viewModel.disableEncryption(tree.value) },
|
||||
getStatusText = { tree -> viewModel.getStorageStatus(tree.value) },
|
||||
isEncryptionOpened = { tree -> viewModel.isEncryptionSessionOpen(tree.value) },
|
||||
)
|
||||
}
|
||||
item { Spacer(modifier = Modifier.height(8.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.isLoading) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Box(modifier = Modifier.fillMaxSize().alpha(0.6f).background(Color.Black))
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.width(64.dp),
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
|
||||
|
||||
import com.github.nullptroma.wallenc.domain.datatypes.Tree
|
||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
|
||||
|
||||
data class VaultBrowserScreenState(
|
||||
val storagesList: List<Tree<IStorageInfo>>,
|
||||
val isLoading: Boolean,
|
||||
/** FAB «добавить storage»: активна только когда vault доступен (сеть/API/путь). */
|
||||
val addStorageFabEnabled: Boolean = false,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.settings
|
||||
|
||||
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
class SettingsRoute: ScreenRoute()
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.github.nullptroma.wallenc.ui.R
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(modifier: Modifier, viewModel: SettingsViewModel) {
|
||||
Column (modifier = modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||
Text(text = stringResource(id = R.string.settings_title))
|
||||
// Text(text = viewModel)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.settings
|
||||
|
||||
class SettingsScreenState
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.settings
|
||||
|
||||
import com.github.nullptroma.wallenc.ui.ViewModelBase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @javax.inject.Inject constructor() :
|
||||
ViewModelBase<SettingsScreenState>(SettingsScreenState())
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.shared
|
||||
|
||||
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class TextEditRoute(val text: String): ScreenRoute()
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.shared
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
fun TextEditScreen(text: String) {
|
||||
Text("Hello from TextEdit with text $text")
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.github.nullptroma.wallenc.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.github.nullptroma.wallenc.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun WallencTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.github.nullptroma.wallenc.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.github.nullptroma.wallenc.ui.utils
|
||||
|
||||
|
||||
fun debouncedLambda(debounceMs: Long = 300, action: ()->Unit) : ()->Unit {
|
||||
var latest: Long = 0
|
||||
return {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - latest >= debounceMs) {
|
||||
latest = now
|
||||
action()
|
||||
}
|
||||
}
|
||||
}
|
||||
46
ui/src/main/res/values/strings.xml
Normal file
46
ui/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="nav_label_local_vault">Local</string>
|
||||
<string name="nav_label_remote_vaults">Remotes</string>
|
||||
<string name="nav_label_main">Main</string>
|
||||
<string name="nav_label_settings">Settings</string>
|
||||
|
||||
<string name="settings_title">Settings Screen Title!</string>
|
||||
<string name="no_name"><noname></string>
|
||||
<string name="show_storage_item_menu">Show storage item menu</string>
|
||||
<string name="rename">Rename</string>
|
||||
<string name="remove">Remove</string>
|
||||
<string name="encrypt">Encrypt</string>
|
||||
<string name="new_name_title">New name</string>
|
||||
<string name="remove_confirmation_dialog">Delete storage "%1$s"?</string>
|
||||
<string name="storage_lock_actions">Storage encryption actions</string>
|
||||
|
||||
<string name="task_pipeline_title">Task pipeline</string>
|
||||
<string name="task_pipeline_jobs">Jobs</string>
|
||||
<string name="task_pipeline_log">Log</string>
|
||||
<string name="task_pipeline_cancel_all">Cancel all</string>
|
||||
<string name="task_pipeline_open">Open task pipeline</string>
|
||||
<string name="task_pipeline_run_test">Run test task</string>
|
||||
<string name="task_pipeline_test_dialog_title">Test task setup</string>
|
||||
<string name="task_pipeline_test_dialog_duration">Duration: %1$d s</string>
|
||||
<string name="task_pipeline_test_dialog_start">Start</string>
|
||||
<string name="task_pipeline_test_dialog_cancel">Cancel</string>
|
||||
<string name="task_pipeline_test_dialog_infinity">Infinity (indeterminate progress)</string>
|
||||
<string name="task_state_queued">Queued</string>
|
||||
<string name="task_state_running">Running</string>
|
||||
<string name="task_state_completed">Completed</string>
|
||||
<string name="task_state_cancelled">Cancelled</string>
|
||||
<string name="task_state_failed">Failed: %1$s</string>
|
||||
|
||||
<string name="remote_vaults_add_cd">Add remote vault</string>
|
||||
<string name="remote_vaults_empty_hint">No remote vaults yet. Tap + to add Yandex.</string>
|
||||
<string name="remote_vaults_add_title">Add vault</string>
|
||||
<string name="remote_vaults_add_pick_provider">Choose provider:</string>
|
||||
<string name="remote_vaults_provider_yandex">Yandex</string>
|
||||
<string name="remote_vaults_add_cancel">Cancel</string>
|
||||
<string name="remote_vault_type_yandex">Yandex</string>
|
||||
<string name="remote_vault_delete_cd">Remove remote vault</string>
|
||||
<string name="remote_vault_remove_title">Remove remote vault?</string>
|
||||
<string name="remote_vault_remove_message">Remove \"%1$s\" from this device? The account data on the server is not deleted.</string>
|
||||
|
||||
</resources>
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.github.nullptroma.wallenc.ui
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user