This commit is contained in:
Minecon724 2025-04-20 07:51:21 +02:00
commit 34af1f025f
Signed by: Minecon724
GPG key ID: A02E6E67AB961189
14 changed files with 322 additions and 91 deletions

View file

@ -1,6 +1,7 @@
package eu.m724.pojavbackup.core.datastore
import androidx.datastore.core.DataStore
import eu.m724.pojavbackup.proto.BackupDestination
import eu.m724.pojavbackup.proto.Settings
import eu.m724.pojavbackup.proto.WorldOrder
import kotlinx.coroutines.flow.Flow
@ -28,4 +29,26 @@ class SettingsRepository @Inject constructor(
.build()
}
}
/* Destination */
suspend fun getDestinations(): List<BackupDestination> {
return getSettings().destinationsList
}
suspend fun addDestination(destination: BackupDestination) {
dataStore.updateData {
it.toBuilder()
.addDestinations(destination)
.build()
}
}
suspend fun removeDestination(index: Int) {
dataStore.updateData {
it.toBuilder()
.removeDestinations(index)
.build()
}
}
}

View file

@ -5,15 +5,14 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
@ -29,7 +28,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hierarchy
@ -50,18 +48,20 @@ import eu.m724.pojavbackup.ui.theme.PojavBackupTheme
class HomeActivity : ComponentActivity() {
private val viewModel: HomeViewModel by viewModels()
private val setupResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == 0) {
// TODO success
} else {
// TODO failure
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.load(
onSetupNeeded = {
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == 0) {
// TODO success
} else {
// TODO failure
}
}.launch(Intent(applicationContext, SetupActivity::class.java))
setupResult.launch(Intent(applicationContext, SetupActivity::class.java))
}
)
@ -122,10 +122,14 @@ fun HomeScaffold(
startDestination = HomeScreen.Dashboard,
modifier = Modifier.padding(innerPadding),
enterTransition = {
fadeIn() + slideInHorizontally(initialOffsetX = { it / 10 })
fadeIn(
animationSpec = tween(100)
) + slideInHorizontally(initialOffsetX = { it / 10 })
},
exitTransition = {
fadeOut() + slideOutHorizontally(targetOffsetX = { -it / 10 })
fadeOut(
animationSpec = tween(100)
) + slideOutHorizontally(targetOffsetX = { -it / 15 })
}
) {
composable<HomeScreen.Dashboard> {

View file

@ -1,6 +1,5 @@
package eu.m724.pojavbackup.home.screen.history
import android.system.Os.stat
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@ -10,9 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CardElevation
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -23,7 +20,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import eu.m724.pojavbackup.R
import eu.m724.pojavbackup.core.BackupStatus
@ -31,7 +27,6 @@ import eu.m724.pojavbackup.home.screen.ScreenColumn
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
@Composable
fun HistoryScreen() {
@ -101,6 +96,7 @@ fun BackupCard(
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {

View file

@ -4,6 +4,9 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
@ -11,11 +14,14 @@ import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
@ -30,17 +36,26 @@ import dagger.hilt.android.AndroidEntryPoint
import eu.m724.pojavbackup.R
import eu.m724.pojavbackup.settings.screen.SettingsScreen
import eu.m724.pojavbackup.settings.screen.content.ContentScreen
import eu.m724.pojavbackup.settings.screen.destination.DestinationScreen
import eu.m724.pojavbackup.settings.screen.options.OptionsScreen
import eu.m724.pojavbackup.ui.theme.PojavBackupTheme
@AndroidEntryPoint
class SettingsActivity : ComponentActivity() {
private val viewModel: SettingsViewModel by viewModels()
private val openDocumentTree = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) {
viewModel.onOpenDocumentTree(it)
}
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val startPage = when (intent.getStringExtra("settingsPage")) {
"options" -> SettingsScreen.Options
"content" -> SettingsScreen.Content
"destination" -> SettingsScreen.Destination
else -> SettingsScreen.Options
}
@ -51,6 +66,25 @@ class SettingsActivity : ComponentActivity() {
PojavBackupTheme {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = {
Text("Settings")
},
navigationIcon = {
IconButton(
onClick = {
finish()
}
) {
Icon(
painter = painterResource(R.drawable.baseline_arrow_back_24),
contentDescription = "Exit Settings"
)
}
}
)
},
bottomBar = {
NavigationBar {
ScreenNavigationBarItem(
@ -65,6 +99,12 @@ class SettingsActivity : ComponentActivity() {
route = SettingsScreen.Content,
iconResourceId = R.drawable.baseline_folder_copy_24
)
ScreenNavigationBarItem(
navController = navController,
label = "Destination",
route = SettingsScreen.Destination,
iconResourceId = R.drawable.baseline_cloud_24
)
}
}
) { innerPadding ->
@ -73,10 +113,14 @@ class SettingsActivity : ComponentActivity() {
startDestination = startPage,
modifier = Modifier.padding(innerPadding),
enterTransition = {
fadeIn() + slideInHorizontally(initialOffsetX = { it / 10 })
fadeIn(
animationSpec = tween(100)
) + slideInHorizontally(initialOffsetX = { it / 10 })
},
exitTransition = {
fadeOut() + slideOutHorizontally(targetOffsetX = { -it / 10 })
fadeOut(
animationSpec = tween(100)
) + slideOutHorizontally(targetOffsetX = { -it / 15 })
}
) {
composable<SettingsScreen.Options> {
@ -85,12 +129,19 @@ class SettingsActivity : ComponentActivity() {
composable<SettingsScreen.Content> {
ContentScreen(navController)
}
composable<SettingsScreen.Destination> {
DestinationScreen(navController, onAddDestination = { onAddDestination() })
}
// Add more destinations similarly.
}
}
}
}
}
fun onAddDestination() {
openDocumentTree.launch(null)
}
}
// TODO those functions are reused in Home
@ -107,6 +158,7 @@ fun RowScope.ScreenNavigationBarItem(
selected = selected,
onClick = {
if (!selected) {
navController.popBackStack() // this makes back exit settings
navController.navigate(route)
}
},

View file

@ -0,0 +1,42 @@
package eu.m724.pojavbackup.settings
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.m724.pojavbackup.core.datastore.SettingsRepository
import eu.m724.pojavbackup.proto.BackupDestination
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
@ApplicationContext private val appContext: Context,
private val settingsRepository: SettingsRepository
) : ViewModel() {
private val TAG = javaClass.name
fun onOpenDocumentTree(uri: Uri?) {
if (uri != null) {
Log.i(TAG, "Got URI: $uri")
appContext.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
val destination = BackupDestination.newBuilder()
.setLabel("label")
.setUri(uri.toString())
.build()
viewModelScope.launch {
settingsRepository.addDestination(destination)
}
}
}
}

View file

@ -13,6 +13,7 @@ import kotlinx.serialization.Serializable
@Serializable sealed interface SettingsScreen {
@Serializable data object Options : SettingsScreen
@Serializable data object Content : SettingsScreen
@Serializable data object Destination : SettingsScreen
}
@Composable

View file

@ -12,13 +12,11 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CardElevation
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@ -55,41 +53,42 @@ fun ContentScreen(
val worlds by viewModel.worlds.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.listWorlds()
}
ScreenColumn {
val state = rememberReorderableLazyListState(onMove = { from, to ->
viewModel.moveWorld(from.index, to.index)
})
if (worlds.size <= 1) { // separator
Text(
text = "No worlds available!"
)
} else {
val state = rememberReorderableLazyListState(onMove = { from, to ->
viewModel.moveWorld(from.index, to.index)
})
LazyColumn(
modifier = Modifier
.reorderable(state)
.detectReorderAfterLongPress(state),
state = state.listState,
horizontalAlignment = Alignment.CenterHorizontally
) {
items(
items = worlds,
key = { it.id }
) { world ->
ReorderableItem(state, key = world.id) { isDragging ->
if (!world.id.isEmpty()) {
WorldInfoCard(
bitmap = world.icon,
id = world.id,
displayName = world.displayName,
lastPlayed = world.lastPlayed,
elevation = if (isDragging) CardDefaults.elevatedCardElevation() else CardDefaults.cardElevation()
)
} else {
Text(
text = "↑ Worlds above this line will be backed up ↑",
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
textAlign = TextAlign.Center
)
LazyColumn(
modifier = Modifier
.reorderable(state)
.detectReorderAfterLongPress(state),
state = state.listState,
horizontalAlignment = Alignment.CenterHorizontally
) {
items(
items = worlds,
key = { it.id }
) { world ->
ReorderableItem(state, key = world.id) { isDragging ->
if (!world.id.isEmpty()) {
WorldInfoCard(
bitmap = world.icon,
id = world.id,
displayName = world.displayName,
lastPlayed = world.lastPlayed
)
} else {
Text(
text = "↑ Worlds above this line will be backed up ↑",
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
textAlign = TextAlign.Center
)
}
}
}
}
@ -118,10 +117,7 @@ fun WorldInfoCard(
iconSize: Dp = 64.dp, // Control icon size here
id: String,
displayName: String,
lastPlayed: ZonedDateTime,
elevation: CardElevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
internalPadding: Dp = 16.dp,
spacingBetweenIconAndText: Dp = 16.dp
lastPlayed: ZonedDateTime
) {
// Formatter for the timestamp - remember caches the formatter across recompositions
val formatter = remember {
@ -131,15 +127,14 @@ fun WorldInfoCard(
lastPlayed.format(formatter)
}
Card(
ElevatedCard(
modifier = modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.width(300.dp),
elevation = elevation
.width(300.dp)
) {
Row(
modifier = Modifier
.padding(internalPadding)
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
@ -163,20 +158,17 @@ fun WorldInfoCard(
contentScale = ContentScale.Crop // Crop is usually best for fixed aspect ratio
)
Spacer(modifier = Modifier.width(spacingBetweenIconAndText))
Spacer(modifier = Modifier.width(16.dp))
// --- Text Column ---
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = displayName,
style = MaterialTheme.typography.titleLarge
)
Spacer(
modifier = Modifier.width(5.dp)
)
Text(
text = "Last played $formattedTimestamp", // Use formatted timestamp
style = MaterialTheme.typography.bodySmall,

View file

@ -10,13 +10,11 @@ import eu.m724.pojavbackup.core.World
import eu.m724.pojavbackup.core.WorldRepository
import eu.m724.pojavbackup.core.datastore.SettingsRepository
import eu.m724.pojavbackup.proto.WorldOrder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Instant
import java.time.ZoneOffset
import javax.inject.Inject
@ -30,32 +28,16 @@ class ContentScreenViewModel @Inject constructor(
private val _worlds = MutableStateFlow<List<World>>(emptyList())
val worlds: StateFlow<List<World>> = _worlds.asStateFlow()
fun listWorlds() {
init {
viewModelScope.launch {
withContext(Dispatchers.IO) {
// TODO load order
val worldOrder = settingsRepository.getSettings().worldOrder
val worlds = worldOrder.worldIdsList.map {
settingsRepository.getSettingsFlow().collect { settings ->
val worlds = settings.worldOrder.worldIdsList.map {
// TODO mark deleted worlds better
worldRepository.getWorld(it) ?: World(it, "Deleted world", Instant.EPOCH.atZone(ZoneOffset.UTC), null)
}.toMutableList()
worlds.add(worldOrder.separatorIndex, World.SEPARATOR)
worlds.add(settings.worldOrder.separatorIndex, World.SEPARATOR)
_worlds.update { worlds }
}
}
}
fun listExtras() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
// TODO load order
val worldOrder = settingsRepository.getSettings().worldOrder
val worlds = worldOrder.worldIdsList.map {
// TODO mark deleted worlds better
worldRepository.getWorld(it) ?: World(it, "Deleted world", Instant.EPOCH.atZone(ZoneOffset.UTC), null)
}.toMutableList()
worlds.add(worldOrder.separatorIndex, World.SEPARATOR)
// TODO same for extras
_worlds.update { worlds }
}

View file

@ -0,0 +1,83 @@
package eu.m724.pojavbackup.settings.screen.destination
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import eu.m724.pojavbackup.R
import eu.m724.pojavbackup.settings.screen.ScreenColumn
@Composable
fun DestinationScreen(
navController: NavController,
onAddDestination: () -> Unit
) {
val viewModel: DestinationScreenViewModel = hiltViewModel()
val destinations by viewModel.destinations.collectAsStateWithLifecycle()
ScreenColumn {
Column(
modifier = Modifier.fillMaxWidth().fillMaxHeight(0.5f), // TODO make room for more destinations
horizontalAlignment = Alignment.CenterHorizontally
) {
if (destinations.isEmpty()) {
Text("There are no destinations.")
} else {
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally
) {
items(
items = destinations
) { destination ->
Card { // this is TODO
Text(destination.label)
Text(destination.uri)
}
}
}
}
}
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(
onClick = onAddDestination
) {
Icon(
painter = painterResource(R.drawable.baseline_add_24),
contentDescription = null
)
Spacer(
modifier = Modifier.width(10.dp)
)
Text(
text = "Add destination"
)
}
}
}
}

View file

@ -0,0 +1,34 @@
package eu.m724.pojavbackup.settings.screen.destination
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.m724.pojavbackup.core.datastore.SettingsRepository
import eu.m724.pojavbackup.proto.BackupDestination
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class DestinationScreenViewModel @Inject constructor(
@ApplicationContext private val appContext: Context,
private val settingsRepository: SettingsRepository
) : ViewModel() {
private val _destinations = MutableStateFlow<List<BackupDestination>>(emptyList())
val destinations: StateFlow<List<BackupDestination>> = _destinations.asStateFlow()
init {
viewModelScope.launch {
settingsRepository.getSettingsFlow().collect { settings ->
_destinations.update {
settings.destinationsList
}
}
}
}
}

View file

@ -8,7 +8,14 @@ message WorldOrder {
int32 separatorIndex = 2;
}
message BackupDestination {
string label = 1;
string uri = 2; // TODO
}
message Settings {
WorldOrder worldOrder = 1;
repeated string extraPaths = 2;
repeated BackupDestination destinations = 3;
}

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96z"/>
</vector>