diff options
| author | Claude <noreply@anthropic.com> | 2026-05-25 18:56:05 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-05-25 18:56:05 +0000 |
| commit | 620aa6c7419a611639be5f005c34a27e19bfee89 (patch) | |
| tree | e848914804c8b1e5782ec1e098a8bf9f616c451f /android-app | |
| parent | 4d476844170b2892599274b28fe1235fa6286655 (diff) | |
| parent | 02776054778c3cbc8aefc43376b105becfec2b86 (diff) | |
Merge claude/optimize-nav-location-tracking-Vac5Z into main
Resolve conflict in TackDetector.kt: keep null-safe lastTackMs check
from main (branch had a NPE-unsafe subtraction on nullable Long).
https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn
Diffstat (limited to 'android-app')
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 3d022e8..49de36f 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 @@ -227,6 +230,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> |
