diff --git a/.impeccable/surfaces/tutorial-guidance.md b/.impeccable/surfaces/tutorial-guidance.md new file mode 100644 index 000000000..6b4883436 --- /dev/null +++ b/.impeccable/surfaces/tutorial-guidance.md @@ -0,0 +1,53 @@ +--- +version: 1 +slug: "tutorial-guidance" +primary_target: "lib/features/onboarding/widgets/tutorial_overlay.dart" +related_targets: ["lib/providers/tutorial_provider.dart","lib/features/onboarding/tutorial_targets.dart","lib/features/onboarding/tutorial_navigation.dart"] +--- + +# Interactive tutorial + +Visitor mode: Operate. Help listeners find and play their own music with real +controls, without requiring playback or setup to finish the guide. + +## Direction contract + +Keep the five welcome posters unchanged. This is compact guidance over the +existing Material dark library, settings, and player, not another poster screen. +Use the app's bundled font stack, paper text, one acid accent, and native +48dp-minimum buttons. Bound and scroll the tooltip for short windows, large text, +safe insets, and the keyboard. No decorative animation or perpetual motion. + +The invitation is optional. Adapt the core route to an empty or populated +library, the configured search destination, and existing playback. Listeners +can use the highlighted control or Continue; Back and End tour remain available. +Finish with Done, not a compulsory manual launch. Independently replayable +library, player/queue, appearance, and audio guides live in the guide picker, +also accessible from the existing Interactive Tutorial setting. + +Only relevant real actions advance a step. Observe successful playback rather +than requesting playback. Never change volume, queue, output, DSP, or user +preferences for a demonstration. Missing controls, loading, permissions, picker +cancellation, and playback failure must not trap the listener. Yield to modal +routes and Android permission/folder pickers; restore guidance on return. + +Anchor actual rendered controls across portrait, rail, landscape, and player +layouts. Measure relative to the overlay after layout, not fixed delays. Keep +real controls hit-testable and retain native semantics. Deep-link existing +manual sections instead of duplicating their detailed instructions. + +Persist dismissed and completed states separately per stable guide ID. Preserve +legacy completion and never downgrade completion when a replay is dismissed. +English and Chinese are localized; Taglish guidance and controls remain English. + +## Verification boundary + +Provider and widget tests cover guide adaptation/progression, unavailable +controls, real taps, persistence/migration, route/modal handoff, large text, +keyboard bounds, Chinese, and English guidance under Taglish. The existing +onboarding and landscape mini-player handoff regressions remain in scope. + +Native Android visual, TalkBack, permission-picker, and playback sign-off is +still open: the emulator did not come online. No APK was installed on the +user's A063, and no device music data was touched. Do not describe widget +coverage or a successful APK build as native-device validation. diff --git a/DESIGN.md b/DESIGN.md index 19108bb1a..f09153672 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -76,6 +76,8 @@ Flick's poster visual system combines Replay color fields, large branded type, b Scope is the onboarding/Replay-derived poster visual system only. Standard player, library, and settings screens retain their existing Material dark theme in `lib/core/theme/app_theme.dart`. Onboarding page order and exact compositions belong to `.impeccable/surfaces/onboarding-screens-onboarding-screen-dart-eb866e34.md`, not universal design rules. +Interactive tutorial guidance is separately scoped in `.impeccable/surfaces/tutorial-guidance.md`: compact Material tooltips with a restrained acid accent, not full-screen posters. It preserves the five welcome pages and the surrounding app theme. + The frozen implementation includes the reviewed font-wrapping fix. The handoff reports 25 passing onboarding tests using bundled fonts, including English/Taglish whole-word regressions at 320 × 568 with 2x text, and a clean targeted analyzer. The full suite has an unrelated Isar binary mismatch. Review PNGs are supplementary Flutter widget renders, not device screenshots or shipping assets. Native Android sign-off remains blocked: the emulator did not boot, the user's phone was untouched, and the final native gate is not closed. **Key Characteristics:** diff --git a/docs/BAYBAYIN_REVIEW.md b/docs/BAYBAYIN_REVIEW.md index de44b1667..0c788896c 100644 --- a/docs/BAYBAYIN_REVIEW.md +++ b/docs/BAYBAYIN_REVIEW.md @@ -3,9 +3,10 @@ **Draft, not linguistically verified.** The Filipino reviewer decides whether the wording is natural and the Baybayin spelling is correct. Passing tests does not approve a translation. The 2,507-message Latin resource, eleven curated -Baybayin labels, fifteen native-speaker-supplied Taglish hooks and six streak -greetings remain drafts for review. The latest corrections are screen-specific, -not a global replacement of Filipino with English. +Baybayin labels, fifteen native-speaker-supplied Taglish hooks, five fixed +streak greetings and one day-count streak greeting remain drafts for review. The +latest corrections are screen-specific, not a global replacement of Filipino +with English. ## Try it @@ -105,7 +106,8 @@ subsequently turning them off survives a restart. CSV/TXT recaps are always English regardless of UI language. - The streak popup keeps its count label and both buttons in English (`N day streak`, `Continue`, `Don't show again today`). Its message is one of - six Taglish greetings chosen at random each time the popup opens. + five fixed Taglish greetings or a day-count line, chosen at random each time + the popup opens. - The Milestones screen, category names, progress units, dates, locked sheet and celebration card are English in Taglish. The streak banner and its **Current Streak** label are English too; the streak popup's greeting keeps @@ -223,9 +225,10 @@ stability, locale isolation, review coverage and layout. ## Filipino-only streak greetings The launch-day streak popup keeps its visible count label and both buttons in -English under Taglish. Its message is one of the six native-speaker-supplied -Taglish lines below, chosen at random each time the popup opens. English and -Chinese keep their existing tier-specific messages. +English under Taglish. Its message is one of the five fixed +native-speaker-supplied Taglish lines below or a line that fills in the current +streak, chosen at random each time the popup opens. English and Chinese keep +their existing tier-specific messages. | Original Taglish greeting | Status | |---|---| @@ -234,7 +237,7 @@ Chinese keep their existing tier-specific messages. | Handa mo na earphones mo ulit. Hahaha. | draft | | Hobby mo talaga music, maem? | draft | | Share your music with? | draft | -| Baka mamaya puro ka relapse ha? | draft | +| {days} days na ha? Keep it up! | draft | Edit `lib/l10n/streak_greeting.dart` and the corresponding rows together. @@ -342,8 +345,8 @@ host widget renders with bundled fonts, not a substitute for Android hardware. Before removing the experimental/draft notices, the Filipino reviewer should: -- [ ] Review Latin Filipino wording, all eleven Baybayin spellings, home hooks - and the six streak greetings. +- [ ] Review Latin Filipino wording, all eleven Baybayin spellings, home hooks, + the five fixed streak greetings and the day-count streak line. - [ ] Test Android API 26 and a current Android version, including bold text, combining/final marks, narrow widths and large system font sizes. - [ ] Switch Taglish ↔ English ↔ Chinese and enable/disable headings live. @@ -375,7 +378,7 @@ Before removing the experimental/draft notices, the Filipino reviewer should: verify actual Filipino pronunciation rather than assuming language support. - [ ] Check home hooks across time boundaries and day changes, including long hooks at enlarged text settings. Confirm the streak popup rotates through - all six lines and fits the longest greeting. + the five fixed lines and the day-count line and fits the longest greeting. - [ ] Check native dialogs, notifications and home-screen widgets separately for Latin Filipino font support, clipping and stale strings after switching. diff --git a/lib/app/app.dart b/lib/app/app.dart index 5aa62b381..0d9d632bb 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -35,6 +35,7 @@ import 'package:flick/providers/equalizer_provider.dart'; import 'package:flick/providers/providers.dart'; import 'package:flick/features/onboarding/screens/onboarding_screen.dart'; import 'package:flick/features/onboarding/tutorial_targets.dart'; +import 'package:flick/features/onboarding/tutorial_navigation.dart'; import 'package:flick/features/onboarding/widgets/tutorial_overlay.dart'; import 'package:flick/widgets/common/embedded_mini_player.dart'; import 'package:flick/widgets/common/landscape_mini_player_dock.dart'; @@ -75,6 +76,7 @@ class FlickPlayerApp extends StatelessWidget { debugShowCheckedModeBanner: false, theme: AppTheme.darkTheme, navigatorKey: rootNavigatorKey, + navigatorObservers: [tutorialNavigation.root], localizationsDelegates: kAppLocalizationsDelegates, supportedLocales: kSupportedLocales, localeResolutionCallback: resolveAppLocale, @@ -101,7 +103,9 @@ class FlickPlayerApp extends StatelessWidget { (preferences) => preferences.connectionNoticesEnabled, ), ), - child: child ?? const SizedBox.shrink(), + child: TutorialHost( + child: child ?? const SizedBox.shrink(), + ), ), ), ); @@ -461,6 +465,7 @@ class _MainShellState extends ConsumerState appPreferencesProvider, (previous, next) { if (!mounted) return; + if (ref.read(tutorialProvider).active) return; final notifier = ref.read(whatsNewProvider.notifier); notifier.evaluate(); final pending = ref.read(whatsNewProvider).pendingEntry; @@ -749,6 +754,11 @@ class _MainShellState extends ConsumerState void _handleBackPress(bool didPop, dynamic result) { if (didPop) return; + if (ref.read(tutorialProvider).active && tutorialNavigation.visible) { + ref.read(tutorialProvider.notifier).skip(); + return; + } + // Back events only reach the root navigator; detail screens pushed on // the nested tab navigator must be popped here manually. final nested = nestedNavigatorKey.currentState; @@ -898,7 +908,10 @@ class _MainShellState extends ConsumerState child: Navigator( key: nestedNavigatorKey, initialRoute: '/', - observers: [_navBarVisibilityObserver], + observers: [ + _navBarVisibilityObserver, + tutorialNavigation.nested, + ], onGenerateRoute: (settings) { if (settings.name == '/') { return PageRouteBuilder( @@ -1118,9 +1131,6 @@ class _MainShellState extends ConsumerState // Pinned scan/preload progress bubble (overlay minimized) const Positioned.fill(child: FloatingScanProgress()), - // Interactive tutorial overlay - const Positioned.fill(child: TutorialOverlay()), - // USB DAC attach → auto bit-perfect switch (decline-aware) const UsbBitPerfectPrompt(), ], diff --git a/lib/core/utils/navigation_helper.dart b/lib/core/utils/navigation_helper.dart index a5edf3c7e..eddc57df4 100644 --- a/lib/core/utils/navigation_helper.dart +++ b/lib/core/utils/navigation_helper.dart @@ -130,6 +130,7 @@ class NavigationHelper { PageRouteBuilder( pageBuilder: (context, animation, secondaryAnimation) => const QueueScreen(), + settings: const RouteSettings(name: '/queue'), transitionsBuilder: (context, animation, secondaryAnimation, child) { if (AppConstants.animationNormal == Duration.zero) { return child; diff --git a/lib/features/manual/screens/manual_screen.dart b/lib/features/manual/screens/manual_screen.dart index 0dbfdffb7..6a709414c 100644 --- a/lib/features/manual/screens/manual_screen.dart +++ b/lib/features/manual/screens/manual_screen.dart @@ -79,6 +79,18 @@ class _ManualScreenState extends ConsumerState { @override Widget build(BuildContext context) { final currentSong = ref.watch(currentSongProvider); + // Put a deep-linked section first so it is built even in a lazy list. + // The normal manual keeps its established reading order. + final sections = widget.initialSection == null + ? kManualSections + : [ + ...kManualSections.where( + (section) => section.id == widget.initialSection, + ), + ...kManualSections.where( + (section) => section.id != widget.initialSection, + ), + ]; return Scaffold( backgroundColor: Colors.transparent, body: Stack( @@ -100,9 +112,9 @@ class _ManualScreenState extends ConsumerState { horizontal: AppConstants.spacingMd, vertical: AppConstants.spacingMd, ), - itemCount: kManualSections.length, + itemCount: sections.length, itemBuilder: (context, i) { - final section = kManualSections[i]; + final section = sections[i]; final initiallyExpanded = widget.initialSection == section.id; return _ManualSectionCard( diff --git a/lib/features/milestone/widgets/streak_popup.dart b/lib/features/milestone/widgets/streak_popup.dart index 8fd716ad6..b7c5ca795 100644 --- a/lib/features/milestone/widgets/streak_popup.dart +++ b/lib/features/milestone/widgets/streak_popup.dart @@ -39,13 +39,14 @@ class _StreakPopupState extends State late final AnimationController _pulse; late final AnimationController _flame; - /// One supplied Taglish greeting per opening. Kept for English and Chinese - /// too, but only shown when the copy role is Filipino. - late final String _taglishGreeting = randomTaglishStreakGreeting(); + /// One greeting per opening. Kept for English and Chinese too, but only + /// shown when the copy role is Filipino. + late final String _taglishGreeting; @override void initState() { super.initState(); + _taglishGreeting = randomTaglishStreakGreeting(widget.streak); _reveal = AnimationController( vsync: this, duration: const Duration(milliseconds: 1100), diff --git a/lib/features/onboarding/tutorial_navigation.dart b/lib/features/onboarding/tutorial_navigation.dart new file mode 100644 index 000000000..f1ace35d5 --- /dev/null +++ b/lib/features/onboarding/tutorial_navigation.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; + +/// Both stacks report here, so a nested sheet also suspends guidance. +class TutorialNavigation extends ChangeNotifier { + final root = TutorialRouteObserver(); + final nested = TutorialRouteObserver(); + TutorialNavigation() { + root.onChanged = notifyListeners; + nested.onChanged = notifyListeners; + } + String? get currentName => root.top?.isFirst == true + ? nested.top?.settings.name ?? root.top?.settings.name + : root.top?.settings.name; + bool get visible { + final route = root.top; + if (route == null || route is PopupRoute || nested.top is PopupRoute) { + return false; + } + final name = currentName; + return (route.isFirst && (nested.top?.isFirst ?? true)) || + name == '/full_player' || + name == '/queue' || + (name?.startsWith('/tutorial/') ?? false); + } + + @override + void dispose() { + root.onChanged = null; + nested.onChanged = null; + super.dispose(); + } +} + +class TutorialRouteObserver extends NavigatorObserver { + final List> _routes = []; + VoidCallback? onChanged; + Route? get top => _routes.lastOrNull; + bool _scheduled = false; + void _changed() { + if (_scheduled) return; + _scheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _scheduled = false; + onChanged?.call(); + }); + } + + @override + void didPush(Route route, Route? previousRoute) { + if (previousRoute == null) _routes.clear(); + _routes.add(route); + _changed(); + } + + @override + void didPop(Route route, Route? previousRoute) { + _routes.remove(route); + _changed(); + } + + @override + void didRemove(Route route, Route? previousRoute) { + _routes.remove(route); + _changed(); + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + final index = oldRoute == null ? -1 : _routes.indexOf(oldRoute); + if (index >= 0) { + if (newRoute == null) { + _routes.removeAt(index); + } else { + _routes[index] = newRoute; + } + } + _changed(); + } +} + +final tutorialNavigation = TutorialNavigation(); diff --git a/lib/features/onboarding/tutorial_targets.dart b/lib/features/onboarding/tutorial_targets.dart index c4f90dd45..89e7a14b4 100644 --- a/lib/features/onboarding/tutorial_targets.dart +++ b/lib/features/onboarding/tutorial_targets.dart @@ -1,28 +1,79 @@ import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -enum TutorialTarget { navBar, miniPlayer, songsSearchBar, songsSortButton } +enum TutorialTarget { + navBar, + miniPlayer, + songsSearchBar, + songsSortButton, + songLibrary, + addMusicFolder, + playerQueue, + playerLyrics, +} + +/// Layout handoffs may overlap: unregister only the owner and prefer the +/// most recently mounted, visible target on the current route. +class TutorialTargetRegistry extends ChangeNotifier { + final Map> _keys = {}; + bool _scheduled = false; + bool _disposed = false; + void register(TutorialTarget target, GlobalKey key) { + (_keys[target] ??= []).add(key); + changed(); + } -class TutorialTargetRegistry { - final Map _keys = {}; + void unregister(TutorialTarget target, [GlobalKey? key]) { + if (key == null) { + _keys.remove(target); + } else { + _keys[target]?.remove(key); + } + changed(); + } + + void changed() { + if (_scheduled || _disposed) return; + _scheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _scheduled = false; + if (!_disposed) notifyListeners(); + }); + } - void register(TutorialTarget target, GlobalKey key) => _keys[target] = key; - void unregister(TutorialTarget target) => _keys.remove(target); + Rect? rectFor(TutorialTarget target, {RenderBox? relativeTo}) { + for (final key in (_keys[target] ?? []).reversed) { + final ctx = key.currentContext; + if (ctx == null || ModalRoute.of(ctx)?.isCurrent == false) continue; + final object = ctx.findRenderObject(); + if (object is! RenderBox || + !object.attached || + !object.hasSize || + object.size.isEmpty) { + continue; + } + final rect = MatrixUtils.transformRect( + object.getTransformTo(relativeTo), + Offset.zero & object.size, + ); + if (rect.isFinite && !rect.isEmpty) return rect; + } + return null; + } - Rect? rectFor(TutorialTarget target) { - final key = _keys[target]; - if (key == null) return null; - final ctx = key.currentContext; - if (ctx == null) return null; - final box = ctx.findRenderObject() as RenderBox?; - if (box == null || !box.attached || !box.hasSize) return null; - final pos = box.localToGlobal(Offset.zero); - return Rect.fromLTWH(pos.dx, pos.dy, box.size.width, box.size.height); + @override + void dispose() { + _disposed = true; + super.dispose(); } } -final tutorialTargetRegistryProvider = - Provider((ref) => TutorialTargetRegistry()); +final tutorialTargetRegistryProvider = Provider((ref) { + final registry = TutorialTargetRegistry(); + ref.onDispose(registry.dispose); + return registry; +}); class TutorialTargetAnchor extends ConsumerStatefulWidget { const TutorialTargetAnchor({ @@ -30,37 +81,59 @@ class TutorialTargetAnchor extends ConsumerStatefulWidget { required this.target, required this.child, }); - final TutorialTarget target; final Widget child; - @override ConsumerState createState() => _TutorialTargetAnchorState(); } class _TutorialTargetAnchorState extends ConsumerState { - late final GlobalKey _key = GlobalKey(); + final _key = GlobalKey(); late final TutorialTargetRegistry _registry; - @override void initState() { super.initState(); - _registry = ref.read(tutorialTargetRegistryProvider); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; + _registry = ref.read(tutorialTargetRegistryProvider) + ..register(widget.target, _key); + } + + @override + void didUpdateWidget(TutorialTargetAnchor oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.target != widget.target) { + _registry.unregister(oldWidget.target, _key); _registry.register(widget.target, _key); - }); + } } @override void dispose() { - _registry.unregister(widget.target); + _registry.unregister(widget.target, _key); super.dispose(); } @override - Widget build(BuildContext context) { - return KeyedSubtree(key: _key, child: widget.child); + Widget build(BuildContext context) => + _LayoutSignal(key: _key, onPaint: _registry.changed, child: widget.child); +} + +class _LayoutSignal extends SingleChildRenderObjectWidget { + const _LayoutSignal({super.key, required this.onPaint, required super.child}); + final VoidCallback onPaint; + @override + RenderObject createRenderObject(BuildContext context) => _SignalBox(onPaint); + @override + void updateRenderObject(BuildContext context, _SignalBox renderObject) => + renderObject.onPaint = onPaint; +} + +class _SignalBox extends RenderProxyBox { + _SignalBox(this.onPaint); + VoidCallback onPaint; + @override + void paint(PaintingContext context, Offset offset) { + super.paint(context, offset); + onPaint(); } } diff --git a/lib/features/onboarding/widgets/tutorial_overlay.dart b/lib/features/onboarding/widgets/tutorial_overlay.dart index 8e8f25f7e..a26e30b24 100644 --- a/lib/features/onboarding/widgets/tutorial_overlay.dart +++ b/lib/features/onboarding/widgets/tutorial_overlay.dart @@ -1,465 +1,594 @@ +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; -import 'package:flick/core/theme/app_colors.dart'; -import 'package:flick/core/constants/app_constants.dart'; -import 'package:flick/providers/tutorial_provider.dart'; -import 'package:flick/providers/navigation_provider.dart'; -import 'package:flick/features/onboarding/tutorial_targets.dart'; +import 'package:flick/core/navigation/root_navigator.dart'; +import 'package:flick/core/utils/navigation_helper.dart'; import 'package:flick/features/manual/screens/manual_screen.dart'; +import 'package:flick/features/onboarding/tutorial_navigation.dart'; +import 'package:flick/features/onboarding/tutorial_targets.dart'; +import 'package:flick/features/settings/screens/library_settings_screen.dart'; +import 'package:flick/features/settings/screens/bottom_bar_settings_screen.dart'; +import 'package:flick/features/settings/screens/player_layout_settings_screen.dart'; +import 'package:flick/features/settings/screens/audio_settings_screen.dart'; +import 'package:flick/features/settings/screens/equalizer_screen.dart'; import 'package:flick/l10n/l10n.dart'; +import 'package:flick/models/nav_bar_config.dart'; +import 'package:flick/providers/nav_bar_config_provider.dart'; +import 'package:flick/providers/navigation_provider.dart'; +import 'package:flick/providers/player_provider.dart'; +import 'package:flick/providers/songs_provider.dart'; +import 'package:flick/providers/tutorial_provider.dart'; -class TutorialOverlay extends ConsumerStatefulWidget { - const TutorialOverlay({super.key}); - +/// Above both navigators, but never above a modal or an unrelated destination. +class TutorialHost extends ConsumerStatefulWidget { + const TutorialHost({super.key, required this.child}); + final Widget child; @override - ConsumerState createState() => _TutorialOverlayState(); + ConsumerState createState() => _TutorialHostState(); } -class _TutorialOverlayState extends ConsumerState - with SingleTickerProviderStateMixin { - late final AnimationController _controller; - late final Animation _opacity; - Rect? _currentSpotlight; - bool _measureAttempted = false; - +class _TutorialHostState extends ConsumerState + with WidgetsBindingObserver { + bool _resumed = true; @override void initState() { super.initState(); - _controller = AnimationController( - vsync: this, - duration: AppConstants.animationNormal, - ); - _opacity = CurvedAnimation( - parent: _controller, - curve: Curves.easeOutCubic, - ); - - ref.listenManual( - tutorialProvider.select((s) => s.currentStep), - (prev, next) => _onStepChanged(next), - ); - } - - void _onStepChanged(int stepIndex) { - final step = TutorialStep.values[ - stepIndex.clamp(0, TutorialStep.values.length - 1)]; - - if (step.requiredTabIndex != null) { - ref - .read(navigationIndexProvider.notifier) - .setIndex(step.requiredTabIndex!); - } - - setState(() { - _currentSpotlight = null; - _measureAttempted = step.spotlightTarget == null; - }); - - if (step.spotlightTarget == null) return; - - final target = step.spotlightTarget!; - final delay = step.requiredTabIndex != null - ? AppConstants.animationNormal + const Duration(milliseconds: 50) - : const Duration(milliseconds: 50); - - Future.delayed(delay, () { - if (!mounted) return; - if (ref.read(tutorialProvider).currentStep != stepIndex) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - if (ref.read(tutorialProvider).currentStep != stepIndex) return; - final rect = ref.read(tutorialTargetRegistryProvider).rectFor(target); - setState(() { - _currentSpotlight = rect; - _measureAttempted = true; - }); - }); - }); + WidgetsBinding.instance.addObserver(this); + tutorialNavigation.addListener(_routeChanged); } @override void dispose() { - _controller.dispose(); + tutorialNavigation.removeListener(_routeChanged); + WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override - Widget build(BuildContext context) { - final state = ref.watch(tutorialProvider); + void didChangeAppLifecycleState(AppLifecycleState state) { + setState(() => _resumed = state == AppLifecycleState.resumed); + } - if (state.active) { - _controller.forward(); - } else { - _controller.reverse(); + void _routeChanged() { + if (!mounted) return; + final name = tutorialNavigation.currentName; + if (tutorialNavigation.visible) { + if (name == '/full_player') { + ref + .read(tutorialProvider.notifier) + .onAction(TutorialAction.playerOpened); + } + if (name == '/queue') { + ref + .read(tutorialProvider.notifier) + .onAction(TutorialAction.queueOpened); + } } - - return FadeTransition( - opacity: _opacity, - child: - state.active ? _buildOverlay(context, state) : const SizedBox.shrink(), - ); + setState(() {}); } - Widget _buildOverlay(BuildContext context, TutorialState state) { - final screenSize = MediaQuery.of(context).size; - final step = state.step; - - final hasSpotlight = step.spotlightTarget != null && - _currentSpotlight != null && - _measureAttempted; - final centered = step.spotlightTarget == null || !hasSpotlight; - + @override + Widget build(BuildContext context) { + final state = ref.watch(tutorialProvider); + if (state.active) { + ref.listen(isPlayingProvider, (previous, next) { + if (_resumed && + tutorialNavigation.visible && + previous == false && + next) { + ref + .read(tutorialProvider.notifier) + .onAction(TutorialAction.playbackStarted); + } + }); + if (state.step == TutorialStep.addMusic) { + ref.listen(songsProvider, (previous, next) { + if (next.value?.songs.isNotEmpty ?? false) { + ref + .read(tutorialProvider.notifier) + .onAction(TutorialAction.libraryReady); + } + }); + } + } + final inQueue = tutorialNavigation.currentName == '/queue'; return Stack( children: [ - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () {}, - child: RepaintBoundary( - child: CustomPaint( - size: screenSize, - painter: _SpotlightPainter( - spotlight: hasSpotlight ? _currentSpotlight : null, - borderRadius: 16, - ), - ), - ), - ), - if (centered) - _buildCenterTooltip(context, state, step) - else - _buildAnchoredTooltip(context, state, step, _currentSpotlight!), + widget.child, + if (_resumed && + tutorialNavigation.visible && + (!inQueue || state.step == TutorialStep.queue || state.picker)) + const Positioned.fill(child: TutorialOverlay()), ], ); } +} - Widget _buildCenterTooltip( - BuildContext context, - TutorialState state, - TutorialStep step, - ) { - return Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: AppConstants.spacingXl), - child: _TooltipCard( - step: step, - currentStep: state.currentStep, - totalSteps: state.totalSteps, - isLastStep: state.isLastStep, - onNext: () => ref.read(tutorialProvider.notifier).nextStep(), - onBack: state.currentStep > 0 - ? () => ref.read(tutorialProvider.notifier).previousStep() - : null, - onSkip: () => ref.read(tutorialProvider.notifier).skip(), - onOpenManual: () => _openManualAndComplete(context), - ), - ), - ); - } +/// No gesture barrier: the spotlight and the rest of the real app remain usable. +class TutorialOverlay extends ConsumerStatefulWidget { + const TutorialOverlay({super.key}); + @override + ConsumerState createState() => _TutorialOverlayState(); +} - Widget _buildAnchoredTooltip( - BuildContext context, - TutorialState state, - TutorialStep step, - Rect spotlight, - ) { - final screenH = MediaQuery.of(context).size.height; - final above = spotlight.center.dy > screenH / 2; - return Positioned( - left: AppConstants.spacingMd, - right: AppConstants.spacingMd, - top: above ? null : spotlight.bottom + AppConstants.spacingMd, - bottom: above - ? screenH - spotlight.top + AppConstants.spacingMd - : null, - child: _TooltipCard( - step: step, - currentStep: state.currentStep, - totalSteps: state.totalSteps, - isLastStep: state.isLastStep, - onNext: () => ref.read(tutorialProvider.notifier).nextStep(), - onBack: state.currentStep > 0 - ? () => ref.read(tutorialProvider.notifier).previousStep() - : null, - onSkip: () => ref.read(tutorialProvider.notifier).skip(), - onOpenManual: () => _openManualAndComplete(context), - ), - ); +class _TutorialOverlayState extends ConsumerState + with WidgetsBindingObserver { + static const _accent = Color(0xFFE3F369); + final _overlayKey = GlobalKey(); + late final TutorialTargetRegistry _registry; + late final ProviderSubscription _subscription; + Rect? _spotlight; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _registry = ref.read(tutorialTargetRegistryProvider)..addListener(_measure); + _subscription = ref.listenManual(tutorialProvider, (previous, next) { + if (previous?.step != next.step || + previous?.active != next.active || + previous?.picker != next.picker || + previous?.guide != next.guide) { + _scheduleActivation(); + } + }); + _scheduleActivation(); } - void _openManualAndComplete(BuildContext context) { - ref.read(tutorialProvider.notifier).complete(); - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const ManualScreen()), - ); + @override + void dispose() { + _subscription.close(); + _registry.removeListener(_measure); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); } -} - -class _SpotlightPainter extends CustomPainter { - final Rect? spotlight; - final double borderRadius; - - const _SpotlightPainter({this.spotlight, this.borderRadius = 16}); @override - void paint(Canvas canvas, Size size) { - final paint = Paint()..color = Colors.black.withValues(alpha: 0.78); + void didChangeMetrics() => _registry.changed(); + void _scheduleActivation() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final state = ref.read(tutorialProvider); + if (state.active && !state.picker) { + if (state.step == TutorialStep.miniPlayer && + tutorialNavigation.currentName == '/full_player') { + ref + .read(tutorialProvider.notifier) + .onAction(TutorialAction.playerOpened); + return; + } + if (state.step == TutorialStep.searchEntry && + tutorialNavigation.root.top?.settings.name == '/tutorial/library') { + _navigator.pop(); + } + var tab = state.step.requiredTabIndex; + final dedicatedSearch = ref + .read(navBarConfigProvider) + .orderedButtons + .contains(NavBarButton.search); + if ((state.step == TutorialStep.searchEntry && dedicatedSearch) || + (state.step == TutorialStep.playSong && + ref.read(navigationIndexProvider) == + NavBarButton.search.pageIndex)) { + tab = NavBarButton.search.pageIndex; + } + if (tab != null && tutorialNavigation.root.top?.isFirst != false) { + ref.read(navigationIndexProvider.notifier).setIndex(tab); + } + } + _measure(); + }); + } - if (spotlight == null) { - canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), paint); - return; + void _measure() { + if (!mounted) return; + final target = ref.read(tutorialProvider).step.spotlightTarget; + final object = _overlayKey.currentContext?.findRenderObject(); + Rect? rect; + if (object is RenderBox && object.hasSize && target != null) { + rect = _registry.rectFor(target, relativeTo: object); + final viewport = Offset.zero & object.size; + if (rect != null) rect = rect.intersect(viewport); + if (rect?.isEmpty ?? false) rect = null; } + if (_spotlight != rect) setState(() => _spotlight = rect); + } - final fullPath = Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height)); - final cutout = Path() - ..addRRect(RRect.fromRectAndRadius(spotlight!, Radius.circular(borderRadius))); - - final result = Path.combine(PathOperation.difference, fullPath, cutout); - canvas.drawPath(result, paint); - - final borderPaint = Paint() - ..color = AppColors.accent.withValues(alpha: 0.25) - ..style = PaintingStyle.stroke - ..strokeWidth = 1.5; - canvas.drawRRect( - RRect.fromRectAndRadius(spotlight!, Radius.circular(borderRadius)), - borderPaint, + NavigatorState get _navigator => + rootNavigatorKey.currentState ?? Navigator.of(context); + Future _openPage(Widget page, String name) async { + FocusManager.instance.primaryFocus?.unfocus(); + await _navigator.push( + MaterialPageRoute( + settings: RouteSettings(name: '/tutorial/$name'), + builder: (_) => _GuidePage(child: page), + ), ); + if (mounted) _scheduleActivation(); } - @override - bool shouldRepaint(covariant _SpotlightPainter old) => - spotlight != old.spotlight || borderRadius != old.borderRadius; -} - -class _TooltipCard extends StatelessWidget { - final TutorialStep step; - final int currentStep; - final int totalSteps; - final bool isLastStep; - final VoidCallback onNext; - final VoidCallback? onBack; - final VoidCallback onSkip; - final VoidCallback onOpenManual; + Future _manual(String? section) async { + final notifier = ref.read(tutorialProvider.notifier); + notifier.setPaused(true); + await _navigator.push( + MaterialPageRoute( + builder: (_) => ManualScreen(initialSection: section), + ), + ); + // The route-aware host unmounts this overlay while the manual is open. + notifier.setPaused(false); + } - const _TooltipCard({ - required this.step, - required this.currentStep, - required this.totalSteps, - required this.isLastStep, - required this.onNext, - this.onBack, - required this.onSkip, - required this.onOpenManual, - }); + void _continue() { + FocusManager.instance.primaryFocus?.unfocus(); + final state = ref.read(tutorialProvider); + if (state.step == TutorialStep.welcome) { + ref + .read(tutorialProvider.notifier) + .beginCore( + hasMusic: ref.read(songsProvider).value?.songs.isNotEmpty ?? false, + ); + } else { + // Leave only guide-owned destinations; never pop a route the listener opened. + if (tutorialNavigation.root.top?.settings.name?.startsWith( + '/tutorial/', + ) ?? + false) { + _navigator.pop(); + } + ref.read(tutorialProvider.notifier).nextStep(); + } + } @override Widget build(BuildContext context) { + final state = ref.watch(tutorialProvider); + if (!state.active || state.paused) return const SizedBox.shrink(); final l10n = context.l10n.englishForFilipino; - final showManualButton = step.isManualPointer && isLastStep; - return Container( - padding: const EdgeInsets.all(AppConstants.spacingLg), - decoration: BoxDecoration( - color: AppColors.surfaceLight.withValues(alpha: 0.95), - borderRadius: BorderRadius.circular(AppConstants.radiusLg), - border: Border.all(color: AppColors.glassBorderStrong), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 24, - offset: const Offset(0, 8), - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppColors.glassBackgroundStrong, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - '${currentStep + 1}/$totalSteps', - style: const TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: 12, - fontWeight: FontWeight.w600, - color: AppColors.textTertiary, + return LayoutBuilder( + builder: (context, constraints) { + final media = MediaQuery.of(context); + final availableHeight = math.max( + 0.0, + constraints.maxHeight - + media.padding.top - + math.max(media.viewInsets.bottom, media.padding.bottom) - + 24, + ); + final centered = + state.picker || + state.step == TutorialStep.welcome || + state.isLastStep; + final above = + !centered && + _spotlight != null && + _spotlight!.center.dy > constraints.maxHeight / 2; + return Stack( + key: _overlayKey, + children: [ + if (!centered && _spotlight != null) + Positioned.fill( + child: IgnorePointer( + child: ExcludeSemantics( + child: CustomPaint(painter: _SpotlightPainter(_spotlight!)), ), ), ), - const Spacer(), - GestureDetector( - onTap: onSkip, - child: Text( - l10n.skip, - style: TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: 13, - fontWeight: FontWeight.w500, - color: AppColors.textTertiary, + Positioned.fill( + child: Padding( + padding: EdgeInsets.fromLTRB( + media.padding.left + 12, + media.padding.top + 12, + media.padding.right + 12, + math.max(media.viewInsets.bottom, media.padding.bottom) + 12, + ), + child: Align( + alignment: centered + ? Alignment.center + : above + ? Alignment.topCenter + : Alignment.bottomCenter, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 440, + maxHeight: centered + ? availableHeight + : math.min( + availableHeight, + math.max(180, availableHeight * .48), + ), + ), + child: Material( + color: const Color(0xFF20211E), + borderRadius: BorderRadius.circular(16), + elevation: 8, + shadowColor: Colors.black54, + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), + child: state.picker + ? _picker(state, l10n) + : _tooltip(state, l10n), + ), + ), ), ), ), - ], - ), - const SizedBox(height: AppConstants.spacingMd), - Text( - step.title(l10n), - style: const TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: 20, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, ), - ), - const SizedBox(height: 6), - Text( - step.description(l10n), - style: const TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: 14, - color: AppColors.textSecondary, - height: 1.5, + ], + ); + }, + ); + } + + ButtonStyle get _textStyle => TextButton.styleFrom( + foregroundColor: const Color(0xFFF1F0EB), + minimumSize: const Size(48, 48), + ); + + void _selectGuide(TutorialGuide guide) { + final name = tutorialNavigation.currentName; + // Choosing a library/appearance guide from the player's finish screen is + // an explicit request to return to the shell, not an automatic route pop. + if (guide != TutorialGuide.player && + (name == '/full_player' || name == '/queue')) { + tutorialNavigation.root.navigator?.popUntil((route) => route.isFirst); + tutorialNavigation.nested.navigator?.popUntil((route) => route.isFirst); + } + ref.read(tutorialProvider.notifier).start(guide: guide); + } + + Widget _picker(TutorialState state, AppLocalizations l10n) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + l10n.tourGuides, + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700), ), ), - const SizedBox(height: AppConstants.spacingLg), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (onBack != null) - Padding( - padding: const EdgeInsets.only(right: AppConstants.spacingSm), - child: _SecondaryButton( - label: l10n.back, - icon: LucideIcons.chevronLeft, - onTap: onBack!, - ), - ), - if (showManualButton) - _PrimaryButton( - label: l10n.openManual, - icon: LucideIcons.bookOpen, - onTap: onOpenManual, - ) - else - _PrimaryButton( - label: isLastStep ? l10n.getStarted : l10n.next, - icon: isLastStep ? null : LucideIcons.chevronRight, - onTap: onNext, - ), - ], + IconButton( + onPressed: () => ref.read(tutorialProvider.notifier).skip(), + icon: Icon(Icons.close, semanticLabel: l10n.close), ), ], ), - ); - } -} - -class _PrimaryButton extends StatelessWidget { - final String label; - final IconData? icon; - final VoidCallback onTap; - - const _PrimaryButton({ - required this.label, - this.icon, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: AppConstants.spacingMd + 4, - vertical: 10, + Text( + l10n.tourPickerBody, + style: const TextStyle( + color: Color(0xFFD3D5CC), + fontSize: 15, + height: 1.4, ), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFFF5F7FF), Color(0xFF9CC4FF)], + ), + const SizedBox(height: 16), + for (final guide in TutorialGuide.values) + ListTile( + contentPadding: EdgeInsets.zero, + minVerticalPadding: 12, + title: Text(guide.title(l10n)), + trailing: Icon( + state.progress[guide] == TutorialProgress.completed + ? Icons.check_circle_outline + : Icons.arrow_forward, + color: _accent, ), - borderRadius: BorderRadius.circular(AppConstants.radiusMd), + onTap: () => _selectGuide(guide), ), - child: Row( - mainAxisSize: MainAxisSize.min, + ], + ); + Widget _tooltip(TutorialState state, AppLocalizations l10n) { + final step = state.step; + final welcome = step == TutorialStep.welcome; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( children: [ - if (icon != null) ...[ - Icon(icon, size: 16, color: const Color(0xFF0A111A)), - const SizedBox(width: 4), - ], - Text( - label, + Expanded( + child: Text( + '${state.currentStep + 1} / ${state.totalSteps}', + style: const TextStyle(color: Color(0xFFD3D5CC), fontSize: 13), + ), + ), + TextButton( + style: _textStyle, + onPressed: () => ref.read(tutorialProvider.notifier).skip(), + child: Text(welcome ? l10n.tourNotNow : l10n.tourEnd), + ), + ], + ), + Semantics( + header: true, + liveRegion: true, + child: Text( + step.title(l10n), + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: Color(0xFFF1F0EB), + height: 1.15, + ), + ), + ), + const SizedBox(height: 10), + Text( + step.description(l10n), + style: const TextStyle( + fontSize: 15, + height: 1.4, + color: Color(0xFFD3D5CC), + ), + ), + if (state.actionConfirmed) + Padding( + padding: const EdgeInsets.only(top: 10), + child: Semantics( + liveRegion: true, + child: Text( + l10n.tourActionConfirmed, + style: const TextStyle(color: _accent, fontSize: 14), + ), + ), + ), + if (_spotlight == null && step.spotlightTarget != null) + Padding( + padding: const EdgeInsets.only(top: 10), + child: Text( + l10n.tourUnavailable, style: const TextStyle( - fontFamily: 'GoogleSansFlex', + color: Color(0xFFD3D5CC), fontSize: 14, - fontWeight: FontWeight.w600, - color: Color(0xFF0A111A), + height: 1.35, ), ), - if (icon == LucideIcons.chevronRight) ...[ - const SizedBox(width: 4), - const Icon( - LucideIcons.chevronRight, - size: 16, - color: Color(0xFF0A111A), + ), + ..._stepActions(state, l10n), + const SizedBox(height: 16), + Wrap( + alignment: WrapAlignment.end, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + runSpacing: 4, + children: [ + if (state.currentStep > 0) + TextButton( + style: _textStyle, + onPressed: () => + ref.read(tutorialProvider.notifier).previousStep(), + child: Text(l10n.back), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: _accent, + foregroundColor: const Color(0xFF151610), + minimumSize: const Size(112, 48), ), - ], + onPressed: _continue, + child: Text( + welcome + ? l10n.tourStart + : state.isLastStep + ? l10n.done + : l10n.tourContinue, + ), + ), ], ), + ], + ); + } + + List _stepActions(TutorialState state, AppLocalizations l10n) { + final step = state.step; + Widget action(String label, VoidCallback callback) => Align( + alignment: Alignment.centerLeft, + child: TextButton( + style: _textStyle, + onPressed: callback, + child: Text(label), ), ); + final song = ref.watch(currentSongProvider); + return [ + if (step == TutorialStep.addMusic && + tutorialNavigation.root.top?.settings.name != '/tutorial/library') + action( + l10n.addMusicFolder, + () => _openPage( + const LibrarySettingsScreen(initialSection: 'libraryLabel'), + 'library', + ), + ), + if (step == TutorialStep.playSong && song != null) + action( + l10n.tourUseCurrent, + () => ref.read(tutorialProvider.notifier).nextStep(), + ), + if ((step == TutorialStep.miniPlayer || + step == TutorialStep.fullPlayerHint || + step == TutorialStep.queue || + step == TutorialStep.lyrics) && + song != null && + tutorialNavigation.root.top?.settings.name != '/full_player') + action( + l10n.openPlayer, + () => NavigationHelper.navigateToFullPlayer( + _navigator.context, + heroTag: 'tutorial_player_art', + ), + ), + if (step == TutorialStep.navBar) + action( + l10n.tourOpenNavigation, + () => _openPage(const BottomBarSettingsScreen(), 'navigation'), + ), + if (step == TutorialStep.playerLayout) + action( + l10n.tourOpenLayout, + () => _openPage(const PlayerLayoutSettingsScreen(), 'layout'), + ), + if (step == TutorialStep.audioOutput) + action( + l10n.tourOpenAudio, + () => _openPage(const AudioSettingsScreen(), 'audio'), + ), + if (step == TutorialStep.audioDsp) + action( + l10n.equalizer, + () => _openPage(const EqualizerScreen(), 'equalizer'), + ), + if (step.manualSection != null) + action(l10n.tourReadMore, () => _manual(step.manualSection)), + if (state.isLastStep) ...[ + action(l10n.tourGuides, () async { + await ref.read(tutorialProvider.notifier).complete(); + if (mounted) ref.read(tutorialProvider.notifier).showPicker(); + }), + action(l10n.openManual, () => _manual(null)), + ], + ]; } } -class _SecondaryButton extends StatelessWidget { - final String label; - final IconData icon; - final VoidCallback onTap; - - const _SecondaryButton({ - required this.label, - required this.icon, - required this.onTap, - }); +class _GuidePage extends ConsumerWidget { + const _GuidePage({required this.child}); + final Widget child; + @override + Widget build(BuildContext context, WidgetRef ref) => PopScope( + canPop: !ref.watch(tutorialProvider).active, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) ref.read(tutorialProvider.notifier).skip(); + }, + child: child, + ); +} +class _SpotlightPainter extends CustomPainter { + const _SpotlightPainter(this.rect); + final Rect rect; @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: AppConstants.spacingMd, - vertical: 10, - ), - decoration: BoxDecoration( - color: AppColors.glassBackgroundStrong, - borderRadius: BorderRadius.circular(AppConstants.radiusMd), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16, color: AppColors.textSecondary), - const SizedBox(width: 4), - Text( - label, - style: const TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.textSecondary, - ), - ), - ], - ), - ), + void paint(Canvas canvas, Size size) { + final hole = RRect.fromRectAndRadius( + rect.inflate(3), + const Radius.circular(12), + ); + final path = Path() + ..fillType = PathFillType.evenOdd + ..addRect(Offset.zero & size) + ..addRRect(hole); + canvas.drawPath(path, Paint()..color = Colors.black.withValues(alpha: .32)); + canvas.drawRRect( + hole, + Paint() + ..color = const Color(0xFFE3F369) + ..style = PaintingStyle.stroke + ..strokeWidth = 2, ); } + + @override + bool shouldRepaint(_SpotlightPainter oldDelegate) => oldDelegate.rect != rect; } diff --git a/lib/features/player/screens/full_player_screen.dart b/lib/features/player/screens/full_player_screen.dart index 91f8b934d..70715a270 100644 --- a/lib/features/player/screens/full_player_screen.dart +++ b/lib/features/player/screens/full_player_screen.dart @@ -952,6 +952,10 @@ class _FullPlayerScreenState extends ConsumerState canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; + if (ref.read(tutorialProvider).active) { + ref.read(tutorialProvider.notifier).skip(); + return; + } _close(context); }, child: Scaffold( diff --git a/lib/features/player/widgets/player_action_button_row.dart b/lib/features/player/widgets/player_action_button_row.dart index 9c352d5eb..71a1d68c8 100644 --- a/lib/features/player/widgets/player_action_button_row.dart +++ b/lib/features/player/widgets/player_action_button_row.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flick/features/onboarding/tutorial_targets.dart'; import 'package:flick/widgets/common/flick_snackbar.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -301,17 +302,20 @@ class _PlayerActionButtonRowState extends ConsumerState { case PlayerActionButton.none: return const SizedBox.shrink(); case PlayerActionButton.lyrics: - return _buildLyricsButton( - context: context, - actionPadding: actionPadding, - actionRadius: actionRadius, - actionIconSize: actionIconSize, - inactiveBg: inactiveBg, - inactiveBorder: inactiveBorder, - albumColor: albumColor, - accentBlend: accentBlend, - hasAlbumTint: hasAlbumTint, - lyricsMode: lyricsMode, + return TutorialTargetAnchor( + target: TutorialTarget.playerLyrics, + child: _buildLyricsButton( + context: context, + actionPadding: actionPadding, + actionRadius: actionRadius, + actionIconSize: actionIconSize, + inactiveBg: inactiveBg, + inactiveBorder: inactiveBorder, + albumColor: albumColor, + accentBlend: accentBlend, + hasAlbumTint: hasAlbumTint, + lyricsMode: lyricsMode, + ), ); case PlayerActionButton.favorites: if (song.isExternal) { @@ -360,16 +364,19 @@ class _PlayerActionButtonRowState extends ConsumerState { accentBlend: accentBlend, ); case PlayerActionButton.queue: - return _buildQueueButton( - context: context, - actionPadding: actionPadding, - actionRadius: actionRadius, - actionIconSize: actionIconSize, - inactiveBg: inactiveBg, - inactiveBorder: inactiveBorder, - albumColor: albumColor, - accentBlend: accentBlend, - hasAlbumTint: hasAlbumTint, + return TutorialTargetAnchor( + target: TutorialTarget.playerQueue, + child: _buildQueueButton( + context: context, + actionPadding: actionPadding, + actionRadius: actionRadius, + actionIconSize: actionIconSize, + inactiveBg: inactiveBg, + inactiveBorder: inactiveBorder, + albumColor: albumColor, + accentBlend: accentBlend, + hasAlbumTint: hasAlbumTint, + ), ); case PlayerActionButton.sleepTimer: return _buildSleepTimerButton( @@ -452,7 +459,14 @@ class _PlayerActionButtonRowState extends ConsumerState { return Tooltip( message: lyricsMode ? l10n.hideLyrics : l10n.showLyrics, child: GestureDetector( - onTap: () => widget.onToggleLyrics(), + onTap: () { + widget.onToggleLyrics(); + if (!lyricsMode) { + ref + .read(tutorialProvider.notifier) + .onAction(TutorialAction.lyricsOpened); + } + }, child: Container( padding: actionPadding, decoration: widget.compact diff --git a/lib/features/search/screens/search_screen.dart b/lib/features/search/screens/search_screen.dart index ae75978de..6c495bd06 100644 --- a/lib/features/search/screens/search_screen.dart +++ b/lib/features/search/screens/search_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flick/features/onboarding/tutorial_targets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flick/core/constants/app_constants.dart'; @@ -64,6 +65,7 @@ class _SearchScreenState extends ConsumerState { final q = value.trim(); if (mounted && q != _debouncedQuery) { setState(() => _debouncedQuery = q); + ref.read(tutorialProvider.notifier).onAction(TutorialAction.searchUsed); } }); } @@ -131,15 +133,18 @@ class _SearchScreenState extends ConsumerState { ), ], ), - child: GlassSearchBar( - controller: _controller, - focusNode: _focusNode, - autofocus: autoFocus, - textInputAction: TextInputAction.search, - hintText: l10n.searchSongsArtistsAlbums, - showBackground: false, - onChanged: _onSearchChanged, - onClear: _clearSearch, + child: TutorialTargetAnchor( + target: TutorialTarget.songsSearchBar, + child: GlassSearchBar( + controller: _controller, + focusNode: _focusNode, + autofocus: autoFocus, + textInputAction: TextInputAction.search, + hintText: l10n.searchSongsArtistsAlbums, + showBackground: false, + onChanged: _onSearchChanged, + onClear: _clearSearch, + ), ), ), ), @@ -163,14 +168,17 @@ class _SearchScreenState extends ConsumerState { ), ), Expanded( - child: !hasInput - ? _buildEmptyState(context) - : _buildResultsArea( - context, - hasQuery: hasQuery, - resultsAsync: resultsAsync, - enabled: enabled, - ), + child: TutorialTargetAnchor( + target: TutorialTarget.songLibrary, + child: !hasInput + ? _buildEmptyState(context) + : _buildResultsArea( + context, + hasQuery: hasQuery, + resultsAsync: resultsAsync, + enabled: enabled, + ), + ), ), ], ), diff --git a/lib/features/settings/screens/app_info_settings_screen.dart b/lib/features/settings/screens/app_info_settings_screen.dart index 6ccdb99ff..e5f9bc884 100644 --- a/lib/features/settings/screens/app_info_settings_screen.dart +++ b/lib/features/settings/screens/app_info_settings_screen.dart @@ -723,7 +723,7 @@ class _AppInfoSettingsScreenState extends ConsumerState title: l10n.englishForFilipino.interactiveTutorial, subtitle: l10n.stepByStepWalkthroughOfThe, onTap: () { - ref.read(tutorialProvider.notifier).start(); + ref.read(tutorialProvider.notifier).showPicker(); Navigator.of(context).popUntil((route) => route.isFirst); }, ), diff --git a/lib/features/settings/screens/library_settings_screen.dart b/lib/features/settings/screens/library_settings_screen.dart index c4fff531e..6445a5400 100644 --- a/lib/features/settings/screens/library_settings_screen.dart +++ b/lib/features/settings/screens/library_settings_screen.dart @@ -35,6 +35,7 @@ import 'package:flick/services/permission_service.dart'; import 'package:flick/widgets/common/glass_bottom_sheet.dart'; import 'package:flick/widgets/common/vinyl_record.dart'; import 'package:flick/l10n/settings_l10n.dart'; +import 'package:flick/features/onboarding/tutorial_targets.dart'; class LibrarySettingsScreen extends ConsumerStatefulWidget { const LibrarySettingsScreen({super.key, this.initialSection}); @@ -449,6 +450,8 @@ class _LibrarySettingsScreenState extends ConsumerState } Future _addFolder() async { + final tutorial = ref.read(tutorialProvider.notifier); + tutorial.setPaused(true); try { final permissionService = PermissionService(); final hasPermission = await permissionService.hasStoragePermission(); @@ -470,6 +473,7 @@ class _LibrarySettingsScreenState extends ConsumerState } final folder = await _folderService.addFolder(); + tutorial.setPaused(false); if (folder != null) { await _loadLibraryData(); await _scanFolder(folder.uri, folder.displayName); @@ -489,6 +493,11 @@ class _LibrarySettingsScreenState extends ConsumerState context, ).showFlickSnackBar(SnackBar(content: Text(l10n.failedToAddFolder(e)))); } + } finally { + tutorial.setPaused(false); + if (mounted) { + if (_songCount > 0) tutorial.onAction(TutorialAction.libraryReady); + } } } @@ -2108,11 +2117,14 @@ class _LibrarySettingsScreenState extends ConsumerState : const SizedBox.shrink(), ), ), - ActionButton( - icon: LucideIcons.folderPlus, - title: l10n.englishForFilipino.addMusicFolder, - subtitle: l10n.selectAFolderToScan, - onTap: _isScanning ? null : _addFolder, + TutorialTargetAnchor( + target: TutorialTarget.addMusicFolder, + child: ActionButton( + icon: LucideIcons.folderPlus, + title: l10n.englishForFilipino.addMusicFolder, + subtitle: l10n.selectAFolderToScan, + onTap: _isScanning ? null : _addFolder, + ), ), if (_folders.isNotEmpty) ...[ const SettingsDivider(), diff --git a/lib/features/songs/screens/songs_screen.dart b/lib/features/songs/screens/songs_screen.dart index 76bbe8d3a..72694abb2 100644 --- a/lib/features/songs/screens/songs_screen.dart +++ b/lib/features/songs/screens/songs_screen.dart @@ -243,6 +243,11 @@ class _SongsScreenState extends ConsumerState _selectedIndex = 0; _lastSyncedSong = null; }); + if (value.trim().isNotEmpty) { + ref + .read(tutorialProvider.notifier) + .onAction(TutorialAction.searchUsed); + } }, ), ), @@ -253,112 +258,117 @@ class _SongsScreenState extends ConsumerState // Content based on async state Expanded( - child: songsAsync.when( - loading: () => _buildLoadingState(), - error: (error, stack) => _buildErrorState(error), - data: (songsState) { - final isAlbumMode = - songsState.sortOption == SongSortOption.album; - final allSongs = songsState.sortedSongs; - var songs = allSongs; - if (_cachedSongs != songsState.songs) { - _cachedSongs = songsState.songs; - _lastSyncedSong = null; - } - - if (_searchQuery.isNotEmpty) { - songs = songs.where((song) { - return song.title.toLowerCase().contains( - _searchQuery, - ) || - song.artist.toLowerCase().contains(_searchQuery); - }).toList(); - } - _cachedDisplaySongs = songs; - - if (!isAlbumMode) { - _wasInAlbumMode = false; - final sig = - '${songs.length}:${songs.isNotEmpty ? songs.first.id : ''}'; - if (sig != _listPaginationSignature) { - _listPaginationSignature = sig; - _resetListPagination(); + child: TutorialTargetAnchor( + target: TutorialTarget.songLibrary, + child: songsAsync.when( + loading: () => _buildLoadingState(), + error: (error, stack) => _buildErrorState(error), + data: (songsState) { + final isAlbumMode = + songsState.sortOption == SongSortOption.album; + final allSongs = songsState.sortedSongs; + var songs = allSongs; + if (_cachedSongs != songsState.songs) { + _cachedSongs = songsState.songs; + _lastSyncedSong = null; } - } - if (songs.isEmpty && _searchQuery.isEmpty) { - return _buildEmptyState(); - } + if (_searchQuery.isNotEmpty) { + songs = songs.where((song) { + return song.title.toLowerCase().contains( + _searchQuery, + ) || + song.artist.toLowerCase().contains( + _searchQuery, + ); + }).toList(); + } + _cachedDisplaySongs = songs; + + if (!isAlbumMode) { + _wasInAlbumMode = false; + final sig = + '${songs.length}:${songs.isNotEmpty ? songs.first.id : ''}'; + if (sig != _listPaginationSignature) { + _listPaginationSignature = sig; + _resetListPagination(); + } + } - if (songs.isEmpty && _searchQuery.isNotEmpty) { - return _buildNoSearchResultsState(); - } + if (songs.isEmpty && _searchQuery.isEmpty) { + return _buildEmptyState(); + } - if (isAlbumMode) { - if (!_wasInAlbumMode) { - _wasInAlbumMode = true; - _visibleAlbumCount = _getAlbumPageSize(); - _albumPaginationSignature = ''; + if (songs.isEmpty && _searchQuery.isNotEmpty) { + return _buildNoSearchResultsState(); } - final albumGroups = songsState.albumGroups; - if (_searchQuery.isNotEmpty) { - final filteredGroups = []; - for (final group in albumGroups) { - final filtered = group.songs.where((song) { - return song.title.toLowerCase().contains( - _searchQuery, - ) || - song.artist.toLowerCase().contains( - _searchQuery, - ); - }).toList(); - if (filtered.isNotEmpty) { - filteredGroups.add( - AlbumGroup( - key: group.key, - albumName: group.albumName, - albumArtist: group.albumArtist, - songs: filtered, - ), - ); + + if (isAlbumMode) { + if (!_wasInAlbumMode) { + _wasInAlbumMode = true; + _visibleAlbumCount = _getAlbumPageSize(); + _albumPaginationSignature = ''; + } + final albumGroups = songsState.albumGroups; + if (_searchQuery.isNotEmpty) { + final filteredGroups = []; + for (final group in albumGroups) { + final filtered = group.songs.where((song) { + return song.title.toLowerCase().contains( + _searchQuery, + ) || + song.artist.toLowerCase().contains( + _searchQuery, + ); + }).toList(); + if (filtered.isNotEmpty) { + filteredGroups.add( + AlbumGroup( + key: group.key, + albumName: group.albumName, + albumArtist: group.albumArtist, + songs: filtered, + ), + ); + } } + if (filteredGroups.isEmpty) { + return _buildNoSearchResultsState(); + } + return _buildAlbumGridView(filteredGroups); } - if (filteredGroups.isEmpty) { - return _buildNoSearchResultsState(); + if (albumGroups.isEmpty) { + return _buildEmptyState(); } - return _buildAlbumGridView(filteredGroups); + return _buildAlbumGridView(albumGroups); } - if (albumGroups.isEmpty) { - return _buildEmptyState(); - } - return _buildAlbumGridView(albumGroups); - } - _alignCurrentSongAfterSongsLoad(songs); + _alignCurrentSongAfterSongsLoad(songs); - if (_selectedIndex >= songs.length) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() => _selectedIndex = 0); - } - }); - } + if (_selectedIndex >= songs.length) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() => _selectedIndex = 0); + } + }); + } - _syncSelectedTokenForIndex(songs, _selectedIndex); + _syncSelectedTokenForIndex(songs, _selectedIndex); - final tokenToIndexMap = _buildFastIndexMap(songs); - final appPrefs = ref.watch(appPreferencesProvider); + final tokenToIndexMap = _buildFastIndexMap(songs); + final appPrefs = ref.watch(appPreferencesProvider); - return _buildSongsView( - songs, - viewMode, - tokenToIndexMap, - navBarVisible, - swipeActionsEnabled, - appPrefs.fastIndexEnabled, - orbitSideOverflow, - ); - }, + return _buildSongsView( + songs, + viewMode, + tokenToIndexMap, + navBarVisible, + swipeActionsEnabled, + appPrefs.fastIndexEnabled, + orbitSideOverflow, + ); + }, + ), ), ), ], @@ -2263,6 +2273,7 @@ class _SongsScreenState extends ConsumerState }); }, ); + ref.read(tutorialProvider.notifier).onAction(TutorialAction.sortOpened); } Future _shufflePlayFromLibrary( diff --git a/lib/features/songs/widgets/song_actions_bottom_sheet.dart b/lib/features/songs/widgets/song_actions_bottom_sheet.dart index 0676385fc..4b08f4d14 100644 --- a/lib/features/songs/widgets/song_actions_bottom_sheet.dart +++ b/lib/features/songs/widgets/song_actions_bottom_sheet.dart @@ -45,7 +45,7 @@ class SongActionsBottomSheet extends ConsumerWidget { VoidCallback? onSelect, VoidCallback? onRemoveFromPlaylist, }) { - return showModalBottomSheet( + final sheet = showModalBottomSheet( useRootNavigator: true, context: context, isScrollControlled: true, @@ -59,6 +59,10 @@ class SongActionsBottomSheet extends ConsumerWidget { ), ), ); + ProviderScope.containerOf(context) + .read(tutorialProvider.notifier) + .onAction(TutorialAction.songActionsOpened); + return sheet; } @override diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 216340a7f..a14c45c45 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,4 +1,44 @@ { + "tourCore": "Find & play music", + "tourLibrary": "Library tools", + "tourPlayer": "Player & queue", + "tourAppearance": "Make Flick yours", + "tourAudio": "Audio & output", + "tourGuides": "Explore guides", + "tourPickerBody": "Choose a short guide. Try the real controls, or continue at your own pace.", + "tourWelcomeTitle": "Your music, one step away", + "tourWelcomeBody": "Find a song, play it, and meet your player. You can use the real controls, continue without trying them, or leave at any time.", + "tourAddBody": "No music yet? Open Library settings and choose Add music folder. Scanning may take a while. Cancel, deny permission, or continue without adding anything — you can return later.", + "tourSearchBody": "Type a song or artist into the highlighted search field. Search lives in its own tab when you add it to your navigation. Continue if you would rather browse.", + "tourPlayTitle": "Pick something to play", + "tourPlayBody": "Tap a song you want to hear. The guide moves on when playback starts. Nothing starts automatically; if music is unavailable or playback fails, you can still continue.", + "tourMiniBody": "The mini player follows your music. Tap it to open the full player, or use Open player below. A song must be loaded first; you can continue without one.", + "tourFullBody": "Use the player to pause, skip, and seek. Queue, lyrics, and other actions depend on your chosen layout. The guide will not change your volume, queue, or audio settings.", + "tourFinishTitle": "Take it from here", + "tourFinishBody": "Done closes this guide. Explore another guide whenever you like, or find them again in Settings → App info → Interactive Tutorial.", + "tourSortBody": "Open the highlighted sort control to explore sorting and filters. Opening it is enough for this guide; no need to change your library view.", + "tourActionsBody": "Long-press a song for its actions, including Play next, queue, and song information. Opening the menu does not change your queue. Continue if there are no songs yet.", + "tourPlaylistsBody": "Find Playlists in the library menu or your customized navigation. Use a song’s actions to add it to a playlist. Read the playlist guide for the full workflow.", + "tourQueueBody": "Open Queue from the full player to see upcoming songs. Its button depends on your layout. You can look without reordering or removing anything.", + "tourLyricsBody": "Open Lyrics in the full player if your layout includes it. Lyrics are not available for every song. Continue or read the manual if the control or lyrics are unavailable.", + "tourNavBody": "Long-press the navigation bar to customize it, or open Navigation settings below. Choose your own tabs and order; this guide never changes them for you.", + "tourPlayerLayoutTitle": "A player that fits you", + "tourLayoutBody": "Explore Player layout to choose how your player looks and which actions it shows. Changes are optional and yours to make.", + "tourOutputTitle": "Know your output", + "tourOutputBody": "Audio settings show the available output paths. USB DAC and device hi-res support depend on connected hardware. This guide does not enable a mode or promise bit-perfect output; check the player’s output indicator.", + "tourDspTitle": "Shape sound deliberately", + "tourDspBody": "The equalizer and other DSP tools are optional. Processing changes the signal and can conflict with bit-perfect goals. Explore the controls without enabling anything, and check the manual before changing your audio path.", + "tourNotNow": "Not now", + "tourEnd": "End tour", + "tourStart": "Start tour", + "tourContinue": "Continue", + "tourUseCurrent": "Use the current song", + "tourReadMore": "Read this in the manual", + "tourOpenNavigation": "Open Navigation settings", + "tourOpenLayout": "Open Player layout", + "tourOpenAudio": "Open Audio settings", + "tourActionConfirmed": "Got it. On to the next step.", + "tourUnavailable": "Control not visible? You can continue, or use the manual below.", "filipinoDonationNote": "Thank you for supporting Flick.", "@filipinoDonationNote": { "description": "Personal note shown only in the Taglish locale, above the donation cards." @@ -3053,10 +3093,6 @@ "@customWidth": { "description": "lib/features/settings/widgets/mini_player_customization.dart:90" }, - "customizeAudioDisplayNavigationAnd": "Customize audio, display, navigation, and integrations.", - "@customizeAudioDisplayNavigationAnd": { - "description": "lib/providers/tutorial_provider.dart:44" - }, "customizeBottomBar": "Customize Bottom Bar", "@customizeBottomBar": { "description": "lib/widgets/navigation/flick_nav_bar.dart:463" @@ -5707,10 +5743,6 @@ "@letOboePickTheBestApi": { "description": "lib/features/settings/screens/uac2_preferences_screen.dart:662" }, - "letSTakeAQuickTour": "Let's take a quick tour of your new music player.", - "@letSTakeAQuickTour": { - "description": "lib/providers/tutorial_provider.dart:8" - }, "letTheSystemDecideBestBattery": "Let the system decide — best battery life", "@letTheSystemDecideBestBattery": { "description": "lib/features/settings/screens/interface_settings_screen.dart:212" @@ -8217,10 +8249,6 @@ "@renamePreset": { "description": "lib/features/settings/screens/equalizer_screen.dart:437" }, - "reorderFilterAndShuffleFromThis": "Reorder, filter, and shuffle from this header.", - "@reorderFilterAndShuffleFromThis": { - "description": "lib/providers/tutorial_provider.dart:28" - }, "repeat": "Repeat: {arg1}", "@repeat": { "description": "lib/features/player/widgets/player_controls.dart:218", @@ -8772,10 +8800,6 @@ "@search": { "description": "lib/providers/tutorial_provider.dart:21" }, - "searchAcrossSongsArtistsAndAlbums": "Search across songs, artists, and albums instantly.", - "@searchAcrossSongsArtistsAndAlbums": { - "description": "lib/providers/tutorial_provider.dart:22" - }, "searchAgain": "Search Again", "@searchAgain": { "description": "lib/features/albums/widgets/identify_album_sheet.dart:241" @@ -9264,10 +9288,6 @@ "@shownWhenThereIsRoomBeside": { "description": "lib/features/settings/widgets/mini_player_customization.dart:153" }, - "showsWhatSPlayingTapTo": "Shows what's playing. Tap to open the full player.", - "@showsWhatSPlayingTapTo": { - "description": "lib/providers/tutorial_provider.dart:39" - }, "shuffle": "Shuffle", "@shuffle": { "description": "lib/widgets/common/detail_header.dart:267" @@ -9878,10 +9898,6 @@ } } }, - "songsTab": "Songs Tab", - "@songsTab": { - "description": "lib/providers/tutorial_provider.dart:16" - }, "songsU2022Albums": "{arg1} songs • {uniqueAlbums} albums", "@songsU2022Albums": { "description": "lib/features/artists/screens/artists_screen.dart:675", @@ -10316,26 +10332,14 @@ "@tapEditTextTopRight": { "description": "lib/features/player/screens/lyrics_sync_screen.dart:661" }, - "tapIconsToSwitchTabsLong": "Tap icons to switch tabs. Long-press to customize the bar.", - "@tapIconsToSwitchTabsLong": { - "description": "lib/providers/tutorial_provider.dart:12" - }, "tapTheHeartIconOnAny": "Tap the heart icon on any song\nto add it to your favorites", "@tapTheHeartIconOnAny": { "description": "lib/features/favorites/screens/favorites_screen.dart:361" }, - "tapTheMiniPlayerForWaveform": "Tap the mini player for waveform seekbar, EQ, lyrics, and visualizer.", - "@tapTheMiniPlayerForWaveform": { - "description": "lib/providers/tutorial_provider.dart:50" - }, "tapToInspect12602280Png": "Tap to inspect · 1260 × 2280 PNG", "@tapToInspect12602280Png": { "description": "lib/features/recap/screens/replay_studio_screen.dart:450" }, - "tapToPlayLongPressFor": "Tap to play, long-press for options (queue, play next, info).", - "@tapToPlayLongPressFor": { - "description": "lib/providers/tutorial_provider.dart:34" - }, "tapToPreview": "Tap to preview", "@tapToPreview": { "description": "lib/features/player/widgets/online_lyrics_search_sheet.dart:955" @@ -10395,10 +10399,6 @@ "@textSize2": { "description": "lib/features/settings/screens/player_layout_settings_screen.dart:132" }, - "thatSTheTour": "That's the tour!", - "@thatSTheTour": { - "description": "lib/providers/tutorial_provider.dart:53" - }, "theAppDoesNotKnowinglyCollect": "The app does not knowingly collect any information from anyone, regardless of age.", "@theAppDoesNotKnowinglyCollect": { "description": "lib/features/settings/screens/privacy_policy_screen.dart:81" @@ -11516,10 +11516,6 @@ "@waitingForPlayback": { "description": "lib/features/settings/screens/uac2_preferences_screen.dart:1110" }, - "wantEveryControlDocumentedOpenThe": "Want every control documented? Open the in-app Manual anytime from Settings → Help & Manual.", - "@wantEveryControlDocumentedOpenThe": { - "description": "lib/providers/tutorial_provider.dart:55" - }, "was": "was: {arg1}", "@was": { "description": "lib/features/albums/widgets/identify_album_sheet.dart:537", @@ -11586,10 +11582,6 @@ "@welcomeToFlick": { "description": "lib/features/menu/screens/menu_screen.dart:305" }, - "welcomeToFlick2": "Welcome to Flick", - "@welcomeToFlick2": { - "description": "lib/providers/tutorial_provider.dart:7" - }, "whatItShows": "What it shows", "@whatItShows": { "description": "lib/features/manual/data/manual_data.dart" @@ -11896,10 +11888,6 @@ "@yourWeeklyRecapAppearsOnceYou": { "description": "lib/data/repositories/recently_played_repository.dart:35" }, - "yourWholeLibraryLivesHereTap": "Your whole library lives here. Tap any song to play it.", - "@yourWholeLibraryLivesHereTap": { - "description": "lib/providers/tutorial_provider.dart:17" - }, "yourYearlyRecapFillsInAs": "Your yearly recap fills in as you keep listening throughout the year.", "@yourYearlyRecapFillsInAs": { "description": "lib/data/repositories/recently_played_repository.dart:39" diff --git a/lib/l10n/app_tl.arb b/lib/l10n/app_tl.arb index ea7bcca8d..bdfb45da6 100644 --- a/lib/l10n/app_tl.arb +++ b/lib/l10n/app_tl.arb @@ -1,4 +1,44 @@ { + "tourCore": "Find & play music", + "tourLibrary": "Library tools", + "tourPlayer": "Player & queue", + "tourAppearance": "Make Flick yours", + "tourAudio": "Audio & output", + "tourGuides": "Explore guides", + "tourPickerBody": "Choose a short guide. Try the real controls, or continue at your own pace.", + "tourWelcomeTitle": "Your music, one step away", + "tourWelcomeBody": "Find a song, play it, and meet your player. You can use the real controls, continue without trying them, or leave at any time.", + "tourAddBody": "No music yet? Open Library settings and choose Add music folder. Scanning may take a while. Cancel, deny permission, or continue without adding anything — you can return later.", + "tourSearchBody": "Type a song or artist into the highlighted search field. Search lives in its own tab when you add it to your navigation. Continue if you would rather browse.", + "tourPlayTitle": "Pick something to play", + "tourPlayBody": "Tap a song you want to hear. The guide moves on when playback starts. Nothing starts automatically; if music is unavailable or playback fails, you can still continue.", + "tourMiniBody": "The mini player follows your music. Tap it to open the full player, or use Open player below. A song must be loaded first; you can continue without one.", + "tourFullBody": "Use the player to pause, skip, and seek. Queue, lyrics, and other actions depend on your chosen layout. The guide will not change your volume, queue, or audio settings.", + "tourFinishTitle": "Take it from here", + "tourFinishBody": "Done closes this guide. Explore another guide whenever you like, or find them again in Settings → App info → Interactive Tutorial.", + "tourSortBody": "Open the highlighted sort control to explore sorting and filters. Opening it is enough for this guide; no need to change your library view.", + "tourActionsBody": "Long-press a song for its actions, including Play next, queue, and song information. Opening the menu does not change your queue. Continue if there are no songs yet.", + "tourPlaylistsBody": "Find Playlists in the library menu or your customized navigation. Use a song’s actions to add it to a playlist. Read the playlist guide for the full workflow.", + "tourQueueBody": "Open Queue from the full player to see upcoming songs. Its button depends on your layout. You can look without reordering or removing anything.", + "tourLyricsBody": "Open Lyrics in the full player if your layout includes it. Lyrics are not available for every song. Continue or read the manual if the control or lyrics are unavailable.", + "tourNavBody": "Long-press the navigation bar to customize it, or open Navigation settings below. Choose your own tabs and order; this guide never changes them for you.", + "tourPlayerLayoutTitle": "A player that fits you", + "tourLayoutBody": "Explore Player layout to choose how your player looks and which actions it shows. Changes are optional and yours to make.", + "tourOutputTitle": "Know your output", + "tourOutputBody": "Audio settings show the available output paths. USB DAC and device hi-res support depend on connected hardware. This guide does not enable a mode or promise bit-perfect output; check the player’s output indicator.", + "tourDspTitle": "Shape sound deliberately", + "tourDspBody": "The equalizer and other DSP tools are optional. Processing changes the signal and can conflict with bit-perfect goals. Explore the controls without enabling anything, and check the manual before changing your audio path.", + "tourNotNow": "Not now", + "tourEnd": "End tour", + "tourStart": "Start tour", + "tourContinue": "Continue", + "tourUseCurrent": "Use the current song", + "tourReadMore": "Read this in the manual", + "tourOpenNavigation": "Open Navigation settings", + "tourOpenLayout": "Open Player layout", + "tourOpenAudio": "Open Audio settings", + "tourActionConfirmed": "Got it. On to the next step.", + "tourUnavailable": "Control not visible? You can continue, or use the manual below.", "filipinoDonationNote": "Donations will help me a lot! Hehehe. Sana masarap ulam mo lagi!", "playingLabel": "Tumutugtog", "@@locale": "tl", @@ -524,7 +564,6 @@ "customFormatIsDisabledInBit": "Naka-off ang pasadyang format sa Bit-perfect (USB DAC) mode dahil kailangang eksaktong tumugma ang sample rate. I-off ang Bit-perfect (USB DAC) upang magtakda ng pasadyang format.", "customFormatIsUnavailableBecauseAudio": "Hindi magamit ang pasadyang format dahil naka-off ang Format ng tunog sa Mga pagsasaayos.", "customWidth": "Pasadyang lapad", - "customizeAudioDisplayNavigationAnd": "Ipasadya ang tunog, pagpapakita, nabigasyon, at mga integrasyon.", "customizeBottomBar": "Ipasadya ang bar sa ibaba", "customizeHomeScreenWidgets": "Ipasadya ang mga widget sa home screen", "customizeOrbital": "Ipasadya ang Orbital", @@ -1079,7 +1118,6 @@ "letAndroidChooseTheOutputDevice": "Hayaang piliin ng Android ang output device", "letAndroidNegotiateTheCodecAutomatically": "Hayaang awtomatikong pagkasunduan ng Android ang codec", "letOboePickTheBestApi": "Hayaang piliin ng Oboe ang pinakamahusay na API. AAudio muna, saka OpenSL ES bilang pamalit.", - "letSTakeAQuickTour": "Tingnan natin sandali ang bago mong music player.", "letTheSystemDecideBestBattery": "Hayaang magpasya ang sistema — pinakamatipid sa baterya", "letsFlickScanYourEntireLibrary": "Pinapayagan ang Flick na direktang i-scan ang buong aklatan, kabilang ang DSD/DSF/WavPack na itinatago ng ilang aparato sa media index ng sistema", "level": "Antas", @@ -1612,7 +1650,6 @@ "removingDuplicates": "Inaalis ang magkakapareho…", "rename": "Palitan ang pangalan", "renamePreset": "Palitan ang pangalan ng preset", - "reorderFilterAndShuffleFromThis": "Baguhin ang ayos, salain, at ihalo mula sa pamagat na ito.", "repeat": "Ulit: {arg1}", "repeatMode": "Mode ng pag-ulit", "repeatRewind": "Balik-ulit", @@ -1738,7 +1775,6 @@ "scrollingPausesWhenReducedMotionIs": "Humihinto ang pag-scroll kapag naka-on ang pinababang galaw", "seamlessTransitionBetweenTracks": "Tuloy-tuloy na paglipat sa pagitan ng mga kanta", "search": "Hanapin", - "searchAcrossSongsArtistsAndAlbums": "Agad na maghanap sa mga kanta, kumanta, at album.", "searchAgain": "Maghanap muli", "searchArtistTitle": "Hanapin ang kumanta + pamagat...", "searchArtists": "Hanapin ang mga kumanta...", @@ -1847,7 +1883,6 @@ "showingOfAlbums": "Ipinapakita ang {visibleCount} sa {totalCount} album", "showingYourLastLoadedReplay": "Ipinapakita ang huling na-load mong Replay.", "shownWhenThereIsRoomBeside": "Ipinapakita kung may puwang sa tabi ng mga kontrol", - "showsWhatSPlayingTapTo": "Ipinapakita ang tumutugtog. Pindutin upang buksan ang buong player.", "shuffle": "Ihalo", "shuffle2": "Paghalo: {arg1}", "shuffleFavorites": "Ihalo ang mga paborito", @@ -1977,7 +2012,6 @@ "songsFound": "{arg1} kanta ang nahanap", "songsIn": "{songs, plural, =1{{songs} kanta} other{{songs} kanta}} sa {folders, plural, =1{{folders} folder} other{{folders} folder}}", "songsInRotation": "{count, plural, =1{{count} kanta sa iyong pag-ikot} other{{count} kanta sa iyong pag-ikot}}", - "songsTab": "Tab ng mga kanta", "songsU2022Albums": "{arg1} kanta • {uniqueAlbums} album", "songsYouPlayWillAppearHere": "Dito lilitaw ang mga kantang patutugtugin mo", "songsYouScanIntoYourLibrary": "Dito lilitaw ang mga kantang na-scan sa iyong aklatan", @@ -2079,11 +2113,8 @@ "tapAllOrEnableAChip": "Pindutin ang Lahat o pumili ng kategorya upang makita ang mga resulta", "tapChipsToFilterCategories": "Pindutin ang mga chip upang salain ang mga kategorya", "tapEditTextTopRight": "1. Pindutin ang \"Baguhin ang teksto\" (kanang itaas) at idikit ang lirika — isang linya sa bawat hanay.\n2. Patugtugin ang kanta.\n3. Piliin ang kasalukuyang linya sa listahan ng Mga linya.\n4. Pindutin ang \"Itala at sumunod\" kapag narinig mo ang linyang iyon.\n5. I-save kapag tapos na.", - "tapIconsToSwitchTabsLong": "Pindutin ang mga icon upang lumipat ng tab. Pindutin nang matagal upang baguhin ang bar.", "tapTheHeartIconOnAny": "Pindutin ang puso sa alinmang kanta\nupang idagdag sa iyong mga paborito", - "tapTheMiniPlayerForWaveform": "Pindutin ang mini player para sa waveform seekbar, EQ, lirika, at visualizer.", "tapToInspect12602280Png": "Pindutin upang suriin · 1260 × 2280 PNG", - "tapToPlayLongPressFor": "Pindutin upang patugtugin, pindutin nang matagal para sa mga opsiyon (pila, isunod sa pagtugtog, impormasyon).", "tapToPreview": "Pindutin para sa paunang tingin", "targetMs": "target {arg1} ms", "tempo": "Tempo", @@ -2095,7 +2126,6 @@ "textPlacement2": "Puwesto ng teksto", "textSize": "Laki ng teksto", "textSize2": "Laki ng teksto", - "thatSTheTour": "Iyan ang ating maikling paglilibot!", "theAppDoesNotKnowinglyCollect": "Hindi sadyang nangongolekta ang app ng anumang impormasyon mula sa sinuman, anuman ang edad.", "theAppQueriesPublicApisMusicbrainz": "Naghahanap ang app sa mga pampublikong API (MusicBrainz/Cover Art Archive, iTunes, Deezer) ng tugmang larawan ng album. Metadata ng lokal na musika ang gamit sa paghahanap. Lokal na naka-cache ang mga na-download na larawan.", "theAudioEngineDeterminesHowYour": "Ang audio engine ang nagtatakda kung paano tutugtugin ang iyong musika. Magkakaiba ang balanse ng pagiging maaasahan, kalidad, at compatibility ng bawat opsiyon.", @@ -2326,7 +2356,6 @@ "wAgo": "{arg1} linggo ang nakalipas", "waitingForBrowserAuthorization": "Hinihintay ang pahintulot sa browser...", "waitingForPlayback": "Naghihintay ng pagtugtog", - "wantEveryControlDocumentedOpenThe": "Nais malaman ang bawat kontrol? Buksan anumang oras ang Gabay sa app mula sa Mga pagsasaayos → Tulong at gabay.", "was": "dati: {arg1}", "wave": "Alon", "waveform": "Waveform", @@ -2338,7 +2367,6 @@ "weekly": "Lingguhan", "welcomeBackLetSStartA": "Maligayang pagbabalik — simulan natin ang sunod-sunod na pakikinig.", "welcomeToFlick": "Maligayang pagdating sa Flick!", - "welcomeToFlick2": "Maligayang pagdating sa Flick", "whatSNew": "Ano'ng bago", "whenYouPlayOverBluetoothAndroid": "Kapag tumutugtog sa Bluetooth, nakikipagkasundo ang Android sa iyong headphone o speaker para sa codec.", "whereShouldTheLrcFileBe": "Saan ise-save ang .lrc file?", @@ -2402,7 +2430,6 @@ "yourScrobblingHistoryWillRemainOn": "Mananatili sa Last.fm ang iyong kasaysayan ng scrobbling, ngunit hindi na magpapadala ang Flick.", "yourSessionHistory": "Kasaysayan ng iyong mga sesyon", "yourWeeklyRecapAppearsOnceYou": "Lilitaw ang iyong lingguhang buod kapag nagsimula kang makinig ngayong linggo.", - "yourWholeLibraryLivesHereTap": "Narito ang buong aklatan mo. Pindutin ang alinmang kanta upang patugtugin.", "yourYearlyRecapFillsInAs": "Unti-unting nabubuo ang iyong taunang buod habang patuloy kang nakikinig sa buong taon.", "aSwipeGestureWhenConfigured": "Kung visualizer ang naka-set na swipe action mo, isang swipe lang sa mini player para i-on o i-off ito doon mismo.", "addTheSearchTabVia": "Wala pang Search tab? Long-press sa nav bar, tapos buksan ang Bottom Bar Settings para idagdag ito.", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1838d83f9..a6a8e7efd 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,4 +1,44 @@ { + "tourCore": "查找和播放音乐", + "tourLibrary": "音乐库工具", + "tourPlayer": "播放器和队列", + "tourAppearance": "打造你的 Flick", + "tourAudio": "音频和输出", + "tourGuides": "探索指南", + "tourPickerBody": "选择一个简短指南。试用真实控件,或按自己的节奏继续。", + "tourWelcomeTitle": "离你的音乐,只差一步", + "tourWelcomeBody": "找一首歌,播放它,再认识一下播放器。你可以操作真实控件,也可以直接继续,或随时退出。", + "tourAddBody": "还没有音乐?打开音乐库设置,选择「添加音乐文件夹」。扫描可能需要一些时间。取消、拒绝权限或直接继续都没关系,稍后可以再来。", + "tourSearchBody": "在高亮的搜索框中输入歌曲或歌手。若导航栏已添加搜索,搜索会位于独立标签页。想直接浏览也可以继续。", + "tourPlayTitle": "选一首想听的歌", + "tourPlayBody": "点按你想听的歌曲。播放成功后,指南会进入下一步。不会自动开始播放;没有音乐或播放失败时,也可以继续。", + "tourMiniBody": "迷你播放器随音乐出现。点按它可打开完整播放器,也可以使用下方按钮。需要先加载一首歌;没有歌曲也可以继续。", + "tourFullBody": "在播放器中暂停、切歌和调整进度。队列、歌词及其他操作取决于你选择的布局。指南不会更改音量、队列或音频设置。", + "tourFinishTitle": "接下来,交给你", + "tourFinishBody": "点按「完成」关闭指南。你可以探索其他指南,也可以随时前往 设置 → 应用信息 → 交互式教程 再次查看。", + "tourSortBody": "打开高亮的排序控件,了解排序和筛选。只需打开即可,无须更改音乐库视图。", + "tourActionsBody": "长按歌曲可查看下一首播放、队列和歌曲信息等操作。仅打开菜单不会更改队列。没有歌曲时可以继续。", + "tourPlaylistsBody": "在音乐库菜单或自定义导航中找到播放列表。通过歌曲操作将其加入播放列表。完整操作可查看播放列表指南。", + "tourQueueBody": "从完整播放器打开队列,查看即将播放的歌曲。按钮位置取决于布局。你可以仅查看,不必调整顺序或移除歌曲。", + "tourLyricsBody": "如果布局中有歌词按钮,可在完整播放器中打开。并非每首歌都有歌词。控件或歌词不可用时,可以继续或阅读手册。", + "tourNavBody": "长按导航栏进行自定义,或使用下方按钮打开导航设置。标签和顺序由你决定,指南不会替你更改。", + "tourPlayerLayoutTitle": "适合你的播放器", + "tourLayoutBody": "在播放器布局中选择外观和显示的操作。是否更改,完全由你决定。", + "tourOutputTitle": "了解音频输出", + "tourOutputBody": "音频设置显示可用的输出路径。USB DAC 和设备高解析度支持取决于硬件。指南不会启用模式,也不保证位完美输出;请查看播放器的输出指示。", + "tourDspTitle": "有意识地调整声音", + "tourDspBody": "均衡器和其他 DSP 工具均为可选。信号处理会改变音频,可能与位完美目标冲突。你可以只查看控件,不启用任何功能;更改音频路径前请先阅读手册。", + "tourNotNow": "暂时不用", + "tourEnd": "结束教程", + "tourStart": "开始教程", + "tourContinue": "继续", + "tourUseCurrent": "使用当前歌曲", + "tourReadMore": "在手册中阅读此节", + "tourOpenNavigation": "打开导航设置", + "tourOpenLayout": "打开播放器布局", + "tourOpenAudio": "打开音频设置", + "tourActionConfirmed": "操作成功,进入下一步。", + "tourUnavailable": "看不到控件?可以继续,或查看下方手册。", "filipinoDonationNote": "感谢你支持 Flick。", "playingLabel": "播放中", "aDraggableOverlayShowsWhileUsing": "使用其他应用时显示可拖动的悬浮窗", @@ -688,7 +728,6 @@ "customFormatIsDisabledInBit": "位完美(USB DAC)模式下自定义格式已禁用,因为需要精确匹配采样率。禁用位完美(USB DAC)才能设置自定义格式。", "customFormatIsUnavailableBecauseAudio": "由于设置中已禁用音频格式,自定义格式不可用。", "customWidth": "自定义宽度", - "customizeAudioDisplayNavigationAnd": "自定义音频、显示、导航与集成。", "customizeBottomBar": "自定义底部栏", "customizeHomeScreenWidgets": "自定义主屏幕小组件", "customizeOrbital": "自定义 Orbital", @@ -1231,7 +1270,6 @@ "letAndroidChooseTheOutputDevice": "让 Android 选择输出设备", "letAndroidNegotiateTheCodecAutomatically": "让 Android 自动协商编解码器", "letOboePickTheBestApi": "由 Oboe 自动选择最佳 API。优先 AAudio,其次回退到 OpenSL ES。", - "letSTakeAQuickTour": "快速了解你的新音乐播放器。", "letTheSystemDecideBestBattery": "由系统决定——最省电", "letsFlickScanYourEntireLibrary": "让 Flick 直接扫描你的整个音乐库,包括部分设备对系统媒体索引隐藏的 DSD/DSF/WavPack 文件", "level": "电平", @@ -1763,7 +1801,6 @@ "removingDuplicates": "正在移除重复项…", "rename": "重命名", "renamePreset": "重命名预设", - "reorderFilterAndShuffleFromThis": "在此标题栏可重新排序、筛选和随机播放。", "repeat": "循环:{arg1}", "repeatMode": "循环模式", "repeatRewind": "重温回放", @@ -1888,7 +1925,6 @@ "scrollingPausesWhenReducedMotionIs": "启用“减弱动态效果”时暂停滚动", "seamlessTransitionBetweenTracks": "曲目间无缝衔接", "search": "搜索", - "searchAcrossSongsArtistsAndAlbums": "即时搜索歌曲、艺术家和专辑。", "searchAgain": "重新搜索", "searchArtistTitle": "搜索艺术家 + 标题…", "searchArtists": "搜索艺术家…", @@ -1997,7 +2033,6 @@ "showingOfAlbums": "显示 {totalCount} 张专辑中的 {visibleCount} 张", "showingYourLastLoadedReplay": "显示上次载入的回顾。", "shownWhenThereIsRoomBeside": "控件旁边有空间时显示", - "showsWhatSPlayingTapTo": "显示当前播放内容,点按打开完整播放器。", "shuffle": "随机播放", "shuffle2": "随机播放:{arg1}", "shuffleFavorites": "随机播放收藏", @@ -2127,7 +2162,6 @@ "songsFound": "找到 {arg1} 首歌曲", "songsIn": "{songs} 首歌曲,分布在 {folders} 个文件夹中", "songsInRotation": "{count} 首歌曲在你的循环播放中", - "songsTab": "歌曲标签页", "songsU2022Albums": "{arg1} 首歌曲 • {uniqueAlbums} 张专辑", "songsYouPlayWillAppearHere": "你播放的歌曲会显示在这里", "songsYouScanIntoYourLibrary": "扫描进音乐库的歌曲会显示在这里", @@ -2227,11 +2261,8 @@ "tapAllOrEnableAChip": "点按“全部”或启用标签以查看结果", "tapChipsToFilterCategories": "点按标签筛选分类", "tapEditTextTopRight": "1. 点右上角「编辑文本」,把歌词粘贴进来——一行一句。\n2. 播放这首歌。\n3. 在「歌词行」列表里选中当前这一行。\n4. 听到那一句时点「打点并下一句」。\n5. 完成后保存。", - "tapIconsToSwitchTabsLong": "点按图标切换标签页,长按可自定义导航栏。", "tapTheHeartIconOnAny": "点按任意歌曲上的爱心图标\n即可加入收藏", - "tapTheMiniPlayerForWaveform": "点按迷你播放器,打开波形进度条、均衡器、歌词和可视化效果。", "tapToInspect12602280Png": "点击查看 · 1260 × 2280 PNG", - "tapToPlayLongPressFor": "点按播放,长按查看更多选项(播放队列、下一首播放、信息)。", "tapToPreview": "点按预览", "targetMs": "目标 {arg1} ms", "tempo": "速度", @@ -2243,7 +2274,6 @@ "textPlacement2": "文字位置", "textSize": "文字大小", "textSize2": "文字大小", - "thatSTheTour": "导览到此结束!", "theAppDoesNotKnowinglyCollect": "本应用不会在任何年龄段有意收集任何人的任何信息。", "theAppQueriesPublicApisMusicbrainz": "应用会查询公开 API(MusicBrainz/Cover Art Archive、iTunes、Deezer)以查找匹配的专辑封面。搜索查询使用本地音乐元数据。下载的图片会缓存在本地。", "theAudioEngineDeterminesHowYour": "音频引擎决定音乐的播放方式。每个选项在可靠性、音质和兼容性上各有取舍。", @@ -2475,7 +2505,6 @@ "wAgo": "{arg1} 周前", "waitingForBrowserAuthorization": "正在等待浏览器授权…", "waitingForPlayback": "等待播放", - "wantEveryControlDocumentedOpenThe": "想了解每个控件的说明?随时可从「设置 → 帮助与手册」打开应用内手册。", "was": "原为:{arg1}", "wave": "波形", "waveform": "波形", @@ -2488,7 +2517,6 @@ "weekly": "每周", "welcomeBackLetSStartA": "欢迎回来,来开启连续收听吧。", "welcomeToFlick": "欢迎使用 Flick!", - "welcomeToFlick2": "欢迎使用 Flick", "whatItShows": "显示内容", "whatSNew": "新功能", "whenBitPerfectOutputIs": "启用位完美输出时,胶囊会显示当前发送给 DAC 的采样率与位深。", @@ -2554,6 +2582,5 @@ "yourScrobblingHistoryWillRemainOn": "你的收听记录会保留在 Last.fm,但 Flick 将不再发送记录。", "yourSessionHistory": "你的收听记录", "yourWeeklyRecapAppearsOnceYou": "本周开始收听,你的每周回顾就会出现。", - "yourWholeLibraryLivesHereTap": "你的整个音乐库都在这里,点按任意歌曲即可播放。", "yourYearlyRecapFillsInAs": "随着你全年持续收听,你的年度回顾会逐渐充实。" } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 52da05d92..73250d7d3 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -101,6 +101,246 @@ abstract class AppLocalizations { Locale('zh'), ]; + /// No description provided for @tourCore. + /// + /// In en, this message translates to: + /// **'Find & play music'** + String get tourCore; + + /// No description provided for @tourLibrary. + /// + /// In en, this message translates to: + /// **'Library tools'** + String get tourLibrary; + + /// No description provided for @tourPlayer. + /// + /// In en, this message translates to: + /// **'Player & queue'** + String get tourPlayer; + + /// No description provided for @tourAppearance. + /// + /// In en, this message translates to: + /// **'Make Flick yours'** + String get tourAppearance; + + /// No description provided for @tourAudio. + /// + /// In en, this message translates to: + /// **'Audio & output'** + String get tourAudio; + + /// No description provided for @tourGuides. + /// + /// In en, this message translates to: + /// **'Explore guides'** + String get tourGuides; + + /// No description provided for @tourPickerBody. + /// + /// In en, this message translates to: + /// **'Choose a short guide. Try the real controls, or continue at your own pace.'** + String get tourPickerBody; + + /// No description provided for @tourWelcomeTitle. + /// + /// In en, this message translates to: + /// **'Your music, one step away'** + String get tourWelcomeTitle; + + /// No description provided for @tourWelcomeBody. + /// + /// In en, this message translates to: + /// **'Find a song, play it, and meet your player. You can use the real controls, continue without trying them, or leave at any time.'** + String get tourWelcomeBody; + + /// No description provided for @tourAddBody. + /// + /// In en, this message translates to: + /// **'No music yet? Open Library settings and choose Add music folder. Scanning may take a while. Cancel, deny permission, or continue without adding anything — you can return later.'** + String get tourAddBody; + + /// No description provided for @tourSearchBody. + /// + /// In en, this message translates to: + /// **'Type a song or artist into the highlighted search field. Search lives in its own tab when you add it to your navigation. Continue if you would rather browse.'** + String get tourSearchBody; + + /// No description provided for @tourPlayTitle. + /// + /// In en, this message translates to: + /// **'Pick something to play'** + String get tourPlayTitle; + + /// No description provided for @tourPlayBody. + /// + /// In en, this message translates to: + /// **'Tap a song you want to hear. The guide moves on when playback starts. Nothing starts automatically; if music is unavailable or playback fails, you can still continue.'** + String get tourPlayBody; + + /// No description provided for @tourMiniBody. + /// + /// In en, this message translates to: + /// **'The mini player follows your music. Tap it to open the full player, or use Open player below. A song must be loaded first; you can continue without one.'** + String get tourMiniBody; + + /// No description provided for @tourFullBody. + /// + /// In en, this message translates to: + /// **'Use the player to pause, skip, and seek. Queue, lyrics, and other actions depend on your chosen layout. The guide will not change your volume, queue, or audio settings.'** + String get tourFullBody; + + /// No description provided for @tourFinishTitle. + /// + /// In en, this message translates to: + /// **'Take it from here'** + String get tourFinishTitle; + + /// No description provided for @tourFinishBody. + /// + /// In en, this message translates to: + /// **'Done closes this guide. Explore another guide whenever you like, or find them again in Settings → App info → Interactive Tutorial.'** + String get tourFinishBody; + + /// No description provided for @tourSortBody. + /// + /// In en, this message translates to: + /// **'Open the highlighted sort control to explore sorting and filters. Opening it is enough for this guide; no need to change your library view.'** + String get tourSortBody; + + /// No description provided for @tourActionsBody. + /// + /// In en, this message translates to: + /// **'Long-press a song for its actions, including Play next, queue, and song information. Opening the menu does not change your queue. Continue if there are no songs yet.'** + String get tourActionsBody; + + /// No description provided for @tourPlaylistsBody. + /// + /// In en, this message translates to: + /// **'Find Playlists in the library menu or your customized navigation. Use a song’s actions to add it to a playlist. Read the playlist guide for the full workflow.'** + String get tourPlaylistsBody; + + /// No description provided for @tourQueueBody. + /// + /// In en, this message translates to: + /// **'Open Queue from the full player to see upcoming songs. Its button depends on your layout. You can look without reordering or removing anything.'** + String get tourQueueBody; + + /// No description provided for @tourLyricsBody. + /// + /// In en, this message translates to: + /// **'Open Lyrics in the full player if your layout includes it. Lyrics are not available for every song. Continue or read the manual if the control or lyrics are unavailable.'** + String get tourLyricsBody; + + /// No description provided for @tourNavBody. + /// + /// In en, this message translates to: + /// **'Long-press the navigation bar to customize it, or open Navigation settings below. Choose your own tabs and order; this guide never changes them for you.'** + String get tourNavBody; + + /// No description provided for @tourPlayerLayoutTitle. + /// + /// In en, this message translates to: + /// **'A player that fits you'** + String get tourPlayerLayoutTitle; + + /// No description provided for @tourLayoutBody. + /// + /// In en, this message translates to: + /// **'Explore Player layout to choose how your player looks and which actions it shows. Changes are optional and yours to make.'** + String get tourLayoutBody; + + /// No description provided for @tourOutputTitle. + /// + /// In en, this message translates to: + /// **'Know your output'** + String get tourOutputTitle; + + /// No description provided for @tourOutputBody. + /// + /// In en, this message translates to: + /// **'Audio settings show the available output paths. USB DAC and device hi-res support depend on connected hardware. This guide does not enable a mode or promise bit-perfect output; check the player’s output indicator.'** + String get tourOutputBody; + + /// No description provided for @tourDspTitle. + /// + /// In en, this message translates to: + /// **'Shape sound deliberately'** + String get tourDspTitle; + + /// No description provided for @tourDspBody. + /// + /// In en, this message translates to: + /// **'The equalizer and other DSP tools are optional. Processing changes the signal and can conflict with bit-perfect goals. Explore the controls without enabling anything, and check the manual before changing your audio path.'** + String get tourDspBody; + + /// No description provided for @tourNotNow. + /// + /// In en, this message translates to: + /// **'Not now'** + String get tourNotNow; + + /// No description provided for @tourEnd. + /// + /// In en, this message translates to: + /// **'End tour'** + String get tourEnd; + + /// No description provided for @tourStart. + /// + /// In en, this message translates to: + /// **'Start tour'** + String get tourStart; + + /// No description provided for @tourContinue. + /// + /// In en, this message translates to: + /// **'Continue'** + String get tourContinue; + + /// No description provided for @tourUseCurrent. + /// + /// In en, this message translates to: + /// **'Use the current song'** + String get tourUseCurrent; + + /// No description provided for @tourReadMore. + /// + /// In en, this message translates to: + /// **'Read this in the manual'** + String get tourReadMore; + + /// No description provided for @tourOpenNavigation. + /// + /// In en, this message translates to: + /// **'Open Navigation settings'** + String get tourOpenNavigation; + + /// No description provided for @tourOpenLayout. + /// + /// In en, this message translates to: + /// **'Open Player layout'** + String get tourOpenLayout; + + /// No description provided for @tourOpenAudio. + /// + /// In en, this message translates to: + /// **'Open Audio settings'** + String get tourOpenAudio; + + /// No description provided for @tourActionConfirmed. + /// + /// In en, this message translates to: + /// **'Got it. On to the next step.'** + String get tourActionConfirmed; + + /// No description provided for @tourUnavailable. + /// + /// In en, this message translates to: + /// **'Control not visible? You can continue, or use the manual below.'** + String get tourUnavailable; + /// Personal note shown only in the Taglish locale, above the donation cards. /// /// In en, this message translates to: @@ -4181,12 +4421,6 @@ abstract class AppLocalizations { /// **'Custom Width'** String get customWidth; - /// lib/providers/tutorial_provider.dart:44 - /// - /// In en, this message translates to: - /// **'Customize audio, display, navigation, and integrations.'** - String get customizeAudioDisplayNavigationAnd; - /// lib/widgets/navigation/flick_nav_bar.dart:463 /// /// In en, this message translates to: @@ -7439,12 +7673,6 @@ abstract class AppLocalizations { /// **'Let Oboe pick the best API. AAudio first, then OpenSL ES fallback.'** String get letOboePickTheBestApi; - /// lib/providers/tutorial_provider.dart:8 - /// - /// In en, this message translates to: - /// **'Let\'s take a quick tour of your new music player.'** - String get letSTakeAQuickTour; - /// lib/features/settings/screens/interface_settings_screen.dart:212 /// /// In en, this message translates to: @@ -10630,12 +10858,6 @@ abstract class AppLocalizations { /// **'Rename Preset'** String get renamePreset; - /// lib/providers/tutorial_provider.dart:28 - /// - /// In en, this message translates to: - /// **'Reorder, filter, and shuffle from this header.'** - String get reorderFilterAndShuffleFromThis; - /// lib/features/player/widgets/player_controls.dart:218 /// /// In en, this message translates to: @@ -11380,12 +11602,6 @@ abstract class AppLocalizations { /// **'Search'** String get search; - /// lib/providers/tutorial_provider.dart:22 - /// - /// In en, this message translates to: - /// **'Search across songs, artists, and albums instantly.'** - String get searchAcrossSongsArtistsAndAlbums; - /// lib/features/albums/widgets/identify_album_sheet.dart:241 /// /// In en, this message translates to: @@ -12034,12 +12250,6 @@ abstract class AppLocalizations { /// **'Shown when there is room beside the controls'** String get shownWhenThereIsRoomBeside; - /// lib/providers/tutorial_provider.dart:39 - /// - /// In en, this message translates to: - /// **'Shows what\'s playing. Tap to open the full player.'** - String get showsWhatSPlayingTapTo; - /// lib/widgets/common/detail_header.dart:267 /// /// In en, this message translates to: @@ -12814,12 +13024,6 @@ abstract class AppLocalizations { /// **'{count, plural, =1{{count} song in your rotation} other{{count} songs in your rotation}}'** String songsInRotation(int count); - /// lib/providers/tutorial_provider.dart:16 - /// - /// In en, this message translates to: - /// **'Songs Tab'** - String get songsTab; - /// lib/features/artists/screens/artists_screen.dart:675 /// /// In en, this message translates to: @@ -13414,36 +13618,18 @@ abstract class AppLocalizations { /// **'1. Tap \"Edit Text\" (top right) and paste the lyrics — one line per row.\n2. Play the song.\n3. Pick the current line in the Lines list.\n4. Tap \"Stamp & Next\" when you hear that line.\n5. Save when done.'** String get tapEditTextTopRight; - /// lib/providers/tutorial_provider.dart:12 - /// - /// In en, this message translates to: - /// **'Tap icons to switch tabs. Long-press to customize the bar.'** - String get tapIconsToSwitchTabsLong; - /// lib/features/favorites/screens/favorites_screen.dart:361 /// /// In en, this message translates to: /// **'Tap the heart icon on any song\nto add it to your favorites'** String get tapTheHeartIconOnAny; - /// lib/providers/tutorial_provider.dart:50 - /// - /// In en, this message translates to: - /// **'Tap the mini player for waveform seekbar, EQ, lyrics, and visualizer.'** - String get tapTheMiniPlayerForWaveform; - /// lib/features/recap/screens/replay_studio_screen.dart:450 /// /// In en, this message translates to: /// **'Tap to inspect · 1260 × 2280 PNG'** String get tapToInspect12602280Png; - /// lib/providers/tutorial_provider.dart:34 - /// - /// In en, this message translates to: - /// **'Tap to play, long-press for options (queue, play next, info).'** - String get tapToPlayLongPressFor; - /// lib/features/player/widgets/online_lyrics_search_sheet.dart:955 /// /// In en, this message translates to: @@ -13510,12 +13696,6 @@ abstract class AppLocalizations { /// **'Text Size'** String get textSize2; - /// lib/providers/tutorial_provider.dart:53 - /// - /// In en, this message translates to: - /// **'That\'s the tour!'** - String get thatSTheTour; - /// lib/features/settings/screens/privacy_policy_screen.dart:81 /// /// In en, this message translates to: @@ -14906,12 +15086,6 @@ abstract class AppLocalizations { /// **'Waiting for playback'** String get waitingForPlayback; - /// lib/providers/tutorial_provider.dart:55 - /// - /// In en, this message translates to: - /// **'Want every control documented? Open the in-app Manual anytime from Settings → Help & Manual.'** - String get wantEveryControlDocumentedOpenThe; - /// lib/features/albums/widgets/identify_album_sheet.dart:537 /// /// In en, this message translates to: @@ -14984,12 +15158,6 @@ abstract class AppLocalizations { /// **'Welcome to Flick!'** String get welcomeToFlick; - /// lib/providers/tutorial_provider.dart:7 - /// - /// In en, this message translates to: - /// **'Welcome to Flick'** - String get welcomeToFlick2; - /// lib/features/manual/data/manual_data.dart /// /// In en, this message translates to: @@ -15380,12 +15548,6 @@ abstract class AppLocalizations { /// **'Your weekly recap appears once you start listening this week.'** String get yourWeeklyRecapAppearsOnceYou; - /// lib/providers/tutorial_provider.dart:17 - /// - /// In en, this message translates to: - /// **'Your whole library lives here. Tap any song to play it.'** - String get yourWholeLibraryLivesHereTap; - /// lib/data/repositories/recently_played_repository.dart:39 /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index d8cc2c473..262ccca81 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -8,6 +8,144 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @override + String get tourCore => 'Find & play music'; + + @override + String get tourLibrary => 'Library tools'; + + @override + String get tourPlayer => 'Player & queue'; + + @override + String get tourAppearance => 'Make Flick yours'; + + @override + String get tourAudio => 'Audio & output'; + + @override + String get tourGuides => 'Explore guides'; + + @override + String get tourPickerBody => + 'Choose a short guide. Try the real controls, or continue at your own pace.'; + + @override + String get tourWelcomeTitle => 'Your music, one step away'; + + @override + String get tourWelcomeBody => + 'Find a song, play it, and meet your player. You can use the real controls, continue without trying them, or leave at any time.'; + + @override + String get tourAddBody => + 'No music yet? Open Library settings and choose Add music folder. Scanning may take a while. Cancel, deny permission, or continue without adding anything — you can return later.'; + + @override + String get tourSearchBody => + 'Type a song or artist into the highlighted search field. Search lives in its own tab when you add it to your navigation. Continue if you would rather browse.'; + + @override + String get tourPlayTitle => 'Pick something to play'; + + @override + String get tourPlayBody => + 'Tap a song you want to hear. The guide moves on when playback starts. Nothing starts automatically; if music is unavailable or playback fails, you can still continue.'; + + @override + String get tourMiniBody => + 'The mini player follows your music. Tap it to open the full player, or use Open player below. A song must be loaded first; you can continue without one.'; + + @override + String get tourFullBody => + 'Use the player to pause, skip, and seek. Queue, lyrics, and other actions depend on your chosen layout. The guide will not change your volume, queue, or audio settings.'; + + @override + String get tourFinishTitle => 'Take it from here'; + + @override + String get tourFinishBody => + 'Done closes this guide. Explore another guide whenever you like, or find them again in Settings → App info → Interactive Tutorial.'; + + @override + String get tourSortBody => + 'Open the highlighted sort control to explore sorting and filters. Opening it is enough for this guide; no need to change your library view.'; + + @override + String get tourActionsBody => + 'Long-press a song for its actions, including Play next, queue, and song information. Opening the menu does not change your queue. Continue if there are no songs yet.'; + + @override + String get tourPlaylistsBody => + 'Find Playlists in the library menu or your customized navigation. Use a song’s actions to add it to a playlist. Read the playlist guide for the full workflow.'; + + @override + String get tourQueueBody => + 'Open Queue from the full player to see upcoming songs. Its button depends on your layout. You can look without reordering or removing anything.'; + + @override + String get tourLyricsBody => + 'Open Lyrics in the full player if your layout includes it. Lyrics are not available for every song. Continue or read the manual if the control or lyrics are unavailable.'; + + @override + String get tourNavBody => + 'Long-press the navigation bar to customize it, or open Navigation settings below. Choose your own tabs and order; this guide never changes them for you.'; + + @override + String get tourPlayerLayoutTitle => 'A player that fits you'; + + @override + String get tourLayoutBody => + 'Explore Player layout to choose how your player looks and which actions it shows. Changes are optional and yours to make.'; + + @override + String get tourOutputTitle => 'Know your output'; + + @override + String get tourOutputBody => + 'Audio settings show the available output paths. USB DAC and device hi-res support depend on connected hardware. This guide does not enable a mode or promise bit-perfect output; check the player’s output indicator.'; + + @override + String get tourDspTitle => 'Shape sound deliberately'; + + @override + String get tourDspBody => + 'The equalizer and other DSP tools are optional. Processing changes the signal and can conflict with bit-perfect goals. Explore the controls without enabling anything, and check the manual before changing your audio path.'; + + @override + String get tourNotNow => 'Not now'; + + @override + String get tourEnd => 'End tour'; + + @override + String get tourStart => 'Start tour'; + + @override + String get tourContinue => 'Continue'; + + @override + String get tourUseCurrent => 'Use the current song'; + + @override + String get tourReadMore => 'Read this in the manual'; + + @override + String get tourOpenNavigation => 'Open Navigation settings'; + + @override + String get tourOpenLayout => 'Open Player layout'; + + @override + String get tourOpenAudio => 'Open Audio settings'; + + @override + String get tourActionConfirmed => 'Got it. On to the next step.'; + + @override + String get tourUnavailable => + 'Control not visible? You can continue, or use the manual below.'; + @override String get filipinoDonationNote => 'Thank you for supporting Flick.'; @@ -2411,10 +2549,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get customWidth => 'Custom Width'; - @override - String get customizeAudioDisplayNavigationAnd => - 'Customize audio, display, navigation, and integrations.'; - @override String get customizeBottomBar => 'Customize Bottom Bar'; @@ -4363,10 +4497,6 @@ class AppLocalizationsEn extends AppLocalizations { String get letOboePickTheBestApi => 'Let Oboe pick the best API. AAudio first, then OpenSL ES fallback.'; - @override - String get letSTakeAQuickTour => - 'Let\'s take a quick tour of your new music player.'; - @override String get letTheSystemDecideBestBattery => 'Let the system decide — best battery life'; @@ -6234,10 +6364,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get renamePreset => 'Rename Preset'; - @override - String get reorderFilterAndShuffleFromThis => - 'Reorder, filter, and shuffle from this header.'; - @override String repeat(Object arg1) { return 'Repeat: $arg1'; @@ -6656,10 +6782,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get search => 'Search'; - @override - String get searchAcrossSongsArtistsAndAlbums => - 'Search across songs, artists, and albums instantly.'; - @override String get searchAgain => 'Search Again'; @@ -7047,10 +7169,6 @@ class AppLocalizationsEn extends AppLocalizations { String get shownWhenThereIsRoomBeside => 'Shown when there is room beside the controls'; - @override - String get showsWhatSPlayingTapTo => - 'Shows what\'s playing. Tap to open the full player.'; - @override String get shuffle => 'Shuffle'; @@ -7560,9 +7678,6 @@ class AppLocalizationsEn extends AppLocalizations { return '$_temp0'; } - @override - String get songsTab => 'Songs Tab'; - @override String songsU2022Albums(Object arg1, Object uniqueAlbums) { return '$arg1 songs • $uniqueAlbums albums'; @@ -7911,25 +8026,13 @@ class AppLocalizationsEn extends AppLocalizations { String get tapEditTextTopRight => '1. Tap \"Edit Text\" (top right) and paste the lyrics — one line per row.\n2. Play the song.\n3. Pick the current line in the Lines list.\n4. Tap \"Stamp & Next\" when you hear that line.\n5. Save when done.'; - @override - String get tapIconsToSwitchTabsLong => - 'Tap icons to switch tabs. Long-press to customize the bar.'; - @override String get tapTheHeartIconOnAny => 'Tap the heart icon on any song\nto add it to your favorites'; - @override - String get tapTheMiniPlayerForWaveform => - 'Tap the mini player for waveform seekbar, EQ, lyrics, and visualizer.'; - @override String get tapToInspect12602280Png => 'Tap to inspect · 1260 × 2280 PNG'; - @override - String get tapToPlayLongPressFor => - 'Tap to play, long-press for options (queue, play next, info).'; - @override String get tapToPreview => 'Tap to preview'; @@ -7969,9 +8072,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get textSize2 => 'Text Size'; - @override - String get thatSTheTour => 'That\'s the tour!'; - @override String get theAppDoesNotKnowinglyCollect => 'The app does not knowingly collect any information from anyone, regardless of age.'; @@ -8813,10 +8913,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get waitingForPlayback => 'Waiting for playback'; - @override - String get wantEveryControlDocumentedOpenThe => - 'Want every control documented? Open the in-app Manual anytime from Settings → Help & Manual.'; - @override String was(Object arg1) { return 'was: $arg1'; @@ -8878,9 +8974,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get welcomeToFlick => 'Welcome to Flick!'; - @override - String get welcomeToFlick2 => 'Welcome to Flick'; - @override String get whatItShows => 'What it shows'; @@ -9111,10 +9204,6 @@ class AppLocalizationsEn extends AppLocalizations { String get yourWeeklyRecapAppearsOnceYou => 'Your weekly recap appears once you start listening this week.'; - @override - String get yourWholeLibraryLivesHereTap => - 'Your whole library lives here. Tap any song to play it.'; - @override String get yourYearlyRecapFillsInAs => 'Your yearly recap fills in as you keep listening throughout the year.'; diff --git a/lib/l10n/generated/app_localizations_tl.dart b/lib/l10n/generated/app_localizations_tl.dart index 91b2a9bca..913a33f7c 100644 --- a/lib/l10n/generated/app_localizations_tl.dart +++ b/lib/l10n/generated/app_localizations_tl.dart @@ -8,6 +8,144 @@ import 'app_localizations.dart'; class AppLocalizationsTl extends AppLocalizations { AppLocalizationsTl([String locale = 'tl']) : super(locale); + @override + String get tourCore => 'Find & play music'; + + @override + String get tourLibrary => 'Library tools'; + + @override + String get tourPlayer => 'Player & queue'; + + @override + String get tourAppearance => 'Make Flick yours'; + + @override + String get tourAudio => 'Audio & output'; + + @override + String get tourGuides => 'Explore guides'; + + @override + String get tourPickerBody => + 'Choose a short guide. Try the real controls, or continue at your own pace.'; + + @override + String get tourWelcomeTitle => 'Your music, one step away'; + + @override + String get tourWelcomeBody => + 'Find a song, play it, and meet your player. You can use the real controls, continue without trying them, or leave at any time.'; + + @override + String get tourAddBody => + 'No music yet? Open Library settings and choose Add music folder. Scanning may take a while. Cancel, deny permission, or continue without adding anything — you can return later.'; + + @override + String get tourSearchBody => + 'Type a song or artist into the highlighted search field. Search lives in its own tab when you add it to your navigation. Continue if you would rather browse.'; + + @override + String get tourPlayTitle => 'Pick something to play'; + + @override + String get tourPlayBody => + 'Tap a song you want to hear. The guide moves on when playback starts. Nothing starts automatically; if music is unavailable or playback fails, you can still continue.'; + + @override + String get tourMiniBody => + 'The mini player follows your music. Tap it to open the full player, or use Open player below. A song must be loaded first; you can continue without one.'; + + @override + String get tourFullBody => + 'Use the player to pause, skip, and seek. Queue, lyrics, and other actions depend on your chosen layout. The guide will not change your volume, queue, or audio settings.'; + + @override + String get tourFinishTitle => 'Take it from here'; + + @override + String get tourFinishBody => + 'Done closes this guide. Explore another guide whenever you like, or find them again in Settings → App info → Interactive Tutorial.'; + + @override + String get tourSortBody => + 'Open the highlighted sort control to explore sorting and filters. Opening it is enough for this guide; no need to change your library view.'; + + @override + String get tourActionsBody => + 'Long-press a song for its actions, including Play next, queue, and song information. Opening the menu does not change your queue. Continue if there are no songs yet.'; + + @override + String get tourPlaylistsBody => + 'Find Playlists in the library menu or your customized navigation. Use a song’s actions to add it to a playlist. Read the playlist guide for the full workflow.'; + + @override + String get tourQueueBody => + 'Open Queue from the full player to see upcoming songs. Its button depends on your layout. You can look without reordering or removing anything.'; + + @override + String get tourLyricsBody => + 'Open Lyrics in the full player if your layout includes it. Lyrics are not available for every song. Continue or read the manual if the control or lyrics are unavailable.'; + + @override + String get tourNavBody => + 'Long-press the navigation bar to customize it, or open Navigation settings below. Choose your own tabs and order; this guide never changes them for you.'; + + @override + String get tourPlayerLayoutTitle => 'A player that fits you'; + + @override + String get tourLayoutBody => + 'Explore Player layout to choose how your player looks and which actions it shows. Changes are optional and yours to make.'; + + @override + String get tourOutputTitle => 'Know your output'; + + @override + String get tourOutputBody => + 'Audio settings show the available output paths. USB DAC and device hi-res support depend on connected hardware. This guide does not enable a mode or promise bit-perfect output; check the player’s output indicator.'; + + @override + String get tourDspTitle => 'Shape sound deliberately'; + + @override + String get tourDspBody => + 'The equalizer and other DSP tools are optional. Processing changes the signal and can conflict with bit-perfect goals. Explore the controls without enabling anything, and check the manual before changing your audio path.'; + + @override + String get tourNotNow => 'Not now'; + + @override + String get tourEnd => 'End tour'; + + @override + String get tourStart => 'Start tour'; + + @override + String get tourContinue => 'Continue'; + + @override + String get tourUseCurrent => 'Use the current song'; + + @override + String get tourReadMore => 'Read this in the manual'; + + @override + String get tourOpenNavigation => 'Open Navigation settings'; + + @override + String get tourOpenLayout => 'Open Player layout'; + + @override + String get tourOpenAudio => 'Open Audio settings'; + + @override + String get tourActionConfirmed => 'Got it. On to the next step.'; + + @override + String get tourUnavailable => + 'Control not visible? You can continue, or use the manual below.'; + @override String get filipinoDonationNote => 'Donations will help me a lot! Hehehe. Sana masarap ulam mo lagi!'; @@ -2440,10 +2578,6 @@ class AppLocalizationsTl extends AppLocalizations { @override String get customWidth => 'Pasadyang lapad'; - @override - String get customizeAudioDisplayNavigationAnd => - 'Ipasadya ang tunog, pagpapakita, nabigasyon, at mga integrasyon.'; - @override String get customizeBottomBar => 'Ipasadya ang bar sa ibaba'; @@ -4415,10 +4549,6 @@ class AppLocalizationsTl extends AppLocalizations { String get letOboePickTheBestApi => 'Hayaang piliin ng Oboe ang pinakamahusay na API. AAudio muna, saka OpenSL ES bilang pamalit.'; - @override - String get letSTakeAQuickTour => - 'Tingnan natin sandali ang bago mong music player.'; - @override String get letTheSystemDecideBestBattery => 'Hayaang magpasya ang sistema — pinakamatipid sa baterya'; @@ -6302,10 +6432,6 @@ class AppLocalizationsTl extends AppLocalizations { @override String get renamePreset => 'Palitan ang pangalan ng preset'; - @override - String get reorderFilterAndShuffleFromThis => - 'Baguhin ang ayos, salain, at ihalo mula sa pamagat na ito.'; - @override String repeat(Object arg1) { return 'Ulit: $arg1'; @@ -6730,10 +6856,6 @@ class AppLocalizationsTl extends AppLocalizations { @override String get search => 'Hanapin'; - @override - String get searchAcrossSongsArtistsAndAlbums => - 'Agad na maghanap sa mga kanta, kumanta, at album.'; - @override String get searchAgain => 'Maghanap muli'; @@ -7128,10 +7250,6 @@ class AppLocalizationsTl extends AppLocalizations { String get shownWhenThereIsRoomBeside => 'Ipinapakita kung may puwang sa tabi ng mga kontrol'; - @override - String get showsWhatSPlayingTapTo => - 'Ipinapakita ang tumutugtog. Pindutin upang buksan ang buong player.'; - @override String get shuffle => 'Ihalo'; @@ -7642,9 +7760,6 @@ class AppLocalizationsTl extends AppLocalizations { return '$_temp0'; } - @override - String get songsTab => 'Tab ng mga kanta'; - @override String songsU2022Albums(Object arg1, Object uniqueAlbums) { return '$arg1 kanta • $uniqueAlbums album'; @@ -7999,26 +8114,14 @@ class AppLocalizationsTl extends AppLocalizations { String get tapEditTextTopRight => '1. Pindutin ang \"Baguhin ang teksto\" (kanang itaas) at idikit ang lirika — isang linya sa bawat hanay.\n2. Patugtugin ang kanta.\n3. Piliin ang kasalukuyang linya sa listahan ng Mga linya.\n4. Pindutin ang \"Itala at sumunod\" kapag narinig mo ang linyang iyon.\n5. I-save kapag tapos na.'; - @override - String get tapIconsToSwitchTabsLong => - 'Pindutin ang mga icon upang lumipat ng tab. Pindutin nang matagal upang baguhin ang bar.'; - @override String get tapTheHeartIconOnAny => 'Pindutin ang puso sa alinmang kanta\nupang idagdag sa iyong mga paborito'; - @override - String get tapTheMiniPlayerForWaveform => - 'Pindutin ang mini player para sa waveform seekbar, EQ, lirika, at visualizer.'; - @override String get tapToInspect12602280Png => 'Pindutin upang suriin · 1260 × 2280 PNG'; - @override - String get tapToPlayLongPressFor => - 'Pindutin upang patugtugin, pindutin nang matagal para sa mga opsiyon (pila, isunod sa pagtugtog, impormasyon).'; - @override String get tapToPreview => 'Pindutin para sa paunang tingin'; @@ -8058,9 +8161,6 @@ class AppLocalizationsTl extends AppLocalizations { @override String get textSize2 => 'Laki ng teksto'; - @override - String get thatSTheTour => 'Iyan ang ating maikling paglilibot!'; - @override String get theAppDoesNotKnowinglyCollect => 'Hindi sadyang nangongolekta ang app ng anumang impormasyon mula sa sinuman, anuman ang edad.'; @@ -8914,10 +9014,6 @@ class AppLocalizationsTl extends AppLocalizations { @override String get waitingForPlayback => 'Naghihintay ng pagtugtog'; - @override - String get wantEveryControlDocumentedOpenThe => - 'Nais malaman ang bawat kontrol? Buksan anumang oras ang Gabay sa app mula sa Mga pagsasaayos → Tulong at gabay.'; - @override String was(Object arg1) { return 'dati: $arg1'; @@ -8980,9 +9076,6 @@ class AppLocalizationsTl extends AppLocalizations { @override String get welcomeToFlick => 'Maligayang pagdating sa Flick!'; - @override - String get welcomeToFlick2 => 'Maligayang pagdating sa Flick'; - @override String get whatItShows => 'Ano ang ipinapakita'; @@ -9217,10 +9310,6 @@ class AppLocalizationsTl extends AppLocalizations { String get yourWeeklyRecapAppearsOnceYou => 'Lilitaw ang iyong lingguhang buod kapag nagsimula kang makinig ngayong linggo.'; - @override - String get yourWholeLibraryLivesHereTap => - 'Narito ang buong aklatan mo. Pindutin ang alinmang kanta upang patugtugin.'; - @override String get yourYearlyRecapFillsInAs => 'Unti-unting nabubuo ang iyong taunang buod habang patuloy kang nakikinig sa buong taon.'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 491e52691..1b3573340 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -8,6 +8,138 @@ import 'app_localizations.dart'; class AppLocalizationsZh extends AppLocalizations { AppLocalizationsZh([String locale = 'zh']) : super(locale); + @override + String get tourCore => '查找和播放音乐'; + + @override + String get tourLibrary => '音乐库工具'; + + @override + String get tourPlayer => '播放器和队列'; + + @override + String get tourAppearance => '打造你的 Flick'; + + @override + String get tourAudio => '音频和输出'; + + @override + String get tourGuides => '探索指南'; + + @override + String get tourPickerBody => '选择一个简短指南。试用真实控件,或按自己的节奏继续。'; + + @override + String get tourWelcomeTitle => '离你的音乐,只差一步'; + + @override + String get tourWelcomeBody => '找一首歌,播放它,再认识一下播放器。你可以操作真实控件,也可以直接继续,或随时退出。'; + + @override + String get tourAddBody => + '还没有音乐?打开音乐库设置,选择「添加音乐文件夹」。扫描可能需要一些时间。取消、拒绝权限或直接继续都没关系,稍后可以再来。'; + + @override + String get tourSearchBody => + '在高亮的搜索框中输入歌曲或歌手。若导航栏已添加搜索,搜索会位于独立标签页。想直接浏览也可以继续。'; + + @override + String get tourPlayTitle => '选一首想听的歌'; + + @override + String get tourPlayBody => + '点按你想听的歌曲。播放成功后,指南会进入下一步。不会自动开始播放;没有音乐或播放失败时,也可以继续。'; + + @override + String get tourMiniBody => + '迷你播放器随音乐出现。点按它可打开完整播放器,也可以使用下方按钮。需要先加载一首歌;没有歌曲也可以继续。'; + + @override + String get tourFullBody => + '在播放器中暂停、切歌和调整进度。队列、歌词及其他操作取决于你选择的布局。指南不会更改音量、队列或音频设置。'; + + @override + String get tourFinishTitle => '接下来,交给你'; + + @override + String get tourFinishBody => + '点按「完成」关闭指南。你可以探索其他指南,也可以随时前往 设置 → 应用信息 → 交互式教程 再次查看。'; + + @override + String get tourSortBody => '打开高亮的排序控件,了解排序和筛选。只需打开即可,无须更改音乐库视图。'; + + @override + String get tourActionsBody => + '长按歌曲可查看下一首播放、队列和歌曲信息等操作。仅打开菜单不会更改队列。没有歌曲时可以继续。'; + + @override + String get tourPlaylistsBody => + '在音乐库菜单或自定义导航中找到播放列表。通过歌曲操作将其加入播放列表。完整操作可查看播放列表指南。'; + + @override + String get tourQueueBody => + '从完整播放器打开队列,查看即将播放的歌曲。按钮位置取决于布局。你可以仅查看,不必调整顺序或移除歌曲。'; + + @override + String get tourLyricsBody => + '如果布局中有歌词按钮,可在完整播放器中打开。并非每首歌都有歌词。控件或歌词不可用时,可以继续或阅读手册。'; + + @override + String get tourNavBody => '长按导航栏进行自定义,或使用下方按钮打开导航设置。标签和顺序由你决定,指南不会替你更改。'; + + @override + String get tourPlayerLayoutTitle => '适合你的播放器'; + + @override + String get tourLayoutBody => '在播放器布局中选择外观和显示的操作。是否更改,完全由你决定。'; + + @override + String get tourOutputTitle => '了解音频输出'; + + @override + String get tourOutputBody => + '音频设置显示可用的输出路径。USB DAC 和设备高解析度支持取决于硬件。指南不会启用模式,也不保证位完美输出;请查看播放器的输出指示。'; + + @override + String get tourDspTitle => '有意识地调整声音'; + + @override + String get tourDspBody => + '均衡器和其他 DSP 工具均为可选。信号处理会改变音频,可能与位完美目标冲突。你可以只查看控件,不启用任何功能;更改音频路径前请先阅读手册。'; + + @override + String get tourNotNow => '暂时不用'; + + @override + String get tourEnd => '结束教程'; + + @override + String get tourStart => '开始教程'; + + @override + String get tourContinue => '继续'; + + @override + String get tourUseCurrent => '使用当前歌曲'; + + @override + String get tourReadMore => '在手册中阅读此节'; + + @override + String get tourOpenNavigation => '打开导航设置'; + + @override + String get tourOpenLayout => '打开播放器布局'; + + @override + String get tourOpenAudio => '打开音频设置'; + + @override + String get tourActionConfirmed => '操作成功,进入下一步。'; + + @override + String get tourUnavailable => '看不到控件?可以继续,或查看下方手册。'; + @override String get filipinoDonationNote => '感谢你支持 Flick。'; @@ -2230,9 +2362,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get customWidth => '自定义宽度'; - @override - String get customizeAudioDisplayNavigationAnd => '自定义音频、显示、导航与集成。'; - @override String get customizeBottomBar => '自定义底部栏'; @@ -4053,9 +4182,6 @@ class AppLocalizationsZh extends AppLocalizations { String get letOboePickTheBestApi => '由 Oboe 自动选择最佳 API。优先 AAudio,其次回退到 OpenSL ES。'; - @override - String get letSTakeAQuickTour => '快速了解你的新音乐播放器。'; - @override String get letTheSystemDecideBestBattery => '由系统决定——最省电'; @@ -5807,9 +5933,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get renamePreset => '重命名预设'; - @override - String get reorderFilterAndShuffleFromThis => '在此标题栏可重新排序、筛选和随机播放。'; - @override String repeat(Object arg1) { return '循环:$arg1'; @@ -6206,9 +6329,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get search => '搜索'; - @override - String get searchAcrossSongsArtistsAndAlbums => '即时搜索歌曲、艺术家和专辑。'; - @override String get searchAgain => '重新搜索'; @@ -6556,9 +6676,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get shownWhenThereIsRoomBeside => '控件旁边有空间时显示'; - @override - String get showsWhatSPlayingTapTo => '显示当前播放内容,点按打开完整播放器。'; - @override String get shuffle => '随机播放'; @@ -6996,9 +7113,6 @@ class AppLocalizationsZh extends AppLocalizations { return '$count 首歌曲在你的循环播放中'; } - @override - String get songsTab => '歌曲标签页'; - @override String songsU2022Albums(Object arg1, Object uniqueAlbums) { return '$arg1 首歌曲 • $uniqueAlbums 张专辑'; @@ -7316,21 +7430,12 @@ class AppLocalizationsZh extends AppLocalizations { String get tapEditTextTopRight => '1. 点右上角「编辑文本」,把歌词粘贴进来——一行一句。\n2. 播放这首歌。\n3. 在「歌词行」列表里选中当前这一行。\n4. 听到那一句时点「打点并下一句」。\n5. 完成后保存。'; - @override - String get tapIconsToSwitchTabsLong => '点按图标切换标签页,长按可自定义导航栏。'; - @override String get tapTheHeartIconOnAny => '点按任意歌曲上的爱心图标\n即可加入收藏'; - @override - String get tapTheMiniPlayerForWaveform => '点按迷你播放器,打开波形进度条、均衡器、歌词和可视化效果。'; - @override String get tapToInspect12602280Png => '点击查看 · 1260 × 2280 PNG'; - @override - String get tapToPlayLongPressFor => '点按播放,长按查看更多选项(播放队列、下一首播放、信息)。'; - @override String get tapToPreview => '点按预览'; @@ -7370,9 +7475,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get textSize2 => '文字大小'; - @override - String get thatSTheTour => '导览到此结束!'; - @override String get theAppDoesNotKnowinglyCollect => '本应用不会在任何年龄段有意收集任何人的任何信息。'; @@ -8165,10 +8267,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get waitingForPlayback => '等待播放'; - @override - String get wantEveryControlDocumentedOpenThe => - '想了解每个控件的说明?随时可从「设置 → 帮助与手册」打开应用内手册。'; - @override String was(Object arg1) { return '原为:$arg1'; @@ -8211,9 +8309,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get welcomeToFlick => '欢迎使用 Flick!'; - @override - String get welcomeToFlick2 => '欢迎使用 Flick'; - @override String get whatItShows => '显示内容'; @@ -8430,9 +8525,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get yourWeeklyRecapAppearsOnceYou => '本周开始收听,你的每周回顾就会出现。'; - @override - String get yourWholeLibraryLivesHereTap => '你的整个音乐库都在这里,点按任意歌曲即可播放。'; - @override String get yourYearlyRecapFillsInAs => '随着你全年持续收听,你的年度回顾会逐渐充实。'; } diff --git a/lib/l10n/streak_greeting.dart b/lib/l10n/streak_greeting.dart index c5a347760..af215e6b3 100644 --- a/lib/l10n/streak_greeting.dart +++ b/lib/l10n/streak_greeting.dart @@ -9,10 +9,19 @@ const taglishStreakGreetings = [ 'Handa mo na earphones mo ulit. Hahaha.', 'Hobby mo talaga music, maem?', 'Share your music with?', - 'Baka mamaya puro ka relapse ha?', ]; -String randomTaglishStreakGreeting([Random? random]) { +/// Template for the day-count greeting. The popup fills in the active streak. +const taglishStreakDaysGreetingTemplate = '{days} days na ha? Keep it up!'; + +String taglishStreakDaysGreeting(int streak) => + taglishStreakDaysGreetingTemplate.replaceFirst('{days}', '$streak'); + +String randomTaglishStreakGreeting(int streak, [Random? random]) { final source = random ?? Random(); - return taglishStreakGreetings[source.nextInt(taglishStreakGreetings.length)]; + final index = source.nextInt(taglishStreakGreetings.length + 1); + if (index == taglishStreakGreetings.length) { + return taglishStreakDaysGreeting(streak); + } + return taglishStreakGreetings[index]; } diff --git a/lib/providers/tutorial_provider.dart b/lib/providers/tutorial_provider.dart index a81e7696e..225eab6d3 100644 --- a/lib/providers/tutorial_provider.dart +++ b/lib/providers/tutorial_provider.dart @@ -3,152 +3,383 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:flick/features/onboarding/tutorial_targets.dart'; import 'package:flick/l10n/l10n.dart'; -/// Steps of the first-run spotlight tour. -/// -/// Titles and descriptions are resolved through [title] / [description] rather -/// than stored as fields: enum values are implicitly `const`, so they can only -/// hold compile-time constants, and a localized string is resolved at runtime. -/// Keeping them as fields would pin the whole tour to English. +enum TutorialGuide { + core('core'), + library('library'), + player('player'), + appearance('appearance'), + audio('audio'); + + const TutorialGuide(this.id); + final String id; + String title(AppLocalizations l10n) => switch (this) { + core => l10n.tourCore, + library => l10n.tourLibrary, + player => l10n.tourPlayer, + appearance => l10n.tourAppearance, + audio => l10n.tourAudio, + }; +} + +enum TutorialProgress { dismissed, completed } + +enum TutorialAction { + libraryReady, + searchUsed, + playbackStarted, + playerOpened, + sortOpened, + songActionsOpened, + queueOpened, + lyricsOpened, +} + +/// IDs, not enum ordinals, identify steps across releases. enum TutorialStep { - welcome, - navBar(spotlightTarget: TutorialTarget.navBar), - songsTab(requiredTabIndex: 1), + welcome('welcome'), + addMusic('add_music', spotlightTarget: TutorialTarget.addMusicFolder), searchEntry( + 'find_music', spotlightTarget: TutorialTarget.songsSearchBar, requiredTabIndex: 1, + action: TutorialAction.searchUsed, ), + playSong( + 'play_song', + spotlightTarget: TutorialTarget.songLibrary, + requiredTabIndex: 1, + action: TutorialAction.playbackStarted, + ), + miniPlayer( + 'mini_player', + spotlightTarget: TutorialTarget.miniPlayer, + action: TutorialAction.playerOpened, + ), + fullPlayerHint('full_player'), + finish('finish'), sortButton( + 'sort', spotlightTarget: TutorialTarget.songsSortButton, requiredTabIndex: 1, + action: TutorialAction.sortOpened, + ), + songCardGestures( + 'song_actions', + spotlightTarget: TutorialTarget.songLibrary, + requiredTabIndex: 1, + action: TutorialAction.songActionsOpened, + ), + playlists('playlists'), + queue( + 'queue', + spotlightTarget: TutorialTarget.playerQueue, + action: TutorialAction.queueOpened, ), - songCardGestures(requiredTabIndex: 1), - miniPlayer(spotlightTarget: TutorialTarget.miniPlayer), - settingsTab(requiredTabIndex: 2), - fullPlayerHint, - manualPointer(isManualPointer: true); + lyrics( + 'lyrics', + spotlightTarget: TutorialTarget.playerLyrics, + action: TutorialAction.lyricsOpened, + ), + navBar('navigation', spotlightTarget: TutorialTarget.navBar), + playerLayout('player_layout'), + audioOutput('audio_output'), + audioDsp('audio_dsp'); - const TutorialStep({ + const TutorialStep( + this.id, { this.spotlightTarget, this.requiredTabIndex, - this.isManualPointer = false, + this.action, }); - + final String id; final TutorialTarget? spotlightTarget; final int? requiredTabIndex; - final bool isManualPointer; - - /// Localized step title. + final TutorialAction? action; String title(AppLocalizations l10n) => switch (this) { - TutorialStep.welcome => l10n.welcomeToFlick2, - TutorialStep.navBar => l10n.navigationBar, - TutorialStep.songsTab => l10n.songsTab, - TutorialStep.searchEntry => l10n.search, - TutorialStep.sortButton => l10n.sortFilter, - TutorialStep.songCardGestures => l10n.songGestures, - TutorialStep.miniPlayer => l10n.miniPlayer, - TutorialStep.settingsTab => l10n.settings, - TutorialStep.fullPlayerHint => l10n.fullPlayer, - TutorialStep.manualPointer => l10n.thatSTheTour, - }; - - /// Localized step description. + welcome => l10n.tourWelcomeTitle, + addMusic => l10n.addMusicFolder, + searchEntry => l10n.search, + playSong => l10n.tourPlayTitle, + miniPlayer => l10n.miniPlayer, + fullPlayerHint => l10n.fullPlayer, + finish => l10n.tourFinishTitle, + sortButton => l10n.sortFilter, + songCardGestures => l10n.songGestures, + playlists => l10n.playlists, + queue => l10n.queue, + lyrics => l10n.lyrics, + navBar => l10n.navigationBar, + playerLayout => l10n.tourPlayerLayoutTitle, + audioOutput => l10n.tourOutputTitle, + audioDsp => l10n.tourDspTitle, + }; String description(AppLocalizations l10n) => switch (this) { - TutorialStep.welcome => l10n.letSTakeAQuickTour, - TutorialStep.navBar => l10n.tapIconsToSwitchTabsLong, - TutorialStep.songsTab => l10n.yourWholeLibraryLivesHereTap, - TutorialStep.searchEntry => l10n.searchAcrossSongsArtistsAndAlbums, - TutorialStep.sortButton => l10n.reorderFilterAndShuffleFromThis, - TutorialStep.songCardGestures => l10n.tapToPlayLongPressFor, - TutorialStep.miniPlayer => l10n.showsWhatSPlayingTapTo, - TutorialStep.settingsTab => l10n.customizeAudioDisplayNavigationAnd, - TutorialStep.fullPlayerHint => l10n.tapTheMiniPlayerForWaveform, - TutorialStep.manualPointer => l10n.wantEveryControlDocumentedOpenThe, - }; + welcome => l10n.tourWelcomeBody, + addMusic => l10n.tourAddBody, + searchEntry => l10n.tourSearchBody, + playSong => l10n.tourPlayBody, + miniPlayer => l10n.tourMiniBody, + fullPlayerHint => l10n.tourFullBody, + finish => l10n.tourFinishBody, + sortButton => l10n.tourSortBody, + songCardGestures => l10n.tourActionsBody, + playlists => l10n.tourPlaylistsBody, + queue => l10n.tourQueueBody, + lyrics => l10n.tourLyricsBody, + navBar => l10n.tourNavBody, + playerLayout => l10n.tourLayoutBody, + audioOutput => l10n.tourOutputBody, + audioDsp => l10n.tourDspBody, + }; + String? get manualSection => switch (this) { + searchEntry => 'search', + sortButton || songCardGestures || playSong => 'songs', + addMusic => 'folders', + playlists => 'playlists', + miniPlayer => 'mini_player', + fullPlayerHint => 'full_player', + queue => 'queue', + lyrics => 'lyrics', + audioDsp => 'equalizer', + navBar || playerLayout || audioOutput => 'settings', + _ => null, + }; } -class TutorialState { - final bool active; - final int currentStep; - final bool completed; - final bool autoStartPending; +List _steps(TutorialGuide guide, bool includeAddMusic) => + switch (guide) { + TutorialGuide.core => [ + TutorialStep.welcome, + if (includeAddMusic) TutorialStep.addMusic, + TutorialStep.searchEntry, + TutorialStep.playSong, + TutorialStep.miniPlayer, + TutorialStep.fullPlayerHint, + TutorialStep.finish, + ], + TutorialGuide.library => const [ + TutorialStep.sortButton, + TutorialStep.songCardGestures, + TutorialStep.playlists, + TutorialStep.finish, + ], + TutorialGuide.player => const [ + TutorialStep.miniPlayer, + TutorialStep.queue, + TutorialStep.lyrics, + TutorialStep.finish, + ], + TutorialGuide.appearance => const [ + TutorialStep.navBar, + TutorialStep.playerLayout, + TutorialStep.finish, + ], + TutorialGuide.audio => const [ + TutorialStep.audioOutput, + TutorialStep.audioDsp, + TutorialStep.finish, + ], + }; +class TutorialState { const TutorialState({ this.active = false, this.currentStep = 0, this.completed = false, this.autoStartPending = false, + this.guide = TutorialGuide.core, + this.picker = false, + this.includeAddMusic = true, + this.actionConfirmed = false, + this.paused = false, + this.progress = const {}, }); - + final bool active; + final int currentStep; + final bool completed; + final bool autoStartPending; + final TutorialGuide guide; + final bool picker; + final bool includeAddMusic; + final bool actionConfirmed; + final bool paused; + final Map progress; + List get steps => _steps(guide, includeAddMusic); + TutorialStep get step => steps[currentStep.clamp(0, steps.length - 1)]; + bool get isLastStep => currentStep >= steps.length - 1; + int get totalSteps => steps.length; TutorialState copyWith({ bool? active, int? currentStep, bool? completed, bool? autoStartPending, - }) { - return TutorialState( - active: active ?? this.active, - currentStep: currentStep ?? this.currentStep, - completed: completed ?? this.completed, - autoStartPending: autoStartPending ?? this.autoStartPending, - ); - } - - TutorialStep get step => - TutorialStep.values[currentStep.clamp(0, TutorialStep.values.length - 1)]; - bool get isLastStep => currentStep >= TutorialStep.values.length - 1; - int get totalSteps => TutorialStep.values.length; + TutorialGuide? guide, + bool? picker, + bool? includeAddMusic, + bool? actionConfirmed, + bool? paused, + Map? progress, + }) => TutorialState( + active: active ?? this.active, + currentStep: currentStep ?? this.currentStep, + completed: completed ?? this.completed, + autoStartPending: autoStartPending ?? this.autoStartPending, + guide: guide ?? this.guide, + picker: picker ?? this.picker, + includeAddMusic: includeAddMusic ?? this.includeAddMusic, + actionConfirmed: actionConfirmed ?? this.actionConfirmed, + paused: paused ?? this.paused, + progress: progress ?? this.progress, + ); } class TutorialNotifier extends Notifier { - static const _prefKey = 'tutorial_completed'; - bool _initialized = false; - @override TutorialState build() { - if (!_initialized) { - _initialized = true; - Future.microtask(_loadPreference); - } + Future.microtask(_loadPreference); return const TutorialState(); } Future _loadPreference() async { final prefs = await SharedPreferences.getInstance(); - final completed = prefs.getBool(_prefKey) ?? false; if (!ref.mounted) return; - state = state.copyWith(completed: completed); + final progress = {}; + for (final guide in TutorialGuide.values) { + final saved = prefs.getString('tutorial_v2_${guide.id}'); + for (final value in TutorialProgress.values) { + if (saved == value.name) progress[guide] = value; + } + } + if (!progress.containsKey(TutorialGuide.core) && + (prefs.getBool('tutorial_completed') ?? false)) { + progress[TutorialGuide.core] = TutorialProgress.completed; + } + // A slow preference read must not rewind a guide already started by the user. + for (final entry in state.progress.entries) { + if (progress[entry.key] != TutorialProgress.completed) { + progress[entry.key] = entry.value; + } + } + state = state.copyWith( + progress: progress, + completed: progress[TutorialGuide.core] == TutorialProgress.completed, + autoStartPending: + state.autoStartPending && !progress.containsKey(TutorialGuide.core), + ); } void flagAutoStart() { - state = const TutorialState(autoStartPending: true, completed: false); + if (state.progress.containsKey(TutorialGuide.core)) return; + state = state.copyWith(autoStartPending: true); + } + + void start({TutorialGuide guide = TutorialGuide.core}) { + state = state.copyWith( + active: true, + guide: guide, + currentStep: 0, + picker: false, + autoStartPending: false, + actionConfirmed: false, + paused: false, + includeAddMusic: true, + ); } - void start() { - state = const TutorialState(active: true, currentStep: 0); + void showPicker() => state = state.copyWith( + active: true, + picker: true, + autoStartPending: false, + paused: false, + ); + void beginCore({required bool hasMusic}) { + if (!state.active || state.step != TutorialStep.welcome) return; + state = state.copyWith( + includeAddMusic: !hasMusic, + currentStep: 1, + actionConfirmed: false, + ); } void nextStep() { - if (state.currentStep < TutorialStep.values.length - 1) { - state = state.copyWith(currentStep: state.currentStep + 1); - } else { + if (!state.active || state.picker) return; + if (state.isLastStep) { complete(); + } else { + state = state.copyWith( + currentStep: state.currentStep + 1, + actionConfirmed: false, + ); } } void previousStep() { - if (state.currentStep > 0) { - state = state.copyWith(currentStep: state.currentStep - 1); + if (state.active && state.currentStep > 0) { + state = state.copyWith( + currentStep: state.currentStep - 1, + actionConfirmed: false, + ); } } - void skip() { - complete(); + void onAction(TutorialAction action) { + if (!state.active || state.picker || state.paused) return; + final expected = state.step == TutorialStep.addMusic + ? TutorialAction.libraryReady + : state.step.action; + if (expected != action) return; + nextStep(); + state = state.copyWith(actionConfirmed: true); + } + + void setPaused(bool paused) { + if (ref.mounted) state = state.copyWith(paused: paused); + } + + Future skip() async { + if (!state.active) return; + if (state.picker) { + state = state.copyWith(active: false, picker: false); + return; + } + await _finish(TutorialProgress.dismissed); } - Future complete() async { - state = const TutorialState(active: false, completed: true); + Future complete() => _finish(TutorialProgress.completed); + Future _finish(TutorialProgress result) async { + final guide = state.guide; + final progress = {...state.progress}; + // Replaying and dismissing a completed guide never downgrades its status. + if (progress[guide] != TutorialProgress.completed) progress[guide] = result; + state = state.copyWith( + active: false, + picker: false, + autoStartPending: false, + paused: false, + progress: progress, + completed: progress[TutorialGuide.core] == TutorialProgress.completed, + ); final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_prefKey, true); + final key = 'tutorial_v2_${guide.id}'; + final saved = prefs.getString(key); + final wasCompleted = + saved == TutorialProgress.completed.name || + (saved == null && + guide == TutorialGuide.core && + (prefs.getBool('tutorial_completed') ?? false)); + final status = + wasCompleted || + (ref.mounted && state.progress[guide] == TutorialProgress.completed) + ? TutorialProgress.completed + : progress[guide]!; + if (ref.mounted && state.progress[guide] != status) { + final latest = {...state.progress, guide: status}; + state = state.copyWith( + progress: latest, + completed: latest[TutorialGuide.core] == TutorialProgress.completed, + ); + } + await prefs.setString(key, status.name); } } diff --git a/lib/widgets/common/floating_mini_player.dart b/lib/widgets/common/floating_mini_player.dart index a97a65093..8949e4f37 100644 --- a/lib/widgets/common/floating_mini_player.dart +++ b/lib/widgets/common/floating_mini_player.dart @@ -122,6 +122,7 @@ class _FloatingMiniPlayerState extends ConsumerState }, transitionDuration: const Duration(milliseconds: 300), opaque: false, + settings: const RouteSettings(name: '/full_player'), barrierColor: Colors.black, ), ); diff --git a/test/features/onboarding/tutorial_overlay_test.dart b/test/features/onboarding/tutorial_overlay_test.dart new file mode 100644 index 000000000..374efe052 --- /dev/null +++ b/test/features/onboarding/tutorial_overlay_test.dart @@ -0,0 +1,293 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flick/core/theme/app_theme.dart'; +import 'package:flick/features/onboarding/tutorial_navigation.dart'; +import 'package:flick/features/onboarding/tutorial_targets.dart'; +import 'package:flick/features/onboarding/widgets/tutorial_overlay.dart'; +import 'package:flick/l10n/l10n.dart'; +import 'package:flick/providers/player_provider.dart'; +import 'package:flick/providers/songs_provider.dart'; +import 'package:flick/providers/tutorial_provider.dart'; + +class _Playback extends Notifier { + @override + bool build() => false; + void play() => state = true; +} + +final _playback = NotifierProvider<_Playback, bool>(_Playback.new); + +class _EmptySongs extends SongsNotifier { + @override + Future build() async => SongsState(); +} + +Widget _host( + Widget child, { + double scale = 1, + List observers = const [], + Locale locale = const Locale('en'), + double keyboardInset = 0, + bool globalHost = false, +}) => ProviderScope( + overrides: [ + currentSongProvider.overrideWithValue(null), + isPlayingProvider.overrideWith((ref) => ref.watch(_playback)), + songsProvider.overrideWith(_EmptySongs.new), + ], + child: MaterialApp( + theme: AppTheme.darkTheme, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: locale, + navigatorObservers: observers, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(scale), + viewInsets: EdgeInsets.only(bottom: keyboardInset), + ), + child: globalHost ? TutorialHost(child: child!) : child!, + ), + home: Scaffold(body: child), + ), +); + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets( + 'highlighted real control receives taps and only its action advances', + (tester) async { + var taps = 0; + await tester.pumpWidget( + _host( + Stack( + children: [ + Align( + alignment: Alignment.topCenter, + child: TutorialTargetAnchor( + target: TutorialTarget.songsSearchBar, + child: Consumer( + builder: (context, ref, _) => TextButton( + onPressed: () { + taps++; + ref + .read(tutorialProvider.notifier) + .onAction(TutorialAction.searchUsed); + }, + child: const Text('Real search'), + ), + ), + ), + ), + const Positioned.fill(child: TutorialOverlay()), + ], + ), + ), + ); + final container = ProviderScope.containerOf( + tester.element(find.byType(TutorialOverlay)), + ); + container.read(tutorialProvider.notifier) + ..start() + ..beginCore(hasMusic: true); + await tester.pump(); + await tester.pump(); + await tester.tap(find.text('Real search')); + await tester.pump(); + expect(taps, 1); + expect(container.read(tutorialProvider).step, TutorialStep.playSong); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'missing controls remain skippable at large text and landscape sizes', + (tester) async { + tester.view.physicalSize = const Size(800, 400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(_host(const TutorialOverlay(), scale: 2)); + final container = ProviderScope.containerOf( + tester.element(find.byType(TutorialOverlay)), + ); + container + .read(tutorialProvider.notifier) + .start(guide: TutorialGuide.player); + await tester.pump(); + await tester.pump(); + final copy = lookupFlickLocalizations(const Locale('en')); + expect(find.text(copy.tourUnavailable), findsOneWidget); + await tester.ensureVisible(find.text(copy.tourContinue)); + await tester.tap(find.text(copy.tourContinue)); + await tester.pump(); + expect(container.read(tutorialProvider).step, TutorialStep.queue); + expect(tester.takeException(), isNull); + tester.view.physicalSize = const Size(400, 800); + await tester.pump(); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'registry preserves earlier owners and rejects zero-size targets', + (tester) async { + final first = GlobalKey(); + final second = GlobalKey(); + await tester.pumpWidget( + _host( + Column( + children: [ + SizedBox(key: first, width: 60, height: 40), + SizedBox(key: second, width: 0, height: 0), + ], + ), + ), + ); + final registry = TutorialTargetRegistry(); + addTearDown(registry.dispose); + registry.register(TutorialTarget.miniPlayer, first); + registry.register(TutorialTarget.miniPlayer, second); + expect( + registry.rectFor(TutorialTarget.miniPlayer)?.size, + const Size(60, 40), + ); + registry.unregister(TutorialTarget.miniPlayer, second); + expect( + registry.rectFor(TutorialTarget.miniPlayer)?.size, + const Size(60, 40), + ); + registry.unregister(TutorialTarget.miniPlayer, first); + expect(registry.rectFor(TutorialTarget.miniPlayer), isNull); + await tester.pump(); + }, + ); + + testWidgets( + 'navigation allows player routes but yields to modal and unrelated pages', + (tester) async { + final navigation = TutorialNavigation(); + addTearDown(navigation.dispose); + await tester.pumpWidget( + _host(const SizedBox(), observers: [navigation.root]), + ); + await tester.pump(); + expect(navigation.visible, isTrue); + final context = tester.element(find.byType(Scaffold)); + final navigator = Navigator.of(context); + navigator.push( + MaterialPageRoute( + settings: const RouteSettings(name: '/full_player'), + builder: (_) => const Scaffold(), + ), + ); + await tester.pumpAndSettle(); + expect(navigation.visible, isTrue); + expect(navigation.currentName, '/full_player'); + showDialog(context: context, builder: (_) => const AlertDialog()); + await tester.pumpAndSettle(); + expect(navigation.visible, isFalse); + navigator.pop(); + await tester.pumpAndSettle(); + expect(navigation.visible, isTrue); + navigator.push(MaterialPageRoute(builder: (_) => const Scaffold())); + await tester.pumpAndSettle(); + expect(navigation.visible, isFalse); + }, + ); + + testWidgets( + 'global host follows playback and player, and yields to dialogs', + (tester) async { + await tester.pumpWidget( + _host( + const SizedBox(), + globalHost: true, + observers: [tutorialNavigation.root], + ), + ); + await tester.pump(); + final context = tester.element(find.byType(Scaffold)); + final container = ProviderScope.containerOf(context); + container.read(tutorialProvider.notifier) + ..start() + ..beginCore(hasMusic: true) + ..nextStep(); + await tester.pump(); + container.read(_playback.notifier).play(); + await tester.pump(); + expect(container.read(tutorialProvider).step, TutorialStep.miniPlayer); + final navigator = Navigator.of(context); + navigator.push( + MaterialPageRoute( + settings: const RouteSettings(name: '/full_player'), + builder: (_) => const Scaffold(), + ), + ); + await tester.pumpAndSettle(); + expect( + container.read(tutorialProvider).step, + TutorialStep.fullPlayerHint, + ); + final copy = lookupFlickLocalizations(const Locale('en')); + expect(find.text(copy.tourFullBody), findsOneWidget); + showDialog(context: context, builder: (_) => const AlertDialog()); + await tester.pumpAndSettle(); + expect(find.byType(TutorialOverlay), findsNothing); + navigator.pop(); + await tester.pumpAndSettle(); + expect(find.text(copy.tourFullBody), findsOneWidget); + container.read(tutorialProvider.notifier).showPicker(); + await tester.pump(); + await tester.tap(find.text(copy.tourLibrary)); + await tester.pumpAndSettle(); + expect(tutorialNavigation.root.top?.isFirst, isTrue); + expect(container.read(tutorialProvider).step, TutorialStep.sortButton); + navigator.push(MaterialPageRoute(builder: (_) => const Scaffold())); + await tester.pumpAndSettle(); + expect(find.byType(TutorialOverlay), findsNothing); + }, + ); + + testWidgets( + 'Chinese large text keeps keyboard-safe, labeled native actions', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final semantics = tester.ensureSemantics(); + await tester.pumpWidget( + _host( + const TutorialOverlay(), + locale: const Locale('zh'), + scale: 2, + keyboardInset: 300, + ), + ); + final container = ProviderScope.containerOf( + tester.element(find.byType(TutorialOverlay)), + ); + container.read(tutorialProvider.notifier) + ..start() + ..beginCore(hasMusic: true); + await tester.pump(); + await tester.pump(); + final copy = lookupFlickLocalizations(const Locale('zh')); + expect(find.text(copy.tourSearchBody), findsOneWidget); + final button = find.widgetWithText(FilledButton, copy.tourContinue); + await tester.ensureVisible(button); + expect(tester.getSemantics(button).label, contains(copy.tourContinue)); + expect(tester.getSize(button).height, greaterThanOrEqualTo(48)); + expect(tester.getRect(button).bottom, lessThanOrEqualTo(500)); + await tester.tap(button); + await tester.pump(); + expect(container.read(tutorialProvider).step, TutorialStep.playSong); + expect(tester.takeException(), isNull); + semantics.dispose(); + }, + ); +} diff --git a/test/l10n/guidance_locale_test.dart b/test/l10n/guidance_locale_test.dart index 03537f536..bd67211ed 100644 --- a/test/l10n/guidance_locale_test.dart +++ b/test/l10n/guidance_locale_test.dart @@ -10,6 +10,7 @@ import 'package:flick/features/onboarding/widgets/tutorial_overlay.dart'; import 'package:flick/features/settings/screens/app_info_settings_screen.dart'; import 'package:flick/l10n/l10n.dart'; import 'package:flick/providers/player_provider.dart'; +import 'package:flick/providers/songs_provider.dart'; import 'package:flick/providers/tutorial_provider.dart'; import 'package:flick/providers/update_check_provider.dart'; @@ -18,6 +19,11 @@ class _ActiveTutorial extends TutorialNotifier { TutorialState build() => const TutorialState(active: true); } +class _EmptySongs extends SongsNotifier { + @override + Future build() async => SongsState(); +} + class _OfflineUpdates extends UpdateCheckNotifier { @override UpdateCheckState build() => const UpdateCheckState(); @@ -26,6 +32,7 @@ class _OfflineUpdates extends UpdateCheckNotifier { Widget _host(Widget child, {Locale locale = kFilipinoLocale}) => ProviderScope( overrides: [ currentSongProvider.overrideWithValue(null), + songsProvider.overrideWith(_EmptySongs.new), tutorialProvider.overrideWith(_ActiveTutorial.new), updateCheckProvider.overrideWith(_OfflineUpdates.new), ], @@ -57,28 +64,44 @@ void main() { 'all Taglish interactive tutorial steps and controls are English', (tester) async { final english = lookupFlickLocalizations(kFallbackLocale); - final taglish = lookupFlickLocalizations(kFilipinoLocale); await tester.pumpWidget(_host(const Scaffold(body: TutorialOverlay()))); await tester.pump(); - - for (var i = 0; i < TutorialStep.values.length; i++) { - final step = TutorialStep.values[i]; - expect(find.text(step.title(english)), findsOneWidget); - expect(find.text(step.description(english)), findsOneWidget); - expect(find.text(step.description(taglish)), findsNothing); - expect(find.text(english.skip), findsOneWidget); - expect(find.text(taglish.skip), findsNothing); - if (i > 0) expect(find.text(english.back), findsOneWidget); - - if (i == TutorialStep.values.length - 1) { - expect(find.text(english.openManual), findsOneWidget); - } else { - await tester.tap(find.text(english.next)); + final container = ProviderScope.containerOf( + tester.element(find.byType(TutorialOverlay)), + ); + final notifier = container.read(tutorialProvider.notifier); + for (final guide in TutorialGuide.values) { + notifier.start(guide: guide); + await tester.pump(); + final steps = container.read(tutorialProvider).steps; + for (var i = 0; i < steps.length; i++) { + final step = steps[i]; + expect(find.text(step.title(english)), findsWidgets); + expect(find.text(step.description(english)), findsOneWidget); + expect( + find.text( + step == TutorialStep.welcome + ? english.tourNotNow + : english.tourEnd, + ), + findsOneWidget, + ); + if (i > 0) expect(find.text(english.back), findsOneWidget); + if (i == steps.length - 1) { + expect(find.text(english.done), findsOneWidget); + } + // Exercise Continue even when the real control is unavailable. + final label = step == TutorialStep.welcome + ? english.tourStart + : i == steps.length - 1 + ? english.done + : english.tourContinue; + await tester.ensureVisible(find.text(label)); + await tester.tap(find.text(label)); await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); await tester.pump(); + expect(tester.takeException(), isNull); } - expect(tester.takeException(), isNull); } await tester.pumpWidget(const SizedBox()); }, diff --git a/test/l10n/streak_popup_test.dart b/test/l10n/streak_popup_test.dart index ed3821375..17f0432e3 100644 --- a/test/l10n/streak_popup_test.dart +++ b/test/l10n/streak_popup_test.dart @@ -1,6 +1,7 @@ // The streak popup's Taglish copy is screen-specific: the count label and both -// buttons stay English, while the message rotates through six supplied lines. -// These checks do not certify the Tagalog wording. +// buttons stay English, while the message rotates through five supplied lines +// and one line that includes the current streak. These checks do not certify +// the Tagalog wording. import 'dart:io'; import 'dart:math'; @@ -48,12 +49,16 @@ Finder _streakCount(int streak, String dayStreakLabel) => find.descendant( matching: find.text('$streak'), ); +bool _isTaglishGreeting(String text) => + taglishStreakGreetings.contains(text) || + RegExp(r'^\d+ days na ha\? Keep it up!$').hasMatch(text); + String _visibleGreeting(WidgetTester tester) { final texts = tester .widgetList(find.byType(Text)) .map((text) => text.data) .whereType() - .where(taglishStreakGreetings.contains) + .where(_isTaglishGreeting) .toList(); expect(texts, hasLength(1)); return texts.single; @@ -61,7 +66,7 @@ String _visibleGreeting(WidgetTester tester) { void main() { test( - 'the six supplied Taglish streak greetings are verbatim, random and documented', + 'the five supplied Taglish streak greetings and day-count line are verbatim, random and documented', () { expect(taglishStreakGreetings, [ 'Uy oh, may nagbabalik!', @@ -69,20 +74,28 @@ void main() { 'Handa mo na earphones mo ulit. Hahaha.', 'Hobby mo talaga music, maem?', 'Share your music with?', - 'Baka mamaya puro ka relapse ha?', ]); + expect( + taglishStreakDaysGreetingTemplate, + '{days} days na ha? Keep it up!', + ); + expect(taglishStreakDaysGreeting(12), '12 days na ha? Keep it up!'); final seen = {}; final random = Random(7); for (var i = 0; i < 120; i++) { - seen.add(randomTaglishStreakGreeting(random)); + seen.add(randomTaglishStreakGreeting(8, random)); } - expect(seen, taglishStreakGreetings.toSet()); + expect(seen, {...taglishStreakGreetings, taglishStreakDaysGreeting(8)}); final review = File('docs/BAYBAYIN_REVIEW.md').readAsStringSync(); for (final greeting in taglishStreakGreetings) { expect(review, contains('| $greeting | draft |')); } + expect( + review, + contains('| $taglishStreakDaysGreetingTemplate | draft |'), + ); }, ); @@ -109,7 +122,10 @@ void main() { expect(find.text(taglish.aWeekAndCountingYourDedication), findsNothing); expect(find.text(taglish.incredibleOverAMonthOfDaily), findsNothing); - expect(_visibleGreeting(tester), isIn(taglishStreakGreetings)); + expect( + _visibleGreeting(tester), + isIn([...taglishStreakGreetings, taglishStreakDaysGreeting(streak)]), + ); await _disposeStreak(tester); } }, @@ -133,7 +149,7 @@ void main() { .widgetList(find.byType(Text)) .map((text) => text.data) .whereType() - .where(taglishStreakGreetings.contains), + .where(_isTaglishGreeting), isEmpty, ); diff --git a/test/providers/tutorial_provider_test.dart b/test/providers/tutorial_provider_test.dart new file mode 100644 index 000000000..ea298799d --- /dev/null +++ b/test/providers/tutorial_provider_test.dart @@ -0,0 +1,177 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flick/providers/tutorial_provider.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late ProviderContainer container; + late TutorialNotifier notifier; + setUp(() { + SharedPreferences.setMockInitialValues({}); + container = ProviderContainer(); + notifier = container.read(tutorialProvider.notifier); + }); + tearDown(() => container.dispose()); + + test( + 'core adapts to an empty or populated library without playing anything', + () { + notifier.start(); + notifier.beginCore(hasMusic: false); + expect(container.read(tutorialProvider).step, TutorialStep.addMusic); + notifier.onAction(TutorialAction.libraryReady); + expect(container.read(tutorialProvider).step, TutorialStep.searchEntry); + notifier.start(); + notifier.beginCore(hasMusic: true); + expect(container.read(tutorialProvider).step, TutorialStep.searchEntry); + expect( + container.read(tutorialProvider).steps, + isNot(contains(TutorialStep.addMusic)), + ); + }, + ); + + test( + 'only the relevant action advances; pauses and inactive sessions ignore actions', + () { + notifier.start(); + notifier.beginCore(hasMusic: true); + notifier.onAction(TutorialAction.playerOpened); + expect(container.read(tutorialProvider).step, TutorialStep.searchEntry); + notifier.setPaused(true); + notifier.onAction(TutorialAction.searchUsed); + expect(container.read(tutorialProvider).step, TutorialStep.searchEntry); + notifier.setPaused(false); + notifier.onAction(TutorialAction.searchUsed); + expect(container.read(tutorialProvider).step, TutorialStep.playSong); + expect(container.read(tutorialProvider).actionConfirmed, isTrue); + notifier.onAction(TutorialAction.searchUsed); + expect(container.read(tutorialProvider).step, TutorialStep.playSong); + notifier.previousStep(); + expect(container.read(tutorialProvider).actionConfirmed, isFalse); + }, + ); + + test( + 'continue always permits completion with no library, playback, or targets', + () async { + for (final guide in TutorialGuide.values) { + notifier.start(guide: guide); + final total = container.read(tutorialProvider).totalSteps; + for (var i = 0; i < total; i++) { + notifier.nextStep(); + } + expect(container.read(tutorialProvider).active, isFalse); + expect( + container.read(tutorialProvider).progress[guide], + TutorialProgress.completed, + ); + } + await Future.delayed(Duration.zero); + final prefs = await SharedPreferences.getInstance(); + for (final guide in TutorialGuide.values) { + expect(prefs.getString('tutorial_v2_${guide.id}'), 'completed'); + } + }, + ); + + test( + 'dismissal is not completion, persists independently, and suppresses auto-start', + () async { + notifier.start(); + await notifier.skip(); + expect(container.read(tutorialProvider).completed, isFalse); + expect( + container.read(tutorialProvider).progress[TutorialGuide.core], + TutorialProgress.dismissed, + ); + notifier.flagAutoStart(); + expect(container.read(tutorialProvider).autoStartPending, isFalse); + notifier.start(guide: TutorialGuide.audio); + await notifier.complete(); + expect(container.read(tutorialProvider).completed, isFalse); + expect( + container.read(tutorialProvider).progress[TutorialGuide.audio], + TutorialProgress.completed, + ); + notifier.start(guide: TutorialGuide.audio); + await notifier.skip(); + expect( + container.read(tutorialProvider).progress[TutorialGuide.audio], + TutorialProgress.completed, + ); + }, + ); + + test('closing the picker does not record completion or dismissal', () async { + notifier.showPicker(); + await notifier.skip(); + expect(container.read(tutorialProvider).progress, isEmpty); + expect(container.read(tutorialProvider).active, isFalse); + }); + + test( + 'legacy completion loads without rewinding an explicitly started guide', + () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('tutorial_completed', true); + container.dispose(); + container = ProviderContainer(); + notifier = container.read(tutorialProvider.notifier); + notifier.flagAutoStart(); + notifier.start(guide: TutorialGuide.library); + notifier.nextStep(); + await Future.delayed(Duration.zero); + expect(container.read(tutorialProvider).completed, isTrue); + expect(container.read(tutorialProvider).autoStartPending, isFalse); + expect(container.read(tutorialProvider).guide, TutorialGuide.library); + expect( + container.read(tutorialProvider).step, + TutorialStep.songCardGestures, + ); + }, + ); + + test( + 'guide and step IDs are unique and stable, and guidance stays out of core', + () { + expect( + TutorialGuide.values.map((guide) => guide.id).toSet().length, + TutorialGuide.values.length, + ); + expect( + TutorialStep.values.map((step) => step.id).toSet().length, + TutorialStep.values.length, + ); + expect( + const TutorialState().steps, + isNot(contains(TutorialStep.audioOutput)), + ); + expect( + const TutorialState().steps, + isNot(contains(TutorialStep.sortButton)), + ); + }, + ); + + test('early replay dismissal cannot overwrite saved completion', () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('tutorial_completed', true); + container.dispose(); + container = ProviderContainer(); + notifier = container.read(tutorialProvider.notifier); + notifier.start(); + await notifier.skip(); + expect(container.read(tutorialProvider).completed, isTrue); + expect(prefs.getString('tutorial_v2_core'), 'completed'); + + container.dispose(); + container = ProviderContainer(); + notifier = container.read(tutorialProvider.notifier); + notifier.start(); + await notifier.skip(); + expect(container.read(tutorialProvider).completed, isTrue); + expect(prefs.getString('tutorial_v2_core'), 'completed'); + }); +}