mirror of
https://github.com/Dr-Blank/Vaani.git
synced 2025-12-07 19:49:29 +00:00
ui: better sleep timer ui in player and fix auto turn on settings (#43)
* refactor: enhance sleep timer functionality and improve duration formatting * refactor: update sleep timer settings handling * refactor: update cancel icon for sleep timer button * refactor: implement isBetween method for TimeOfDay and simplify sleep timer logic * refactor: update alwaysAutoTurnOnTimer default value and improve icon usage in settings * refactor: remove unused IconButton and update sleep timer preset durations
This commit is contained in:
parent
933bfc5750
commit
12100ffbcd
18 changed files with 755 additions and 383 deletions
|
|
@ -19,7 +19,17 @@ class SleepTimer {
|
|||
|
||||
set duration(Duration value) {
|
||||
_duration = value;
|
||||
clearCountDownTimer();
|
||||
_logger.fine('duration set to $value');
|
||||
|
||||
/// if the timer is active, restart it with the new duration
|
||||
/// if the timer is not active, do nothing
|
||||
if (isActive && player.playing) {
|
||||
_logger.fine('timer is active counting down with new duration');
|
||||
startCountDown(value);
|
||||
} else {
|
||||
_logger.fine('timer is not active');
|
||||
clearCountDownTimer();
|
||||
}
|
||||
}
|
||||
|
||||
/// The player to be paused
|
||||
|
|
|
|||
|
|
@ -11,20 +11,16 @@ part 'sleep_timer_provider.g.dart';
|
|||
class SleepTimer extends _$SleepTimer {
|
||||
@override
|
||||
core.SleepTimer? build() {
|
||||
final appSettings = ref.watch(appSettingsProvider);
|
||||
final sleepTimerSettings = appSettings.playerSettings.sleepTimerSettings;
|
||||
bool isEnabled = sleepTimerSettings.autoTurnOnTimer;
|
||||
if (!isEnabled) {
|
||||
final sleepTimerSettings = ref.watch(sleepTimerSettingsProvider);
|
||||
if (!sleepTimerSettings.autoTurnOnTimer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((!sleepTimerSettings.alwaysAutoTurnOnTimer) &&
|
||||
(sleepTimerSettings.autoTurnOnTime
|
||||
.toTimeOfDay()
|
||||
.isAfter(TimeOfDay.now()) &&
|
||||
sleepTimerSettings.autoTurnOffTime
|
||||
.toTimeOfDay()
|
||||
.isBefore(TimeOfDay.now()))) {
|
||||
!shouldBuildRightNow(
|
||||
sleepTimerSettings.autoTurnOnTime,
|
||||
sleepTimerSettings.autoTurnOffTime,
|
||||
)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -36,10 +32,16 @@ class SleepTimer extends _$SleepTimer {
|
|||
return sleepTimer;
|
||||
}
|
||||
|
||||
void setTimer(Duration resultingDuration) {
|
||||
void setTimer(Duration? resultingDuration, {bool notifyListeners = true}) {
|
||||
if (resultingDuration == null || resultingDuration.inSeconds == 0) {
|
||||
cancelTimer();
|
||||
return;
|
||||
}
|
||||
if (state != null) {
|
||||
state!.duration = resultingDuration;
|
||||
ref.notifyListeners();
|
||||
if (notifyListeners) {
|
||||
ref.notifyListeners();
|
||||
}
|
||||
} else {
|
||||
final timer = core.SleepTimer(
|
||||
duration: resultingDuration,
|
||||
|
|
@ -62,3 +64,11 @@ class SleepTimer extends _$SleepTimer {
|
|||
state = null;
|
||||
}
|
||||
}
|
||||
|
||||
bool shouldBuildRightNow(Duration autoTurnOnTime, Duration autoTurnOffTime) {
|
||||
final now = TimeOfDay.now();
|
||||
return now.isBetween(
|
||||
autoTurnOnTime.toTimeOfDay(),
|
||||
autoTurnOffTime.toTimeOfDay(),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ part of 'sleep_timer_provider.dart';
|
|||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$sleepTimerHash() => r'4f80bcc342e918c70c547b8b24790ccd88aba8c3';
|
||||
String _$sleepTimerHash() => r'2679454a217d0630a833d730557ab4e4feac2e56';
|
||||
|
||||
/// See also [SleepTimer].
|
||||
@ProviderFor(SleepTimer)
|
||||
|
|
|
|||
366
lib/features/sleep_timer/view/sleep_timer_button.dart
Normal file
366
lib/features/sleep_timer/view/sleep_timer_button.dart
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:list_wheel_scroll_view_nls/list_wheel_scroll_view_nls.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:vaani/features/player/view/player_when_expanded.dart';
|
||||
import 'package:vaani/features/player/view/widgets/speed_selector.dart';
|
||||
import 'package:vaani/features/sleep_timer/core/sleep_timer.dart';
|
||||
import 'package:vaani/features/sleep_timer/providers/sleep_timer_provider.dart'
|
||||
show sleepTimerProvider;
|
||||
import 'package:vaani/main.dart';
|
||||
import 'package:vaani/settings/app_settings_provider.dart';
|
||||
import 'package:vaani/shared/extensions/duration_format.dart';
|
||||
import 'package:vaani/shared/hooks.dart';
|
||||
|
||||
class SleepTimerButton extends HookConsumerWidget {
|
||||
const SleepTimerButton({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final sleepTimer = ref.watch(sleepTimerProvider);
|
||||
final durationState = useState(sleepTimer?.duration);
|
||||
|
||||
// if sleep timer is not active, show the button with the sleep timer icon
|
||||
// if the sleep timer is active, show the remaining time in a pill shaped container
|
||||
return Tooltip(
|
||||
message: 'Sleep Timer',
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
appLogger.fine('Sleep Timer button pressed');
|
||||
pendingPlayerModals++;
|
||||
// show the sleep timer dialog
|
||||
await showModalBottomSheet<Duration?>(
|
||||
context: context,
|
||||
barrierLabel: 'Sleep Timer',
|
||||
builder: (context) {
|
||||
return SleepTimerBottomSheet(
|
||||
onDurationSelected: (duration) {
|
||||
durationState.value = duration;
|
||||
// ref
|
||||
// .read(sleepTimerProvider.notifier)
|
||||
// .setTimer(duration, notifyListeners: false);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
pendingPlayerModals--;
|
||||
ref.read(sleepTimerProvider.notifier).setTimer(durationState.value);
|
||||
appLogger
|
||||
.fine('Sleep Timer dialog closed with ${durationState.value}');
|
||||
},
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: sleepTimer == null
|
||||
? Icon(
|
||||
Symbols.bedtime,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
)
|
||||
: RemainingSleepTimeDisplay(
|
||||
timer: sleepTimer,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SleepTimerBottomSheet extends HookConsumerWidget {
|
||||
const SleepTimerBottomSheet({
|
||||
super.key,
|
||||
this.onDurationSelected,
|
||||
});
|
||||
|
||||
final void Function(Duration?)? onDurationSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final sleepTimer = ref.watch(sleepTimerProvider);
|
||||
final sleepTimerSettings = ref.watch(sleepTimerSettingsProvider);
|
||||
|
||||
final durationOptions = sleepTimerSettings.presetDurations;
|
||||
final minDuration = Duration.zero;
|
||||
final maxDuration = <Duration>[
|
||||
...durationOptions,
|
||||
sleepTimerSettings.maxDuration,
|
||||
].reduce((a, b) => a > b ? a : b);
|
||||
final incrementStep = Duration(minutes: 1);
|
||||
final allPossibleDurations = [
|
||||
for (var i = minDuration; i <= maxDuration; i += incrementStep) i,
|
||||
];
|
||||
|
||||
final scrollController = useFixedExtentScrollController(
|
||||
initialItem:
|
||||
allPossibleDurations.indexOf(sleepTimer?.duration ?? minDuration),
|
||||
);
|
||||
|
||||
final durationState = useState<Duration>(
|
||||
sleepTimer?.duration ?? minDuration,
|
||||
);
|
||||
|
||||
// useEffect to rebuild the sleep timer when the duration changes
|
||||
useEffect(
|
||||
() {
|
||||
onDurationSelected?.call(durationState.value);
|
||||
return null;
|
||||
},
|
||||
[durationState.value],
|
||||
);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// the title
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0, bottom: 8.0),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Sleep Timer',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// a inverted triangle to indicate the speed selector
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Icon(
|
||||
Icons.arrow_drop_down,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
|
||||
// the speed selector
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0, right: 8.0, left: 8.0),
|
||||
child: SizedBox(
|
||||
height: 80,
|
||||
child: SleepTimerWheel(
|
||||
durationState: durationState,
|
||||
availableDurations: allPossibleDurations,
|
||||
scrollController: scrollController,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// a cancel button to cancel the sleep timer
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: TextButton.icon(
|
||||
onPressed: () {
|
||||
ref.read(sleepTimerProvider.notifier).cancelTimer();
|
||||
onDurationSelected?.call(null);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
icon: const Icon(Symbols.bedtime_off),
|
||||
label: const Text('Cancel Sleep Timer'),
|
||||
),
|
||||
),
|
||||
|
||||
// the speed buttons
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Wrap(
|
||||
spacing: 8.0,
|
||||
alignment: WrapAlignment.center,
|
||||
runAlignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: durationOptions
|
||||
.map(
|
||||
(timerDuration) => TextButton(
|
||||
style: timerDuration == durationState.value
|
||||
? TextButton.styleFrom(
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.primaryContainer,
|
||||
foregroundColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimaryContainer,
|
||||
)
|
||||
// border if not selected
|
||||
: TextButton.styleFrom(
|
||||
side: BorderSide(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer,
|
||||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
// animate the wheel to the selected speed
|
||||
var index = allPossibleDurations.indexOf(timerDuration);
|
||||
// if the speed is not in the list
|
||||
if (index == -1) {
|
||||
// find the nearest speed
|
||||
final nearestDuration = allPossibleDurations.firstWhere(
|
||||
(element) => element > timerDuration,
|
||||
orElse: () => allPossibleDurations.last,
|
||||
);
|
||||
index = allPossibleDurations.indexOf(nearestDuration);
|
||||
}
|
||||
await scrollController.animateToItem(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
},
|
||||
child: Text(timerDuration.smartBinaryFormat),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RemainingSleepTimeDisplay extends HookConsumerWidget {
|
||||
const RemainingSleepTimeDisplay({
|
||||
super.key,
|
||||
required this.timer,
|
||||
});
|
||||
|
||||
final SleepTimer timer;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final remainingTime = useStream(timer.remainingTimeStream).data;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Text(
|
||||
timer.timer == null
|
||||
? timer.duration.smartBinaryFormat
|
||||
: remainingTime?.smartBinaryFormat ?? '',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SleepTimerWheel extends StatelessWidget {
|
||||
const SleepTimerWheel({
|
||||
super.key,
|
||||
required this.availableDurations,
|
||||
required this.scrollController,
|
||||
required this.durationState,
|
||||
this.showIncrementButtons = true,
|
||||
});
|
||||
|
||||
final List<Duration> availableDurations;
|
||||
final ValueNotifier<Duration?> durationState;
|
||||
final FixedExtentScrollController scrollController;
|
||||
final bool showIncrementButtons;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// a minus button to decrease the speed
|
||||
if (showIncrementButtons)
|
||||
IconButton.filledTonal(
|
||||
icon: const Icon(Icons.remove),
|
||||
onPressed: () {
|
||||
// animate to index - 1
|
||||
final index = availableDurations
|
||||
.indexOf(durationState.value ?? Duration.zero);
|
||||
if (index > 0) {
|
||||
scrollController.animateToItem(
|
||||
index - 1,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
// the speed selector wheel
|
||||
Flexible(
|
||||
child: ListWheelScrollViewX(
|
||||
controller: scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemExtent: itemExtent,
|
||||
diameterRatio: 1.5, squeeze: 1.2,
|
||||
// useMagnifier: true,
|
||||
// magnification: 1.5,
|
||||
physics: const FixedExtentScrollPhysics(),
|
||||
children: availableDurations
|
||||
.map(
|
||||
(duration) => DurationLine(duration: duration),
|
||||
)
|
||||
.toList(),
|
||||
onSelectedItemChanged: (index) {
|
||||
durationState.value = availableDurations[index];
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
if (showIncrementButtons)
|
||||
// a plus button to increase the speed
|
||||
IconButton.filledTonal(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () {
|
||||
// animate to index + 1
|
||||
final index = availableDurations
|
||||
.indexOf(durationState.value ?? Duration.zero);
|
||||
if (index < availableDurations.length - 1) {
|
||||
scrollController.animateToItem(
|
||||
index + 1,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DurationLine extends StatelessWidget {
|
||||
const DurationLine({
|
||||
super.key,
|
||||
required this.duration,
|
||||
});
|
||||
|
||||
final Duration duration;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// a vertical line
|
||||
Expanded(
|
||||
child: Container(
|
||||
// thick if multiple of 1, thin if multiple of 0.5 and transparent if multiple of 0.05
|
||||
width: duration.inMinutes % 5 == 0
|
||||
? 3
|
||||
: duration.inMinutes % 2.5 == 0
|
||||
? 2
|
||||
: 0.5,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
Opacity(
|
||||
opacity: duration.inMinutes % 2.5 == 0 ? 1 : 0,
|
||||
child: Text(
|
||||
'${duration.inMinutes}m',
|
||||
style: TextStyle(
|
||||
fontSize: Theme.of(context).textTheme.labelSmall?.fontSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue