summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--android-app/SESSION_STATE.md32
-rw-r--r--android-app/app/src/main/AndroidManifest.xml1
-rw-r--r--android-app/app/src/main/cpp/CMakeLists.txt59
-rw-r--r--android-app/app/src/main/cpp/native-lib.cpp14
-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
-rw-r--r--android-app/app/src/main/res/layout/activity_main.xml25
-rw-r--r--android-app/app/src/main/res/layout/fragment_voice_log.xml66
13 files changed, 462 insertions, 0 deletions
diff --git a/android-app/SESSION_STATE.md b/android-app/SESSION_STATE.md
new file mode 100644
index 0000000..f1c9edc
--- /dev/null
+++ b/android-app/SESSION_STATE.md
@@ -0,0 +1,32 @@
+# SESSION_STATE.md
+
+## Current Task Goal
+Add VoiceLogFragment with SpeechRecognizer integration to the sailing companion Android app.
+
+## Status: [APPROVED]
+
+## Completed Items
+- [APPROVED] Add RECORD_AUDIO permission to AndroidManifest.xml
+- [APPROVED] Create logbook data layer stubs:
+ - `app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt`
+ - `app/src/main/kotlin/org/terst/nav/logbook/VoiceLogState.kt` (sealed class: Idle, Listening, Result, Saved, Error)
+ - `app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt`
+ - `app/src/main/kotlin/org/terst/nav/logbook/InMemoryLogbookRepository.kt`
+- [APPROVED] Create `app/src/main/res/layout/fragment_voice_log.xml`
+- [APPROVED] Create `app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt`
+- [APPROVED] Add voice log FAB (`fab_voice_log`) and fragment container (`voice_log_container`) to `activity_main.xml`
+- [APPROVED] Wire VoiceLogFragment into MainActivity via FAB toggle
+
+## Navigation Integration Note
+The existing app uses a single-activity design (no BottomNavigationView). VoiceLogFragment is integrated via:
+- A FAB (`fab_voice_log`) positioned above `fab_tidal` on the right side
+- A `FrameLayout` (`voice_log_container`) covering the full screen, initially `GONE`
+- FAB click shows/hides the container and adds/pops VoiceLogFragment on the FragmentManager back stack
+
+## Package Adaptation
+Task instructions referenced `com.example.androidapp` package but the actual sailing companion uses `org.terst.nav`. All files were created with the correct package.
+
+## Next Steps
+- N/A (task complete)
+- Future: Persist logbook entries to a Room database instead of in-memory storage
+- Future: Classify entry type from speech text rather than defaulting to GENERAL
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>