Add simple settings
This commit is contained in:
parent
e90d0f21d1
commit
068d672eee
34 changed files with 994 additions and 568 deletions
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="2.0.21" />
|
||||
<option name="version" value="2.1.20" />
|
||||
</component>
|
||||
</project>
|
1
.idea/misc.xml
generated
1
.idea/misc.xml
generated
|
@ -1,4 +1,3 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
|
|
|
@ -3,6 +3,9 @@ plugins {
|
|||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.protobuf)
|
||||
}
|
||||
|
||||
android {
|
||||
|
@ -41,7 +44,6 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
|
@ -56,6 +58,10 @@ dependencies {
|
|||
implementation(libs.knbt)
|
||||
implementation(libs.reorderable)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.hilt.android)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
implementation(libs.androidx.datastore)
|
||||
implementation(libs.protobuf.javalite)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
@ -63,4 +69,20 @@ dependencies {
|
|||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
ksp(libs.hilt.compiler)
|
||||
}
|
||||
|
||||
protobuf {
|
||||
protoc {
|
||||
artifact = "com.google.protobuf:protoc:4.30.2"
|
||||
}
|
||||
generateProtoTasks {
|
||||
all().forEach { task ->
|
||||
task.builtins {
|
||||
create("java") {
|
||||
option("lite")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -8,6 +8,7 @@
|
|||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".PojavBackupApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
|
@ -16,17 +17,21 @@
|
|||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.PojavBackup"
|
||||
tools:targetApi="31">
|
||||
android:theme="@style/Theme.PojavBackup">
|
||||
<activity
|
||||
android:name=".home.HomeActivity"
|
||||
android:name=".settings.SettingsActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/title_activity_home"
|
||||
android:label="@string/title_activity_settings"
|
||||
android:theme="@style/Theme.PojavBackup" />
|
||||
<activity
|
||||
android:name=".setup.MainActivity"
|
||||
android:name=".setup.SetupActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/title_activity_setup"
|
||||
android:theme="@style/Theme.PojavBackup" />
|
||||
<activity
|
||||
android:name=".home.HomeActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/title_activity_main"
|
||||
android:label="@string/title_activity_home"
|
||||
android:theme="@style/Theme.PojavBackup">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
@ -34,17 +39,6 @@
|
|||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name=".MyCloudProvider"
|
||||
android:authorities="eu.m724.pojavbackup"
|
||||
android:exported="true"
|
||||
android:grantUriPermissions="true"
|
||||
android:permission="android.permission.MANAGE_DOCUMENTS">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||
</intent-filter>
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
|
@ -1,138 +0,0 @@
|
|||
package eu.m724.pojavbackup
|
||||
|
||||
import android.R
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.os.CancellationSignal
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.DocumentsContract.Root
|
||||
import android.provider.DocumentsProvider
|
||||
import android.util.Log
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
|
||||
class MyCloudProvider : DocumentsProvider() {
|
||||
val TAG: String = "MinimalDocsProvider"
|
||||
|
||||
// Define the authority (must match AndroidManifest.xml)
|
||||
// Use your app's package name + ".documents" or similar unique identifier
|
||||
val AUTHORITY: String = "com.example.myapp.minimalprovider"
|
||||
|
||||
// Define a unique ID for our single root
|
||||
val ROOT_ID: String = "minimal_root"
|
||||
|
||||
// Define the document ID for the root directory itself
|
||||
val ROOT_DOC_ID: String = "minimal_root_directory"
|
||||
|
||||
private val DEFAULT_ROOT_PROJECTION: Array<String> = arrayOf(
|
||||
DocumentsContract.Root.COLUMN_ROOT_ID,
|
||||
DocumentsContract.Root.COLUMN_MIME_TYPES,
|
||||
DocumentsContract.Root.COLUMN_FLAGS,
|
||||
DocumentsContract.Root.COLUMN_ICON,
|
||||
DocumentsContract.Root.COLUMN_TITLE,
|
||||
DocumentsContract.Root.COLUMN_SUMMARY,
|
||||
DocumentsContract.Root.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Root.COLUMN_AVAILABLE_BYTES
|
||||
)
|
||||
private val DEFAULT_DOCUMENT_PROJECTION: Array<String> = arrayOf(
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_MIME_TYPE,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
|
||||
DocumentsContract.Document.COLUMN_FLAGS,
|
||||
DocumentsContract.Document.COLUMN_SIZE
|
||||
)
|
||||
|
||||
override fun onCreate(): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryRoots(projection: Array<out String>?): Cursor {
|
||||
Log.d(TAG, "queryRoots called")
|
||||
|
||||
// Use the default projection if none is provided
|
||||
val resolvedProjection: Array<out String> = projection ?: DEFAULT_ROOT_PROJECTION
|
||||
|
||||
|
||||
// Create a MatrixCursor to hold the root information
|
||||
val cursor = MatrixCursor(resolvedProjection)
|
||||
|
||||
|
||||
// Add a single row for our minimal root
|
||||
val row = cursor.newRow()
|
||||
row.add(Root.COLUMN_ROOT_ID, ROOT_ID)
|
||||
row.add(Root.COLUMN_TITLE, "My Minimal Provider") // The name shown in the file manager
|
||||
row.add(Root.COLUMN_DOCUMENT_ID, ROOT_DOC_ID) // ID of the root directory document
|
||||
|
||||
|
||||
// --- IMPORTANT: Add an icon! ---
|
||||
// Replace R.mipmap.ic_launcher with your actual app icon or a dedicated icon
|
||||
// If you don't have one, the entry might not show up correctly.
|
||||
row.add(Root.COLUMN_ICON, R.mipmap.sym_def_app_icon)
|
||||
|
||||
|
||||
// --- Set Flags ---
|
||||
// FLAG_SUPPORTS_CREATE: If you plan to allow creating files/folders later
|
||||
// FLAG_LOCAL_ONLY: If the data is only on the device
|
||||
// FLAG_SUPPORTS_SEARCH: If you plan to implement search
|
||||
// For minimal display, LOCAL_ONLY is often sufficient.
|
||||
row.add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY /* | Root.FLAG_SUPPORTS_CREATE */)
|
||||
|
||||
|
||||
// You could add other columns here if they are in the projection
|
||||
// row.add(Root.COLUMN_SUMMARY, "A basic example provider");
|
||||
// row.add(Root.COLUMN_AVAILABLE_BYTES, null); // Or calculate if known
|
||||
return cursor
|
||||
}
|
||||
|
||||
override fun queryDocument(documentId: String?, projection: Array<out String>?): Cursor {
|
||||
println("queryDocument called for ID: $documentId")
|
||||
|
||||
throw FileNotFoundException("Document not found: $documentId")
|
||||
}
|
||||
|
||||
override fun queryChildDocuments(
|
||||
parentDocumentId: String?,
|
||||
projection: Array<out String>?,
|
||||
sortOrder: String?
|
||||
): Cursor {
|
||||
println("queryChildDocuments called for parent ID: $parentDocumentId")
|
||||
|
||||
val resolvedProjection: Array<out String> = projection
|
||||
?: DEFAULT_DOCUMENT_PROJECTION
|
||||
val cursor = MatrixCursor(resolvedProjection)
|
||||
|
||||
|
||||
// Check if the request is for the children of our root directory
|
||||
if (ROOT_DOC_ID.equals(parentDocumentId)) {
|
||||
// --- THIS IS WHERE YOU WOULD ADD ACTUAL FILES/FOLDERS ---
|
||||
// For this minimal example, we return an empty cursor, meaning the root directory is empty.
|
||||
|
||||
// Example of adding a dummy file (if you wanted to test further):
|
||||
/*
|
||||
MatrixCursor.RowBuilder row = cursor.newRow();
|
||||
row.add(Document.COLUMN_DOCUMENT_ID, "dummy_file_1");
|
||||
row.add(Document.COLUMN_DISPLAY_NAME, "hello.txt");
|
||||
row.add(Document.COLUMN_MIME_TYPE, "text/plain");
|
||||
row.add(Document.COLUMN_FLAGS, Document.FLAG_SUPPORTS_WRITE | Document.FLAG_SUPPORTS_DELETE);
|
||||
row.add(Document.COLUMN_SIZE, 1234);
|
||||
row.add(Document.COLUMN_LAST_MODIFIED, System.currentTimeMillis());
|
||||
*/
|
||||
} else {
|
||||
// We only know the root, so any other parent ID is invalid
|
||||
println("queryChildDocuments requested for unknown parent ID: $parentDocumentId")
|
||||
throw FileNotFoundException("Parent document not found: $parentDocumentId")
|
||||
}
|
||||
|
||||
return cursor
|
||||
}
|
||||
|
||||
override fun openDocument(
|
||||
documentId: String?,
|
||||
mode: String?,
|
||||
signal: CancellationSignal?
|
||||
): ParcelFileDescriptor {
|
||||
throw FileNotFoundException("Cannot open document in minimal provider: $documentId");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package eu.m724.pojavbackup
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class PojavBackupApplication : Application() {
|
||||
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
package eu.m724.pojavbackup.core
|
||||
|
||||
import android.content.ContentResolver
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
|
||||
class DataLoader(
|
||||
private val contentResolver: ContentResolver,
|
||||
private val dataDirectory: DocumentFile
|
||||
) {
|
||||
|
||||
fun listWorlds(): List<World> {
|
||||
return WorldDetector(contentResolver, getSavesDirectory())
|
||||
.listWorlds()
|
||||
}
|
||||
|
||||
fun listWorlds(consumer: (World) -> Unit) {
|
||||
WorldDetector(contentResolver, getSavesDirectory())
|
||||
.listWorlds(consumer)
|
||||
}
|
||||
|
||||
fun getWorld(id: String): World? {
|
||||
return WorldDetector(contentResolver, getSavesDirectory())
|
||||
.getWorld(id)
|
||||
}
|
||||
|
||||
fun getSavesDirectory(): DocumentFile {
|
||||
return dataDirectory.findFile(".minecraft")!!.findFile("saves")!!
|
||||
}
|
||||
}
|
|
@ -4,10 +4,11 @@ import android.content.ContentResolver
|
|||
import android.graphics.BitmapFactory
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import net.benwoodworth.knbt.Nbt
|
||||
import net.benwoodworth.knbt.NbtCompound
|
||||
import net.benwoodworth.knbt.NbtCompression
|
||||
import net.benwoodworth.knbt.NbtTag
|
||||
import net.benwoodworth.knbt.NbtVariant
|
||||
import net.benwoodworth.knbt.decodeFromStream
|
||||
import net.benwoodworth.knbt.nbtCompound
|
||||
|
@ -27,19 +28,14 @@ class WorldDetector(
|
|||
compression = NbtCompression.Gzip
|
||||
}
|
||||
|
||||
// TODO not at once
|
||||
/**
|
||||
* List worlds in the savesDirectory
|
||||
*
|
||||
* @return The worlds
|
||||
*/
|
||||
fun listWorlds(): List<World> {
|
||||
return savesDirectory.listFiles().mapNotNull {
|
||||
try {
|
||||
getWorldFromDirectory(it)
|
||||
} catch (e: InvalidWorldException) {
|
||||
Log.i(TAG, "${it.name} is invalid: ${e.message}")
|
||||
null
|
||||
fun listWorlds(): Flow<World> {
|
||||
return flow {
|
||||
savesDirectory.listFiles().mapNotNull {
|
||||
try {
|
||||
emit(getWorldFromDirectory(it))
|
||||
} catch (e: InvalidWorldException) {
|
||||
Log.i(TAG, "${it.name} is invalid: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
package eu.m724.pojavbackup.core
|
||||
|
||||
import android.content.Context
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class WorldRepository @Inject constructor(
|
||||
@ApplicationContext private val appContext: Context
|
||||
) {
|
||||
private lateinit var savesDirectory: DocumentFile
|
||||
private lateinit var worldDetector: WorldDetector
|
||||
|
||||
private var worldsCache: List<World>? = null
|
||||
private val cacheMutex = Mutex() // To ensure thread-safe access to cache
|
||||
|
||||
fun setSavesDirectory(documentFile: DocumentFile) {
|
||||
this.savesDirectory = documentFile
|
||||
this.worldDetector = WorldDetector(appContext.contentResolver, savesDirectory)
|
||||
}
|
||||
|
||||
suspend fun listWorlds(): List<World> {
|
||||
cacheMutex.withLock {
|
||||
if (worldsCache != null) {
|
||||
return worldsCache!! // Return copy or immutable list if needed
|
||||
}
|
||||
}
|
||||
|
||||
// If cache is empty, fetch data on IO dispatcher
|
||||
val freshData = withContext(Dispatchers.IO) {
|
||||
worldDetector.listWorlds().toList() // TODO
|
||||
}
|
||||
|
||||
// Store in cache (thread-safe)
|
||||
cacheMutex.withLock {
|
||||
worldsCache = freshData
|
||||
}
|
||||
|
||||
return freshData
|
||||
}
|
||||
|
||||
suspend fun getWorld(id: String): World? {
|
||||
return listWorlds().firstOrNull { it.id == id }
|
||||
}
|
||||
|
||||
suspend fun clearCache() {
|
||||
cacheMutex.withLock {
|
||||
worldsCache = null
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
package eu.m724.pojavbackup.core.datastore
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.dataStoreFile
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import eu.m724.pojavbackup.proto.Settings
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val USER_PREFERENCES_FILE_NAME = "user_prefs.pb"
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class) // Provides dependencies for the entire app
|
||||
object DataStoreModule {
|
||||
@Provides
|
||||
@Singleton // Ensure only one instance is created
|
||||
fun provideUserPreferencesDataStore(
|
||||
@ApplicationContext applicationContext: Context,
|
||||
// Optional: You can inject CoroutineScope/Dispatcher if you want to customize it
|
||||
// userPreferencesSerializer: UserPreferencesSerializer // Inject serializer if it's a class with dependencies
|
||||
): DataStore<Settings> {
|
||||
return DataStoreFactory.create(
|
||||
serializer = SettingsSerializer, // Use your serializer instance/object
|
||||
produceFile = { applicationContext.dataStoreFile(USER_PREFERENCES_FILE_NAME) },
|
||||
// Optional: Add corruption handler
|
||||
// corruptionHandler = ReplaceFileCorruptionHandler { ex -> /* Handle corruption */ UserPreferences.getDefaultInstance() },
|
||||
// Optional: Specify a custom scope, though the default (SupervisorJob + Dispatchers.IO) is usually fine
|
||||
// scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
)
|
||||
}
|
||||
|
||||
// If your Serializer was a class and had dependencies, you'd provide it too:
|
||||
// @Provides
|
||||
// fun provideUserPreferencesSerializer(): UserPreferencesSerializer {
|
||||
// return UserPreferencesSerializer(/* dependencies */)
|
||||
// }
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
package eu.m724.pojavbackup.core.datastore
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import eu.m724.pojavbackup.proto.Settings
|
||||
import eu.m724.pojavbackup.proto.WorldOrder
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SettingsRepository @Inject constructor(
|
||||
private val dataStore: DataStore<Settings>
|
||||
) {
|
||||
fun getSettingsFlow(): Flow<Settings> {
|
||||
return dataStore.data
|
||||
}
|
||||
|
||||
suspend fun getSettings(): Settings {
|
||||
return dataStore.data.first()
|
||||
}
|
||||
|
||||
suspend fun updateWorldOrder(worldOrder: WorldOrder) {
|
||||
dataStore.updateData {
|
||||
it.toBuilder()
|
||||
.clearWorldOrder()
|
||||
.setWorldOrder(worldOrder)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package eu.m724.pojavbackup.core.datastore
|
||||
|
||||
import androidx.datastore.core.CorruptionException
|
||||
import androidx.datastore.core.Serializer
|
||||
import com.google.protobuf.InvalidProtocolBufferException
|
||||
import eu.m724.pojavbackup.proto.Settings
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
object SettingsSerializer : Serializer<Settings> {
|
||||
override val defaultValue: Settings = Settings.getDefaultInstance()
|
||||
|
||||
override suspend fun readFrom(input: InputStream): Settings {
|
||||
try {
|
||||
return Settings.parseFrom(input)
|
||||
} catch (exception: InvalidProtocolBufferException) {
|
||||
throw CorruptionException("Cannot read proto.", exception)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun writeTo(t: Settings, output: OutputStream) {
|
||||
t.writeTo(output)
|
||||
}
|
||||
}
|
|
@ -1,21 +1,24 @@
|
|||
package eu.m724.pojavbackup.home
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
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.ColumnScope
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
|
@ -26,76 +29,126 @@ 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
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.m724.pojavbackup.R
|
||||
import eu.m724.pojavbackup.home.screen.Screen
|
||||
import eu.m724.pojavbackup.home.screen.HomeScreen
|
||||
import eu.m724.pojavbackup.home.screen.dashboard.DashboardScreen
|
||||
import eu.m724.pojavbackup.home.screen.history.HistoryScreen
|
||||
import eu.m724.pojavbackup.settings.SettingsActivity
|
||||
import eu.m724.pojavbackup.setup.SetupActivity
|
||||
import eu.m724.pojavbackup.ui.theme.PojavBackupTheme
|
||||
|
||||
@AndroidEntryPoint
|
||||
class HomeActivity : ComponentActivity() {
|
||||
private val viewModel: HomeViewModel by viewModels()
|
||||
|
||||
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))
|
||||
}
|
||||
)
|
||||
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
val navController = rememberNavController()
|
||||
|
||||
PojavBackupTheme {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
ScreenNavigationBarItem(
|
||||
navController = navController,
|
||||
label = "Dashboard",
|
||||
route = Screen.Dashboard,
|
||||
iconResourceId = R.drawable.baseline_home_24
|
||||
)
|
||||
ScreenNavigationBarItem(
|
||||
navController = navController,
|
||||
label = "History",
|
||||
route = Screen.History,
|
||||
iconResourceId = R.drawable.baseline_history_24
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Dashboard,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
enterTransition = {
|
||||
fadeIn() + slideInHorizontally(initialOffsetX = { it / 10 })
|
||||
},
|
||||
exitTransition = {
|
||||
fadeOut() + slideOutHorizontally(targetOffsetX = { -it / 10 })
|
||||
}
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
if (uiState.loading) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
composable<Screen.Dashboard> {
|
||||
DashboardScreen(navController)
|
||||
}
|
||||
composable<Screen.History> {
|
||||
HistoryScreen()
|
||||
}
|
||||
// Add more destinations similarly.
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
HomeScaffold(
|
||||
onSettingsOpen = { onSettingsOpen(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSettingsOpen(page: String) {
|
||||
startActivity(Intent(applicationContext, SettingsActivity::class.java).putExtra("settingsPage", page))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeScaffold(
|
||||
onSettingsOpen: (String) -> Unit
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
ScreenNavigationBarItem(
|
||||
navController = navController,
|
||||
label = "Dashboard",
|
||||
route = HomeScreen.Dashboard,
|
||||
iconResourceId = R.drawable.baseline_home_24
|
||||
)
|
||||
ScreenNavigationBarItem(
|
||||
navController = navController,
|
||||
label = "History",
|
||||
route = HomeScreen.History,
|
||||
iconResourceId = R.drawable.baseline_history_24
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = HomeScreen.Dashboard,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
enterTransition = {
|
||||
fadeIn() + slideInHorizontally(initialOffsetX = { it / 10 })
|
||||
},
|
||||
exitTransition = {
|
||||
fadeOut() + slideOutHorizontally(targetOffsetX = { -it / 10 })
|
||||
}
|
||||
) {
|
||||
composable<HomeScreen.Dashboard> {
|
||||
DashboardScreen(
|
||||
navController = navController,
|
||||
onWorldsIncludedClick = {
|
||||
onSettingsOpen("content")
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<HomeScreen.History> {
|
||||
HistoryScreen()
|
||||
}
|
||||
// Add more destinations similarly.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RowScope.ScreenNavigationBarItem(
|
||||
navController: NavController,
|
||||
label: String,
|
||||
route: Screen,
|
||||
route: HomeScreen,
|
||||
iconResourceId: Int
|
||||
) {
|
||||
val selected = isSelected(navController, route)
|
||||
|
@ -122,7 +175,7 @@ fun RowScope.ScreenNavigationBarItem(
|
|||
@Composable
|
||||
fun isSelected(
|
||||
navController: NavController,
|
||||
route: Screen
|
||||
route: HomeScreen
|
||||
): Boolean {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
package eu.m724.pojavbackup.home
|
||||
|
||||
data class HomeUiState(
|
||||
val loading: Boolean = true
|
||||
)
|
|
@ -1,4 +1,87 @@
|
|||
package eu.m724.pojavbackup.home
|
||||
|
||||
class HomeViewModel {
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
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.WorldRepository
|
||||
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 javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class HomeViewModel @Inject constructor(
|
||||
@ApplicationContext private val appContext: Context,
|
||||
private val worldRepository: WorldRepository
|
||||
) : ViewModel() {
|
||||
private val TAG = javaClass.name
|
||||
|
||||
private val _uiState = MutableStateFlow(HomeUiState())
|
||||
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun load(
|
||||
onSetupNeeded: () -> Unit
|
||||
) {
|
||||
// TODO this should be dynamic (selected) or a list (debug/normal version)
|
||||
val uri = "content://net.kdt.pojavlaunch.scoped.gamefolder.debug/tree/%2Fstorage%2Femulated%2F0%2FAndroid%2Fdata%2Fnet.kdt.pojavlaunch.debug%2Ffiles".toUri()
|
||||
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val hasPermission = checkForStoragePermission(uri)
|
||||
|
||||
if (hasPermission) {
|
||||
// some paths were checked earlier
|
||||
val documentFile = DocumentFile.fromTreeUri(appContext, uri)!!
|
||||
.findFile(".minecraft")!!
|
||||
.findFile("saves")
|
||||
|
||||
if (documentFile != null) {
|
||||
worldRepository.setSavesDirectory(documentFile)
|
||||
worldRepository.listWorlds()
|
||||
} else {
|
||||
// TODO handle if "saves" doesn't exist
|
||||
}
|
||||
|
||||
_uiState.update { it.copy(loading = false) }
|
||||
} else {
|
||||
// TODO there could be that only one or two permissions are missing
|
||||
onSetupNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkForStoragePermission(uri: Uri): Boolean {
|
||||
Log.i(TAG, "Checking for storage permission...")
|
||||
|
||||
// TODO Is this the right way? This isn't in https://developer.android.com/training/data-storage/shared/documents-files
|
||||
val directory = DocumentFile.fromTreeUri(appContext, uri)
|
||||
|
||||
if (directory == null || !directory.isDirectory) {
|
||||
Log.i(TAG, "No permission or not a directory")
|
||||
return false
|
||||
}
|
||||
|
||||
val dotMinecraftExists = directory.findFile(".minecraft") != null
|
||||
val controlMapExists = directory.findFile("controlmap") != null
|
||||
|
||||
if (!dotMinecraftExists || !controlMapExists) {
|
||||
Log.i(TAG, "Selected directory is missing .minecraft or controlmap")
|
||||
return false
|
||||
}
|
||||
|
||||
Log.i(TAG, "Yes we have permission")
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
package eu.m724.pojavbackup.home.screen
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
|
@ -11,9 +10,9 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable sealed interface Screen {
|
||||
@Serializable data object Dashboard : Screen
|
||||
@Serializable data object History : Screen
|
||||
@Serializable sealed interface HomeScreen {
|
||||
@Serializable data object Dashboard : HomeScreen
|
||||
@Serializable data object History : HomeScreen
|
||||
}
|
||||
|
||||
@Composable
|
|
@ -9,42 +9,43 @@ import androidx.compose.foundation.layout.Spacer
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import eu.m724.pojavbackup.R
|
||||
import eu.m724.pojavbackup.home.screen.Screen
|
||||
import eu.m724.pojavbackup.home.screen.HomeScreen
|
||||
import eu.m724.pojavbackup.home.screen.ScreenColumn
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun DashboardScreen(
|
||||
navController: NavController
|
||||
navController: NavController,
|
||||
onWorldsIncludedClick: () -> Unit,
|
||||
) {
|
||||
val viewModel: DashboardScreenViewModel = hiltViewModel()
|
||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||
|
||||
ScreenColumn {
|
||||
FlowRow(
|
||||
maxItemsInEachRow = 3
|
||||
) {
|
||||
DashboardCard(
|
||||
title = "Worlds included",
|
||||
value = "1",
|
||||
value = settings.worldOrder.separatorIndex,
|
||||
iconResourceId = R.drawable.baseline_mosque_24,
|
||||
onClick = {
|
||||
|
||||
}
|
||||
onClick = onWorldsIncludedClick
|
||||
)
|
||||
|
||||
DashboardCard(
|
||||
|
@ -58,7 +59,7 @@ fun DashboardScreen(
|
|||
value = "1d ago",
|
||||
iconResourceId = R.drawable.baseline_access_time_filled_24,
|
||||
onClick = {
|
||||
navController.navigate(Screen.History)
|
||||
navController.navigate(HomeScreen.History)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
package eu.m724.pojavbackup.home.screen.dashboard
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.m724.pojavbackup.core.datastore.SettingsRepository
|
||||
import eu.m724.pojavbackup.proto.Settings
|
||||
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 DashboardScreenViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository
|
||||
) : ViewModel() {
|
||||
private val _settings = MutableStateFlow<Settings>(Settings.getDefaultInstance())
|
||||
val settings: StateFlow<Settings> = _settings.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.getSettingsFlow().collect { newSettings ->
|
||||
_settings.update { newSettings }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,153 @@
|
|||
package eu.m724.pojavbackup.settings
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
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.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
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.options.OptionsScreen
|
||||
import eu.m724.pojavbackup.ui.theme.PojavBackupTheme
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SettingsActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val startPage = when (intent.getStringExtra("settingsPage")) {
|
||||
"options" -> SettingsScreen.Options
|
||||
"content" -> SettingsScreen.Content
|
||||
else -> SettingsScreen.Options
|
||||
}
|
||||
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
val navController = rememberNavController()
|
||||
|
||||
PojavBackupTheme {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
ScreenNavigationBarItem(
|
||||
navController = navController,
|
||||
label = "Options",
|
||||
route = SettingsScreen.Options,
|
||||
iconResourceId = R.drawable.baseline_settings_24
|
||||
)
|
||||
ScreenNavigationBarItem(
|
||||
navController = navController,
|
||||
label = "Content",
|
||||
route = SettingsScreen.Content,
|
||||
iconResourceId = R.drawable.baseline_folder_copy_24
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startPage,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
enterTransition = {
|
||||
fadeIn() + slideInHorizontally(initialOffsetX = { it / 10 })
|
||||
},
|
||||
exitTransition = {
|
||||
fadeOut() + slideOutHorizontally(targetOffsetX = { -it / 10 })
|
||||
}
|
||||
) {
|
||||
composable<SettingsScreen.Options> {
|
||||
OptionsScreen(navController)
|
||||
}
|
||||
composable<SettingsScreen.Content> {
|
||||
ContentScreen(navController)
|
||||
}
|
||||
// Add more destinations similarly.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO those functions are reused in Home
|
||||
@Composable
|
||||
fun RowScope.ScreenNavigationBarItem(
|
||||
navController: NavController,
|
||||
label: String,
|
||||
route: SettingsScreen,
|
||||
iconResourceId: Int
|
||||
) {
|
||||
val selected = isSelected(navController, route)
|
||||
|
||||
NavigationBarItem(
|
||||
selected = selected,
|
||||
onClick = {
|
||||
if (!selected) {
|
||||
navController.navigate(route)
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(iconResourceId),
|
||||
contentDescription = label
|
||||
)
|
||||
},
|
||||
label = {
|
||||
Text(label)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun isSelected(
|
||||
navController: NavController,
|
||||
route: SettingsScreen
|
||||
): Boolean {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
||||
// Check if the current destination's hierarchy contains a destination
|
||||
// whose route definition matches the target 'route' object.
|
||||
|
||||
// For @Serializable object routes, Navigation Compose typically uses a stable
|
||||
// route string derived from the object's definition. We compare against that.
|
||||
return currentDestination?.hierarchy?.any { destination ->
|
||||
// Check if the destination in the hierarchy corresponds to the
|
||||
// target 'route' object passed into this function.
|
||||
// For @Serializable objects/classes used as routes, comparing
|
||||
// the destination's 'route' property is the standard way.
|
||||
destination.route == navController.graph.findNode(route)?.route
|
||||
// Explanation:
|
||||
// 1. `navController.graph.findNode(route)`: Finds the NavDestination node
|
||||
// within the navigation graph that corresponds to your @Serializable 'route' object.
|
||||
// (Requires NavController knows about this route, typically added via `composable(typeMap = ...)`)
|
||||
// 2. `?.route`: Gets the unique route string pattern associated with that node.
|
||||
// 3. `destination.route`: Gets the route string pattern of the current destination being checked in the hierarchy.
|
||||
// 4. `==`: Compares if they are the same route.
|
||||
|
||||
} == true // If currentDestination is null, it's not selected, return false.
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package eu.m724.pojavbackup.settings.screen
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable sealed interface SettingsScreen {
|
||||
@Serializable data object Options : SettingsScreen
|
||||
@Serializable data object Content : SettingsScreen
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScreenColumn(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (ColumnScope.() -> Unit)
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().padding(top = 50.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
content = content
|
||||
)
|
||||
}
|
|
@ -0,0 +1,203 @@
|
|||
package eu.m724.pojavbackup.settings.screen.content
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
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.padding
|
||||
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.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
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
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.home.screen.ScreenColumn
|
||||
import org.burnoutcrew.reorderable.ReorderableItem
|
||||
import org.burnoutcrew.reorderable.detectReorderAfterLongPress
|
||||
import org.burnoutcrew.reorderable.rememberReorderableLazyListState
|
||||
import org.burnoutcrew.reorderable.reorderable
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
|
||||
@Composable
|
||||
fun ContentScreen(
|
||||
navController: NavController,
|
||||
) {
|
||||
val viewModel: ContentScreenViewModel = hiltViewModel()
|
||||
|
||||
val worlds by viewModel.worlds.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.listWorlds()
|
||||
}
|
||||
|
||||
ScreenColumn {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Composable Card that displays a square icon (Bitmap or default drawable) on the left
|
||||
* and text information (ID, Display Name, formatted Timestamp) on the right.
|
||||
*
|
||||
* @param modifier Optional Modifier for the Card.
|
||||
* @param bitmap The Bitmap for the icon to display. If null, uses a pack.png-like default icon.
|
||||
* @param iconSize The size for the square icon (width and height).
|
||||
* @param id The ID text to display.
|
||||
* @param displayName The display name text.
|
||||
* @param lastPlayed The ZonedDateTime timestamp to display, formatted by locale.
|
||||
* @param elevation The elevation of the card.
|
||||
* @param internalPadding Padding inside the card, around the content.
|
||||
* @param spacingBetweenIconAndText Space between the icon and the text column.
|
||||
*/
|
||||
@Composable
|
||||
fun WorldInfoCard(
|
||||
modifier: Modifier = Modifier,
|
||||
bitmap: Bitmap?,
|
||||
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
|
||||
) {
|
||||
// Formatter for the timestamp - remember caches the formatter across recompositions
|
||||
val formatter = remember {
|
||||
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT) // Adjust FormatStyle as needed (SHORT, MEDIUM, LONG, FULL)
|
||||
}
|
||||
val formattedTimestamp = remember(lastPlayed, formatter) { // Only reformat when timestamp or formatter changes
|
||||
lastPlayed.format(formatter)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.width(300.dp),
|
||||
elevation = elevation
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(internalPadding)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// --- Icon ---
|
||||
// Determine the correct painter based on whether bitmap is null
|
||||
val painter: Painter = if (bitmap != null) {
|
||||
// Remember the BitmapPainter based on the bitmap input
|
||||
remember(bitmap) { BitmapPainter(bitmap.asImageBitmap()) }
|
||||
} else {
|
||||
// Use painterResource for the default drawable
|
||||
painterResource(id = R.drawable.default_world_icon)
|
||||
}
|
||||
|
||||
Image(
|
||||
painter = painter, // Use the determined painter
|
||||
contentDescription = "world icon", // Hardcoded content description
|
||||
modifier = Modifier
|
||||
.size(iconSize)
|
||||
.align(Alignment.CenterVertically)
|
||||
.clip(CardDefaults.shape), // TODO match corner radius
|
||||
contentScale = ContentScale.Crop // Crop is usually best for fixed aspect ratio
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(spacingBetweenIconAndText))
|
||||
|
||||
// --- 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,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, name = "Card with Default Icon")
|
||||
@Composable
|
||||
fun WorldInfoCardPreviewDefaultIcon() {
|
||||
MaterialTheme {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
WorldInfoCard(
|
||||
bitmap = null, // Test the default icon case
|
||||
id = "world-001",
|
||||
displayName = "Earth",
|
||||
lastPlayed = ZonedDateTime.now().minusDays(1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
package eu.m724.pojavbackup.settings.screen.content
|
||||
|
||||
import android.content.Context
|
||||
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.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
|
||||
|
||||
@HiltViewModel
|
||||
class ContentScreenViewModel @Inject constructor(
|
||||
@ApplicationContext private val appContext: Context,
|
||||
private val worldRepository: WorldRepository,
|
||||
private val settingsRepository: SettingsRepository
|
||||
) : ViewModel() {
|
||||
private val _worlds = MutableStateFlow<List<World>>(emptyList())
|
||||
val worlds: StateFlow<List<World>> = _worlds.asStateFlow()
|
||||
|
||||
fun listWorlds() {
|
||||
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)
|
||||
|
||||
_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)
|
||||
|
||||
_worlds.update { worlds }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun moveWorld(fromIndex: Int, toIndex: Int) {
|
||||
// Similar to mutableStateOf, create a NEW list
|
||||
val currentList = _worlds.value.toMutableList()
|
||||
// Check bounds for safety
|
||||
if (fromIndex in currentList.indices && toIndex >= 0 && toIndex <= currentList.size) {
|
||||
val item = currentList.removeAt(fromIndex)
|
||||
currentList.add(toIndex, item)
|
||||
_worlds.value = currentList // Assign the new list to the flow
|
||||
|
||||
updateWorldOrder()
|
||||
} else {
|
||||
Log.e("Reorder", "Invalid indices: from $fromIndex, to $toIndex, size ${currentList.size}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateWorldOrder() {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.updateWorldOrder(
|
||||
WorldOrder.newBuilder()
|
||||
.addAllWorldIds(_worlds.value.filter { it != World.SEPARATOR }.map { it.id })
|
||||
.setSeparatorIndex(_worlds.value.indexOf(World.SEPARATOR))
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package eu.m724.pojavbackup.settings.screen.options
|
||||
|
||||
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.navigation.NavController
|
||||
|
||||
@Composable
|
||||
fun OptionsScreen(
|
||||
navController: NavController,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("This is the options screen")
|
||||
Text("It's empty for now")
|
||||
}
|
||||
}
|
|
@ -1,8 +1,6 @@
|
|||
package eu.m724.pojavbackup.setup
|
||||
|
||||
import android.R.attr.end
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
|
@ -10,62 +8,32 @@ import androidx.activity.compose.setContent
|
|||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.Image
|
||||
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.fillMaxSize
|
||||
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.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CardElevation
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import eu.m724.pojavbackup.ui.theme.PojavBackupTheme
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.compose.NavHost
|
||||
import eu.m724.pojavbackup.R
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.m724.pojavbackup.home.HomeActivity
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.burnoutcrew.reorderable.ReorderableItem
|
||||
import org.burnoutcrew.reorderable.detectReorder
|
||||
import org.burnoutcrew.reorderable.detectReorderAfterLongPress
|
||||
import org.burnoutcrew.reorderable.rememberReorderableLazyListState
|
||||
import org.burnoutcrew.reorderable.reorderable
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import kotlin.jvm.javaClass
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
@AndroidEntryPoint
|
||||
class SetupActivity : ComponentActivity() {
|
||||
private val viewModel: SetupViewModel by viewModels()
|
||||
|
||||
private val openDocumentTree = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) {
|
||||
|
@ -126,8 +94,6 @@ fun SetupScreen(
|
|||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
val worldsState by viewModel.worlds.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
|
@ -177,145 +143,5 @@ fun SetupScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO this is for testing world loading only
|
||||
/*val state = rememberReorderableLazyListState(onMove = { from, to ->
|
||||
viewModel.moveWorld(from.index, to.index)
|
||||
})
|
||||
|
||||
LazyColumn(
|
||||
state = state.listState,
|
||||
modifier = Modifier
|
||||
.reorderable(state)
|
||||
.detectReorderAfterLongPress(state)
|
||||
) {
|
||||
items(
|
||||
items = worldsState,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Composable Card that displays a square icon (Bitmap or default drawable) on the left
|
||||
* and text information (ID, Display Name, formatted Timestamp) on the right.
|
||||
*
|
||||
* @param modifier Optional Modifier for the Card.
|
||||
* @param bitmap The Bitmap for the icon to display. If null, uses a pack.png-like default icon.
|
||||
* @param iconSize The size for the square icon (width and height).
|
||||
* @param id The ID text to display.
|
||||
* @param displayName The display name text.
|
||||
* @param lastPlayed The ZonedDateTime timestamp to display, formatted by locale.
|
||||
* @param elevation The elevation of the card.
|
||||
* @param internalPadding Padding inside the card, around the content.
|
||||
* @param spacingBetweenIconAndText Space between the icon and the text column.
|
||||
*/
|
||||
@Composable
|
||||
fun WorldInfoCard(
|
||||
modifier: Modifier = Modifier,
|
||||
bitmap: Bitmap?,
|
||||
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
|
||||
) {
|
||||
// Formatter for the timestamp - remember caches the formatter across recompositions
|
||||
val formatter = remember {
|
||||
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) // Adjust FormatStyle as needed (SHORT, MEDIUM, LONG, FULL)
|
||||
}
|
||||
val formattedTimestamp = remember(lastPlayed, formatter) { // Only reformat when timestamp or formatter changes
|
||||
lastPlayed.format(formatter)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
elevation = elevation
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(internalPadding)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// --- Icon ---
|
||||
// Determine the correct painter based on whether bitmap is null
|
||||
val painter: Painter = if (bitmap != null) {
|
||||
// Remember the BitmapPainter based on the bitmap input
|
||||
remember(bitmap) { BitmapPainter(bitmap.asImageBitmap()) }
|
||||
} else {
|
||||
// Use painterResource for the default drawable
|
||||
painterResource(id = R.drawable.default_world_icon)
|
||||
}
|
||||
|
||||
Image(
|
||||
painter = painter, // Use the determined painter
|
||||
contentDescription = "world icon", // Hardcoded content description
|
||||
modifier = Modifier
|
||||
.size(iconSize)
|
||||
.align(Alignment.CenterVertically)
|
||||
.clip(CardDefaults.shape), // TODO match corner radius
|
||||
contentScale = ContentScale.Crop // Crop is usually best for fixed aspect ratio
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(spacingBetweenIconAndText))
|
||||
|
||||
// --- 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,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, name = "Card with Default Icon")
|
||||
@Composable
|
||||
fun WorldInfoCardPreviewDefaultIcon() {
|
||||
MaterialTheme {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
WorldInfoCard(
|
||||
bitmap = null, // Test the default icon case
|
||||
id = "world-001",
|
||||
displayName = "Earth",
|
||||
lastPlayed = ZonedDateTime.now().minusDays(1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,26 +1,17 @@
|
|||
package eu.m724.pojavbackup.setup
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.PackageManager.NameNotFoundException
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import eu.m724.pojavbackup.core.World
|
||||
import eu.m724.pojavbackup.core.WorldDetector
|
||||
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
|
||||
|
||||
class SetupViewModel : ViewModel() {
|
||||
private val TAG: String = javaClass.name
|
||||
|
@ -33,9 +24,6 @@ class SetupViewModel : ViewModel() {
|
|||
private val _uiState = MutableStateFlow(SetupUiState())
|
||||
val uiState: StateFlow<SetupUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _worlds = MutableStateFlow<List<World>>(emptyList())
|
||||
val worlds: StateFlow<List<World>> = _worlds.asStateFlow()
|
||||
|
||||
// TODO we could make the check call separate and not pass context here
|
||||
fun onOpenDocumentTree(context: Context, uri: Uri?, result: (Boolean) -> Unit) {
|
||||
if (uri != null) {
|
||||
|
@ -79,45 +67,9 @@ class SetupViewModel : ViewModel() {
|
|||
|
||||
Log.i(TAG, "Yes we have permission")
|
||||
|
||||
// TODO remove
|
||||
listWorlds(context, directory)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// TODO World functions certainly don't belong here. This is for testing.
|
||||
private fun listWorlds(context: Context, documentFile: DocumentFile) {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
Log.i(TAG, "listing worlds")
|
||||
|
||||
val worldDetector = WorldDetector(
|
||||
contentResolver = context.contentResolver,
|
||||
savesDirectory = documentFile.findFile(".minecraft")!!.findFile("saves")!!
|
||||
)
|
||||
|
||||
worldDetector.listWorlds { world ->
|
||||
_worlds.update { it + world }
|
||||
}
|
||||
|
||||
_worlds.update { it + World.SEPARATOR }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun moveWorld(fromIndex: Int, toIndex: Int) {
|
||||
// Similar to mutableStateOf, create a NEW list
|
||||
val currentList = _worlds.value.toMutableList()
|
||||
// Check bounds for safety
|
||||
if (fromIndex in currentList.indices && toIndex >= 0 && toIndex <= currentList.size) {
|
||||
val item = currentList.removeAt(fromIndex)
|
||||
currentList.add(toIndex, item)
|
||||
_worlds.value = currentList // Assign the new list to the flow
|
||||
} else {
|
||||
Log.e("Reorder", "Invalid indices: from $fromIndex, to $toIndex, size ${currentList.size}")
|
||||
}
|
||||
}
|
||||
|
||||
fun detectInstalledLauncherPackage(packageManager: PackageManager): List<String> {
|
||||
return POJAV_PACKAGES.filter {
|
||||
try {
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
package eu.m724.pojavbackup.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)
|
|
@ -1,6 +1,5 @@
|
|||
package eu.m724.pojavbackup.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
@ -11,48 +10,23 @@ 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 PojavBackupTheme(
|
||||
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)
|
||||
}
|
||||
val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
val colorScheme = when {
|
||||
dynamicColor && darkTheme -> dynamicDarkColorScheme(LocalContext.current)
|
||||
dynamicColor && !darkTheme -> dynamicLightColorScheme(LocalContext.current)
|
||||
|
||||
darkTheme -> darkColorScheme()
|
||||
else -> lightColorScheme()
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
package eu.m724.pojavbackup.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
|
||||
)
|
||||
*/
|
||||
)
|
14
app/src/main/proto/settings.proto
Normal file
14
app/src/main/proto/settings.proto
Normal file
|
@ -0,0 +1,14 @@
|
|||
syntax = "proto3";
|
||||
|
||||
option java_package = "eu.m724.pojavbackup.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
message WorldOrder {
|
||||
repeated string worldIds = 1;
|
||||
int32 separatorIndex = 2;
|
||||
}
|
||||
|
||||
message Settings {
|
||||
WorldOrder worldOrder = 1;
|
||||
repeated string extraPaths = 2;
|
||||
}
|
7
app/src/main/res/drawable/baseline_folder_copy_24.xml
Normal file
7
app/src/main/res/drawable/baseline_folder_copy_24.xml
Normal file
|
@ -0,0 +1,7 @@
|
|||
<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="M3,6H1v13c0,1.1 0.9,2 2,2h17v-2H3V6z"/>
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M21,4h-7l-2,-2H7C5.9,2 5.01,2.9 5.01,4L5,15c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V6C23,4.9 22.1,4 21,4z"/>
|
||||
|
||||
</vector>
|
5
app/src/main/res/drawable/baseline_settings_24.xml
Normal file
5
app/src/main/res/drawable/baseline_settings_24.xml
Normal 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.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
|
||||
|
||||
</vector>
|
|
@ -1,5 +1,6 @@
|
|||
<resources>
|
||||
<string name="app_name">PojavBackup</string>
|
||||
<string name="title_activity_main">MainActivity</string>
|
||||
<string name="title_activity_setup">SetupActivity</string>
|
||||
<string name="title_activity_home">HomeActivity</string>
|
||||
<string name="title_activity_settings">SettingsActivity</string>
|
||||
</resources>
|
|
@ -4,4 +4,7 @@ plugins {
|
|||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
alias(libs.plugins.ksp) apply false
|
||||
alias(libs.plugins.hilt) apply false
|
||||
alias(libs.plugins.protobuf) apply false
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
[versions]
|
||||
agp = "8.9.1"
|
||||
kotlin = "2.0.21"
|
||||
kotlin = "2.1.20"
|
||||
coreKtx = "1.16.0"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.2.1"
|
||||
|
@ -14,6 +14,12 @@ lifecycleViewmodelCompose = "2.8.7"
|
|||
knbt = "0.11.8"
|
||||
reorderable = "0.9.6"
|
||||
navigation = "2.8.9"
|
||||
hilt = "2.56.1"
|
||||
ksp = "2.1.20-2.0.0"
|
||||
hiltNavigationCompose = "1.2.0"
|
||||
datastore = "1.1.4"
|
||||
protobufJavalite = "4.30.2"
|
||||
protobuf = "0.9.5"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
|
@ -36,10 +42,17 @@ androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "l
|
|||
knbt = { group = "net.benwoodworth.knbt", name = "knbt", version.ref = "knbt" }
|
||||
reorderable = { group = "org.burnoutcrew.composereorderable", name = "reorderable", version.ref = "reorderable" }
|
||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
|
||||
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
||||
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
|
||||
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose"}
|
||||
androidx-datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" }
|
||||
protobuf-javalite = { group = "com.google.protobuf", name = "protobuf-javalite", version.ref = "protobufJavalite"}
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin"}
|
||||
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt"}
|
||||
protobuf = { id = "com.google.protobuf", version.ref = "protobuf" }
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue