summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-05-25 18:12:11 +0000
committerClaude <noreply@anthropic.com>2026-05-25 18:12:11 +0000
commit02776054778c3cbc8aefc43376b105becfec2b86 (patch)
tree1ab6db199bc09ff4a6c83e51e271124ee08b7960
parent619deab18a0cd56d844c5911a9bbda20d4439269 (diff)
Persist log entries and photos; stamp with GPS location
- LogbookStorage: Moshi-backed JSON persistence to getExternalFilesDir, photos saved to Pictures/Nav/ instead of cache - LogbookRepository: file-backed replacement for in-memory store, loads saved entries on init so they survive app restarts - VoiceLogViewModel: expose entries StateFlow, accept lat/lon in save() - VoiceLogFragment: switch camera to TakePicture(Uri) via FileProvider for full-res photos; gallery copies content URI to persistent storage; passes current GPS position to save(); shows scrollable entries list - MainViewModel: expose currentPosition StateFlow updated on each GPS fix - AndroidManifest: add FileProvider for Pictures/Nav/ directory - TripReportViewModel: updated to use LogbookRepository https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn
-rw-r--r--android-app/app/src/main/AndroidManifest.xml9
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt4
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookRepository.kt18
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookStorage.kt54
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt15
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt4
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt4
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt148
-rw-r--r--android-app/app/src/main/res/layout/fragment_voice_log.xml28
-rw-r--r--android-app/app/src/main/res/layout/item_logbook_entry.xml50
-rw-r--r--android-app/app/src/main/res/xml/file_paths.xml4
11 files changed, 314 insertions, 24 deletions
diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml
index 23817bb..1701e17 100644
--- a/android-app/app/src/main/AndroidManifest.xml
+++ b/android-app/app/src/main/AndroidManifest.xml
@@ -23,6 +23,15 @@
<service
android:name=".LocationService"
android:foregroundServiceType="location" />
+ <provider
+ android:name="androidx.core.content.FileProvider"
+ android:authorities="${applicationId}.fileprovider"
+ android:exported="false"
+ android:grantUriPermissions="true">
+ <meta-data
+ android:name="android.support.FILE_PROVIDER_PATHS"
+ android:resource="@xml/file_paths" />
+ </provider>
<activity
android:name=".MainActivity"
android:exported="true"
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt b/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
index 0bec116..0fe5708 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
@@ -16,7 +16,8 @@ class NavApplication : Application() {
private set
companion object {
- val logbookRepository = org.terst.nav.logbook.InMemoryLogbookRepository()
+ lateinit var logbookRepository: org.terst.nav.logbook.LogbookRepository
+ private set
lateinit var trackRepository: org.terst.nav.track.TrackRepository
lateinit var boatProfileRepository: org.terst.nav.tripreport.BoatProfileRepository
lateinit var vesselRepository: org.terst.nav.vessel.VesselRepository
@@ -35,6 +36,7 @@ class NavApplication : Application() {
override fun onCreate() {
super.onCreate()
featureFlags = FeatureFlags(this)
+ logbookRepository = org.terst.nav.logbook.LogbookRepository(org.terst.nav.logbook.LogbookStorage(this))
trackRepository = org.terst.nav.track.TrackRepository(this)
boatProfileRepository = org.terst.nav.tripreport.BoatProfileRepository(this)
vesselRepository = org.terst.nav.vessel.VesselRepository(this)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookRepository.kt
new file mode 100644
index 0000000..0b4693e
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookRepository.kt
@@ -0,0 +1,18 @@
+package org.terst.nav.logbook
+
+class LogbookRepository(private val storage: LogbookStorage) {
+
+ private val entries = mutableListOf<LogEntry>().apply { addAll(storage.loadAll()) }
+ private var nextId = (entries.maxOfOrNull { it.id } ?: 0L) + 1L
+
+ val photoDir get() = storage.photoDir
+
+ fun save(entry: LogEntry): LogEntry {
+ val saved = entry.copy(id = nextId++)
+ entries.add(saved)
+ storage.saveAll(entries)
+ return saved
+ }
+
+ fun getAll(): List<LogEntry> = entries.toList()
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookStorage.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookStorage.kt
new file mode 100644
index 0000000..35349f0
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookStorage.kt
@@ -0,0 +1,54 @@
+package org.terst.nav.logbook
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.net.Uri
+import android.os.Environment
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+import org.terst.nav.ui.NavLogger
+import java.io.File
+import java.io.FileOutputStream
+
+class LogbookStorage(private val context: Context) {
+
+ private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
+ private val adapter = moshi.adapter(LogEntryList::class.java)
+
+ private val storageFile: File
+ get() = File(context.getExternalFilesDir(null), "nav_logbook.json")
+
+ val photoDir: File
+ get() = File(context.getExternalFilesDir(Environment.DIRECTORY_PICTURES), "Nav")
+ .also { it.mkdirs() }
+
+ fun loadAll(): List<LogEntry> = runCatching {
+ if (!storageFile.exists()) return emptyList()
+ adapter.fromJson(storageFile.readText())?.entries ?: emptyList()
+ }.getOrElse { e ->
+ NavLogger.e("logbook", "loadAll failed: ${e.message}")
+ emptyList()
+ }
+
+ fun saveAll(entries: List<LogEntry>): Boolean = runCatching {
+ storageFile.parentFile?.mkdirs()
+ storageFile.writeText(adapter.toJson(LogEntryList(entries)))
+ true
+ }.getOrElse { false }
+
+ fun saveBitmap(bitmap: Bitmap): String? = runCatching {
+ val dest = File(photoDir, "log_${System.currentTimeMillis()}.jpg")
+ FileOutputStream(dest).use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
+ dest.absolutePath
+ }.getOrElse { null }
+
+ fun copyFromUri(uri: Uri): String? = runCatching {
+ val dest = File(photoDir, "log_${System.currentTimeMillis()}.jpg")
+ context.contentResolver.openInputStream(uri)?.use { input ->
+ FileOutputStream(dest).use { output -> input.copyTo(output) }
+ }
+ dest.absolutePath
+ }.getOrElse { null }
+}
+
+data class LogEntryList(val entries: List<LogEntry>)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt
index 0a68ba8..67e1062 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt
@@ -3,11 +3,14 @@ package org.terst.nav.logbook
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-class VoiceLogViewModel(private val repository: InMemoryLogbookRepository) {
+class VoiceLogViewModel(private val repository: LogbookRepository) {
private val _state = MutableStateFlow<VoiceLogState>(VoiceLogState.Idle)
val state: StateFlow<VoiceLogState> = _state
+ private val _entries = MutableStateFlow<List<LogEntry>>(repository.getAll().reversed())
+ val entries: StateFlow<List<LogEntry>> = _entries
+
fun onListeningStarted() {
_state.value = VoiceLogState.Listening
}
@@ -20,20 +23,18 @@ class VoiceLogViewModel(private val repository: InMemoryLogbookRepository) {
_state.value = VoiceLogState.Error(message)
}
- /**
- * Save an entry with the given text and optional photo path.
- * Called directly by the fragment from the Save button so the user
- * can have edited the recognized text in the EditText beforehand.
- */
- fun save(text: String, photoPath: String? = null) {
+ fun save(text: String, photoPath: String? = null, lat: Double? = null, lon: Double? = null) {
if (text.isBlank() && photoPath == null) return
val entry = LogEntry(
timestampMs = System.currentTimeMillis(),
text = text.ifBlank { "(photo only)" },
entryType = EntryType.GENERAL,
+ lat = lat,
+ lon = lon,
photoPath = photoPath
)
val saved = repository.save(entry)
+ _entries.value = repository.getAll().reversed()
_state.value = VoiceLogState.Saved(saved)
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt
index e474cd2..45cbed5 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt
@@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
-import org.terst.nav.logbook.InMemoryLogbookRepository
+import org.terst.nav.logbook.LogbookRepository
import org.terst.nav.track.TrackRepository
sealed class TripReportState {
@@ -18,7 +18,7 @@ sealed class TripReportState {
class TripReportViewModel(
private val trackRepository: TrackRepository,
- private val logbookRepository: InMemoryLogbookRepository,
+ private val logbookRepository: LogbookRepository,
private val generator: TripReportGenerator = TripReportGenerator()
) : ViewModel() {
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
index 01cec43..49ce0f5 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
@@ -92,6 +92,9 @@ class MainViewModel(
private var lastSog: Double = 0.0
private var lastCog: Double = 0.0
+ private val _currentPosition = MutableStateFlow<Pair<Double, Double>?>(null)
+ val currentPosition: StateFlow<Pair<Double, Double>?> = _currentPosition.asStateFlow()
+
private val aisRepository = AisRepository()
private val trackRepository = NavApplication.trackRepository
@@ -221,6 +224,7 @@ class MainViewModel(
fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) {
lastLat = lat; lastLon = lon; lastSog = sogKnots; lastCog = cogDeg
+ _currentPosition.value = lat to lon
val conditions = _marineConditions.value
val forecast = _forecast.value.firstOrNull()
val point = TrackPoint(
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt
index fb1001a..31d9715 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt
@@ -3,7 +3,7 @@ package org.terst.nav.ui.voicelog
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
-import android.graphics.Bitmap
+import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
import android.os.Bundle
@@ -18,6 +18,7 @@ import android.widget.ImageView
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
@@ -27,9 +28,13 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.textfield.TextInputEditText
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import org.terst.nav.MainActivity
+import org.terst.nav.NavApplication
import org.terst.nav.R
+import org.terst.nav.logbook.LogEntry
import org.terst.nav.logbook.VoiceLogState
import org.terst.nav.logbook.VoiceLogViewModel
import org.terst.nav.track.SafState
@@ -38,12 +43,15 @@ import org.terst.nav.track.TrackDetailSheet
import org.terst.nav.ui.MainViewModel
import java.io.File
import java.io.FileOutputStream
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
import java.util.Locale
class VoiceLogFragment : Fragment() {
private val logViewModel by lazy {
- VoiceLogViewModel(repository = org.terst.nav.NavApplication.logbookRepository)
+ VoiceLogViewModel(repository = NavApplication.logbookRepository)
}
private val mainViewModel: MainViewModel by activityViewModels()
@@ -62,25 +70,47 @@ class VoiceLogFragment : Fragment() {
private lateinit var tvSavedConfirmation: TextView
private var pendingPhotoPath: String? = null
+ private var pendingCameraUri: Uri? = null
private val cameraLauncher = registerForActivityResult(
- ActivityResultContracts.TakePicturePreview()
- ) { bitmap: Bitmap? ->
- if (bitmap != null) {
- val file = File(requireContext().cacheDir, "log_${System.currentTimeMillis()}.jpg")
- try {
- FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.JPEG, 85, it) }
- setPhoto(file.absolutePath) { ivPhoto.setImageBitmap(bitmap) }
- } catch (_: Exception) {
- tvStatus.text = "Couldn't save photo"
+ ActivityResultContracts.TakePicture()
+ ) { success: Boolean ->
+ if (success) {
+ val path = pendingPhotoPath ?: return@registerForActivityResult
+ setPhoto(path) {
+ val bm = BitmapFactory.decodeFile(path)
+ if (bm != null) ivPhoto.setImageBitmap(bm)
+ else ivPhoto.setImageURI(pendingCameraUri)
}
+ } else {
+ pendingPhotoPath?.let { File(it).delete() }
+ pendingPhotoPath = null
+ pendingCameraUri = null
}
}
private val galleryLauncher = registerForActivityResult(
ActivityResultContracts.GetContent()
) { uri: Uri? ->
- if (uri != null) setPhoto(uri.toString()) { ivPhoto.setImageURI(uri) }
+ if (uri != null) {
+ lifecycleScope.launch {
+ val path = withContext(Dispatchers.IO) {
+ runCatching {
+ val dir = NavApplication.logbookRepository.photoDir
+ val dest = File(dir, "log_${System.currentTimeMillis()}.jpg")
+ requireContext().contentResolver.openInputStream(uri)?.use { input ->
+ FileOutputStream(dest).use { output -> input.copyTo(output) }
+ }
+ dest.absolutePath
+ }.getOrNull()
+ }
+ if (path != null) {
+ setPhoto(path) { ivPhoto.setImageURI(Uri.fromFile(File(path))) }
+ } else {
+ tvStatus.text = "Couldn't copy photo"
+ }
+ }
+ }
}
override fun onCreateView(
@@ -108,11 +138,28 @@ class VoiceLogFragment : Fragment() {
setupSpeechRecognizer()
fabMic.setOnClickListener { startListening() }
- btnCamera.setOnClickListener { cameraLauncher.launch(null) }
+
+ btnCamera.setOnClickListener {
+ val dir = NavApplication.logbookRepository.photoDir
+ val file = File(dir, "log_${System.currentTimeMillis()}.jpg")
+ pendingPhotoPath = file.absolutePath
+ pendingCameraUri = FileProvider.getUriForFile(
+ requireContext(), "${requireContext().packageName}.fileprovider", file
+ )
+ cameraLauncher.launch(pendingCameraUri!!)
+ }
+
btnGallery.setOnClickListener { galleryLauncher.launch("image/*") }
btnRemovePhoto.setOnClickListener { clearPhoto() }
+
btnSave.setOnClickListener {
- logViewModel.save(etNote.text?.toString()?.trim() ?: "", pendingPhotoPath)
+ val pos = mainViewModel.currentPosition.value
+ logViewModel.save(
+ text = etNote.text?.toString()?.trim() ?: "",
+ photoPath = pendingPhotoPath,
+ lat = pos?.first,
+ lon = pos?.second
+ )
}
btnClear.setOnClickListener { clearEntry() }
@@ -130,6 +177,23 @@ class VoiceLogFragment : Fragment() {
}
}
+ // Log entries list
+ val tvNoEntries = view.findViewById<TextView>(R.id.tv_no_log_entries)
+ val rvLogEntries = view.findViewById<RecyclerView>(R.id.rv_log_entries)
+ rvLogEntries.layoutManager = LinearLayoutManager(requireContext())
+ rvLogEntries.isNestedScrollingEnabled = false
+ rvLogEntries.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
+ val logAdapter = LogbookEntryAdapter()
+ rvLogEntries.adapter = logAdapter
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ logViewModel.entries.collect { entries ->
+ logAdapter.submitList(entries)
+ tvNoEntries.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE
+ rvLogEntries.visibility = if (entries.isEmpty()) View.GONE else View.VISIBLE
+ }
+ }
+
// Inline track list
val rv = view.findViewById<RecyclerView>(R.id.rv_saved_tracks)
val layoutEmpty = view.findViewById<View>(R.id.layout_empty)
@@ -235,6 +299,7 @@ class VoiceLogFragment : Fragment() {
private fun clearPhoto() {
pendingPhotoPath = null
+ pendingCameraUri = null
ivPhoto.setImageDrawable(null)
framePhoto.visibility = View.GONE
}
@@ -302,3 +367,58 @@ class VoiceLogFragment : Fragment() {
private const val RC_AUDIO = 1001
}
}
+
+// ── Logbook entries adapter ────────────────────────────────────────────────────
+
+private val TIME_FMT: DateTimeFormatter =
+ DateTimeFormatter.ofPattern("dd MMM HH:mm", Locale.getDefault())
+ .withZone(ZoneId.systemDefault())
+
+private class LogbookEntryAdapter : RecyclerView.Adapter<LogbookEntryAdapter.VH>() {
+
+ private var items: List<LogEntry> = emptyList()
+
+ fun submitList(list: List<LogEntry>) {
+ items = list
+ notifyDataSetChanged()
+ }
+
+ inner class VH(view: View) : RecyclerView.ViewHolder(view) {
+ val ivPhoto = view.findViewById<ImageView>(R.id.iv_logbook_photo)
+ val tvTime = view.findViewById<TextView>(R.id.tv_logbook_timestamp)
+ val tvText = view.findViewById<TextView>(R.id.tv_logbook_text)
+ val tvLocation = view.findViewById<TextView>(R.id.tv_logbook_location)
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
+ VH(LayoutInflater.from(parent.context).inflate(R.layout.item_logbook_entry, parent, false))
+
+ override fun getItemCount() = items.size
+
+ override fun onBindViewHolder(holder: VH, position: Int) {
+ val e = items[position]
+ holder.tvTime.text = TIME_FMT.format(Instant.ofEpochMilli(e.timestampMs))
+ holder.tvText.text = e.text
+
+ if (e.lat != null && e.lon != null) {
+ holder.tvLocation.text = "%.4f°, %.4f°".format(e.lat, e.lon)
+ holder.tvLocation.visibility = View.VISIBLE
+ } else {
+ holder.tvLocation.visibility = View.GONE
+ }
+
+ val photoPath = e.photoPath
+ if (photoPath != null && File(photoPath).exists()) {
+ val bm = BitmapFactory.decodeFile(photoPath)
+ if (bm != null) {
+ holder.ivPhoto.setImageBitmap(bm)
+ holder.ivPhoto.visibility = View.VISIBLE
+ } else {
+ holder.ivPhoto.visibility = View.GONE
+ }
+ } else {
+ holder.ivPhoto.setImageDrawable(null)
+ holder.ivPhoto.visibility = View.GONE
+ }
+ }
+}
diff --git a/android-app/app/src/main/res/layout/fragment_voice_log.xml b/android-app/app/src/main/res/layout/fragment_voice_log.xml
index 389add1..4a2ff14 100644
--- a/android-app/app/src/main/res/layout/fragment_voice_log.xml
+++ b/android-app/app/src/main/res/layout/fragment_voice_log.xml
@@ -160,6 +160,34 @@
</LinearLayout>
<!-- end layout_log_entry -->
+ <!-- ── Log entries section ── -->
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="Log Entries"
+ android:textSize="13sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.08"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginBottom="8dp" />
+
+ <TextView
+ android:id="@+id/tv_no_log_entries"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="No log entries yet."
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginBottom="8dp"
+ android:visibility="visible" />
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/rv_log_entries"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:nestedScrollingEnabled="false"
+ android:clipToPadding="false"
+ android:layout_marginBottom="16dp" />
+
<!-- Generate Trip Report — always accessible -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_generate_report"
diff --git a/android-app/app/src/main/res/layout/item_logbook_entry.xml b/android-app/app/src/main/res/layout/item_logbook_entry.xml
new file mode 100644
index 0000000..10cec4a
--- /dev/null
+++ b/android-app/app/src/main/res/layout/item_logbook_entry.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:padding="12dp"
+ android:gravity="center_vertical"
+ android:background="?attr/selectableItemBackground">
+
+ <ImageView
+ android:id="@+id/iv_logbook_photo"
+ android:layout_width="64dp"
+ android:layout_height="48dp"
+ android:scaleType="centerCrop"
+ android:layout_marginEnd="12dp"
+ android:contentDescription="Log photo"
+ android:visibility="gone" />
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <TextView
+ android:id="@+id/tv_logbook_timestamp"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="12sp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ <TextView
+ android:id="@+id/tv_logbook_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="14sp"
+ android:layout_marginTop="2dp" />
+
+ <TextView
+ android:id="@+id/tv_logbook_location"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="12sp"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginTop="2dp"
+ android:visibility="gone" />
+
+ </LinearLayout>
+
+</LinearLayout>
diff --git a/android-app/app/src/main/res/xml/file_paths.xml b/android-app/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..c8f824e
--- /dev/null
+++ b/android-app/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<paths>
+ <external-files-path name="nav_photos" path="Pictures/Nav/" />
+</paths>