Skip to content

Commit 71365af

Browse files
committed
Radio Receive UI Updates
1 parent fbb8260 commit 71365af

File tree

12 files changed

+452
-150
lines changed

12 files changed

+452
-150
lines changed

app/build.gradle

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,11 @@ dependencies {
5656
implementation fileTree(dir: "libs", include: ["*.jar"])
5757

5858
// Operator Libraries
59-
implementation("com.github.OperatorFoundation:AudioCoderAndroid:84b22e4530") // Radio codec (WSPR) Library
59+
implementation("com.github.OperatorFoundation:AudioCoderAndroid:v1.0.1") // Radio codec (WSPR) Library
6060
implementation("com.github.OperatorFoundation:TransmissionAndroid:v1.4.0") // Serial communications
6161
implementation("com.github.OperatorFoundation:ion-android:1.1.0") // Communication protocol
6262
implementation("com.github.OperatorFoundation:CodexKotlin:v1.0.1") // Message Encoding
63-
implementation ("com.github.OperatorFoundation:SignalBridge:ee661b215c") // USBAudio Management
63+
implementation ("com.github.OperatorFoundation:SignalBridge:v1.0.1") // USBAudio Management
6464

6565
// 3rd party
6666
implementation 'com.github.mik3y:usb-serial-for-android:3.9.0'
@@ -90,6 +90,7 @@ dependencies {
9090

9191
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
9292
implementation 'com.github.joshjdevl.libsodiumjni:libsodium-jni-aar:2.0.2'
93+
implementation 'androidx.core:core-animation:1.0.0'
9394

9495
testImplementation 'junit:junit:4.13.2'
9596
androidTestImplementation 'androidx.test.ext:junit:1.1.5'

app/src/main/java/org/nahoft/Nahoft/fragments/ReceiveRadioBottomSheetFragment.kt

Lines changed: 150 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
package org.nahoft.Nahoft.fragments
22

3+
import android.animation.ObjectAnimator
4+
import android.graphics.PorterDuff
35
import android.os.Bundle
46
import android.view.LayoutInflater
57
import android.view.View
68
import android.view.ViewGroup
9+
import android.view.animation.LinearInterpolator
10+
import androidx.core.animation.ValueAnimator
11+
import androidx.core.content.ContextCompat
712
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
813
import kotlinx.coroutines.*
914
import kotlinx.coroutines.flow.*
@@ -60,6 +65,19 @@ class ReceiveRadioBottomSheetFragment : BottomSheetDialogFragment()
6065
private var decodeAttempts = 0
6166
private var startTimeMs = 0L
6267

68+
// Animation tracking
69+
private var currentAnimator: ObjectAnimator? = null
70+
71+
/**
72+
* Animation types for state icon
73+
*/
74+
private enum class AnimationType {
75+
NONE,
76+
PULSE, // For listening states
77+
ROTATE, // For processing
78+
SCALE // For success
79+
}
80+
6381
// Configuration
6482
companion object {
6583
private const val TAG = "ReceiveRadioBottomSheet"
@@ -157,25 +175,136 @@ class ReceiveRadioBottomSheetFragment : BottomSheetDialogFragment()
157175
/**
158176
* Observes WSPR station state changes and updates UI.
159177
*/
160-
private suspend fun observeStationState() {
178+
/**
179+
* Observes WSPR station state changes and updates UI with icons and animations.
180+
*
181+
* State mappings:
182+
* - Running: Green radio icon with pulse animation (listening actively)
183+
* - WaitingForNextWindow: Grey clock icon, no animation (waiting for timing)
184+
* - CollectingAudio: Green radio icon with pulse, shows audio level section
185+
* - ProcessingAudio: Orange sync icon with rotation (working on decode)
186+
* - DecodeCompleted: Green checkmark with scale animation (success!)
187+
* - Error: Red error icon, no animation (something went wrong)
188+
*/
189+
private suspend fun observeStationState()
190+
{
161191
wsprStation?.stationState?.collect { state ->
162-
val statusText = when (state) {
163-
is WSPRStationState.Running -> getString(R.string.listening_for_signals)
164-
is WSPRStationState.WaitingForNextWindow -> getString(R.string.waiting_for_wspr_window)
165-
is WSPRStationState.CollectingAudio -> getString(R.string.collecting_audio)
166-
is WSPRStationState.ProcessingAudio -> getString(R.string.processing_decode)
167-
is WSPRStationState.DecodeCompleted -> getString(R.string.decode_complete)
168-
is WSPRStationState.Error -> "Error: ${state.errorDescription}"
169-
else -> state::class.simpleName ?: "Unknown"
192+
when (state) {
193+
is WSPRStationState.Running -> {
194+
updateStateIcon(R.drawable.ic_radio, R.color.caribbeanGreen, AnimationType.PULSE)
195+
updateStatus(getString(R.string.listening_for_signals))
196+
}
197+
is WSPRStationState.WaitingForNextWindow -> {
198+
updateStateIcon(R.drawable.ic_access_time, R.color.coolGrey, AnimationType.NONE)
199+
updateStatus(getString(R.string.waiting_for_wspr_window))
200+
}
201+
is WSPRStationState.CollectingAudio -> {
202+
updateStateIcon(R.drawable.ic_radio, R.color.caribbeanGreen, AnimationType.PULSE)
203+
updateStatus(getString(R.string.collecting_audio))
204+
// Show audio level section when actively collecting
205+
binding.audioLevelSection.visibility = View.VISIBLE
206+
}
207+
is WSPRStationState.ProcessingAudio -> {
208+
updateStateIcon(R.drawable.ic_sync, R.color.tangerine, AnimationType.ROTATE)
209+
updateStatus(getString(R.string.processing_decode))
210+
}
211+
is WSPRStationState.DecodeCompleted -> {
212+
updateStateIcon(R.drawable.ic_success, R.color.caribbeanGreen, AnimationType.SCALE)
213+
updateStatus(getString(R.string.decode_complete))
214+
}
215+
is WSPRStationState.Error -> {
216+
updateStateIcon(R.drawable.ic_error, R.color.madderLake, AnimationType.NONE)
217+
updateStatus("Error: ${state.errorDescription}")
218+
}
219+
else -> {
220+
updateStateIcon(R.drawable.ic_radio, R.color.coolGrey, AnimationType.NONE)
221+
updateStatus(state::class.simpleName ?: "Unknown")
222+
}
170223
}
171-
updateStatus(statusText)
172224
}
173225
}
174226

227+
/**
228+
* Updates the state icon with color and animation
229+
*/
230+
private fun updateStateIcon(iconRes: Int, colorRes: Int, animationType: AnimationType)
231+
{
232+
if (_binding == null) return
233+
234+
// Cancel any existing animation
235+
currentAnimator?.cancel()
236+
currentAnimator = null
237+
238+
// Reset transformations
239+
binding.ivStateIcon.rotation = 0f
240+
binding.ivStateIcon.scaleX = 1f
241+
binding.ivStateIcon.scaleY = 1f
242+
binding.ivStateIcon.alpha = 1f
243+
244+
// Set icon and color
245+
binding.ivStateIcon.setImageResource(iconRes)
246+
binding.ivStateIcon.setColorFilter(
247+
ContextCompat.getColor(requireContext(), colorRes),
248+
PorterDuff.Mode.SRC_IN
249+
)
250+
251+
// Apply animation based on type
252+
when (animationType) {
253+
AnimationType.PULSE -> startPulseAnimation()
254+
AnimationType.ROTATE -> startRotateAnimation()
255+
AnimationType.SCALE -> startScaleAnimation()
256+
AnimationType.NONE -> {} // No animation
257+
}
258+
}
259+
260+
/**
261+
* Pulse animation for listening states
262+
*/
263+
private fun startPulseAnimation()
264+
{
265+
currentAnimator = ObjectAnimator.ofFloat(binding.ivStateIcon, "alpha", 1f, 0.4f, 1f).apply {
266+
duration = 2000
267+
repeatCount = ValueAnimator.INFINITE
268+
repeatMode = android.animation.ValueAnimator.RESTART
269+
start()
270+
}
271+
}
272+
273+
/**
274+
* Rotation animation for processing state
275+
*/
276+
private fun startRotateAnimation()
277+
{
278+
currentAnimator = ObjectAnimator.ofFloat(binding.ivStateIcon, "rotation", 0f, 360f).apply {
279+
duration = 1500
280+
repeatCount = ValueAnimator.INFINITE
281+
interpolator = LinearInterpolator()
282+
start()
283+
}
284+
}
285+
286+
/**
287+
* Scale animation for success (plays once)
288+
*/
289+
private fun startScaleAnimation()
290+
{
291+
val scaleX = ObjectAnimator.ofFloat(binding.ivStateIcon, "scaleX", 0.8f, 1.2f, 1f).apply {
292+
duration = 400
293+
}
294+
val scaleY = ObjectAnimator.ofFloat(binding.ivStateIcon, "scaleY", 0.8f, 1.2f, 1f).apply {
295+
duration = 400
296+
}
297+
298+
scaleX.start()
299+
scaleY.start()
300+
currentAnimator = scaleX // Track one for cleanup
301+
}
302+
175303
/**
176304
* Observes WSPR cycle timing information and updates progress UI.
177305
*/
178-
private suspend fun observeCycleInformation() {
306+
private suspend fun observeCycleInformation()
307+
{
179308
wsprStation?.cycleInformation?.collect { cycleInfo ->
180309
updateCycleProgress(cycleInfo)
181310
}
@@ -184,18 +313,18 @@ class ReceiveRadioBottomSheetFragment : BottomSheetDialogFragment()
184313
/**
185314
* Observes decode results and attempts message reconstruction/decryption.
186315
*/
187-
private suspend fun observeDecodeResults() {
316+
private suspend fun observeDecodeResults()
317+
{
188318
wsprStation?.decodeResults?.collect { results ->
189-
if (results.isNotEmpty()) {
190-
processDecodeResults(results)
191-
}
319+
if (results.isNotEmpty()) processDecodeResults(results)
192320
}
193321
}
194322

195323
/**
196324
* Observes audio level from USB connection for visual feedback.
197325
*/
198-
private suspend fun observeAudioLevels() {
326+
private suspend fun observeAudioLevels()
327+
{
199328
usbAudioConnection?.getAudioLevel()?.collect { levelInfo ->
200329
val percent = (levelInfo.currentLevel * 100).toInt()
201330
binding.progressAudio.progress = percent
@@ -206,7 +335,8 @@ class ReceiveRadioBottomSheetFragment : BottomSheetDialogFragment()
206335
/**
207336
* Monitors for timeout condition.
208337
*/
209-
private suspend fun monitorTimeout() {
338+
private suspend fun monitorTimeout()
339+
{
210340
delay(TIMEOUT_MS)
211341

212342
// If we get here without being cancelled, we timed out
@@ -224,7 +354,8 @@ class ReceiveRadioBottomSheetFragment : BottomSheetDialogFragment()
224354
* Filters for encoded Nahoft messages (Q prefix), accumulates them,
225355
* and attempts decryption.
226356
*/
227-
private fun processDecodeResults(results: List<WSPRDecodeResult>) {
357+
private fun processDecodeResults(results: List<WSPRDecodeResult>)
358+
{
228359
decodeAttempts++
229360
binding.tvDecodeAttempts.text = decodeAttempts.toString()
230361

@@ -391,7 +522,7 @@ class ReceiveRadioBottomSheetFragment : BottomSheetDialogFragment()
391522
private fun cancelAndDismiss() {
392523
Timber.d("Receive operation cancelled")
393524
stopReceiving()
394-
dismiss()
525+
dismissAllowingStateLoss()
395526
}
396527

397528
/**

app/src/main/java/org/nahoft/nahoft/activities/HomeActivity.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import android.widget.TextView
2222
import androidx.appcompat.app.AlertDialog
2323
import androidx.appcompat.app.AppCompatActivity
2424
import androidx.appcompat.view.ContextThemeWrapper
25-
import androidx.core.app.ActivityCompat
2625
import androidx.core.content.ContextCompat
2726
import androidx.core.view.isVisible
2827
import androidx.core.view.setPadding
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FAFAFA" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
2+
3+
<path android:fillColor="@android:color/white" android:pathData="M612,668L668,612L520,464L520,280L440,280L440,496L612,668ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480ZM480,800Q613,800 706.5,706.5Q800,613 800,480Q800,347 706.5,253.5Q613,160 480,160Q347,160 253.5,253.5Q160,347 160,480Q160,613 253.5,706.5Q347,800 480,800Z"/>
4+
5+
</vector>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FAFAFA" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
2+
3+
<path android:fillColor="@android:color/white" android:pathData="M480,680Q497,680 508.5,668.5Q520,657 520,640Q520,623 508.5,611.5Q497,600 480,600Q463,600 451.5,611.5Q440,623 440,640Q440,657 451.5,668.5Q463,680 480,680ZM440,520L520,520L520,280L440,280L440,520ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
4+
5+
</vector>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#FAFAFA" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
2+
3+
<path android:fillColor="@android:color/white" android:pathData="M240,560L560,560L560,480L240,480L240,560ZM240,440L720,440L720,360L240,360L240,440ZM240,320L720,320L720,240L240,240L240,320ZM80,880L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L240,720L80,880ZM206,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,685L206,640ZM160,640L160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640Z"/>
4+
5+
</vector>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FAFAFA" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
2+
3+
<path android:fillColor="@android:color/white" android:pathData="M197,763Q143,708 111.5,635.5Q80,563 80,480Q80,396 111.5,323.5Q143,251 197,197L254,254Q210,298 185,356Q160,414 160,480Q160,547 185,605Q210,663 254,706L197,763ZM310,650Q278,617 259,573.5Q240,530 240,480Q240,429 259,385.5Q278,342 310,310L367,367Q345,389 332.5,418Q320,447 320,480Q320,513 332.5,542Q345,571 367,593L310,650ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM650,650L593,593Q615,571 627.5,542Q640,513 640,480Q640,447 627.5,418Q615,389 593,367L650,310Q682,342 701,385.5Q720,429 720,480Q720,530 701,573.5Q682,617 650,650ZM763,763L706,706Q750,662 775,604Q800,546 800,480Q800,413 775,355Q750,297 706,254L763,197Q817,251 848.5,323.5Q880,396 880,480Q880,563 848.5,635.5Q817,708 763,763Z"/>
4+
5+
</vector>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FAFAFA" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
2+
3+
<path android:fillColor="@android:color/white" android:pathData="M424,664L706,382L650,326L424,552L310,438L254,494L424,664ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
4+
5+
</vector>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FAFAFA" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
2+
3+
<path android:fillColor="@android:color/white" android:pathData="M212,721Q169,673 144.5,611Q120,549 120,480Q120,330 225,225Q330,120 480,120L480,40L680,190L480,340L480,260Q389,260 324.5,324.5Q260,389 260,480Q260,526 277.5,566Q295,606 325,636L212,721ZM480,920L280,770L480,620L480,700Q571,700 635.5,635.5Q700,571 700,480Q700,434 682.5,394Q665,354 635,324L748,239Q791,287 815.5,349Q840,411 840,480Q840,630 735,735Q630,840 480,840L480,920Z"/>
4+
5+
</vector>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FAFAFA" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
2+
3+
<path android:fillColor="@android:color/white" android:pathData="M480,840Q406,840 340.5,811.5Q275,783 226,734Q177,685 148.5,619.5Q120,554 120,480Q120,436 130,394.5Q140,353 159,316.5Q178,280 204.5,248.5Q231,217 264,192L536,464L480,520L264,304Q234,340 217,384.5Q200,429 200,480Q200,596 282,678Q364,760 480,760Q596,760 678,678Q760,596 760,480Q760,373 691.5,295.5Q623,218 520,204L520,280L440,280L440,120Q450,120 460,120Q470,120 480,120Q554,120 619.5,148.5Q685,177 734,226Q783,275 811.5,340.5Q840,406 840,480Q840,554 811.5,619.5Q783,685 734,734Q685,783 619.5,811.5Q554,840 480,840ZM280,520Q263,520 251.5,508.5Q240,497 240,480Q240,463 251.5,451.5Q263,440 280,440Q297,440 308.5,451.5Q320,463 320,480Q320,497 308.5,508.5Q297,520 280,520ZM480,720Q463,720 451.5,708.5Q440,697 440,680Q440,663 451.5,651.5Q463,640 480,640Q497,640 508.5,651.5Q520,663 520,680Q520,697 508.5,708.5Q497,720 480,720ZM680,520Q663,520 651.5,508.5Q640,497 640,480Q640,463 651.5,451.5Q663,440 680,440Q697,440 708.5,451.5Q720,463 720,480Q720,497 708.5,508.5Q697,520 680,520Z"/>
4+
5+
</vector>

0 commit comments

Comments
 (0)