diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index c224ad5..131e44d 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index a4f09e2..1a1bf72 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4dd8821..354b030 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 25b3816..a211ec4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + android:theme="@style/Theme.PojavBackup"> + @@ -34,17 +39,6 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/MyCloudProvider.kt b/app/src/main/java/eu/m724/pojavbackup/MyCloudProvider.kt deleted file mode 100644 index 6c89956..0000000 --- a/app/src/main/java/eu/m724/pojavbackup/MyCloudProvider.kt +++ /dev/null @@ -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 = 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 = 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?): Cursor { - Log.d(TAG, "queryRoots called") - - // Use the default projection if none is provided - val resolvedProjection: Array = 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?): Cursor { - println("queryDocument called for ID: $documentId") - - throw FileNotFoundException("Document not found: $documentId") - } - - override fun queryChildDocuments( - parentDocumentId: String?, - projection: Array?, - sortOrder: String? - ): Cursor { - println("queryChildDocuments called for parent ID: $parentDocumentId") - - val resolvedProjection: Array = 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"); - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/PojavBackupApplication.kt b/app/src/main/java/eu/m724/pojavbackup/PojavBackupApplication.kt new file mode 100644 index 0000000..96650c3 --- /dev/null +++ b/app/src/main/java/eu/m724/pojavbackup/PojavBackupApplication.kt @@ -0,0 +1,9 @@ +package eu.m724.pojavbackup + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class PojavBackupApplication : Application() { + +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/core/DataLoader.kt b/app/src/main/java/eu/m724/pojavbackup/core/DataLoader.kt deleted file mode 100644 index 1322d45..0000000 --- a/app/src/main/java/eu/m724/pojavbackup/core/DataLoader.kt +++ /dev/null @@ -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 { - 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")!! - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/core/WorldDetector.kt b/app/src/main/java/eu/m724/pojavbackup/core/WorldDetector.kt index 03ced12..3857c82 100644 --- a/app/src/main/java/eu/m724/pojavbackup/core/WorldDetector.kt +++ b/app/src/main/java/eu/m724/pojavbackup/core/WorldDetector.kt @@ -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 { - return savesDirectory.listFiles().mapNotNull { - try { - getWorldFromDirectory(it) - } catch (e: InvalidWorldException) { - Log.i(TAG, "${it.name} is invalid: ${e.message}") - null + fun listWorlds(): Flow { + return flow { + savesDirectory.listFiles().mapNotNull { + try { + emit(getWorldFromDirectory(it)) + } catch (e: InvalidWorldException) { + Log.i(TAG, "${it.name} is invalid: ${e.message}") + } } } } diff --git a/app/src/main/java/eu/m724/pojavbackup/core/WorldRepository.kt b/app/src/main/java/eu/m724/pojavbackup/core/WorldRepository.kt new file mode 100644 index 0000000..3b22f26 --- /dev/null +++ b/app/src/main/java/eu/m724/pojavbackup/core/WorldRepository.kt @@ -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? = 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 { + 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/core/datastore/DataStoreModule.kt b/app/src/main/java/eu/m724/pojavbackup/core/datastore/DataStoreModule.kt new file mode 100644 index 0000000..96bc440 --- /dev/null +++ b/app/src/main/java/eu/m724/pojavbackup/core/datastore/DataStoreModule.kt @@ -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 { + 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 */) + // } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/core/datastore/SettingsRepository.kt b/app/src/main/java/eu/m724/pojavbackup/core/datastore/SettingsRepository.kt new file mode 100644 index 0000000..7cbb3b7 --- /dev/null +++ b/app/src/main/java/eu/m724/pojavbackup/core/datastore/SettingsRepository.kt @@ -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 +) { + fun getSettingsFlow(): Flow { + return dataStore.data + } + + suspend fun getSettings(): Settings { + return dataStore.data.first() + } + + suspend fun updateWorldOrder(worldOrder: WorldOrder) { + dataStore.updateData { + it.toBuilder() + .clearWorldOrder() + .setWorldOrder(worldOrder) + .build() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/core/datastore/SettingsSerializer.kt b/app/src/main/java/eu/m724/pojavbackup/core/datastore/SettingsSerializer.kt new file mode 100644 index 0000000..8e66034 --- /dev/null +++ b/app/src/main/java/eu/m724/pojavbackup/core/datastore/SettingsSerializer.kt @@ -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 { + 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/home/HomeActivity.kt b/app/src/main/java/eu/m724/pojavbackup/home/HomeActivity.kt index be3f3c2..ad33fa6 100644 --- a/app/src/main/java/eu/m724/pojavbackup/home/HomeActivity.kt +++ b/app/src/main/java/eu/m724/pojavbackup/home/HomeActivity.kt @@ -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 { - DashboardScreen(navController) - } - composable { - 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 { + DashboardScreen( + navController = navController, + onWorldsIncludedClick = { + onSettingsOpen("content") + } + ) + } + composable { + 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 diff --git a/app/src/main/java/eu/m724/pojavbackup/home/HomeUiState.kt b/app/src/main/java/eu/m724/pojavbackup/home/HomeUiState.kt new file mode 100644 index 0000000..aba434d --- /dev/null +++ b/app/src/main/java/eu/m724/pojavbackup/home/HomeUiState.kt @@ -0,0 +1,5 @@ +package eu.m724.pojavbackup.home + +data class HomeUiState( + val loading: Boolean = true +) \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/home/HomeViewModel.kt b/app/src/main/java/eu/m724/pojavbackup/home/HomeViewModel.kt index f405120..769ce2d 100644 --- a/app/src/main/java/eu/m724/pojavbackup/home/HomeViewModel.kt +++ b/app/src/main/java/eu/m724/pojavbackup/home/HomeViewModel.kt @@ -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 = _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 + } } \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/home/screen/Screen.kt b/app/src/main/java/eu/m724/pojavbackup/home/screen/HomeScreen.kt similarity index 79% rename from app/src/main/java/eu/m724/pojavbackup/home/screen/Screen.kt rename to app/src/main/java/eu/m724/pojavbackup/home/screen/HomeScreen.kt index f72a61e..b8492a8 100644 --- a/app/src/main/java/eu/m724/pojavbackup/home/screen/Screen.kt +++ b/app/src/main/java/eu/m724/pojavbackup/home/screen/HomeScreen.kt @@ -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 diff --git a/app/src/main/java/eu/m724/pojavbackup/home/screen/dashboard/DashboardScreen.kt b/app/src/main/java/eu/m724/pojavbackup/home/screen/dashboard/DashboardScreen.kt index a91934b..8bea8fd 100644 --- a/app/src/main/java/eu/m724/pojavbackup/home/screen/dashboard/DashboardScreen.kt +++ b/app/src/main/java/eu/m724/pojavbackup/home/screen/dashboard/DashboardScreen.kt @@ -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) } ) } diff --git a/app/src/main/java/eu/m724/pojavbackup/home/screen/dashboard/DashboardScreenViewModel.kt b/app/src/main/java/eu/m724/pojavbackup/home/screen/dashboard/DashboardScreenViewModel.kt new file mode 100644 index 0000000..095afc8 --- /dev/null +++ b/app/src/main/java/eu/m724/pojavbackup/home/screen/dashboard/DashboardScreenViewModel.kt @@ -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.getDefaultInstance()) + val settings: StateFlow = _settings.asStateFlow() + + init { + viewModelScope.launch { + settingsRepository.getSettingsFlow().collect { newSettings -> + _settings.update { newSettings } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/settings/SettingsActivity.kt b/app/src/main/java/eu/m724/pojavbackup/settings/SettingsActivity.kt new file mode 100644 index 0000000..5c7383e --- /dev/null +++ b/app/src/main/java/eu/m724/pojavbackup/settings/SettingsActivity.kt @@ -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 { + OptionsScreen(navController) + } + composable { + 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. +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/settings/screen/SettingsScreen.kt b/app/src/main/java/eu/m724/pojavbackup/settings/screen/SettingsScreen.kt new file mode 100644 index 0000000..41ef73f --- /dev/null +++ b/app/src/main/java/eu/m724/pojavbackup/settings/screen/SettingsScreen.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/settings/screen/content/ContentScreen.kt b/app/src/main/java/eu/m724/pojavbackup/settings/screen/content/ContentScreen.kt new file mode 100644 index 0000000..a1c751d --- /dev/null +++ b/app/src/main/java/eu/m724/pojavbackup/settings/screen/content/ContentScreen.kt @@ -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) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/settings/screen/content/ContentScreenViewModel.kt b/app/src/main/java/eu/m724/pojavbackup/settings/screen/content/ContentScreenViewModel.kt new file mode 100644 index 0000000..f3cb096 --- /dev/null +++ b/app/src/main/java/eu/m724/pojavbackup/settings/screen/content/ContentScreenViewModel.kt @@ -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>(emptyList()) + val worlds: StateFlow> = _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() + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/settings/screen/options/OptionsScreen.kt b/app/src/main/java/eu/m724/pojavbackup/settings/screen/options/OptionsScreen.kt new file mode 100644 index 0000000..f81da2c --- /dev/null +++ b/app/src/main/java/eu/m724/pojavbackup/settings/screen/options/OptionsScreen.kt @@ -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") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/setup/SetupActivity.kt b/app/src/main/java/eu/m724/pojavbackup/setup/SetupActivity.kt index 3eac915..2f42e6e 100644 --- a/app/src/main/java/eu/m724/pojavbackup/setup/SetupActivity.kt +++ b/app/src/main/java/eu/m724/pojavbackup/setup/SetupActivity.kt @@ -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) - ) - } } } \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/setup/SetupViewModel.kt b/app/src/main/java/eu/m724/pojavbackup/setup/SetupViewModel.kt index 16621f4..d5d54fc 100644 --- a/app/src/main/java/eu/m724/pojavbackup/setup/SetupViewModel.kt +++ b/app/src/main/java/eu/m724/pojavbackup/setup/SetupViewModel.kt @@ -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 = _uiState.asStateFlow() - private val _worlds = MutableStateFlow>(emptyList()) - val worlds: StateFlow> = _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 { return POJAV_PACKAGES.filter { try { diff --git a/app/src/main/java/eu/m724/pojavbackup/ui/theme/Color.kt b/app/src/main/java/eu/m724/pojavbackup/ui/theme/Color.kt deleted file mode 100644 index 39b5346..0000000 --- a/app/src/main/java/eu/m724/pojavbackup/ui/theme/Color.kt +++ /dev/null @@ -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) \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/ui/theme/Theme.kt b/app/src/main/java/eu/m724/pojavbackup/ui/theme/Theme.kt index 668f590..0e03768 100644 --- a/app/src/main/java/eu/m724/pojavbackup/ui/theme/Theme.kt +++ b/app/src/main/java/eu/m724/pojavbackup/ui/theme/Theme.kt @@ -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 ) } \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/ui/theme/Type.kt b/app/src/main/java/eu/m724/pojavbackup/ui/theme/Type.kt deleted file mode 100644 index a0fd7db..0000000 --- a/app/src/main/java/eu/m724/pojavbackup/ui/theme/Type.kt +++ /dev/null @@ -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 - ) - */ -) \ No newline at end of file diff --git a/app/src/main/proto/settings.proto b/app/src/main/proto/settings.proto new file mode 100644 index 0000000..8381791 --- /dev/null +++ b/app/src/main/proto/settings.proto @@ -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; +} \ No newline at end of file diff --git a/app/src/main/res/drawable/baseline_folder_copy_24.xml b/app/src/main/res/drawable/baseline_folder_copy_24.xml new file mode 100644 index 0000000..a7c1f0a --- /dev/null +++ b/app/src/main/res/drawable/baseline_folder_copy_24.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/baseline_settings_24.xml b/app/src/main/res/drawable/baseline_settings_24.xml new file mode 100644 index 0000000..6593f3a --- /dev/null +++ b/app/src/main/res/drawable/baseline_settings_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f6986a1..478a0ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,6 @@ PojavBackup - MainActivity + SetupActivity HomeActivity + SettingsActivity \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 5ba8ae0..c6a2f03 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7c8d803..6b5ee18 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }