summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-05-26 09:13:38 +0000
committerClaude <noreply@anthropic.com>2026-05-26 09:13:38 +0000
commit9f298ac64183bcf54d2531e0363b205a04e3366a (patch)
tree818e63d9fee6b628bf34afc69969e3ffcec1cd2c
parentf564e06338f846930300c366774efb97c042a5fb (diff)
Fix gallery photo timestamp and map pin not updating after note save
Gallery photos were always anchored to track.endMs with the final GPS position. Now reads EXIF DateTimeOriginal (parsed in device local time) after copying the file; if the timestamp falls within the track window the note is positioned at the correct time and nearest track point. Notes taken outside the window fall back to the previous behaviour. Map note layer was never refreshed after saving — drawNotes() ran once at style-load time and the GeoJsonSource was never updated. Added refreshNoteLayer() which updates the existing source or creates it if no notes existed before. https://claude.ai/code/session_01KXYBuAHZkvv63DeUG6XaZD
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt48
1 files changed, 42 insertions, 6 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt
index 7c26a01..f101779 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt
@@ -1,5 +1,6 @@
package org.terst.nav.track
+import android.media.ExifInterface
import android.app.Activity
import android.app.AlertDialog
import android.content.Intent
@@ -50,7 +51,9 @@ import java.io.FileOutputStream
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
+import java.text.SimpleDateFormat
import java.util.Locale
+import java.util.TimeZone
import kotlin.math.abs
/** A notable event extracted from a track for display in the log step-through. */
@@ -72,6 +75,7 @@ class TrackDetailSheet : Fragment() {
// Note-adding state
private var pendingNotePhotoPath: String? = null
+ private var pendingNotePhotoTimestampMs: Long? = null
private var noteDialogPhotoFrame: FrameLayout? = null
private var noteDialogPhotoView: ImageView? = null
private var logEventAdapter: LogEventAdapter? = null
@@ -97,18 +101,20 @@ class TrackDetailSheet : Fragment() {
) { uri: Uri? ->
if (uri != null) {
lifecycleScope.launch {
- val path = withContext(Dispatchers.IO) {
+ val result = withContext(Dispatchers.IO) {
runCatching {
val dir = NavApplication.logbookRepository.photoDir
val dest = File(dir, "log_${System.currentTimeMillis()}.jpg")
requireContext().contentResolver.openInputStream(uri)?.use { inp ->
FileOutputStream(dest).use { out -> inp.copyTo(out) }
}
- dest.absolutePath
+ dest.absolutePath to readExifTimestamp(dest.absolutePath)
}.getOrNull()
}
- if (path != null) {
+ if (result != null) {
+ val (path, ts) = result
pendingNotePhotoPath = path
+ pendingNotePhotoTimestampMs = ts
noteDialogPhotoView?.setImageURI(Uri.fromFile(File(path)))
noteDialogPhotoFrame?.visibility = View.VISIBLE
}
@@ -182,6 +188,7 @@ class TrackDetailSheet : Fragment() {
noteDialogPhotoFrame = dialogView.findViewById(R.id.frame_note_photo)
noteDialogPhotoView = dialogView.findViewById(R.id.iv_note_photo)
pendingNotePhotoPath = null
+ pendingNotePhotoTimestampMs = null
noteDialogPhotoFrame?.visibility = View.GONE
dialogView.findViewById<Button>(R.id.btn_note_camera).setOnClickListener {
@@ -204,6 +211,7 @@ class TrackDetailSheet : Fragment() {
dialogView.findViewById<Button>(R.id.btn_note_remove_photo).setOnClickListener {
pendingNotePhotoPath?.let { File(it).delete() }
pendingNotePhotoPath = null
+ pendingNotePhotoTimestampMs = null
noteDialogPhotoView?.setImageDrawable(null)
noteDialogPhotoFrame?.visibility = View.GONE
}
@@ -215,10 +223,13 @@ class TrackDetailSheet : Fragment() {
val text = etNote.text?.toString()?.trim() ?: ""
val path = pendingNotePhotoPath
if (text.isBlank() && path == null) return@setPositiveButton
- val anchor = track.points.lastOrNull()
+ // Use EXIF timestamp if the photo was taken during this track; otherwise anchor to end.
+ val photoTs = pendingNotePhotoTimestampMs
+ val entryTs = if (photoTs != null && photoTs in track.startMs..track.endMs) photoTs else track.endMs
+ val anchor = if (entryTs < track.endMs) nearestTrackPoint(track.points, entryTs) else track.points.lastOrNull()
NavApplication.logbookRepository.save(
LogEntry(
- timestampMs = track.endMs,
+ timestampMs = entryTs,
text = text.ifBlank { "(photo only)" },
entryType = EntryType.GENERAL,
lat = anchor?.lat,
@@ -227,11 +238,15 @@ class TrackDetailSheet : Fragment() {
)
)
pendingNotePhotoPath = null
- logEventAdapter?.update(buildLogEvents(track))
+ pendingNotePhotoTimestampMs = null
+ val newEvents = buildLogEvents(track)
+ logEventAdapter?.update(newEvents)
+ refreshNoteLayer(newEvents)
}
.setNegativeButton("Cancel") { _, _ ->
pendingNotePhotoPath?.let { File(it).delete() }
pendingNotePhotoPath = null
+ pendingNotePhotoTimestampMs = null
}
.show()
}
@@ -354,6 +369,27 @@ class TrackDetailSheet : Fragment() {
private fun nearestTrackPoint(points: List<TrackPoint>, timestampMs: Long): TrackPoint? =
points.minByOrNull { abs(it.timestampMs - timestampMs) }
+ /** Updates the note circle layer after a new note is saved, creating the source if needed. */
+ private fun refreshNoteLayer(events: List<LogEvent>) {
+ val notes = events.filter { it.photoPath != null || it.icon == "📝" }
+ val fc = FeatureCollection.fromFeatures(notes.map { Feature.fromGeometry(Point.fromLngLat(it.lon, it.lat)) })
+ trackMap?.style?.let { style ->
+ (style.getSource("notes-detail-source") as? GeoJsonSource)?.setGeoJson(fc)
+ ?: drawNotes(style, notes)
+ }
+ }
+
+ /** Reads EXIF DateTimeOriginal from a JPEG, parsed as device-local time. Returns null on failure. */
+ private fun readExifTimestamp(filePath: String): Long? = runCatching {
+ val exif = ExifInterface(filePath)
+ val dt = exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)
+ ?: exif.getAttribute(ExifInterface.TAG_DATETIME)
+ ?: return@runCatching null
+ SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US)
+ .apply { timeZone = TimeZone.getDefault() }
+ .parse(dt)?.time
+ }.getOrNull()
+
private fun formatPointDetail(p: TrackPoint): String {
val parts = mutableListOf("%.1f kt %.0f°".format(p.sogKnots, p.cogDeg))
p.windSpeedKnots?.let { parts += "W %.0f kt".format(it) }