From af9cae31253feb9bec9ce0edb815c01c633bfa0f Mon Sep 17 00:00:00 2001 From: Kyle Corry Date: Fri, 3 Jan 2025 17:50:03 -0500 Subject: [PATCH] Load species on experimentation Signed-off-by: Kyle Corry --- .../trail_sense/shared/io/FileSubsystem.kt | 9 ++ .../trail_sense/shared/views/Views.kt | 14 +++ .../ExperimentationFragment.kt | 99 +++++++++++++++---- .../experimentation/SpeciesImportService.kt | 84 +++++++++++++--- .../res/layout/fragment_experimentation.xml | 34 +++---- 5 files changed, 184 insertions(+), 56 deletions(-) diff --git a/app/src/main/java/com/kylecorry/trail_sense/shared/io/FileSubsystem.kt b/app/src/main/java/com/kylecorry/trail_sense/shared/io/FileSubsystem.kt index edd21d1c5..cfd099eb7 100644 --- a/app/src/main/java/com/kylecorry/trail_sense/shared/io/FileSubsystem.kt +++ b/app/src/main/java/com/kylecorry/trail_sense/shared/io/FileSubsystem.kt @@ -47,6 +47,10 @@ class FileSubsystem private constructor(private val context: Context) { return local.getFile(path, create) } + fun getDirectory(path: String, create: Boolean = false): File { + return local.getDirectory(path, create) + } + fun list(path: String): List { return local.list(path) } @@ -148,6 +152,11 @@ class FileSubsystem private constructor(private val context: Context) { get(filename, true) } + suspend fun createTempDirectory(): File = onIO { + val filename = "${TEMP_DIR}/${UUID.randomUUID()}" + getDirectory(filename, true) + } + fun getLocalPath(file: File): String { return local.getRelativePath(file) } diff --git a/app/src/main/java/com/kylecorry/trail_sense/shared/views/Views.kt b/app/src/main/java/com/kylecorry/trail_sense/shared/views/Views.kt index 854c3ba3d..ac3fca5a9 100644 --- a/app/src/main/java/com/kylecorry/trail_sense/shared/views/Views.kt +++ b/app/src/main/java/com/kylecorry/trail_sense/shared/views/Views.kt @@ -1,8 +1,10 @@ package com.kylecorry.trail_sense.shared.views import android.content.Context +import android.net.Uri import android.view.View import android.view.ViewGroup +import android.widget.ImageView import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TextView @@ -54,4 +56,16 @@ object Views { } } + fun image( + context: Context, + uri: Uri, + width: Int = ViewGroup.LayoutParams.WRAP_CONTENT, + height: Int = ViewGroup.LayoutParams.WRAP_CONTENT + ): View { + return ImageView(context).apply { + setImageURI(uri) + layoutParams = ViewGroup.LayoutParams(width, height) + } + } + } \ No newline at end of file diff --git a/app/src/main/java/com/kylecorry/trail_sense/tools/experimentation/ExperimentationFragment.kt b/app/src/main/java/com/kylecorry/trail_sense/tools/experimentation/ExperimentationFragment.kt index 65a43bbfc..e098af6d4 100644 --- a/app/src/main/java/com/kylecorry/trail_sense/tools/experimentation/ExperimentationFragment.kt +++ b/app/src/main/java/com/kylecorry/trail_sense/tools/experimentation/ExperimentationFragment.kt @@ -1,22 +1,29 @@ package com.kylecorry.trail_sense.tools.experimentation +import android.graphics.Bitmap import android.os.Bundle -import android.text.method.LinkMovementMethod -import android.text.util.Linkify +import android.util.Size import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import com.kylecorry.andromeda.alerts.dialog import com.kylecorry.andromeda.core.coroutines.BackgroundMinimumState +import com.kylecorry.andromeda.core.coroutines.onIO +import com.kylecorry.andromeda.core.system.Resources import com.kylecorry.andromeda.fragments.BoundFragment import com.kylecorry.andromeda.fragments.inBackground +import com.kylecorry.andromeda.views.list.AsyncListIcon +import com.kylecorry.andromeda.views.list.ListItem import com.kylecorry.trail_sense.databinding.FragmentExperimentationBinding import com.kylecorry.trail_sense.shared.io.DeleteTempFilesCommand import com.kylecorry.trail_sense.shared.io.FileSubsystem +import com.kylecorry.trail_sense.shared.views.Views import com.kylecorry.trail_sense.tools.species_catalog.Species class ExperimentationFragment : BoundFragment() { - private var species by state(null) + private var species by state>(emptyList()) + private var filter by state("") private val importer by lazy { SpeciesImportService.create(this) } private val files by lazy { FileSubsystem.getInstance(requireContext()) } @@ -28,33 +35,85 @@ class ExperimentationFragment : BoundFragment() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding.text.movementMethod = LinkMovementMethod.getInstance() - binding.text.autoLinkMask = Linkify.WEB_URLS inBackground(BackgroundMinimumState.Created) { - species = importer.import() + val tagOrder = listOf( + "Plant", + "Fungus", + "Mammal", + "Bird", + "Reptile", + "Amphibian", + "Fish", + "Insect", + "Arachnid", + "Crustacean", + "Mollusk", + ) + species = (importer.import() ?: emptyList()).sortedWith( + compareBy( + { + it.tags.minOfOrNull { tag -> + val order = tagOrder.indexOf(tag) + if (order == -1) tagOrder.size else order + } + }, + { it.name }) + ) } - binding.title.setOnClickListener { - inBackground(BackgroundMinimumState.Created) { - species = importer.import() - // TODO: Ask the user if they want to import them, if they do copy the files to local storage and delete temp files - } + + binding.search.setOnSearchListener { + filter = it } } override fun onUpdate() { super.onUpdate() - effect2(species) { - binding.title.title.text = species?.name - binding.text.text = species?.notes - binding.title.subtitle.text = species?.tags?.joinToString(", ") - if (species?.images?.isNotEmpty() == true) { - binding.image.setImageURI(files.uri(species?.images?.firstOrNull() ?: "")) - } else { - binding.image.setImageBitmap(null) - } + effect2(species, filter) { + binding.list.setItems(species.filter { it.name.lowercase().contains(filter.trim()) } + .map { + val firstSentence = it.notes?.substringBefore(".")?.plus(".") ?: "" + ListItem( + it.id, + it.name, + it.tags.joinToString(", ") + "\n\n" + firstSentence.take(200), + icon = AsyncListIcon( + viewLifecycleOwner, + { loadThumbnail(it) }, + size = 48f, + clearOnPause = true + ), + ) { + dialog( + it.name, + it.notes ?: "", + allowLinks = true, + contentView = Views.image( + requireContext(), + files.uri(it.images.first()), + width = ViewGroup.LayoutParams.MATCH_PARENT, + height = Resources.dp(requireContext(), 200f).toInt() + ), + scrollable = true + ) + } + }) + } + } + + private suspend fun loadThumbnail(species: Species): Bitmap = onIO { + val size = Resources.dp(requireContext(), 48f).toInt() + try { + files.bitmap(species.images.first(), Size(size, size)) ?: getDefaultThumbnail() + } catch (e: Exception) { + getDefaultThumbnail() } } + private fun getDefaultThumbnail(): Bitmap { + val size = Resources.dp(requireContext(), 48f).toInt() + return Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + } + override fun onDestroy() { super.onDestroy() inBackground { diff --git a/app/src/main/java/com/kylecorry/trail_sense/tools/experimentation/SpeciesImportService.kt b/app/src/main/java/com/kylecorry/trail_sense/tools/experimentation/SpeciesImportService.kt index 30de1545b..28cbb1e6d 100644 --- a/app/src/main/java/com/kylecorry/trail_sense/tools/experimentation/SpeciesImportService.kt +++ b/app/src/main/java/com/kylecorry/trail_sense/tools/experimentation/SpeciesImportService.kt @@ -1,5 +1,6 @@ package com.kylecorry.trail_sense.tools.experimentation +import com.kylecorry.andromeda.files.ZipUtils import com.kylecorry.andromeda.fragments.AndromedaFragment import com.kylecorry.andromeda.json.JsonConvert import com.kylecorry.luna.coroutines.onIO @@ -18,31 +19,86 @@ class SpeciesImportService( private val uriService: UriService, private val files: FileSubsystem ) : - ImportService { - override suspend fun import(): Species? = onIO { + ImportService> { + + // TODO: Use an enum for tags + private val idToTag = mapOf( + "Africa" to 1, + "Antarctica" to 2, + "Asia" to 3, + "Australia" to 4, + "Europe" to 5, + "North America" to 6, + "South America" to 7, + "Plant" to 8, + "Animal" to 9, + "Fungus" to 10, + "Bird" to 11, + "Mammal" to 12, + "Reptile" to 13, + "Amphibian" to 14, + "Fish" to 15, + "Insect" to 16, + "Arachnid" to 17, + "Crustacean" to 18, + "Mollusk" to 19, + "Forest" to 20, + "Desert" to 21, + "Grassland" to 22, + "Wetland" to 23, + "Mountain" to 24, + "Urban" to 25, + "Marine" to 26, + "Freshwater" to 27, + "Cave" to 28, + "Tundra" to 29, + ).map { it.value to it.key }.toMap() + + override suspend fun import(): List? = onIO { val uri = uriPicker.open( listOf( - "application/json" + "application/json", + "application/zip" ) ) ?: return@onIO null val stream = uriService.inputStream(uri) ?: return@onIO null stream.use { - // TODO: Parse from zip (write images to a temp directory) - return@use parseJson(it) + if (files.getMimeType(uri) == "application/json") { + return@use parseJson(it) + } + return@use parseZip(it) + } + } + + private suspend fun parseZip(stream: InputStream): List? { + val root = files.createTempDirectory() + ZipUtils.unzip(stream, root, MAX_ZIP_FILE_COUNT) + + // Parse each file as a JSON file + val species = mutableListOf() + + for (file in root.listFiles() ?: return null) { + if (file.extension == "json") { + species.addAll(parseJson(file.inputStream()) ?: emptyList()) + } } + + return species } - private suspend fun parseJson(stream: InputStream): Species? { + private suspend fun parseJson(stream: InputStream): List? { val json = stream.bufferedReader().use { it.readText() } return try { val parsed = JsonConvert.fromJson(json) ?: return null val images = parsed.images.map { saveImage(it) } - Species( - 0, - parsed.name, - images, - parsed.tags, - parsed.notes ?: "" + listOf( + Species( + 0, + parsed.name, + images, + parsed.tags.mapNotNull { idToTag[it] }, + parsed.notes ?: "" + ) ) } catch (e: Exception) { null @@ -61,7 +117,7 @@ class SpeciesImportService( class SpeciesJson { var name: String = "" var images: List = emptyList() - var tags: List = emptyList() + var tags: List = emptyList() var notes: String? = null } @@ -73,5 +129,7 @@ class SpeciesImportService( FileSubsystem.getInstance(fragment.requireContext()) ) } + + private const val MAX_ZIP_FILE_COUNT = 10000 } } \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_experimentation.xml b/app/src/main/res/layout/fragment_experimentation.xml index 2940d2328..44de37187 100644 --- a/app/src/main/res/layout/fragment_experimentation.xml +++ b/app/src/main/res/layout/fragment_experimentation.xml @@ -1,29 +1,17 @@ - + android:layout_height="match_parent" + android:orientation="vertical"> - + android:padding="16dp" /> - - - - - - - - - \ No newline at end of file + + \ No newline at end of file