From 418f6ae8c8ccb968c2674548139dab36e2ab1905 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 15 Mar 2026 05:39:21 +0000 Subject: feat: add voice log UI with FAB, fragment container, and logbook domain models - Add VoiceLogFragment, VoiceLogViewModel, VoiceLogState, LogEntry, InMemoryLogbookRepository - Wire voice log FAB in MainActivity to show/hide fragment container - Add RECORD_AUDIO permission to manifest - Add native CMakeLists.txt and native-lib.cpp stubs - Fix missing BufferOverflow import in LocationService Co-Authored-By: Claude Sonnet 4.6 --- .../main/kotlin/org/terst/nav/LocationService.kt | 1 + .../src/main/kotlin/org/terst/nav/MainActivity.kt | 23 +++ .../terst/nav/logbook/InMemoryLogbookRepository.kt | 14 ++ .../main/kotlin/org/terst/nav/logbook/LogEntry.kt | 12 ++ .../kotlin/org/terst/nav/logbook/VoiceLogState.kt | 9 ++ .../org/terst/nav/logbook/VoiceLogViewModel.kt | 37 +++++ .../org/terst/nav/ui/voicelog/VoiceLogFragment.kt | 169 +++++++++++++++++++++ 7 files changed, 265 insertions(+) create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/logbook/InMemoryLogbookRepository.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogState.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt (limited to 'android-app/app/src/main/kotlin/org') 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 d9233a4..51915bd 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 @@ -14,6 +14,7 @@ import android.os.IBinder import android.os.Looper import androidx.core.app.NotificationCompat import com.google.android.gms.location.* +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow 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 d99bf85..a6c063b 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 @@ -11,9 +11,11 @@ import android.content.Intent import android.util.Log import android.view.View import android.widget.Button +import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast +import org.terst.nav.ui.voicelog.VoiceLogFragment import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.widget.ConstraintLayout @@ -113,6 +115,10 @@ class MainActivity : AppCompatActivity() { private lateinit var barometerTrendView: BarometerTrendView private lateinit var polarDiagramView: PolarDiagramView // Reference to the custom view + // Voice Log UI elements + private lateinit var fabVoiceLog: FloatingActionButton + private lateinit var voiceLogContainer: FrameLayout + // Anchor Watch UI elements private lateinit var fabAnchor: FloatingActionButton private lateinit var anchorConfigContainer: ConstraintLayout @@ -351,6 +357,23 @@ class MainActivity : AppCompatActivity() { mobRecoveredButton.setOnClickListener { recoverMob() } + + // Initialize Voice Log + fabVoiceLog = findViewById(R.id.fab_voice_log) + voiceLogContainer = findViewById(R.id.voice_log_container) + + fabVoiceLog.setOnClickListener { + if (voiceLogContainer.visibility == View.VISIBLE) { + supportFragmentManager.popBackStack() + voiceLogContainer.visibility = View.GONE + } else { + voiceLogContainer.visibility = View.VISIBLE + supportFragmentManager.beginTransaction() + .replace(R.id.voice_log_container, VoiceLogFragment()) + .addToBackStack(null) + .commit() + } + } } private fun startLocationService() { diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/InMemoryLogbookRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/InMemoryLogbookRepository.kt new file mode 100644 index 0000000..25dd303 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/InMemoryLogbookRepository.kt @@ -0,0 +1,14 @@ +package org.terst.nav.logbook + +class InMemoryLogbookRepository { + private val entries = mutableListOf() + private var nextId = 1L + + fun save(entry: LogEntry): LogEntry { + val saved = entry.copy(id = nextId++) + entries.add(saved) + return saved + } + + fun getAll(): List = entries.toList() +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt new file mode 100644 index 0000000..17cebfb --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt @@ -0,0 +1,12 @@ +package org.terst.nav.logbook + +enum class EntryType { SAIL_CHANGE, ENGINE, WEATHER_OBS, NAV_EVENT, GENERAL } + +data class LogEntry( + val id: Long = 0L, + val timestampMs: Long, + val text: String, + val entryType: EntryType, + val lat: Double? = null, + val lon: Double? = null +) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogState.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogState.kt new file mode 100644 index 0000000..fe51cf8 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogState.kt @@ -0,0 +1,9 @@ +package org.terst.nav.logbook + +sealed class VoiceLogState { + object Idle : VoiceLogState() + object Listening : VoiceLogState() + data class Result(val recognized: String) : VoiceLogState() + data class Saved(val entry: LogEntry) : VoiceLogState() + data class Error(val message: String) : VoiceLogState() +} 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 new file mode 100644 index 0000000..067cbaf --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt @@ -0,0 +1,37 @@ +package org.terst.nav.logbook + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class VoiceLogViewModel(private val repository: InMemoryLogbookRepository) { + + private val _state = MutableStateFlow(VoiceLogState.Idle) + val state: StateFlow = _state + + fun onListeningStarted() { + _state.value = VoiceLogState.Listening + } + + fun onSpeechRecognized(text: String) { + _state.value = VoiceLogState.Result(text) + } + + fun onRecognitionError(message: String) { + _state.value = VoiceLogState.Error(message) + } + + fun confirmAndSave() { + val current = _state.value as? VoiceLogState.Result ?: return + val entry = LogEntry( + timestampMs = System.currentTimeMillis(), + text = current.recognized, + entryType = EntryType.GENERAL + ) + val saved = repository.save(entry) + _state.value = VoiceLogState.Saved(saved) + } + + fun retry() { + _state.value = VoiceLogState.Idle + } +} 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 new file mode 100644 index 0000000..ef48d37 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt @@ -0,0 +1,169 @@ +package org.terst.nav.ui.voicelog + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.google.android.material.floatingactionbutton.FloatingActionButton +import kotlinx.coroutines.launch +import org.terst.nav.R +import org.terst.nav.logbook.InMemoryLogbookRepository +import org.terst.nav.logbook.VoiceLogState +import org.terst.nav.logbook.VoiceLogViewModel +import java.util.Locale + +class VoiceLogFragment : Fragment() { + + private lateinit var speechRecognizer: SpeechRecognizer + private val viewModel by lazy { + VoiceLogViewModel(repository = InMemoryLogbookRepository()) + } + + private lateinit var tvStatus: TextView + private lateinit var tvRecognized: TextView + private lateinit var fabMic: FloatingActionButton + private lateinit var llConfirm: LinearLayout + private lateinit var btnSave: Button + private lateinit var btnRetry: Button + private lateinit var tvSavedConfirmation: TextView + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = inflater.inflate(R.layout.fragment_voice_log, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + tvStatus = view.findViewById(R.id.tv_status) + tvRecognized = view.findViewById(R.id.tv_recognized) + fabMic = view.findViewById(R.id.fab_mic) + llConfirm = view.findViewById(R.id.ll_confirm_buttons) + btnSave = view.findViewById(R.id.btn_save) + btnRetry = view.findViewById(R.id.btn_retry) + tvSavedConfirmation = view.findViewById(R.id.tv_saved_confirmation) + + setupSpeechRecognizer() + + fabMic.setOnClickListener { startListening() } + btnSave.setOnClickListener { viewModel.confirmAndSave() } + btnRetry.setOnClickListener { viewModel.retry() } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.state.collect { state -> renderState(state) } + } + } + + private fun setupSpeechRecognizer() { + if (!SpeechRecognizer.isRecognitionAvailable(requireContext())) { + tvStatus.text = "Speech recognition not available" + fabMic.isEnabled = false + return + } + speechRecognizer = SpeechRecognizer.createSpeechRecognizer(requireContext()) + speechRecognizer.setRecognitionListener(object : RecognitionListener { + override fun onReadyForSpeech(params: Bundle?) { viewModel.onListeningStarted() } + override fun onResults(results: Bundle?) { + val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) + val text = matches?.firstOrNull() ?: "" + if (text.isNotBlank()) viewModel.onSpeechRecognized(text) + else viewModel.onRecognitionError("Could not understand speech") + } + override fun onError(error: Int) { + viewModel.onRecognitionError("Recognition error: $error") + } + override fun onBeginningOfSpeech() {} + override fun onBufferReceived(buffer: ByteArray?) {} + override fun onEndOfSpeech() {} + override fun onEvent(eventType: Int, params: Bundle?) {} + override fun onPartialResults(partialResults: Bundle?) {} + override fun onRmsChanged(rmsdB: Float) {} + }) + } + + private fun startListening() { + if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.RECORD_AUDIO) + != PackageManager.PERMISSION_GRANTED + ) { + @Suppress("DEPRECATION") + requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), RC_AUDIO) + return + } + val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault()) + putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1) + } + speechRecognizer.startListening(intent) + } + + private fun renderState(state: VoiceLogState) { + when (state) { + is VoiceLogState.Idle -> { + tvStatus.text = "Tap microphone to log" + tvRecognized.text = "" + llConfirm.visibility = View.GONE + tvSavedConfirmation.text = "" + fabMic.isEnabled = true + } + is VoiceLogState.Listening -> { + tvStatus.text = "Listening…" + tvRecognized.text = "" + llConfirm.visibility = View.GONE + fabMic.isEnabled = false + } + is VoiceLogState.Result -> { + tvStatus.text = "Recognized:" + tvRecognized.text = state.recognized + llConfirm.visibility = View.VISIBLE + fabMic.isEnabled = false + } + is VoiceLogState.Saved -> { + tvStatus.text = "Saved!" + tvRecognized.text = state.entry.text + tvSavedConfirmation.text = "[${state.entry.entryType}] entry saved" + llConfirm.visibility = View.GONE + fabMic.isEnabled = true + } + is VoiceLogState.Error -> { + tvStatus.text = "Error: ${state.message}" + tvRecognized.text = "" + llConfirm.visibility = View.GONE + fabMic.isEnabled = true + } + } + } + + @Deprecated("Deprecated in Java") + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + if (requestCode == RC_AUDIO && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) { + startListening() + } + } + + override fun onDestroyView() { + super.onDestroyView() + if (::speechRecognizer.isInitialized) speechRecognizer.destroy() + } + + companion object { + private const val RC_AUDIO = 1001 + } +} -- cgit v1.2.3