Compare commits

..

2 Commits

11 changed files with 182 additions and 73 deletions

1
app/.gitignore vendored
View File

@ -1 +1,2 @@
/build /build
/release

View File

@ -13,8 +13,8 @@ android {
applicationId = "xyz.pixelatedw.recipe" applicationId = "xyz.pixelatedw.recipe"
minSdk = 24 minSdk = 24
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 2
versionName = "1.0" versionName = "1.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@ -28,16 +28,24 @@ android {
) )
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
} }
kotlinOptions { kotlinOptions {
jvmTarget = "11" jvmTarget = "11"
} }
buildFeatures { buildFeatures {
compose = true compose = true
} }
lint {
checkReleaseBuilds = false
abortOnError = false
}
} }
dependencies { dependencies {

View File

@ -11,10 +11,10 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.room.Room import androidx.room.Room
import xyz.pixelatedw.recipe.ui.theme.RecipeTheme
import xyz.pixelatedw.recipe.data.AppDatabase import xyz.pixelatedw.recipe.data.AppDatabase
import xyz.pixelatedw.recipe.data.RecipesView import xyz.pixelatedw.recipe.data.RecipesView
import xyz.pixelatedw.recipe.ui.components.MainScreen import xyz.pixelatedw.recipe.ui.components.MainScreen
import xyz.pixelatedw.recipe.ui.theme.RecipeTheme
import xyz.pixelatedw.recipe.utils.parseRecipes import xyz.pixelatedw.recipe.utils.parseRecipes
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -54,8 +54,7 @@ class MainActivity : ComponentActivity() {
result.data?.data?.let { uri -> result.data?.data?.let { uri ->
parseRecipes(this, uri) parseRecipes(this, uri)
} }
} } else {
else {
importError = "Import cancelled" importError = "Import cancelled"
} }

View File

@ -3,7 +3,6 @@ package xyz.pixelatedw.recipe.data
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.widget.ImageView
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete import androidx.room.Delete
@ -18,6 +17,7 @@ import com.google.gson.reflect.TypeToken
import java.io.File import java.io.File
private val thumbnailsCache: HashMap<String, Bitmap> = hashMapOf()
private val imagesCache: HashMap<String, Bitmap> = hashMapOf() private val imagesCache: HashMap<String, Bitmap> = hashMapOf()
@Entity @Entity
@ -29,11 +29,29 @@ data class Recipe(
val content: String, val content: String,
val hash: Int val hash: Int
) { ) {
fun previewImage(ctx: Context, idx: Int): Bitmap? { fun getThumbnail(ctx: Context, idx: Int): Bitmap? {
return showImage(ctx, idx) val img = this.pics.getOrNull(idx)
if (img != null) {
val cachedImage = thumbnailsCache[img]
if (cachedImage != null) {
return cachedImage
} }
fun showImage(ctx: Context, idx: Int): Bitmap? { val originalFile = File(ctx.filesDir, img)
val thumbnail = File(originalFile.parentFile, "thumb-${originalFile.name}")
if (thumbnail.exists()) {
ctx.contentResolver.openInputStream(thumbnail.toUri()).use {
val bitmapData = BitmapFactory.decodeStream(it)
thumbnailsCache[img] = bitmapData
return bitmapData
}
}
}
return null
}
fun getFullImage(ctx: Context, idx: Int): Bitmap? {
val img = this.pics.getOrNull(idx) val img = this.pics.getOrNull(idx)
if (img != null) { if (img != null) {
val cachedImage = imagesCache[img] val cachedImage = imagesCache[img]
@ -53,6 +71,36 @@ data class Recipe(
return null return null
} }
fun generateThumbnails(ctx: Context) {
for (img in this.pics) {
val file = File(ctx.filesDir, img)
ctx.contentResolver.openInputStream(file.toUri()).use {
var bitmapData = BitmapFactory.decodeStream(it)
val aspectRatio: Float = bitmapData.getWidth() / bitmapData.getHeight().toFloat()
val width = 256
val height = Math.round(width / aspectRatio)
bitmapData = Bitmap.createScaledBitmap(bitmapData, width, height, true);
val thumbnailName = "thumb-${file.name}"
val thumbnailFile = File(file.parentFile, thumbnailName)
if (!thumbnailFile.exists()) {
thumbnailFile.createNewFile();
}
ctx.contentResolver.openOutputStream(thumbnailFile.toUri()).use { out ->
bitmapData.compress(Bitmap.CompressFormat.PNG, 100, out!!)
}
}
}
}
companion object {
fun clearCache() {
thumbnailsCache.clear()
imagesCache.clear()
}
}
} }
inline fun <reified T> Gson.fromJson(json: String?): T = fromJson<T>(json, object: TypeToken<T>() {}.type) inline fun <reified T> Gson.fromJson(json: String?): T = fromJson<T>(json, object: TypeToken<T>() {}.type)
@ -71,6 +119,9 @@ class PicsTypeConvertor {
@Dao @Dao
interface RecipeDao { interface RecipeDao {
@Query("SELECT * FROM recipe")
fun getAll(): List<Recipe>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(recipe: Recipe) fun insert(recipe: Recipe)

View File

@ -37,7 +37,6 @@ data class RecipeWithTags(
@Dao @Dao
interface RecipeWithTagsDao { interface RecipeWithTagsDao {
@Transaction
@Query("SELECT * FROM recipe") @Query("SELECT * FROM recipe")
fun getAll(): List<RecipeWithTags> fun getAll(): List<RecipeWithTags>
@ -47,6 +46,7 @@ interface RecipeWithTagsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(recipe: RecipeTag) fun insert(recipe: RecipeTag)
@Transaction
@Query("DELETE FROM recipetag WHERE recipetag.title = :recipe") @Query("DELETE FROM recipetag WHERE recipetag.title = :recipe")
fun delete(recipe: String) fun delete(recipe: String)
} }

View File

@ -5,6 +5,8 @@ import androidx.room.Entity
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Transaction
@Entity @Entity
data class Tag( data class Tag(
@ -14,6 +16,16 @@ data class Tag(
@Dao @Dao
interface TagDao { interface TagDao {
@Query("SELECT * FROM tag")
fun getAll(): List<Tag>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(tag: Tag) fun insert(tag: Tag)
@Query("SELECT COUNT(*) FROM tag")
fun count(): Int
@Transaction
@Query("DELETE FROM tag WHERE tag.name = :name")
fun delete(name: String)
} }

View File

@ -49,7 +49,6 @@ fun MainScreen(ctx: MainActivity, padding: PaddingValues, view: RecipesView) {
val search by view.search.collectAsState() val search by view.search.collectAsState()
val filters by view.tagFilters.collectAsState() val filters by view.tagFilters.collectAsState()
var activeRecipes by remember { mutableStateOf(recipes) }
var activeTags = 0 var activeTags = 0
val navController = rememberNavController() val navController = rememberNavController()
@ -80,8 +79,9 @@ fun MainScreen(ctx: MainActivity, padding: PaddingValues, view: RecipesView) {
value = search.orEmpty(), value = search.orEmpty(),
onValueChange = { search -> onValueChange = { search ->
view.setSearch(search) view.setSearch(search)
activeRecipes = val newRecipes =
recipes.filter { filterRecipe(it, search, filters) } recipes.filter { filterRecipe(it, search, filters) }
view.setRecipes(newRecipes)
}, },
) )
// Tags / Delete // Tags / Delete
@ -141,10 +141,10 @@ fun MainScreen(ctx: MainActivity, padding: PaddingValues, view: RecipesView) {
state = gridState, state = gridState,
) { ) {
items( items(
count = activeRecipes.size, count = recipes.size,
key = { activeRecipes[it].recipe.title }, key = { recipes[it].recipe.title },
itemContent = { entryId -> itemContent = { entryId ->
val entry = activeRecipes[entryId] val entry = recipes[entryId]
val isSelected = selectedEntries.contains(entry) val isSelected = selectedEntries.contains(entry)
RecipePreview(entry, isSelected, onClick = { RecipePreview(entry, isSelected, onClick = {
view.setActive(entry) view.setActive(entry)
@ -194,13 +194,14 @@ fun MainScreen(ctx: MainActivity, padding: PaddingValues, view: RecipesView) {
openTagFilterDialog = false openTagFilterDialog = false
view.reloadTagFilterState() view.reloadTagFilterState()
activeTags = filters.count { f -> f.checked } activeTags = filters.count { f -> f.checked }
activeRecipes = recipes.filter { val newRecipes = recipes.filter {
filterRecipe( filterRecipe(
it, it,
search, search,
filters filters
) )
} }
ctx.recipeView.setRecipes(newRecipes)
}, },
view, view,
) )

View File

@ -17,17 +17,13 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -84,7 +80,7 @@ fun RecipeInfo(
contentPadding = PaddingValues(horizontal = 64.dp) contentPadding = PaddingValues(horizontal = 64.dp)
) { ) {
items(picsCounts) { idx -> items(picsCounts) { idx ->
val pic = active.recipe.showImage(ctx, idx) val pic = active.recipe.getFullImage(ctx, idx)
if (pic != null) { if (pic != null) {
Image( Image(
bitmap = pic.asImageBitmap(), bitmap = pic.asImageBitmap(),

View File

@ -14,8 +14,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -78,7 +76,7 @@ fun RecipePreview(
.togetherWith(fadeOut(animationSpec = tween(500))) .togetherWith(fadeOut(animationSpec = tween(500)))
} }
) { targetImage -> ) { targetImage ->
val displayImage = entry.recipe.previewImage(LocalContext.current, targetImage) val displayImage = entry.recipe.getThumbnail(LocalContext.current, targetImage)
if (displayImage != null) { if (displayImage != null) {
Image( Image(
bitmap = displayImage.asImageBitmap(), bitmap = displayImage.asImageBitmap(),

View File

@ -13,6 +13,9 @@ import java.io.BufferedReader
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.InputStreamReader import java.io.InputStreamReader
import kotlin.io.path.Path
import kotlin.io.path.absolutePathString
import kotlin.io.path.fileSize
private val recipeFiles = mutableListOf<DocumentFile>() private val recipeFiles = mutableListOf<DocumentFile>()
@ -42,6 +45,41 @@ fun parseRecipeFiles(ctx: MainActivity) {
reader.close() reader.close()
} }
// Clearing unused tags after an import/sync
val usedTags = ctx.recipeView.tagFilters.value.map { t -> t.tag }
for (tag in ctx.db.tagDao().getAll()) {
if (!usedTags.contains(tag.name)) {
ctx.db.tagDao().delete(tag.name)
}
}
// Generate a list with all the pics used by all the registered recipes
val usedPics = ctx.db.recipeDao().getAll()
.asSequence()
.flatMap { it.pics }
.map { File(it) }
.map { p ->
val list = mutableListOf<String>()
list.add(p.name)
list.add("thumb-${p.name}")
list
}
.flatten()
.toList()
// Clear unused images
ctx.filesDir.walkTopDown().forEach {
if (it.name.endsWith(".jpg") && !usedPics.contains(it.name)) {
it.delete()
}
}
Recipe.clearCache()
// ctx.filesDir.walkTopDown().forEach { println(it.absolutePath + " | " + Path(it.absolutePath).fileSize()) }
} }
fun parseDir(ctx: Context, dir: DocumentFile, path: String) { fun parseDir(ctx: Context, dir: DocumentFile, path: String) {
@ -62,7 +100,6 @@ fun handleFile(ctx: Context, file: DocumentFile, path: String) {
} }
val fileName = file.name ?: return val fileName = file.name ?: return
// println("file: ${file.name} | ${file.uri.path} | ${file.length()}")
if (fileName.endsWith(".jpg")) { if (fileName.endsWith(".jpg")) {
val picsDir = File(ctx.filesDir, path) val picsDir = File(ctx.filesDir, path)
@ -155,5 +192,7 @@ fun parseRecipe(ctx: MainActivity, lastModified: Long, text: String) {
hash = hash hash = hash
) )
recipe.generateThumbnails(ctx)
ctx.db.recipeDao().insert(recipe) ctx.db.recipeDao().insert(recipe)
} }

View File

@ -59,11 +59,11 @@ fun sync(ctx: MainActivity, nav: NavHostController, setLoading: (Boolean) -> Uni
inputStream.read(buffer) inputStream.read(buffer)
val contentBufLen = ByteBuffer.wrap(buffer).getLong().toInt() val contentBufLen = ByteBuffer.wrap(buffer).getLong().toInt()
var parentDir = ctx.filesDir var parentDir = File(ctx.filesDir, ".sync")
if (filePath.isNotEmpty()) { if (filePath.isNotEmpty()) {
parentDir = File(ctx.filesDir, filePath) parentDir = File(ctx.filesDir, filePath)
parentDir.mkdirs()
} }
parentDir.mkdirs()
val newFile = File(parentDir, fileName) val newFile = File(parentDir, fileName)
@ -94,6 +94,10 @@ fun sync(ctx: MainActivity, nav: NavHostController, setLoading: (Boolean) -> Uni
parseDir(ctx, docDir, "") parseDir(ctx, docDir, "")
parseRecipeFiles(ctx) parseRecipeFiles(ctx)
val syncFolder = File(ctx.filesDir, ".sync")
if (syncFolder.exists()) {
syncFolder.deleteRecursively()
}
} catch (e: Exception) { } catch (e: Exception) {
ctx.importError = when (e) { ctx.importError = when (e) {
is SocketTimeoutException -> "Connection timed out" is SocketTimeoutException -> "Connection timed out"