diff options
Diffstat (limited to 'android-app/app/src/main/kotlin/org')
3 files changed, 308 insertions, 119 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 + } } |
