diff --git a/lib/features/onboarding/view/user_login.dart b/lib/features/onboarding/view/user_login.dart index 5d12aa8..8aeff14 100644 --- a/lib/features/onboarding/view/user_login.dart +++ b/lib/features/onboarding/view/user_login.dart @@ -22,7 +22,7 @@ import 'package:vaani/settings/api_settings_provider.dart' import 'package:vaani/settings/models/models.dart' as model; class UserLoginWidget extends HookConsumerWidget { - UserLoginWidget({ + const UserLoginWidget({ super.key, required this.server, this.onSuccess, diff --git a/lib/features/player/view/widgets/chapter_selection_button.dart b/lib/features/player/view/widgets/chapter_selection_button.dart index 889392a..04cbd0e 100644 --- a/lib/features/player/view/widgets/chapter_selection_button.dart +++ b/lib/features/player/view/widgets/chapter_selection_button.dart @@ -1,13 +1,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:vaani/features/player/providers/audiobook_player.dart'; -import 'package:vaani/features/player/providers/currently_playing_provider.dart'; -import 'package:vaani/features/player/view/player_when_expanded.dart'; -import 'package:vaani/main.dart'; -import 'package:vaani/shared/extensions/chapter.dart'; -import 'package:vaani/shared/extensions/duration_format.dart'; -import 'package:vaani/shared/hooks.dart'; +import 'package:vaani/features/player/providers/audiobook_player.dart' + show audiobookPlayerProvider; +import 'package:vaani/features/player/providers/currently_playing_provider.dart' + show currentPlayingChapterProvider, currentlyPlayingBookProvider; +import 'package:vaani/features/player/view/player_when_expanded.dart' + show pendingPlayerModals; +import 'package:vaani/features/player/view/widgets/playing_indicator_icon.dart'; +import 'package:vaani/main.dart' show appLogger; +import 'package:vaani/shared/extensions/chapter.dart' show ChapterDuration; +import 'package:vaani/shared/extensions/duration_format.dart' + show DurationFormat; +import 'package:vaani/shared/hooks.dart' show useTimer; class ChapterSelectionButton extends HookConsumerWidget { const ChapterSelectionButton({ @@ -67,6 +72,7 @@ class ChapterSelectionModal extends HookConsumerWidget { useTimer(scrollToCurrentChapter, 500.ms); // useInterval(scrollToCurrentChapter, 500.ms); + final theme = Theme.of(context); return Column( children: [ ListTile( @@ -81,24 +87,41 @@ class ChapterSelectionModal extends HookConsumerWidget { child: currentBook?.chapters == null ? const Text('No chapters found') : Column( - children: [ - for (final chapter in currentBook!.chapters) - ListTile( - title: Text(chapter.title), - trailing: Text( - '(${chapter.duration.smartBinaryFormat})', - ), - selected: currentChapterIndex == chapter.id, - key: currentChapterIndex == chapter.id - ? chapterKey + children: currentBook!.chapters.map( + (chapter) { + final isCurrent = currentChapterIndex == chapter.id; + final isPlayed = currentChapterIndex != null && + chapter.id < currentChapterIndex; + return ListTile( + autofocus: isCurrent, + iconColor: isPlayed && !isCurrent + ? theme.disabledColor : null, + title: Text( + chapter.title, + style: isPlayed && !isCurrent + ? TextStyle(color: theme.disabledColor) + : null, + ), + subtitle: Text( + '(${chapter.duration.smartBinaryFormat})', + style: isPlayed && !isCurrent + ? TextStyle(color: theme.disabledColor) + : null, + ), + trailing: isCurrent + ? const PlayingIndicatorIcon() + : const Icon(Icons.play_arrow), + selected: isCurrent, + key: isCurrent ? chapterKey : null, onTap: () { Navigator.of(context).pop(); notifier.seek(chapter.start + 90.ms); notifier.play(); }, - ), - ], + ); + }, + ).toList(), ), ), ), diff --git a/lib/features/player/view/widgets/playing_indicator_icon.dart b/lib/features/player/view/widgets/playing_indicator_icon.dart new file mode 100644 index 0000000..d179797 --- /dev/null +++ b/lib/features/player/view/widgets/playing_indicator_icon.dart @@ -0,0 +1,194 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; + +/// An icon that animates like audio equalizer bars to indicate playback. +/// +/// Creates multiple vertical bars that independently animate their height +/// in a looping, visually dynamic pattern. +class PlayingIndicatorIcon extends StatefulWidget { + /// The number of vertical bars in the indicator. + final int barCount; + + /// The total width and height of the icon area. + final double size; + + /// The color of the bars. Defaults to the current [IconTheme] color. + final Color? color; + + /// The minimum height factor for a bar (relative to [size]). + /// When [centerSymmetric] is true, this represents the minimum height + /// extending from the center line (so total minimum height is 2 * minHeightFactor * size). + /// When false, it's the minimum height from the bottom. + final double minHeightFactor; + + /// The maximum height factor for a bar (relative to [size]). + /// When [centerSymmetric] is true, this represents the maximum height + /// extending from the center line (so total maximum height is 2 * maxHeightFactor * size). + /// When false, it's the maximum height from the bottom. + final double maxHeightFactor; + + /// Base duration for a full up/down animation cycle for a single bar. + /// Actual duration will vary slightly per bar. + final Duration baseCycleDuration; + + /// If true, the bars animate symmetrically expanding/collapsing from the + /// horizontal center line. If false (default), they expand/collapse from + /// the bottom edge. + final bool centerSymmetric; + + const PlayingIndicatorIcon({ + super.key, + this.barCount = 4, + this.size = 20.0, + this.color, + this.minHeightFactor = 0.2, + this.maxHeightFactor = 1.0, + this.baseCycleDuration = const Duration(milliseconds: 350), + this.centerSymmetric = true, + }); + + @override + State createState() => _PlayingIndicatorIconState(); +} + +class _PlayingIndicatorIconState extends State { + late List<_BarAnimationParams> _animationParams; + final _random = Random(); + + @override + void initState() { + super.initState(); + _animationParams = + List.generate(widget.barCount, _createRandomParams, growable: false); + } + + // Helper to generate random parameters for one bar's animation cycle + _BarAnimationParams _createRandomParams(int index) { + final duration1 = + (widget.baseCycleDuration * (0.8 + _random.nextDouble() * 0.4)); + final duration2 = + (widget.baseCycleDuration * (0.8 + _random.nextDouble() * 0.4)); + + // Note: These factors represent the scale relative to the *half-height* + // if centerSymmetric is true, controlled by the alignment in scaleY. + final targetHeightFactor1 = widget.minHeightFactor + + _random.nextDouble() * + (widget.maxHeightFactor - widget.minHeightFactor); + final targetHeightFactor2 = widget.minHeightFactor + + _random.nextDouble() * + (widget.maxHeightFactor - widget.minHeightFactor); + + // --- Random initial delay --- + final initialDelay = + (_random.nextDouble() * (widget.baseCycleDuration.inMilliseconds / 4)) + .ms; + + return _BarAnimationParams( + duration1: duration1, + duration2: duration2, + targetHeightFactor1: targetHeightFactor1, + targetHeightFactor2: targetHeightFactor2, + initialDelay: initialDelay, + ); + } + + @override + Widget build(BuildContext context) { + final color = widget.color ?? + IconTheme.of(context).color ?? + Theme.of(context).colorScheme.primary; + + // --- Bar geometry calculation --- + final double totalSpacing = widget.size * 0.2; + // Ensure at least 1px spacing if size is very small + final double barSpacing = max(1.0, totalSpacing / (widget.barCount + 1)); + final double availableWidthForBars = + widget.size - (barSpacing * (widget.barCount + 1)); + final double barWidth = max(1.0, availableWidthForBars / widget.barCount); + // Max height remains the full size potential for the container + final double maxHeight = widget.size; + + // Determine the alignment for scaling based on the symmetric flag + final Alignment scaleAlignment = + widget.centerSymmetric ? Alignment.center : Alignment.bottomCenter; + + // Determine the cross axis alignment for the Row + final CrossAxisAlignment rowAlignment = widget.centerSymmetric + ? CrossAxisAlignment.center + : CrossAxisAlignment.end; + + return SizedBox( + width: widget.size, + height: widget.size, + // Clip ensures bars don't draw outside the SizedBox bounds + // especially important for center alignment if maxFactor > 0.5 + child: ClipRect( + child: Row( + // Use calculated alignment + crossAxisAlignment: rowAlignment, + // Use spaceEvenly for better distribution, especially with center alignment + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate( + widget.barCount, + (index) { + final params = _animationParams[index]; + // The actual bar widget that will be animated + return Container( + width: barWidth, + // Set initial height to the max potential height + // The scaleY animation will control the visible height + height: maxHeight, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(barWidth / 2), + ), + ) + .animate( + delay: params.initialDelay, + onPlay: (controller) => controller.repeat( + reverse: true, + ), + ) + // 1. Scale to targetHeightFactor1 + .scaleY( + begin: + widget.minHeightFactor, // Scale factor starts near min + end: params.targetHeightFactor1, + duration: params.duration1, + curve: Curves.easeInOutCirc, + alignment: scaleAlignment, // Apply chosen alignment + ) + // 2. Then scale to targetHeightFactor2 + .then() + .scaleY( + end: params.targetHeightFactor2, + duration: params.duration2, + curve: Curves.easeInOutCirc, + alignment: scaleAlignment, // Apply chosen alignment + ); + }, + growable: false, + ), + ), + ), + ); + } +} + +// Helper class: Renamed height fields for clarity +class _BarAnimationParams { + final Duration duration1; + final Duration duration2; + final double targetHeightFactor1; // Factor relative to total size + final double targetHeightFactor2; // Factor relative to total size + final Duration initialDelay; + + _BarAnimationParams({ + required this.duration1, + required this.duration2, + required this.targetHeightFactor1, + required this.targetHeightFactor2, + required this.initialDelay, + }); +}