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