Compare commits

...

2 Commits

7 changed files with 260 additions and 125 deletions

View File

@ -21,6 +21,9 @@ class MainActivity : ComponentActivity() {
val recipeView: RecipesView by viewModels() val recipeView: RecipesView by viewModels()
lateinit var db: AppDatabase lateinit var db: AppDatabase
var importFinished: Boolean = false
var importError: String = ""
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -50,10 +53,21 @@ class MainActivity : ComponentActivity() {
if (result.resultCode == RESULT_OK) { if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri -> result.data?.data?.let { uri ->
parseRecipes(this, uri) parseRecipes(this, uri)
val recipes = db.recipeWithTagsDao().getAll()
recipeView.setRecipes(recipes)
} }
} }
else {
importError = "Import cancelled"
}
importFinished = true
} }
fun getAppVersion(): String {
val info = this.packageManager.getPackageInfo(packageName, 0)
return if (info.versionName == null) {
"Unknown"
} else {
"v${info.versionName}"
}
}
} }

View File

@ -1,55 +0,0 @@
package xyz.pixelatedw.recipe.ui.components
import android.content.Intent
import androidx.compose.foundation.layout.Box
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.runtime.setValue
import androidx.compose.ui.Modifier
import xyz.pixelatedw.recipe.MainActivity
import xyz.pixelatedw.recipe.utils.sync
@Composable
fun ImportDropdownMenu(ctx: MainActivity, modifier: Modifier) {
var expanded by remember { mutableStateOf(false) }
Box(
modifier = modifier,
) {
IconButton(
onClick = { expanded = !expanded }
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Import recipes"
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text("Import") },
onClick = {
ctx.sourceChooser.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE))
expanded = false
}
)
DropdownMenuItem(
text = { Text("Sync") },
onClick = {
sync(ctx)
expanded = false
}
)
}
}
}

View File

@ -0,0 +1,26 @@
package xyz.pixelatedw.recipe.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun LoadingIndicator(isLoading: Boolean) {
if (!isLoading) return
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator(
modifier = Modifier.width(128.dp),
)
}
}

View File

@ -1,6 +1,5 @@
package xyz.pixelatedw.recipe.ui.components package xyz.pixelatedw.recipe.ui.components
import android.content.Context
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@ -9,19 +8,15 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
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.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -30,7 +25,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@ -39,8 +33,6 @@ import xyz.pixelatedw.recipe.MainActivity
import xyz.pixelatedw.recipe.R import xyz.pixelatedw.recipe.R
import xyz.pixelatedw.recipe.data.RecipeWithTags import xyz.pixelatedw.recipe.data.RecipeWithTags
import xyz.pixelatedw.recipe.data.RecipesView import xyz.pixelatedw.recipe.data.RecipesView
import xyz.pixelatedw.recipe.utils.DEFAULT_SYNC_SERVER_IP
import xyz.pixelatedw.recipe.utils.DEFAULT_SYNC_SERVER_PORT
import java.io.File import java.io.File
@ -51,11 +43,6 @@ fun MainScreen(ctx: MainActivity, padding: PaddingValues, view: RecipesView) {
val search = view.search.collectAsState() val search = view.search.collectAsState()
val filters = view.tagFilters.collectAsState() val filters = view.tagFilters.collectAsState()
val prefs = ctx.getPreferences(Context.MODE_PRIVATE)
var syncIp by remember { mutableStateOf(prefs.getString("syncServerIp", DEFAULT_SYNC_SERVER_IP)!!) }
var syncPort by remember { mutableIntStateOf(prefs.getInt("syncServerPort", DEFAULT_SYNC_SERVER_PORT)) }
val navController = rememberNavController() val navController = rememberNavController()
var openDeletionDialog by remember { mutableStateOf(false) } var openDeletionDialog by remember { mutableStateOf(false) }
@ -118,17 +105,7 @@ fun MainScreen(ctx: MainActivity, padding: PaddingValues, view: RecipesView) {
value = search.value.orEmpty(), value = search.value.orEmpty(),
onValueChange = { search -> view.setSearch(search) }, onValueChange = { search -> view.setSearch(search) },
) )
// Tags // Tags / Delete
IconButton(
modifier = Modifier.weight(0.1f),
onClick = { openTagFilterDialog = true },
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.filter_24),
contentDescription = "Toggle tags filtering menu"
)
}
// Import / Delete
if (selectedEntries.isNotEmpty()) { if (selectedEntries.isNotEmpty()) {
IconButton( IconButton(
modifier = Modifier.weight(0.1f), modifier = Modifier.weight(0.1f),
@ -143,12 +120,23 @@ fun MainScreen(ctx: MainActivity, padding: PaddingValues, view: RecipesView) {
) )
} }
} else { } else {
ImportDropdownMenu(ctx, Modifier.weight(0.1f)) IconButton(
modifier = Modifier.weight(0.1f),
onClick = { openTagFilterDialog = true },
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.filter_24),
contentDescription = "Toggle tags filtering menu"
)
}
} }
// Options // Options
IconButton( IconButton(
modifier = Modifier.weight(0.1f), modifier = Modifier.weight(0.1f),
onClick = { navController.navigate("settings") }, onClick = {
ctx.importError = ""
navController.navigate("settings")
},
) { ) {
Icon( Icon(
imageVector = Icons.Default.Settings, imageVector = Icons.Default.Settings,
@ -217,42 +205,7 @@ fun MainScreen(ctx: MainActivity, padding: PaddingValues, view: RecipesView) {
RecipeInfo(ctx, view, navController, padding, active.value!!) RecipeInfo(ctx, view, navController, padding, active.value!!)
} }
composable("settings") { composable("settings") {
Column(modifier = Modifier.padding(start = 12.dp, top = 48.dp)) { SettingsScreen(ctx, navController)
OutlinedTextField(
label = { Text("Sync Server IP") },
singleLine = true,
value = syncIp,
onValueChange = { syncIp = it },
)
OutlinedTextField(
modifier = Modifier.padding(top = 8.dp),
label = { Text("Sync Server IP") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
value = syncPort.toString(),
onValueChange = {
syncPort = when (it.toIntOrNull()) {
null -> syncPort
else -> it.toInt()
}
},
)
Button(
modifier = Modifier.padding(top = 8.dp),
onClick = {
with (prefs.edit()) {
putString("syncServerIp", syncIp)
putInt("syncServerPort", syncPort)
apply()
}
navController.navigate("list")
},
) {
Text("Save")
}
}
} }
} }
} }

View File

@ -0,0 +1,139 @@
package xyz.pixelatedw.recipe.ui.components
import android.content.Context
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
import xyz.pixelatedw.recipe.MainActivity
import xyz.pixelatedw.recipe.utils.DEFAULT_SYNC_SERVER_IP
import xyz.pixelatedw.recipe.utils.DEFAULT_SYNC_SERVER_PORT
import xyz.pixelatedw.recipe.utils.import
import xyz.pixelatedw.recipe.utils.sync
@Composable
fun SettingsScreen(ctx: MainActivity, nav: NavHostController) {
val prefs = ctx.getPreferences(Context.MODE_PRIVATE)
var syncIp by remember {
mutableStateOf(
prefs.getString(
"syncServerIp",
DEFAULT_SYNC_SERVER_IP
)!!
)
}
var syncPort by remember {
mutableIntStateOf(
prefs.getInt(
"syncServerPort",
DEFAULT_SYNC_SERVER_PORT
)
)
}
val (isLoading, setLoading) = remember { mutableStateOf(false) }
LoadingIndicator(isLoading)
// Error text field
Row(
modifier = Modifier
.fillMaxSize()
.padding(bottom = 128.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.Bottom
) {
Text(
ctx.importError,
color = Color.Red,
fontSize = 32.sp,
softWrap = true,
textAlign = TextAlign.Center,
lineHeight = 32.sp
)
}
Column(
modifier = Modifier.padding(start = 12.dp, top = 48.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
OutlinedTextField(
label = { Text("Sync Server IP") },
singleLine = true,
value = syncIp,
onValueChange = { syncIp = it },
)
OutlinedTextField(
label = { Text("Sync Server Port") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
value = syncPort.toString(),
onValueChange = {
syncPort = when (it.toIntOrNull()) {
null -> syncPort
else -> it.toInt()
}
},
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(onClick = {
import(ctx, nav, setLoading)
}) {
Text("Import")
}
Button(onClick = {
sync(ctx, nav, setLoading)
}) {
Text("Sync")
}
}
Button(
onClick = {
with(prefs.edit()) {
putString("syncServerIp", syncIp)
putInt("syncServerPort", syncPort)
apply()
}
nav.navigate("list")
},
) {
Text("Save")
}
Row(
modifier = Modifier
.fillMaxSize()
.padding(bottom = 64.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.Bottom
) {
Text(ctx.getAppVersion())
}
}
}

View File

@ -0,0 +1,41 @@
package xyz.pixelatedw.recipe.utils
import android.content.Intent
import android.os.Handler
import android.os.Looper
import androidx.navigation.NavHostController
import xyz.pixelatedw.recipe.MainActivity
import java.util.concurrent.Executors
fun import(ctx: MainActivity, nav: NavHostController, setLoading: (Boolean) -> Unit) {
val executor = Executors.newSingleThreadExecutor()
val handler = Handler(Looper.getMainLooper())
executor.execute {
setLoading(true)
ctx.importError = ""
ctx.importFinished = false
try {
ctx.sourceChooser.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE))
// Keep the thread busy until the import is done
// TODO This feels dirty but it doesn't block the main thread and I can't figure out a better way
while(!ctx.importFinished) {}
} catch (e: Exception) {
ctx.importError = when (e) {
else -> "Exception occurred: ${e.javaClass.canonicalName}"
}
e.printStackTrace()
}
handler.post {
if (ctx.importError.isEmpty()) {
val recipes = ctx.db.recipeWithTagsDao().getAll()
ctx.recipeView.setRecipes(recipes)
nav.navigate("list")
}
setLoading(false)
}
}
}

View File

@ -4,29 +4,37 @@ import android.content.Context
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.navigation.NavHostController
import xyz.pixelatedw.recipe.MainActivity import xyz.pixelatedw.recipe.MainActivity
import java.io.DataInputStream import java.io.DataInputStream
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.net.InetSocketAddress
import java.net.Socket import java.net.Socket
import java.net.SocketTimeoutException
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.concurrent.Executors import java.util.concurrent.Executors
const val DEFAULT_SYNC_SERVER_IP = "192.168.0.100" const val DEFAULT_SYNC_SERVER_IP = "192.168.0.100"
const val DEFAULT_SYNC_SERVER_PORT = 9696 const val DEFAULT_SYNC_SERVER_PORT = 9696
const val CONNECTION_TIMEOUT = 1_000 // 1 seconds
fun sync(ctx: MainActivity) { fun sync(ctx: MainActivity, nav: NavHostController, setLoading: (Boolean) -> Unit) {
val executor = Executors.newSingleThreadExecutor() val executor = Executors.newSingleThreadExecutor()
val handler = Handler(Looper.getMainLooper()) val handler = Handler(Looper.getMainLooper())
executor.execute { executor.execute {
setLoading(true)
ctx.importError = ""
try { try {
val prefs = ctx.getPreferences(Context.MODE_PRIVATE) val prefs = ctx.getPreferences(Context.MODE_PRIVATE)
val syncServerIp = prefs.getString("syncServerIp", DEFAULT_SYNC_SERVER_IP) val syncServerIp = prefs.getString("syncServerIp", DEFAULT_SYNC_SERVER_IP)
val syncServerPort = prefs.getInt("syncServerPort", DEFAULT_SYNC_SERVER_PORT) val syncServerPort = prefs.getInt("syncServerPort", DEFAULT_SYNC_SERVER_PORT)
val conn = Socket(syncServerIp, syncServerPort) val conn = Socket()
conn.connect(InetSocketAddress(syncServerIp, syncServerPort), CONNECTION_TIMEOUT)
val stream = conn.getInputStream() val stream = conn.getInputStream()
val inputStream = DataInputStream(stream) val inputStream = DataInputStream(stream)
@ -80,19 +88,28 @@ fun sync(ctx: MainActivity) {
// println("new file: ${newFile.absolutePath} | $contentBufLen | ${newFile.length()}") // println("new file: ${newFile.absolutePath} | $contentBufLen | ${newFile.length()}")
} }
inputStream.close()
val docDir = DocumentFile.fromFile(ctx.filesDir) val docDir = DocumentFile.fromFile(ctx.filesDir)
parseDir(ctx, docDir, "") parseDir(ctx, docDir, "")
parseRecipeFiles(ctx) parseRecipeFiles(ctx)
} catch (e: Exception) { } catch (e: Exception) {
ctx.importError = when (e) {
is SocketTimeoutException -> "Connection timed out"
else -> "Exception occurred: ${e.javaClass.canonicalName}"
}
e.printStackTrace() e.printStackTrace()
} }
handler.post { handler.post {
val recipes = ctx.db.recipeWithTagsDao().getAll() if (ctx.importError.isEmpty()) {
ctx.recipeView.setRecipes(recipes) val recipes = ctx.db.recipeWithTagsDao().getAll()
// println("syncing complete") ctx.recipeView.setRecipes(recipes)
nav.navigate("list")
}
setLoading(false)
} }
} }
} }