summaryrefslogtreecommitdiff
path: root/android-app
diff options
context:
space:
mode:
Diffstat (limited to 'android-app')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt71
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt13
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt343
-rw-r--r--android-app/app/src/main/res/layout/activity_main.xml7
-rw-r--r--android-app/app/src/main/res/layout/dialog_new_note.xml80
-rw-r--r--android-app/app/src/main/res/layout/fragment_voice_log.xml108
6 files changed, 433 insertions, 189 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
index 3c89fc5..b90021e 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
@@ -79,7 +79,8 @@ class LocationService : Service() {
private val nmeaStalenessThresholdMs: Long = 5_000L
private val nmeaExtendedThresholdMs: Long = 10_000L
- private val NOTIFICATION_CHANNEL_ID = "location_service_channel"
+ private val CHANNEL_BACKGROUND = "location_background"
+ private val CHANNEL_RECORDING = "track_recording"
private val NOTIFICATION_ID = 123
private var isAlarmTriggered = false
@@ -302,9 +303,10 @@ class LocationService : Service() {
nmeaStreamManager.stop()
}
ACTION_SET_RECORDING -> {
- val recording = intent.getBooleanExtra(EXTRA_IS_RECORDING, false)
- val mgr = getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager
- mgr.notify(NOTIFICATION_ID, createNotification(recording))
+ val recording = intent.getBooleanExtra(EXTRA_IS_RECORDING, false)
+ val elapsedMs = intent.getLongExtra(EXTRA_ELAPSED_MS, 0L)
+ val distanceNm = intent.getDoubleExtra(EXTRA_DISTANCE_NM, 0.0)
+ startForeground(NOTIFICATION_ID, createNotification(recording, elapsedMs, distanceNm))
}
}
return START_NOT_STICKY
@@ -365,27 +367,48 @@ class LocationService : Service() {
}
private fun createNotificationChannel() {
- val serviceChannel = NotificationChannel(
- NOTIFICATION_CHANNEL_ID,
- "Location Service Channel",
- NotificationManager.IMPORTANCE_LOW
- )
- val manager = getSystemService(NotificationManager::class.java) as NotificationManager
- manager.createNotificationChannel(serviceChannel)
+ val mgr = getSystemService(NotificationManager::class.java) as NotificationManager
+ mgr.createNotificationChannel(NotificationChannel(
+ CHANNEL_BACKGROUND, "Background location",
+ NotificationManager.IMPORTANCE_MIN
+ ).apply { setShowBadge(false) })
+ mgr.createNotificationChannel(NotificationChannel(
+ CHANNEL_RECORDING, "Track recording",
+ NotificationManager.IMPORTANCE_DEFAULT
+ ))
}
- private fun createNotification(recording: Boolean = false): Notification {
- val notificationIntent = Intent(this, MainActivity::class.java)
- val pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
- val title = if (recording) "Recording track" else "Sailing Companion"
- val text = if (recording) "Track recording in progress — tap to open" else "Tracking your location..."
- return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
- .setContentTitle(title)
- .setContentText(text)
- .setSmallIcon(R.drawable.ic_anchor)
- .setContentIntent(pendingIntent)
- .setOngoing(recording)
- .build()
+ private fun createNotification(
+ recording: Boolean = false,
+ elapsedMs: Long = 0L,
+ distanceNm: Double = 0.0
+ ): Notification {
+ val pendingIntent = PendingIntent.getActivity(
+ this, 0,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ return if (recording) {
+ val h = elapsedMs / 3_600_000
+ val m = (elapsedMs % 3_600_000) / 60_000
+ val timeStr = if (h > 0) "${h}h ${m}m" else "${m}m"
+ val distStr = "%.1f nm".format(distanceNm)
+ NotificationCompat.Builder(this, CHANNEL_RECORDING)
+ .setContentTitle(getString(R.string.app_name))
+ .setContentText("Recording journey · $timeStr · $distStr")
+ .setSmallIcon(R.drawable.ic_anchor)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .build()
+ } else {
+ NotificationCompat.Builder(this, CHANNEL_BACKGROUND)
+ .setContentTitle(getString(R.string.app_name))
+ .setContentText("Location active")
+ .setSmallIcon(R.drawable.ic_anchor)
+ .setContentIntent(pendingIntent)
+ .setPriority(NotificationCompat.PRIORITY_MIN)
+ .build()
+ }
}
@SuppressLint("MissingPermission")
@@ -437,6 +460,8 @@ class LocationService : Service() {
const val EXTRA_TIDAL_VISIBILITY = "extra_tidal_visibility"
const val EXTRA_WATCH_RADIUS = "extra_watch_radius"
const val EXTRA_IS_RECORDING = "extra_is_recording"
+ const val EXTRA_ELAPSED_MS = "extra_elapsed_ms"
+ const val EXTRA_DISTANCE_NM = "extra_distance_nm"
private val _locationFlow = MutableSharedFlow<GpsPosition>(replay = 1)
val locationFlow: SharedFlow<GpsPosition> get() = _locationFlow
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
index 6c091ff..052da28 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
@@ -24,6 +24,7 @@ import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
+import androidx.fragment.app.FragmentManager
import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.button.MaterialButton
@@ -310,6 +311,13 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
stats.distanceNm, stats.durationFormatted, stats.avgSogKnots
)
tvTrackStats.visibility = View.VISIBLE
+ // Keep the foreground notification current with latest elapsed time + distance
+ startService(Intent(this@MainActivity, LocationService::class.java).apply {
+ action = LocationService.ACTION_SET_RECORDING
+ putExtra(LocationService.EXTRA_IS_RECORDING, true)
+ putExtra(LocationService.EXTRA_ELAPSED_MS, stats.durationMs)
+ putExtra(LocationService.EXTRA_DISTANCE_NM, stats.distanceNm)
+ })
} else {
tvTrackStats.visibility = View.GONE
}
@@ -522,6 +530,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
bottomSheetBehavior.isHideable = false
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
lastConditions?.let { applyConditions(it) }
+ // Clear any selected track immediately so its layers don't bleed onto the main map.
+ // Also pop the track_detail back stack entry if it exists — without this, the
+ // TrackDetailSheet fragment is never destroyed when navigating via the bottom nav.
+ viewModel.clearSelectedTrack()
+ supportFragmentManager.popBackStack("track_detail", FragmentManager.POP_BACK_STACK_INCLUSIVE)
}
override fun onQuitRequested() {
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 83e8c07..80bdf1a 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
@@ -2,11 +2,13 @@ package org.terst.nav.ui.voicelog
import android.Manifest
import android.app.Activity
+import android.app.AlertDialog
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
+import android.widget.LinearLayout
import android.os.Bundle
import android.provider.MediaStore
import android.speech.RecognitionListener
@@ -31,6 +33,7 @@ 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.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.terst.nav.MainActivity
@@ -40,8 +43,8 @@ import org.terst.nav.logbook.LogEntry
import org.terst.nav.logbook.VoiceLogState
import org.terst.nav.logbook.VoiceLogViewModel
import org.terst.nav.track.SafState
-import org.terst.nav.track.SavedTrackAdapter
-import org.terst.nav.track.TrackDetailSheet
+import org.terst.nav.track.SavedTrack
+import org.terst.nav.track.TrackSummary
import org.terst.nav.ui.MainViewModel
import java.io.File
import java.time.Instant
@@ -49,6 +52,17 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
+/** A merged row in the ship's log — either a completed track or a standalone note. */
+sealed class ShipLogItem {
+ data class TrackRow(val track: SavedTrack) : ShipLogItem()
+ data class NoteRow(val entry: LogEntry) : ShipLogItem()
+
+ val timestampMs: Long get() = when (this) {
+ is TrackRow -> track.startMs
+ is NoteRow -> entry.timestampMs
+ }
+}
+
class VoiceLogFragment : Fragment() {
private val logViewModel by lazy {
@@ -57,6 +71,7 @@ class VoiceLogFragment : Fragment() {
private val mainViewModel: MainViewModel by activityViewModels()
private lateinit var layoutLogEntry: View
+ private lateinit var btnNewNote: MaterialButton
private lateinit var speechRecognizer: SpeechRecognizer
private lateinit var etNote: TextInputEditText
private lateinit var fabMic: FloatingActionButton
@@ -70,25 +85,46 @@ class VoiceLogFragment : Fragment() {
private lateinit var btnClear: MaterialButton
private lateinit var tvSavedConfirmation: TextView
+ // Inline form state
private var pendingPhotoPath: String? = null
private var pendingCameraUri: Uri? = null
+ // Dialog state (reuses cameraLauncher/galleryLauncher with a mode flag)
+ private var dialogMode = false
+ private var activeDialog: AlertDialog? = null
+ private var dialogEt: TextInputEditText? = null
+ private var dialogPhotoFrame: FrameLayout? = null
+ private var dialogPhotoView: ImageView? = null
+ private var pendingDialogPhotoPath: String? = null
+
private val cameraLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val path = pendingPhotoPath ?: return@registerForActivityResult
- lifecycleScope.launch {
- val importedPath = withContext(Dispatchers.IO) {
- NavApplication.logbookRepository.importPhoto(File(path))
- } ?: path
- pendingPhotoPath = importedPath
- setPhoto(importedPath) { displayPhoto(ivPhoto, importedPath) }
+ if (dialogMode) {
+ lifecycleScope.launch {
+ val imported = withContext(Dispatchers.IO) {
+ NavApplication.logbookRepository.importPhoto(File(path))
+ } ?: path
+ pendingDialogPhotoPath = imported
+ dialogPhotoView?.setImageURI(photoUri(imported))
+ dialogPhotoFrame?.visibility = View.VISIBLE
+ }
+ } else {
+ lifecycleScope.launch {
+ val importedPath = withContext(Dispatchers.IO) {
+ NavApplication.logbookRepository.importPhoto(File(path))
+ } ?: path
+ pendingPhotoPath = importedPath
+ setPhoto(importedPath) { displayPhoto(ivPhoto, importedPath) }
+ }
}
} else {
pendingPhotoPath?.let { File(it).delete() }
pendingPhotoPath = null
pendingCameraUri = null
+ if (dialogMode) pendingDialogPhotoPath = null
}
}
@@ -96,14 +132,27 @@ class VoiceLogFragment : Fragment() {
ActivityResultContracts.GetContent()
) { uri: Uri? ->
if (uri != null) {
- lifecycleScope.launch {
- val path = withContext(Dispatchers.IO) {
- NavApplication.logbookRepository.copyPhoto(uri)
+ if (dialogMode) {
+ lifecycleScope.launch {
+ val path = withContext(Dispatchers.IO) {
+ NavApplication.logbookRepository.copyPhoto(uri)
+ }
+ if (path != null) {
+ pendingDialogPhotoPath = path
+ dialogPhotoView?.setImageURI(photoUri(path))
+ dialogPhotoFrame?.visibility = View.VISIBLE
+ }
}
- if (path != null) {
- setPhoto(path) { displayPhoto(ivPhoto, path) }
- } else {
- tvStatus.text = "Couldn't copy photo"
+ } else {
+ lifecycleScope.launch {
+ val path = withContext(Dispatchers.IO) {
+ NavApplication.logbookRepository.copyPhoto(uri)
+ }
+ if (path != null) {
+ setPhoto(path) { displayPhoto(ivPhoto, path) }
+ } else {
+ tvStatus.text = "Couldn't copy photo"
+ }
}
}
}
@@ -119,6 +168,7 @@ class VoiceLogFragment : Fragment() {
super.onViewCreated(view, savedInstanceState)
layoutLogEntry = view.findViewById(R.id.layout_log_entry)
+ btnNewNote = view.findViewById(R.id.btn_new_note)
etNote = view.findViewById(R.id.et_note)
fabMic = view.findViewById(R.id.fab_mic)
btnCamera = view.findViewById(R.id.btn_camera)
@@ -135,20 +185,11 @@ class VoiceLogFragment : Fragment() {
fabMic.setOnClickListener { startListening() }
- btnCamera.setOnClickListener {
- val file = File(NavApplication.logbookRepository.cameraDir, "log_${System.currentTimeMillis()}.jpg")
- pendingPhotoPath = file.absolutePath
- pendingCameraUri = FileProvider.getUriForFile(
- requireContext(), "${requireContext().packageName}.fileprovider", file
- )
- val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
- putExtra(MediaStore.EXTRA_OUTPUT, pendingCameraUri)
- addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
- }
- cameraLauncher.launch(intent)
+ btnCamera.setOnClickListener { launchCamera(forDialog = false) }
+ btnGallery.setOnClickListener {
+ dialogMode = false
+ galleryLauncher.launch("image/*")
}
-
- btnGallery.setOnClickListener { galleryLauncher.launch("image/*") }
btnRemovePhoto.setOnClickListener { clearPhoto() }
btnSave.setOnClickListener {
@@ -164,24 +205,7 @@ class VoiceLogFragment : Fragment() {
}
btnClear.setOnClickListener { clearEntry() }
- layoutLogEntry.visibility = View.VISIBLE
-
- // 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
- }
- }
+ btnNewNote.setOnClickListener { showNewNoteDialog() }
view.findViewById<MaterialButton>(R.id.btn_plan_trip).setOnClickListener {
parentFragmentManager.beginTransaction()
@@ -190,22 +214,29 @@ class VoiceLogFragment : Fragment() {
.commit()
}
- // Inline track list
- val rv = view.findViewById<RecyclerView>(R.id.rv_saved_tracks)
- val layoutEmpty = view.findViewById<View>(R.id.layout_empty)
+ // Show/hide inline form based on recording state
+ viewLifecycleOwner.lifecycleScope.launch {
+ mainViewModel.isRecording.collect { recording ->
+ layoutLogEntry.visibility = if (recording) View.VISIBLE else View.GONE
+ btnNewNote.visibility = if (recording) View.GONE else View.VISIBLE
+ }
+ }
+
+ // Unified ship's log list
+ val layoutEmpty = view.findViewById<LinearLayout>(R.id.layout_empty)
val tvEmpty = view.findViewById<TextView>(R.id.tv_empty)
val btnSetup = view.findViewById<MaterialButton>(R.id.btn_setup_storage)
-
+ val rv = view.findViewById<RecyclerView>(R.id.rv_ship_log)
rv.layoutManager = LinearLayoutManager(requireContext())
rv.isNestedScrollingEnabled = false
rv.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
- val tracksAdapter = SavedTrackAdapter(
- onSelect = { track ->
+ val shipLogAdapter = ShipLogAdapter(
+ onTrackTap = { track ->
mainViewModel.selectTrack(track)
(requireActivity() as MainActivity).showTrackDetail()
},
- onReport = { track ->
+ onTrackReport = { track ->
mainViewModel.selectTrack(track)
parentFragmentManager.beginTransaction()
.replace(R.id.fragment_container, org.terst.nav.tripreport.TripReportFragment.newInstanceForSavedTrack())
@@ -213,13 +244,21 @@ class VoiceLogFragment : Fragment() {
.commit()
}
)
- rv.adapter = tracksAdapter
+ rv.adapter = shipLogAdapter
viewLifecycleOwner.lifecycleScope.launch {
- mainViewModel.savedTracks.collect { tracks ->
- tracksAdapter.submitList(tracks)
- layoutEmpty.visibility = if (tracks.isEmpty()) View.VISIBLE else View.GONE
- rv.visibility = if (tracks.isEmpty()) View.GONE else View.VISIBLE
+ combine(mainViewModel.savedTracks, logViewModel.entries) { tracks, entries ->
+ val standaloneNotes = entries.filter { e ->
+ e.trackId == null && e.text.isNotBlank() && e.text != "(photo only)"
+ }
+ (tracks.map { ShipLogItem.TrackRow(it) } +
+ standaloneNotes.map { ShipLogItem.NoteRow(it) })
+ .sortedByDescending { it.timestampMs }
+ }.collect { items ->
+ shipLogAdapter.submitList(items)
+ val empty = items.isEmpty()
+ layoutEmpty.visibility = if (empty) View.VISIBLE else View.GONE
+ rv.visibility = if (empty) View.GONE else View.VISIBLE
}
}
@@ -228,7 +267,7 @@ class VoiceLogFragment : Fragment() {
mainViewModel.safState.collect { state ->
when (state) {
SafState.NOT_CONFIGURED -> {
- tvEmpty.text = "No saved tracks yet.\nRecord a trip to see it here."
+ tvEmpty.text = "No entries yet.\nRecord a trip to see it here."
btnSetup.text = "Set up track storage"
btnSetup.visibility = View.VISIBLE
}
@@ -251,6 +290,72 @@ class VoiceLogFragment : Fragment() {
}
}
+ private fun showNewNoteDialog() {
+ val dialogView = LayoutInflater.from(requireContext())
+ .inflate(R.layout.dialog_new_note, null)
+ dialogEt = dialogView.findViewById(R.id.et_dialog_note)
+ dialogPhotoFrame = dialogView.findViewById(R.id.frame_dialog_photo)
+ dialogPhotoView = dialogView.findViewById(R.id.iv_dialog_photo)
+ pendingDialogPhotoPath = null
+ dialogPhotoFrame?.visibility = View.GONE
+
+ dialogView.findViewById<MaterialButton>(R.id.btn_dialog_camera).setOnClickListener {
+ launchCamera(forDialog = true)
+ }
+ dialogView.findViewById<MaterialButton>(R.id.btn_dialog_gallery).setOnClickListener {
+ dialogMode = true
+ galleryLauncher.launch("image/*")
+ }
+ dialogView.findViewById<MaterialButton>(R.id.btn_dialog_remove_photo).setOnClickListener {
+ pendingDialogPhotoPath?.let { File(it).delete() }
+ pendingDialogPhotoPath = null
+ dialogPhotoView?.setImageDrawable(null)
+ dialogPhotoFrame?.visibility = View.GONE
+ }
+
+ activeDialog = AlertDialog.Builder(requireContext())
+ .setTitle("New Note")
+ .setView(dialogView)
+ .setPositiveButton("Save") { _, _ ->
+ val text = dialogEt?.text?.toString()?.trim() ?: ""
+ val path = pendingDialogPhotoPath
+ if (text.isBlank() && path == null) return@setPositiveButton
+ val pos = mainViewModel.currentPosition.value
+ logViewModel.save(text = text, photoPath = path, lat = pos?.first, lon = pos?.second, trackId = null)
+ pendingDialogPhotoPath = null
+ resetDialogState()
+ }
+ .setNegativeButton("Cancel") { _, _ ->
+ pendingDialogPhotoPath?.let { File(it).delete() }
+ pendingDialogPhotoPath = null
+ resetDialogState()
+ }
+ .setOnDismissListener { resetDialogState() }
+ .show()
+ }
+
+ private fun resetDialogState() {
+ dialogMode = false
+ activeDialog = null
+ dialogEt = null
+ dialogPhotoFrame = null
+ dialogPhotoView = null
+ }
+
+ private fun launchCamera(forDialog: Boolean) {
+ dialogMode = forDialog
+ val file = File(NavApplication.logbookRepository.cameraDir, "log_${System.currentTimeMillis()}.jpg")
+ pendingPhotoPath = file.absolutePath
+ pendingCameraUri = FileProvider.getUriForFile(
+ requireContext(), "${requireContext().packageName}.fileprovider", file
+ )
+ val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
+ putExtra(MediaStore.EXTRA_OUTPUT, pendingCameraUri)
+ addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
+ }
+ cameraLauncher.launch(intent)
+ }
+
// ── Speech ────────────────────────────────────────────────────────────────
private fun setupSpeechRecognizer() {
@@ -303,12 +408,13 @@ class VoiceLogFragment : Fragment() {
}
private fun displayPhoto(view: ImageView, pathOrUri: String) {
- view.setImageURI(
- if (pathOrUri.startsWith("content://")) Uri.parse(pathOrUri)
- else Uri.fromFile(File(pathOrUri))
- )
+ view.setImageURI(photoUri(pathOrUri))
}
+ private fun photoUri(pathOrUri: String): Uri =
+ if (pathOrUri.startsWith("content://")) Uri.parse(pathOrUri)
+ else Uri.fromFile(File(pathOrUri))
+
private fun clearPhoto() {
pendingPhotoPath = null
pendingCameraUri = null
@@ -373,6 +479,7 @@ class VoiceLogFragment : Fragment() {
override fun onDestroyView() {
super.onDestroyView()
if (::speechRecognizer.isInitialized) speechRecognizer.destroy()
+ activeDialog?.dismiss()
}
companion object {
@@ -403,58 +510,102 @@ private fun decodeThumbnail(context: android.content.Context, pathOrUri: String,
return BitmapFactory.decodeFile(pathOrUri, BitmapFactory.Options().apply { inSampleSize = scale })
}
-// ── Logbook entries adapter ────────────────────────────────────────────────────
+// ── Ship's log adapter ─────────────────────────────────────────────────────────
-private val TIME_FMT: DateTimeFormatter =
- DateTimeFormatter.ofPattern("dd MMM HH:mm", Locale.getDefault())
+private val DATE_FMT: DateTimeFormatter =
+ DateTimeFormatter.ofPattern("dd MMM yyyy HH:mm", Locale.getDefault())
.withZone(ZoneId.systemDefault())
-private class LogbookEntryAdapter : RecyclerView.Adapter<LogbookEntryAdapter.VH>() {
+private class ShipLogAdapter(
+ private val onTrackTap: (SavedTrack) -> Unit,
+ private val onTrackReport: (SavedTrack) -> Unit
+) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
- private var items: List<LogEntry> = emptyList()
+ private var items: List<ShipLogItem> = emptyList()
- fun submitList(list: List<LogEntry>) {
+ fun submitList(list: List<ShipLogItem>) {
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 getItemViewType(position: Int) = when (items[position]) {
+ is ShipLogItem.TrackRow -> VIEW_TRACK
+ is ShipLogItem.NoteRow -> VIEW_NOTE
}
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
- VH(LayoutInflater.from(parent.context).inflate(R.layout.item_logbook_entry, parent, false))
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
+ when (viewType) {
+ VIEW_TRACK -> TrackVH(LayoutInflater.from(parent.context)
+ .inflate(R.layout.item_saved_track, parent, false))
+ else -> NoteVH(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
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+ when (val item = items[position]) {
+ is ShipLogItem.TrackRow -> (holder as TrackVH).bind(item.track)
+ is ShipLogItem.NoteRow -> (holder as NoteVH).bind(item.entry)
+ }
+ }
- 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
+ inner class TrackVH(view: View) : RecyclerView.ViewHolder(view) {
+ val tvDate = view.findViewById<TextView>(R.id.tv_track_date)
+ val tvStats = view.findViewById<TextView>(R.id.tv_track_stats)
+ val tvTacks = view.findViewById<TextView>(R.id.tv_track_tacks)
+ val btnReport = view.findViewById<MaterialButton>(R.id.btn_track_report)
+
+ fun bind(track: SavedTrack) {
+ tvDate.text = DATE_FMT.format(Instant.ofEpochMilli(track.startMs))
+ val s = track.summary
+ val h = s.durationMs / 3_600_000
+ val m = (s.durationMs % 3_600_000) / 60_000
+ val dur = if (h > 0) "${h}h ${m}m" else "${m}m"
+ tvStats.text = "%.1f nm · %s · avg %.1f kt".format(s.distanceNm, dur, s.avgSogKt)
+ if (track.tacks.isNotEmpty()) {
+ tvTacks.text = "${track.tacks.size} tacks"
+ tvTacks.visibility = View.VISIBLE
+ } else {
+ tvTacks.visibility = View.GONE
+ }
+ itemView.setOnClickListener { onTrackTap(track) }
+ btnReport.setOnClickListener { onTrackReport(track) }
}
+ }
+
+ inner class NoteVH(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)
- val photoPath = e.photoPath
- if (photoPath != null) {
- val bm = decodeThumbnail(holder.itemView.context, photoPath, 128, 96)
- if (bm != null) {
- holder.ivPhoto.setImageBitmap(bm)
- holder.ivPhoto.visibility = View.VISIBLE
+ fun bind(e: LogEntry) {
+ tvTime.text = DATE_FMT.format(Instant.ofEpochMilli(e.timestampMs))
+ tvText.text = e.text
+ if (e.lat != null && e.lon != null) {
+ tvLocation.text = "%.4f°, %.4f°".format(e.lat, e.lon)
+ tvLocation.visibility = View.VISIBLE
} else {
- holder.ivPhoto.setImageDrawable(null)
- holder.ivPhoto.visibility = View.GONE
+ tvLocation.visibility = View.GONE
+ }
+ val photoPath = e.photoPath
+ if (photoPath != null) {
+ val bm = decodeThumbnail(itemView.context, photoPath, 128, 96)
+ if (bm != null) {
+ ivPhoto.setImageBitmap(bm)
+ ivPhoto.visibility = View.VISIBLE
+ } else {
+ ivPhoto.visibility = View.GONE
+ }
+ } else {
+ ivPhoto.visibility = View.GONE
}
- } else {
- holder.ivPhoto.setImageDrawable(null)
- holder.ivPhoto.visibility = View.GONE
}
}
+
+ companion object {
+ private const val VIEW_TRACK = 0
+ private const val VIEW_NOTE = 1
+ }
}
diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml
index 99aa192..c6e80e0 100644
--- a/android-app/app/src/main/res/layout/activity_main.xml
+++ b/android-app/app/src/main/res/layout/activity_main.xml
@@ -87,7 +87,7 @@
android:visibility="gone"
android:clickable="true"
android:focusable="true"
- android:background="?attr/colorSurface" />
+ android:background="?attr/colorSurfaceContainerHigh" />
<!-- Fishing mode overlay card — bottom-start, above bottom sheet -->
<org.terst.nav.ui.fishing.FishingOverlayView
@@ -126,7 +126,8 @@
android:layout_height="wrap_content"
app:behavior_hideable="false"
app:behavior_peekHeight="120dp"
- app:cardElevation="4dp"
+ app:cardElevation="0dp"
+ app:cardBackgroundColor="?attr/colorSurfaceContainerHigh"
app:strokeWidth="0dp"
app:shapeAppearance="@style/ShapeAppearance.Nav.BottomSheet"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
@@ -191,7 +192,7 @@
android:id="@+id/bottom_navigation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:background="?attr/colorSurface"
+ android:background="?attr/colorSurfaceContainerHigh"
app:menu="@menu/bottom_nav_menu" />
</LinearLayout>
diff --git a/android-app/app/src/main/res/layout/dialog_new_note.xml b/android-app/app/src/main/res/layout/dialog_new_note.xml
new file mode 100644
index 0000000..a403b8d
--- /dev/null
+++ b/android-app/app/src/main/res/layout/dialog_new_note.xml
@@ -0,0 +1,80 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="20dp">
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="12dp"
+ style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/et_dialog_note"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:minLines="4"
+ android:maxLines="8"
+ android:gravity="top"
+ android:hint="Add a note…"
+ android:inputType="textMultiLine|textCapSentences" />
+
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <!-- Camera / gallery row -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginBottom="8dp">
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_dialog_camera"
+ style="@style/Widget.Material3.Button.IconButton.Outlined"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:contentDescription="Take photo"
+ app:icon="@android:drawable/ic_menu_camera"
+ android:layout_marginEnd="8dp" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_dialog_gallery"
+ style="@style/Widget.Material3.Button.IconButton.Outlined"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:contentDescription="Attach photo from gallery"
+ app:icon="@android:drawable/ic_menu_gallery" />
+
+ </LinearLayout>
+
+ <!-- Photo preview -->
+ <FrameLayout
+ android:id="@+id/frame_dialog_photo"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:visibility="gone">
+
+ <ImageView
+ android:id="@+id/iv_dialog_photo"
+ android:layout_width="120dp"
+ android:layout_height="90dp"
+ android:scaleType="centerCrop"
+ android:contentDescription="Attached photo" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_dialog_remove_photo"
+ style="@style/Widget.Material3.Button.IconButton"
+ android:layout_width="32dp"
+ android:layout_height="32dp"
+ android:layout_gravity="top|end"
+ android:contentDescription="Remove photo"
+ app:icon="@drawable/ic_close" />
+
+ </FrameLayout>
+
+</LinearLayout>
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 cacc6d6..5726154 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
@@ -13,19 +13,48 @@
android:orientation="vertical"
android:padding="20dp">
- <!-- ── Log entry section ── -->
+ <!-- ── Header row: title + new note button (shown when not recording) ── -->
<LinearLayout
- android:id="@+id/layout_log_entry"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:orientation="vertical">
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginBottom="8dp">
<TextView
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="Ship's Log"
+ android:textAppearance="?attr/textAppearanceHeadlineSmall" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_new_note"
+ style="@style/Widget.Material3.Button.TonalButton.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="Log Entry"
- android:textAppearance="?attr/textAppearanceHeadlineSmall"
- android:layout_marginBottom="16dp" />
+ android:text="New Note"
+ app:icon="@android:drawable/ic_input_add"
+ android:visibility="gone" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_plan_trip"
+ style="@style/Widget.Material3.Button.TextButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Plan Trip"
+ android:textSize="12sp"
+ android:layout_marginStart="4dp" />
+
+ </LinearLayout>
+
+ <!-- ── Inline log entry (shown only while recording) ── -->
+ <LinearLayout
+ android:id="@+id/layout_log_entry"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:visibility="gone">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
@@ -156,68 +185,12 @@
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/colorOutline"
- android:layout_marginBottom="20dp" />
+ android:layout_marginBottom="16dp" />
</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" />
-
- <!-- ── Trips section ── -->
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:orientation="horizontal"
- android:gravity="center_vertical"
- android:layout_marginBottom="8dp">
-
- <TextView
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_weight="1"
- android:text="Trips"
- android:textSize="13sp"
- android:textAllCaps="true"
- android:letterSpacing="0.08"
- android:textColor="?attr/colorOnSurfaceVariant" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/btn_plan_trip"
- style="@style/Widget.Material3.Button.TextButton"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="Plan Trip"
- android:textSize="12sp" />
-
- </LinearLayout>
-
- <!-- Empty state -->
+ <!-- ── SAF / empty state ── -->
<LinearLayout
android:id="@+id/layout_empty"
android:layout_width="match_parent"
@@ -231,7 +204,7 @@
android:id="@+id/tv_empty"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="No saved tracks yet.\nRecord a trip to see it here."
+ android:text="No entries yet.\nRecord a trip or add a note to get started."
android:textAlignment="center"
android:textColor="?attr/colorOnSurfaceVariant" />
@@ -245,13 +218,14 @@
</LinearLayout>
+ <!-- ── Unified ship's log list ── -->
<androidx.recyclerview.widget.RecyclerView
- android:id="@+id/rv_saved_tracks"
+ android:id="@+id/rv_ship_log"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
android:clipToPadding="false"
- android:paddingBottom="80dp" />
+ android:paddingBottom="16dp" />
</LinearLayout>