11package org.nahoft.Nahoft.fragments
22
3+ import android.animation.ObjectAnimator
4+ import android.graphics.PorterDuff
35import android.os.Bundle
46import android.view.LayoutInflater
57import android.view.View
68import android.view.ViewGroup
9+ import android.view.animation.LinearInterpolator
10+ import androidx.core.animation.ValueAnimator
11+ import androidx.core.content.ContextCompat
712import com.google.android.material.bottomsheet.BottomSheetDialogFragment
813import kotlinx.coroutines.*
914import 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 /* *
0 commit comments