diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-03-15 05:39:21 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-15 05:39:21 +0000 |
| commit | 418f6ae8c8ccb968c2674548139dab36e2ab1905 (patch) | |
| tree | 7371f4b31a7d75b18626b51231d3ff76ebeb92a9 /android-app/app/src | |
| parent | 18c2f1c038f62fda1c1cea19c12dfdd4ce411602 (diff) | |
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 <noreply@anthropic.com>
Diffstat (limited to 'android-app/app/src')
12 files changed, 430 insertions, 0 deletions
diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index a5ef711..abb4dc5 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.RECORD_AUDIO" /> <application android:allowBackup="true" diff --git a/android-app/app/src/main/cpp/CMakeLists.txt b/android-app/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..9147ce6 --- /dev/null +++ b/android-app/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,59 @@ +# Sets the minimum version of CMake required to build your native library. +# This ensures that a certain set of CMake features is available to +# your build. + +cmake_minimum_required(VERSION 3.4.1) + +# Declares and names the project. +project("wind_visualization_native") + +# Creates and names a library, sets it as either STATIC or SHARED, and +# specifies the source files. +# The wind_visualization_native library will be built as a shared library +# and will include the C++ source file `native-lib.cpp`. +add_library( # Sets the name of the library. + wind_visualization_native + + # Sets the library as a shared library. + SHARED + + # Provides a relative path to your source file(s). + native-lib.cpp ) # Corrected path + +# Searches for a prebuilt static library called 'maplibre' which contains the +# MapLibre GL Native custom layer host implementation. This library is usually +# provided by the MapLibre Android SDK. +find_library( # Sets the name of the path variable. + maplibre-gl-lib + + # Specifies the name of the NDK library that + # CMake should locate. + maplibre ) # Searching for libmaplibre.so + +# Searches for the Android log library. +find_library( # Sets the name of the path variable. + log-lib + + # Specifies the name of the NDK library that + # CMake should locate. + log ) + +# Specifies paths to the header files of a library. +# For example, the MapLibre GL Native SDK headers might be in a +# directory like 'src/main/cpp/maplibre-gl-native-headers'. +# You would add that path here. +# For now, let's just assume we will need JNI headers. +target_include_directories(wind_visualization_native PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + # Add JNI specific include directories if needed, typically handled by Android Gradle Plugin + ) + +# Specifies libraries that CMake should link to your target library. +# This includes the MapLibre GL Native library and the logging library. +target_link_libraries( # Specifies the target library. + wind_visualization_native + + # Links the target library to the log library + # included in the NDK. + ${maplibre-gl-lib} + ${log-lib} ) diff --git a/android-app/app/src/main/cpp/native-lib.cpp b/android-app/app/src/main/cpp/native-lib.cpp new file mode 100644 index 0000000..f606a41 --- /dev/null +++ b/android-app/app/src/main/cpp/native-lib.cpp @@ -0,0 +1,14 @@ +#include <jni.h> +#include <string> +#include <android/log.h> + +#define TAG "WindVisualizationNative" + +extern "C" JNIEXPORT jstring JNICALL +Java_org_terst_nav_MainActivity_stringFromJNI( + JNIEnv* env, + jobject /* this */) { + std::string hello = "Hello from C++"; + __android_log_print(ANDROID_LOG_INFO, TAG, "stringFromJNI called!"); + return env->NewStringUTF(hello.c_str()); +} 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 + } +} diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml index eb2ed3d..54ad0cd 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -514,4 +514,29 @@ </androidx.constraintlayout.widget.ConstraintLayout> + <!-- Voice Log FAB --> + <com.google.android.material.floatingactionbutton.FloatingActionButton + android:id="@+id/fab_voice_log" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_margin="16dp" + android:clickable="true" + android:focusable="true" + android:contentDescription="Open Voice Log" + android:src="@android:drawable/ic_btn_speak_now" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintBottom_toTopOf="@+id/fab_tidal" /> + + <!-- Voice Log Fragment Container --> + <FrameLayout + android:id="@+id/voice_log_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@android:color/white" + android:visibility="gone" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + </androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file 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 new file mode 100644 index 0000000..e5f864c --- /dev/null +++ b/android-app/app/src/main/res/layout/fragment_voice_log.xml @@ -0,0 +1,66 @@ +<?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="match_parent" + android:orientation="vertical" + android:gravity="center" + android:padding="24dp"> + + <TextView + android:id="@+id/tv_status" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Tap microphone to log" + android:textSize="18sp" + android:textAlignment="center" + android:layout_marginBottom="16dp" /> + + <TextView + android:id="@+id/tv_recognized" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="" + android:textSize="16sp" + android:textAlignment="center" + android:padding="12dp" + android:minHeight="80dp" + android:background="#F5F5F5" + android:layout_marginBottom="24dp" /> + + <com.google.android.material.floatingactionbutton.FloatingActionButton + android:id="@+id/fab_mic" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:contentDescription="Start voice recognition" + android:src="@android:drawable/ic_btn_speak_now" + android:layout_marginBottom="16dp" /> + + <LinearLayout + android:id="@+id/ll_confirm_buttons" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:visibility="gone"> + + <Button + android:id="@+id/btn_save" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Save" + android:layout_marginEnd="8dp" /> + + <Button + android:id="@+id/btn_retry" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Retry" /> + </LinearLayout> + + <TextView + android:id="@+id/tv_saved_confirmation" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="" + android:textSize="14sp" + android:layout_marginTop="16dp" /> +</LinearLayout> |
