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:
Dr.Blank 2024-10-02 09:18:06 -04:00 committed by GitHub
parent 933bfc5750
commit 12100ffbcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 755 additions and 383 deletions

View file

@ -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

View file

@ -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(),
);
}

View file

@ -6,7 +6,7 @@ part of 'sleep_timer_provider.dart';
// RiverpodGenerator
// **************************************************************************
String _$sleepTimerHash() => r'4f80bcc342e918c70c547b8b24790ccd88aba8c3';
String _$sleepTimerHash() => r'2679454a217d0630a833d730557ab4e4feac2e56';
/// See also [SleepTimer].
@ProviderFor(SleepTimer)

View 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,
),
),
),
],
);
}
}