Большая реструктуризация проекта

This commit is contained in:
2026-05-11 19:33:32 +03:00
parent ad985679ee
commit 3928ac5409
132 changed files with 574 additions and 450 deletions

View File

@@ -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)
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
</manifest>

View File

@@ -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
}
}

View 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)
)
}
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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") }
}
}
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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} }"
}

View File

@@ -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?)

View File

@@ -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) }
}

View File

@@ -0,0 +1,7 @@
package com.github.nullptroma.wallenc.ui.screens
import android.os.Parcelable
import kotlinx.serialization.Serializable
@Serializable
abstract class ScreenRoute : Parcelable

View File

@@ -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()

View File

@@ -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)
}
}
}
}

View File

@@ -0,0 +1,3 @@
package com.github.nullptroma.wallenc.ui.screens.main
class MainScreenState

View File

@@ -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
}
}
}

View File

@@ -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()

View File

@@ -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))
}
},
)
}
}

View File

@@ -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,
)

View File

@@ -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()
}
}
},
)
}
}

View File

@@ -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()

View File

@@ -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),
)
}
}
}
}

View File

@@ -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")
},
)
}
}

View File

@@ -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)
}
}

View File

@@ -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()

View File

@@ -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,
)
}

View File

@@ -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,
)

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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,
)
}
}
}
}

View File

@@ -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,
)

View File

@@ -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()

View File

@@ -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)
}
}

View File

@@ -0,0 +1,3 @@
package com.github.nullptroma.wallenc.ui.screens.settings
class SettingsScreenState

View File

@@ -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())

View File

@@ -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()

View File

@@ -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")
}

View File

@@ -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)

View File

@@ -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
)
}

View File

@@ -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
)
*/
)

View File

@@ -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()
}
}
}

View 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">&lt;noname&gt;</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>

View File

@@ -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)
}
}