summaryrefslogtreecommitdiff
path: root/android-app/app/src/main/kotlin/org/terst
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app/src/main/kotlin/org/terst')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt1
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt23
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/InMemoryLogbookRepository.kt14
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt12
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogState.kt9
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt37
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt169
7 files changed, 265 insertions, 0 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 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<LogEntry>()
+ private var nextId = 1L
+
+ fun save(entry: LogEntry): LogEntry {
+ val saved = entry.copy(id = nextId++)
+ entries.add(saved)
+ return saved
+ }
+
+ fun getAll(): List<LogEntry> = 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>(VoiceLogState.Idle)
+ val state: StateFlow<VoiceLogState> = _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<out String>,
+ 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
+ }
+}