From 430f7739f2365d8f9a430eab91c678b79427cf52 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Sun, 8 Nov 2026 21:36:57 +0800 Subject: [PATCH 1/6] Replace streak greeting with day-count variant Pass the current streak into randomTaglishStreakGreeting so one of the six lines can interpolate the day count, and update the review doc to describe five fixed greetings plus the day-count line. --- docs/BAYBAYIN_REVIEW.md | 25 +- .../milestone/widgets/streak_popup.dart | 7 +- lib/l10n/app_en.arb | 92 +++-- lib/l10n/app_tl.arb | 53 ++- lib/l10n/app_zh.arb | 53 ++- lib/l10n/generated/app_localizations.dart | 318 +++++++++++++----- lib/l10n/generated/app_localizations_en.dart | 187 +++++++--- lib/l10n/generated/app_localizations_tl.dart | 187 +++++++--- lib/l10n/generated/app_localizations_zh.dart | 172 +++++++--- lib/l10n/streak_greeting.dart | 15 +- 10 files changed, 798 insertions(+), 311 deletions(-) diff --git a/docs/BAYBAYIN_REVIEW.md b/docs/BAYBAYIN_REVIEW.md index de44b166..0c788896 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/features/milestone/widgets/streak_popup.dart b/lib/features/milestone/widgets/streak_popup.dart index 8fd716ad..b7c5ca79 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/l10n/app_en.arb b/lib/l10n/app_en.arb index 216340a7..a14c45c4 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 ea7bcca8..bdfb45da 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 1838d83f..a6a8e7ef 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 52da05d9..73250d7d 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 d8cc2c47..262ccca8 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 91b2a9bc..913a33f7 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 491e5269..1b357334 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 c5a34776..af215e6b 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]; } From ef9169ad2953ed56236c6b283f87381e2ee2c9c0 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Sun, 8 Nov 2026 21:37:25 +0800 Subject: [PATCH 2/6] Add tutorial navigation tracking Register navigator observers for the root and nested navigators so the tutorial can follow route changes, and host the overlay above the app content via TutorialHost instead of a positioned layer. Skip the tutorial on back press while its overlay is visible, and suppress What's New prompts while the tutorial is active. --- lib/app/app.dart | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/app/app.dart b/lib/app/app.dart index 5aa62b38..0d9d632b 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(), ], From d927d765809d29203a8199a498d7e37a6b75a0bb Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Sun, 8 Nov 2026 21:37:39 +0800 Subject: [PATCH 3/6] Rework tutorial into interactive route-aware guides Replace the static spotlight tooltip with a tutorial host that follows real navigation and playback actions. The target registry now keeps multiple keys per target, prefers the most recently mounted visible owner, and ignores zero-size or off-route widgets, so overlapping layout handoffs no longer hide highlights. Add per-guide progress with completed/dismissed tracking that falls back to the legacy completion flag, and ensure dismissing an early replay does not clobber saved completion. Handle picker, keyboard insets, large text, and missing targets with a skip-friendly fallback. --- lib/features/onboarding/tutorial_targets.dart | 127 ++- .../onboarding/widgets/tutorial_overlay.dart | 901 ++++++++++-------- .../onboarding/tutorial_overlay_test.dart | 293 ++++++ test/providers/tutorial_provider_test.dart | 177 ++++ 4 files changed, 1085 insertions(+), 413 deletions(-) create mode 100644 test/features/onboarding/tutorial_overlay_test.dart create mode 100644 test/providers/tutorial_provider_test.dart diff --git a/lib/features/onboarding/tutorial_targets.dart b/lib/features/onboarding/tutorial_targets.dart index c4f90dd4..89e7a14b 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 8e8f25f7..a26e30b2 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/test/features/onboarding/tutorial_overlay_test.dart b/test/features/onboarding/tutorial_overlay_test.dart new file mode 100644 index 00000000..374efe05 --- /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/providers/tutorial_provider_test.dart b/test/providers/tutorial_provider_test.dart new file mode 100644 index 00000000..ea298799 --- /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'); + }); +} From d1dcdbe142febd9442dbd9fa5ec6e76f4b5526dd Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Sun, 8 Nov 2026 21:37:44 +0800 Subject: [PATCH 4/6] Extract tutorial navigation tracking into shared service Move the route observers and popup/visibility logic out of the tutorial host into a reusable TutorialNavigation service so guidance suspension can be queried elsewhere, and document the tutorial's interaction and verification contract. Native-device, TalkBack, and permission-picker checks remain unverified. --- .impeccable/surfaces/tutorial-guidance.md | 53 ++++++++++++ .../onboarding/tutorial_navigation.dart | 81 +++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 .impeccable/surfaces/tutorial-guidance.md create mode 100644 lib/features/onboarding/tutorial_navigation.dart diff --git a/.impeccable/surfaces/tutorial-guidance.md b/.impeccable/surfaces/tutorial-guidance.md new file mode 100644 index 00000000..6b488343 --- /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/lib/features/onboarding/tutorial_navigation.dart b/lib/features/onboarding/tutorial_navigation.dart new file mode 100644 index 00000000..f1ace35d --- /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(); From 33153da0dc3198d5f0e13a76f695d2d95f0154dd Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Sun, 8 Nov 2026 21:37:54 +0800 Subject: [PATCH 5/6] Add interactive tutorial guidance design notes and update tests Document the tutorial's scoped Material tooltip styling in DESIGN.md, and refresh the l10n guidance tests to walk per-guide steps with a stubbed songs provider. Update the streak popup test for the new day-count greeting line. --- DESIGN.md | 2 + test/l10n/guidance_locale_test.dart | 57 ++++++++++++++++++++--------- test/l10n/streak_popup_test.dart | 34 ++++++++++++----- 3 files changed, 67 insertions(+), 26 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 19108bb1..f0915367 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/test/l10n/guidance_locale_test.dart b/test/l10n/guidance_locale_test.dart index 03537f53..bd67211e 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 ed382137..17f0432e 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, ); From b58318e6ab8ed0b9e4e91e37e849bf9de8419b90 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Sun, 8 Nov 2026 21:38:01 +0800 Subject: [PATCH 6/6] Rework tutorial into route-aware guides with picker Split the single spotlight tour into core, library, player, appearance, and audio guides, each with its own steps and progress. Steps now carry id-based persistence and action triggers so the tour advances on real user actions, and guides can deep-link into manual sections. Add a guide picker, pause support, and back-button handling that skips the active tutorial instead of closing the player. --- lib/core/utils/navigation_helper.dart | 1 + .../manual/screens/manual_screen.dart | 16 +- .../player/screens/full_player_screen.dart | 4 + .../widgets/player_action_button_row.dart | 58 ++- .../search/screens/search_screen.dart | 42 +- .../screens/app_info_settings_screen.dart | 2 +- .../screens/library_settings_screen.dart | 22 +- lib/features/songs/screens/songs_screen.dart | 195 +++++---- .../widgets/song_actions_bottom_sheet.dart | 6 +- lib/providers/tutorial_provider.dart | 401 ++++++++++++++---- lib/widgets/common/floating_mini_player.dart | 1 + 11 files changed, 523 insertions(+), 225 deletions(-) diff --git a/lib/core/utils/navigation_helper.dart b/lib/core/utils/navigation_helper.dart index a5edf3c7..eddc57df 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 0dbfdffb..6a709414 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/player/screens/full_player_screen.dart b/lib/features/player/screens/full_player_screen.dart index 91f8b934..70715a27 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 9c352d5e..71a1d68c 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 ae75978d..6c495bd0 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 6ccdb99f..e5f9bc88 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 c4fff531..6445a540 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 76bbe8d3..72694abb 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 0676385f..4b08f4d1 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/providers/tutorial_provider.dart b/lib/providers/tutorial_provider.dart index a81e7696..225eab6d 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 a97a6509..8949e4f3 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, ), );