From 63e1a9b9c8b6587a989a5e8a7483e56c81a77ec8 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:10:10 +0700 Subject: [PATCH] feat(ui): add Mornye glass theme and adaptive music player Add a selectable Mornye theme with shared glass controls, dialogs, sheets, notifications, and collection headers while retaining Material components. Update player artwork, lyrics transitions and highlighting, synchronized rolling time labels, volume controls, and compact navigation. Unify metadata, Library, search, and extension surfaces; preserve restored artwork and report extension load failures. Validation: full Flutter analyzer clean; 713 tests passed and 4 skipped; pinned Dart formatting and diff checks passed; signed ARM64 and ARM32 release APKs built and verified. --- assets/fonts/Inter-LICENSE.txt | 92 ++ assets/fonts/InterVariable.ttf | Bin 0 -> 879708 bytes lib/l10n/arb/app_en.arb | 20 + lib/l10n/arb/app_id.arb | 16 +- lib/main.dart | 6 + lib/models/theme_settings.dart | 21 +- lib/providers/extension_provider.dart | 31 +- lib/providers/music_player_provider.dart | 45 + .../player_motion_artwork_provider.dart | 108 ++ lib/providers/theme_provider.dart | 8 + lib/providers/track_provider.dart | 2 +- lib/screens/album_screen.dart | 78 +- lib/screens/artist_screen.dart | 214 +-- lib/screens/artist_screen_widgets.dart | 277 +++- lib/screens/downloaded_album_screen.dart | 39 +- lib/screens/home_tab.dart | 37 +- lib/screens/home_tab_import.dart | 43 +- lib/screens/home_tab_recent.dart | 10 +- lib/screens/home_tab_search_results.dart | 202 +-- lib/screens/home_tab_widgets.dart | 131 +- lib/screens/library_tracks_folder_screen.dart | 83 +- lib/screens/local_album_screen.dart | 29 +- lib/screens/main_shell.dart | 112 +- lib/screens/now_playing_screen.dart | 1286 +++++++++++++---- lib/screens/playlist_screen.dart | 64 +- lib/screens/queue_tab.dart | 160 +- lib/screens/queue_tab_filter_widgets.dart | 13 +- lib/screens/queue_tab_item_widgets.dart | 12 +- lib/screens/queue_tab_selection.dart | 31 +- lib/screens/queue_tab_widgets.dart | 101 +- .../repo/extension_details_screen.dart | 153 +- lib/screens/repo_tab.dart | 290 ++-- lib/screens/settings/app_settings_page.dart | 63 +- .../settings/appearance_settings_page.dart | 209 ++- lib/screens/settings/backup_restore_page.dart | 12 +- .../settings/cache_management_page.dart | 79 +- .../settings/download_settings_page.dart | 40 +- .../settings/extension_detail_page.dart | 466 +++--- lib/screens/settings/extensions_page.dart | 147 +- lib/screens/settings/files_settings_page.dart | 50 +- .../settings/library_settings_page.dart | 52 +- lib/screens/settings/log_screen.dart | 57 +- .../lyrics_provider_priority_page.dart | 22 +- .../settings/lyrics_settings_page.dart | 94 +- .../settings/metadata_settings_page.dart | 11 +- lib/screens/settings/settings_tab.dart | 60 +- lib/screens/setup_screen.dart | 28 +- lib/screens/track_metadata_actions.dart | 20 +- lib/screens/track_metadata_cards.dart | 79 +- lib/screens/track_metadata_convert.dart | 20 +- lib/screens/track_metadata_edit_sheet.dart | 350 +++-- lib/screens/track_metadata_lyrics.dart | 29 +- lib/screens/track_metadata_mornye.dart | 174 +++ lib/screens/track_metadata_screen.dart | 63 +- lib/screens/track_metadata_screen_menu.dart | 431 +++--- lib/screens/tutorial_screen.dart | 22 +- lib/services/batch_track_actions.dart | 24 +- lib/services/library_database.dart | 11 +- lib/services/local_track_batch_actions.dart | 10 +- lib/services/music_player_service.dart | 60 +- lib/services/shell_navigation_service.dart | 143 +- lib/theme/dynamic_color_wrapper.dart | 10 + lib/theme/mornye_icons.dart | 161 +++ lib/theme/mornye_theme.dart | 320 ++++ lib/utils/confirm_and_delete_tracks.dart | 12 +- lib/utils/nav_bar_inset.dart | 14 + lib/utils/playback_artwork.dart | 26 + lib/widgets/album_detail_header.dart | 355 ++++- lib/widgets/album_track_tile.dart | 11 +- lib/widgets/animation_utils.dart | 390 ++++- lib/widgets/app_action_button.dart | 98 ++ lib/widgets/app_alert_dialog.dart | 291 ++++ lib/widgets/app_announcement_dialog.dart | 10 +- lib/widgets/app_bottom_sheet.dart | 134 +- lib/widgets/app_choice_chip.dart | 75 + lib/widgets/app_content_card.dart | 46 + lib/widgets/app_search_field.dart | 77 +- lib/widgets/app_sliver_header.dart | 6 +- lib/widgets/app_snack_bar.dart | 93 ++ lib/widgets/app_switch.dart | 158 ++ lib/widgets/audio_analysis_info_card.dart | 2 +- lib/widgets/audio_analysis_spectrogram.dart | 14 +- lib/widgets/audio_analysis_widget.dart | 12 +- lib/widgets/batch_convert_sheet.dart | 61 +- lib/widgets/batch_progress_dialog.dart | 7 +- lib/widgets/collection_scaffold.dart | 15 +- lib/widgets/cross_extension_share_sheet.dart | 2 +- lib/widgets/destructive_selection_button.dart | 4 +- lib/widgets/discard_changes_dialog.dart | 12 +- lib/widgets/download_service_picker.dart | 226 ++- lib/widgets/duplicate_review_sheet.dart | 12 +- lib/widgets/extension_repo_card.dart | 46 + lib/widgets/mini_player.dart | 101 +- lib/widgets/mornye_artist_header.dart | 202 +++ lib/widgets/mornye_bottom_bar.dart | 207 +++ lib/widgets/mornye_chrome.dart | 613 ++++++++ lib/widgets/mornye_context_menu.dart | 265 ++++ lib/widgets/mornye_landscape_player.dart | 178 +++ lib/widgets/mornye_metadata_row.dart | 61 + lib/widgets/mornye_playback_button.dart | 143 ++ lib/widgets/mornye_playback_time.dart | 87 ++ lib/widgets/mornye_player_actions_sheet.dart | 126 ++ lib/widgets/mornye_player_artwork.dart | 35 + lib/widgets/mornye_player_background.dart | 107 ++ lib/widgets/mornye_player_details_sheet.dart | 152 ++ .../mornye_player_favorite_button.dart | 75 + lib/widgets/mornye_player_queue.dart | 232 +++ lib/widgets/mornye_player_slider.dart | 48 + lib/widgets/mornye_volume_control.dart | 144 ++ lib/widgets/motion_header_banner.dart | 4 +- lib/widgets/open_on_platform_sheet.dart | 2 +- lib/widgets/playback_seek_slider.dart | 39 + lib/widgets/player_artwork.dart | 8 +- lib/widgets/playlist_picker_sheet.dart | 211 ++- lib/widgets/re_enrich_field_dialog.dart | 264 +++- lib/widgets/re_enrich_review_sheet.dart | 128 +- lib/widgets/selection_action_button.dart | 12 + lib/widgets/selection_bottom_bar.dart | 140 +- lib/widgets/settings_group.dart | 272 ++-- lib/widgets/track_card.dart | 19 +- .../track_collection_quick_actions.dart | 151 +- lib/widgets/track_detail_actions.dart | 16 +- lib/widgets/track_list_tile.dart | 6 +- lib/widgets/update_dialog.dart | 70 +- pubspec.lock | 24 + pubspec.yaml | 7 + test/adaptive_overlay_test.dart | 249 ++++ test/album_track_metadata_test.dart | 13 + test/app_alert_dialog_test.dart | 221 +++ test/app_snack_bar_test.dart | 145 ++ test/app_switch_test.dart | 128 ++ test/design_system_test.dart | 119 ++ test/download_service_picker_drag_test.dart | 132 +- test/extension_initialization_test.dart | 52 + test/models_and_utils_test.dart | 6 + test/mornye_bottom_bar_test.dart | 404 ++++++ test/mornye_collection_sheets_test.dart | 194 +++ test/mornye_context_menu_test.dart | 158 ++ test/mornye_detail_headers_test.dart | 145 ++ test/mornye_library_actions_test.dart | 344 +++++ test/mornye_loading_and_chrome_test.dart | 115 ++ test/mornye_playback_button_test.dart | 105 ++ test/mornye_playback_time_test.dart | 90 ++ test/mornye_player_queue_test.dart | 97 ++ test/mornye_search_glass_test.dart | 65 + test/mornye_volume_control_test.dart | 96 ++ test/motion_header_banner_test.dart | 10 +- test/music_player_media_metadata_test.dart | 59 + test/nav_bar_inset_test.dart | 110 ++ test/now_playing_lyrics_transition_test.dart | 959 +++++++++++- test/now_playing_route_test.dart | 166 +++ test/playback_artwork_test.dart | 76 + test/player_motion_artwork_test.dart | 74 + test/shell_navigation_service_test.dart | 164 +++ test/track_metadata_header_contrast_test.dart | 140 ++ 155 files changed, 15707 insertions(+), 2318 deletions(-) create mode 100644 assets/fonts/Inter-LICENSE.txt create mode 100644 assets/fonts/InterVariable.ttf create mode 100644 lib/providers/player_motion_artwork_provider.dart create mode 100644 lib/screens/track_metadata_mornye.dart create mode 100644 lib/theme/mornye_icons.dart create mode 100644 lib/theme/mornye_theme.dart create mode 100644 lib/utils/playback_artwork.dart create mode 100644 lib/widgets/app_action_button.dart create mode 100644 lib/widgets/app_alert_dialog.dart create mode 100644 lib/widgets/app_choice_chip.dart create mode 100644 lib/widgets/app_content_card.dart create mode 100644 lib/widgets/app_snack_bar.dart create mode 100644 lib/widgets/app_switch.dart create mode 100644 lib/widgets/extension_repo_card.dart create mode 100644 lib/widgets/mornye_artist_header.dart create mode 100644 lib/widgets/mornye_bottom_bar.dart create mode 100644 lib/widgets/mornye_chrome.dart create mode 100644 lib/widgets/mornye_context_menu.dart create mode 100644 lib/widgets/mornye_landscape_player.dart create mode 100644 lib/widgets/mornye_metadata_row.dart create mode 100644 lib/widgets/mornye_playback_button.dart create mode 100644 lib/widgets/mornye_playback_time.dart create mode 100644 lib/widgets/mornye_player_actions_sheet.dart create mode 100644 lib/widgets/mornye_player_artwork.dart create mode 100644 lib/widgets/mornye_player_background.dart create mode 100644 lib/widgets/mornye_player_details_sheet.dart create mode 100644 lib/widgets/mornye_player_favorite_button.dart create mode 100644 lib/widgets/mornye_player_queue.dart create mode 100644 lib/widgets/mornye_player_slider.dart create mode 100644 lib/widgets/mornye_volume_control.dart create mode 100644 test/adaptive_overlay_test.dart create mode 100644 test/app_alert_dialog_test.dart create mode 100644 test/app_snack_bar_test.dart create mode 100644 test/app_switch_test.dart create mode 100644 test/mornye_bottom_bar_test.dart create mode 100644 test/mornye_collection_sheets_test.dart create mode 100644 test/mornye_context_menu_test.dart create mode 100644 test/mornye_detail_headers_test.dart create mode 100644 test/mornye_library_actions_test.dart create mode 100644 test/mornye_loading_and_chrome_test.dart create mode 100644 test/mornye_playback_button_test.dart create mode 100644 test/mornye_playback_time_test.dart create mode 100644 test/mornye_player_queue_test.dart create mode 100644 test/mornye_search_glass_test.dart create mode 100644 test/mornye_volume_control_test.dart create mode 100644 test/nav_bar_inset_test.dart create mode 100644 test/now_playing_route_test.dart create mode 100644 test/playback_artwork_test.dart create mode 100644 test/player_motion_artwork_test.dart diff --git a/assets/fonts/Inter-LICENSE.txt b/assets/fonts/Inter-LICENSE.txt new file mode 100644 index 00000000..9b2ca37b --- /dev/null +++ b/assets/fonts/Inter-LICENSE.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/InterVariable.ttf b/assets/fonts/InterVariable.ttf new file mode 100644 index 0000000000000000000000000000000000000000..4ab79e0102bbe0ffa1ed879b13e52ac8c6487833 GIT binary patch literal 879708 zcmd>`3A|5L+xOSB*S7ZI5JEWr|2a73ka;Gv49PrYCYcj5B}qsUGF8Y>NEt#hq%@h5 zN|G|2V@^70QVHk%UH@wzx4L`oyXSe|_jx{__wDof_FMn8_g;IgbzN)iy{@$nA%ze* z@INEUG^|&@!CN0bFhJ-5mxM^1(6GhbtqydXv{LA&h6&YSYQt7->I4gy%oN@^T-3E~ zqqcXp)Y)=(7q&rrAxfNUhTliJ=RGOx(Aqd*<=w4HmyKL}@qQuXX8i8fs`Z_%`;Be7 z8pqG$_tEYnx(^+1hkq#SgYV;a`JMy2581kDY(*hTUlpQv$v!wP^QH~P@n3O#(|rR* z_4(na`*sWQUN)ifPrVPvRjWoDg&np|*k4}l+q-+O@^gn(!u_h_{EB^%k!xb)KKver z--Y@P95ymb(@UvBs60a4etN**p55bG-BUz}IYsgN{J`!bhs4bmmvR1loS!(T`@r6- z`)~P5s1pl>$kKGk;0K3&@o1$dg}R9PkM{)ioe_0j`sMr-KUI>53_3T32I_ygBU;5%Woqp4Qaii!Q{C}dzEQSa_ye{Jm z`fa?ZoFP0RymICv9C(gZeI%x-7tliCY2+$lQbds6QY=ZwuOup`FibFnm?e{;S6 z=Jo!MqWsNx)y2j?k<+#o)oe^PAB6rSV9q3%sVYtbXq46qW+1`Ck)9b=cLPo(pxjDL47oudk) zy-i8e_kxerdXWqIGxFo;7}5v8C2$>lkGxyK+%Uf}^dx={0MDxOVi9zst|fXPf3F@d zdgv+W+wWsC z&NbE2s~h;jjo&jxFGGA0D$-sz*yAs-n^4wr92>~T%_Q{aKaqa@M&7%ktXU#*+7Yl% z)qjok;Rk<<^mBjbJpUJz7lEJNOrAgezWLby=``Kz&t%-=4E(n+Cp zQ%e6U+6zm^eINZq+5)zI5zL1Ve8YK^Z_V|y8RvMJVCTQXZDO<;Cp!KQv*_mg`|U)> zn=xla-~T}t;obpQWR3&Uo0)~W5lceN`2AP<7wxcxMPF~tU$@WQBl>1~UG$Bq3C~#t z{hyIr7|UPe#fzL?W|SK{p8kf}Mu;&0*QKvz<#XjFF%~S+DPobll)etU z4i14MA)BDz(>c)1;57IG{2W4grNM5T(=sgY5&S+2HvR*;Px?CCXRO*TYSD4ElR^58 z5W40p)y4F>8Pxv|zeRrf9oFe5TtoNyl78!UBEK$^{)URRCw5LFwK=8_(nn>Zbbq?R)y^qOa|Tu~`sh(tJR6 zy?I{X^o@B-YuGPF`X*>8LhXnV=EsgVLViE3(^H|N0UdjoNk3%<;~qp6G2p*QA4AWw zTzGf9hH<N|2p_W&e#~HvHqXsnSXl?-Q&-M_xTU*`|r!7>#zR@{GB#DA}X4ju+v0?e~0NJ z?)(E!q0hoH@Vx&+c^Uct8TEX;cZ9^Qn}Ra?i*bJ!_|Zx{_cnSD;@t!9mtJi#Ew)B` zOiaSMYP28Vow69-V}HSWCGabQ+IVjn3urBdHBTn2dHf@cU6TIHzdeV_i{f5gQRcgN z_kp4wIlU5COQ0_1O+0s|qr5euihoE{G2e@aJz9&j0a!++l4gRZWp7h_p!EU zg>s&QUt&Iu>2t`-fpT7k;#^Dhq;+OltTD0vaRTYXVn`->|1B(PdXrJSV!XMG`!B_PwqYIjGMxi0$Zt^ehD=bz>&_)TAL44wm3@O&Er{lAFWBN~_;Xv5=pCOmuthxe$W zc8s{wE)#d^pG21%_G%0JuLtA6TKKdgQf0p=p^0sP*gm|qgVXNg|+E$CoT+O)yDMYmX9 zUt1M@*8taS!uc2@<}Fc?){e1v={_Q2PKiM}0(}SLa)D@y@mn6(SEV(ezZK)`9?<~r z&E@^2q9mOgTaRTGgKSYzDi(t}jB!mI0=OnF5%xKO`cB2QpNRRHu)g>9i}~hY`ZqV? zd71Idy;F2Esp7d9GX}pJy)@3kX(io@nRv;gEWyExl z3buo}U=&yn=7CLMewfZU?_vC=bKW8p$B1S?GZ>%~MLHM)TC&Ol;qSl9_sYoTjCeaN zmy|ddvJv{;P0+EhUeY4{U${=}M4d$jUJ;Bp%*CpiSPb7=Op5o5#U{6CnW>a$iF8Y( z`Hj7JEaDG%*}=f*6s0O>q`KL1~xt8$@TL?3YgW9ERKBo@c$B1XzI z)Fqc_f!{6UZqY|i6@5I+y*}D{^Kbc`n6AGN(^YfKd(F{D7tluwP$tqh=*P!mzme8C zL@&?+&y-%Eh1W!^0pEiCAt#~4d*HxL(D4;uIry(!lcJYnGb2q5jqw3&r^Hai58v}z zi(ZILtjqx8vZdKCT6(j=SD}ByXcq11ieJm)kj5hIY+eDV=(3ph(TbZnTGd< zhcP!~7fZbyxNf)DsxGFV^&IAa(I}%Y{J)}TpvQ^fx~^yqZLQmi)~c*XL=3ex${LO` z2gteUzv+^(^l;4k!zo|)kDWi9u2GHgJAyUcWn4$cI*JPBRZ&(~5EXS7Q9)M{1NCZg zFQEpDo~k=yo=;%>%@p_PIbeSoTz4RdQgdQxO zgwFkcp$+nh>qB%l(Ol;dO;tY8Lh4Y8$yke64Mm!Fx%fn(k=mW-q>HjVFI3$|bU80G z(=o4y%&C&a^YEuGn8pTym0)hjLFh?3Ux=$esM?r|0quU0Vl5#uZKW+so&(jZv_XjD ztz&bez}&(+r8tNF%d}l=bbZ8xOkKo;*gG`#)MD@X@nQ;|yL*GJV(+cP#NLdwzf_7~ ztHiFR6d7s6mal+gAy?>{c){0_+E+u?v$`T;FL*Pf%zsz@Kb3dWz3AR}hd=J;meVuk z5`7524c;Hxh#mj*yS%8KF>gV4{FzLBu~xa6X<~gQqLi3-gU(`WCZg6&DDkVUjkaab z1@$O};{a_upMl#d)_|>NbOnvwti zi&FdlKZM?OE%sAseBwPlwg(p5x1o3GUa{EVLs&1x;&xh#hbV5OD29W$4Tku9jQK?& z6Y(xa?`De?-qAA1_)U8Ap2r{38T;h)zP%Ig)!?u8ievBP*hh`U2x9xG=_jzS3B+qe zMjOne^y_}O-*;oLE@SWOX3c}PQ`~{}vTok5`tv?i#@^Du+9%4`OTrw7_4^IHjQtzz zuXq`R`zqA$8T+6adh5zaq(^G?_(V|m)3HK%g#ew_X8%D zrr2~W{!I1_W5IJM_L~vArFd(Mf5ch;5!a;nC*`9)@CRZJ@mKLgwFvvwVZ86gy7I5q z`V?!Ue8i@1j%k^U_|;8uFOv~p%IJeXDYaLp}53 zst3MjAitvc3&nvb1{Cun@)g8vVlgkodMKVlcEf&3FIq$2SU*$$VBJbkci$J(bl-ot=dCkur{palQk3I%xI*h1jl z;8Wsn%qvgbG_O4Mr+HbdnW8C7q7caXQNeVl~*3`K7c)aWP z2pNXs6A(|^=yGFT8ISAcW1hpjN9%gq1miLX_MP{OL4IAVX>wsLk`?7;#oD92XixbV zryEUY(b4-%4DfJ$IQ|xSd&E%F4r_|`h^=%G9ci6mBCvfo40jSqBiurh-V@xc-xu-;7(-QUVD;nXNj$R(bBdEN1%=ewe z6!g_*(-`L`3@NqE(bHzqCSPVdZ4~6dp^g?_yR#?=D#ZM`Q z8YBHTnvY|1HO;Ryx6?dI@nX!0i0y3jCWu^kU*1U10{;=%1aTx+4A*xO<0$q^anE2O zVj>hr$+$k8Ln%Irdv2uhNio@2-1VmM8XKqK7>&)rYjJxBa6B5sy6iA;C+sp4 zb5I5J*L2a%e+9owiH;PXqxe#6d}IElK8eLfF)va4iN<~`rUje-(_BFJ$e2fOniFDk z+a&nP+h`~C6U{5sMyR3ZE%rDNf2$aqM|dx|m#L3D@&{Vm<`k3c4A^fUd>6m5ZmdnQ z;aEJD<`bNUIc8Vv-eEf};){6qpGUxEU8w(X{zf|#{rE~~hgU>-_o*l!_X}+DJo3m^ z&xTx9p2J!#$!l;uf=T?S$CzR54T$^DC^o#fTi5kZeS;v z0&qN*w-q`7zYl|1V5JaOa)Xjcw+A?Otp`|&bXhPP;LA@@RIW$dDnu<5E!1{!4txh1 zVC@KKJxbtNHCC2GU(#9@YfwD5a-xjUpif9oXmQXB$DUxW)A8p)7l8AwMuhl%473{< zf;yp$YdCbZJV*@l3qapRCjPtH7ssislc0G)WUqv0SZU(s5)tX=$j-&5>Z-6xJcl83G^9br^Dq|nm z2zHZk$sU6h*ob7>V-HXIDGoLY#w`z$fA$#>Jc9GJLSL zx`=1hI*uF2@6uB!ju88OK)gtHpmdBdKXwk~#cU*slfA+?#7f*3ZN6GqHxdsLNTZFh z_EKG_-KhJuM9`dUPWQsI3-2b^N+~=mp(x`z+I+1KSPbag#Tu~;v?B$a#qVJ>7E#u6 zK>awCi5^O^G3IUpbI?xQGuHlCUlqZ# zn3;mPbDW-!coU#Ll>PBMK>af22>Ols#3PQCh<7!s4&h#l#5B1S@z+{niR>lH%GZ&O zha!#nf3w+xca4qo{*}H7*LReIMIM_Sb9psUo1VKK_74T>i+Sz@ji>);EYbMEc?Z#_ zi(?eS+*UsQk}ijG5bwcw>!KQ_-;2JkO-g-5{zvsgoiBo;;3P=HG4i9Ph*y?X7Gw2m zuun8X-z-Ngy)4xO_d=KAyL5aT&*@y9@k_>jYY_;6-zFFQQ=UYoPoo+${j0K(d{4Dj zW7I@7L%pbt4s>RnRp-#R>U_GOE~-oF%DS%Zp$F>``Vl=X&nACYSQNH} zs~7HGxOd_Hg%=meQ6zVfwncgsnNsAtB3Fv#C|){{t|^sjxG7f?dhfREPr7| z`pczKL`qyriIlri?oR2P(j}!|%J`JYDKk^%rYuX@p0Y3HY|786HZ@4inwlduPileG zLaD`5E2Y**ZJXL9wP)&ksV7oXPwqN-_~g+vKP^bho|ZE$cUp2<{j`Q@52Q^@dphme zv;}F)(+;E^N;{sGdilNU>FMdPFn#~OSgetE$q!UpHBL=Zv($2J|6t?7uyG|_M>o(t z^)NkJPlS!9>t*_=zNCNCSIu~{_zyO22piuG8@GjxdxSQAJK=D``GgAzS0f9f*`j%( ziP56b+R;YQ7NL#1M#o2|MAt<(MR!IIMUO>)h+a+Vm8i|6oHM4->`9uq;5%%!NxO^UQSw|D0XyfBar$ZZiu<pYmwRl$2R1^HN?+*`0DUk zF@K`}m_JTq4)=%nSb_K*?Du@?wIhGNBz^bgs}~1f>~*o-`O`w2PXVVuDtPMrwDXS( zadA4p%p=5^eyP3B^upi9TsxU~rvJ(5C!acrIqNjeJ-sEP+|%C*arWof+2ha7$;i#n zufO>E$1{h{z(dbm`MNsgp1gSGt21Ao=y>|eGm~&nyi=U10EVBz)Ah{aGu6)2JX7z? z)H4&#+<)f2(-GV$2MA8fQ@@^CeQNEgnWymNK@R50(|4RYbn4Sn`*HuU*g zFQnw8qZ$9D4m$B!>e$rAsf|)Ir+l4q_S9P`3sRyf`A>Xz;(SVQGAhJzcWl7X#z)H> zZE>{0muEg7bFB6k6MLTMKC0V^A`KJgBpy%tMTi0~2vIOgq2tNP$@nX!YecpWK{c7vj=C+5OPUb&clNq_0$AP_>&t?%> z@I=W{5>(BC_CeQWS)b)>mh18P;|s+XjW5F`2oYb3Qt>5OZ{!w79_ko>SL|r}jQoEn z9pR%l<<*Qw9q2m#Fa8gG5MMdIYJBzhI`Q@Xd|k%*|Im2U{NMc-51)z0GnxO(_!ZW; zV|?d~Bc$*yv?ht#Z=bi& zTV&(BAG~AU2Jg7H)~n&w^lEu`dUd?IUVX2D*U)R^rFy5mi{2&gTW_BC61INs6pck| z(N#Plo)SyM2QrXZL%e-~oi}1)F zyg6RPOZ3)wQSUVyu}5vZ&Es9L)@HFOHn(@#*0&ApVLi|0_s-aJwywR+`^LUwx7&BU z=Y%IL-bM`GRWgYhqKW7xusbVyiVwv;@sZf&^2^(01KAMoDRpHX?KusK)vn{@N#p$SkjkEGlPgb=yqcjh)RF;%;@1XsbH8ysE9}r3Q%JY9LmVgT#H< zLGG)D;A@UKVy2oaW~q7D8J;hesh7peYNgn(Hi?h1y81-DB|cR^Qo_8es#eWR_A1rN|mK_W?5Qik!5tetfVu^#yTSJ(oxw&C(04Jk$hBl zmJ{^7@-f{-PS^e9)4IQ$p$EvBdZ1jOAC(LB1XoOtmuvJKxmGWb>-0kTnqDN=>lfq( zy;$zkN8}OxjXWy9H(LH+jQr7f>M2!MG;n!TJy+b_D_V-XR8!Gev~l@V2Uksuc2Spr z=w6a|+LS;X_psO_bF0JZq-*7FmlbuKd{EZcd0kQ0Mvl?VTuJ$?9_}KpmL4saxlZyW z`LdpCintQ;OMPDcs?%MfE8yz823~Eip6l$IxX0iUYwMgM zUTN`}dPg?ZNphoJB45`_WW4v=vs&u)hbt7b#!H1SNV&< z-`%P#qQ81j-lGf4mb!>+rHjhex|lqsFRHWJ>4()W*UfborNlj|i7X)T=5D0B!}SpL z-6Pd*^^9w* z7MRnnih4;cRm;>C^Ss)xPN_5Mv}>(}mMR@@^FH3>>+8m@t!uB_xemI$?x;KH-nx(O z>uR`rTur>ykJ0np05?#7uD{a1>T5>1Iu6$g%pnOm+Xb_YN?g+}*RC~&v zw&(2$`(@B9Xc`o>Wo;E(GiV+(3>pVbg1dr7!Pmisph8g5`#2~aR0=8wRf4KPwV--X zBd8ga4Qd6ogFAydLEWHUP~U!IF9fq}n*BDI9n1`71apHq_EPX%@O+RQ6qYsJId{N) z<_>!2WEOkGp2b&4XT?~tPJAWLn4DNeCLvZEm^d*;{wCI%%;HO#2VXf9leJ71iLamJ zSd&$*kf%*H`GvW~WEV%|S<}u8Gml}dwAr*ZL(BtasCm#lY9^TH%=7MJv)X;)J~eA} zHM7>NGaJq8W|Mit?9pe;hi0EyWVYz)W~=$gedxY$hfPsa%oI0!-Iwl=+v`4eU%4Zy zl{zQ~yQ4u7(MlFK8{9tkkwXklOg63EIMW<|0exP4E#pl?)5zTE9+g$xBvV}>p3s*GJ9FPI4`@G z!^^2AsK?Y2x7^FEhp7+MKJ~em&AUY{cCWZq`X|qGQ{1cW2`{Ug>Q=ia-5NK|t#wbi zb#A(Q%{}ebyBThSsifnwfLJYFc;l3?sb{V{9wwPALXs)C-I8;U92`&O*1c-x!cR*X1h&p zj(fw+b(`Hhw?%&LX;a<1&D8XKQ_IU_YO8gog_qaOcU#@F?m4&3R50JW=dsJM%3Kq# zn(JpOFXOrdhEwa4ME-UC9vW+e- z+v*asoh~Wc>)Yi>-CT~+cgsn-yPT|h$SJy~d_wn<&*;H&wjLtq=m+FnJyg!q56Tzx zM7dZ$E|=&@a;csyx9OGgP5rXmu2;#o^eggh{i@uf56Qjy3;B^gD);MS@?-s_{6rs@ zpXw9xGksDX)TiWe{jL1TSoyQ@icV9vs;6Ba`JB5Ce={79zbn2)1SLd7NfAXHC{d=12h=FUu}6ys)fmLI#){$UVKG9D6A!6J z@crRdLa0 zt|E8n)$$#^M!u`p%J=j-`M!QlexTROoqB^jp}&(U`jSl5-^-Kw2UpJZb>-dtu7c}l z+M5ohqv>Qin|nqzxmjFVm>to%xC7H`P|%Ua+%yFkGaj{HTg__)6E?- zaAfp7XmB~e*a5mn7oX1aOW%rG;}Ec1++E#4H{ z#arTSu|vEg-WBheFU(={rTNMnF-OfYbKFEs)FhfDQ@|87g-o&;gfE#(h?3%VQCd_B zCb+Y~qrua`G&9G{HS^4T^Q?GGOcYzhR`Gr?F_;ugF)Pi>W|d1dub5ZGX>mrJ73YG- zgUP`Y=6&;l+38N3*UWme%j}ll$cyq@nI<0&rUp+2PnnbElpG|7m;{r<6b_~bj|GWA zlJ~l|(c9#`5k!Lm-d^uR?^Ex9x8M8N`^5XqX0p!P>}?BjdMVzCAcy_YergYR2ffd| zL*8NUOYf^7n|I#(I>_bC_MQ)J4e|upgIj{E-d6jBx7b_i&G(-5=6bVig7>DkJ-98% z9pnx21+9YoK|&C*ciPgnk-f_{vCXieyxX?1ZEZVyk8Nqo+o5)t9bq4`qwE+v){e7} z*ztCfoouJr3HCAD-gdTq>|pnx?TVFXPut7(w)fe7wtsMsSH>&rRrN}E6}(bj5wEgW z+^eX1dxgAWUOBJ4SJW%*mGo}+?(hnG$zB1kl2_2H;#Ch?25$v#`UCwz_H%pCmo}Sx zQcktmZ4P^j-DCILkL}xbhkeh!Z$Gd*?Jm39?zJD$JaXZycv(xR%O<{}n&gKk1+HPy1*6bN+e%Ywwf@ zH1K_k_ayqT8;5eLn&Sbyo*q}9(b3aH5u<)Xf4K@2d&Lul`gPKXE5!H zIw6!-H>5GNUI?N*3RwcB>xmBl#a4g}piLN=721@Mw?UgR zl5EnPk<^!WGm`4of{_nF?_uOPXiG+phqhuQ)raa0B<%&ZVdP3EVkCsz4QH8Qp3rg`XpcX;zXVh!ZevJAU+MiKhKnE}?1v-#X z*P(+L?Lh}KIu~>ZqsfL3Fq-Tzl+o1R4>FqSHH^`tpu-vc40Hse4?@Y7KvO@DWOOQ& zt^xWibTp%>Zetj48FVb;t%E+ycrQZ7G5F#~h{cTk0ZO(3-W=#s244;cL1h6i5lYts zZw>S%#*0FiGamKl3dYj4PfBSr7UpfDI+t?HD6z zoE~S4gPveC`ELrNvO!ZBc@=t+QPlQRjLHi=&8Rpi*#oFt(6fvpA0yiU?CxUY0l;4r zjpJ_^bszL1NJBo^?K?)1FI-|w8|e3pwovkWv{mPW{>I{AHAZvt>O=>aK=lBc?lman zHRxalU)u;dBxF7G0Y<+79U8I$`XHkhLx+XzgAQl(5h&S-p!7qG{suZSf2XC=$@+?*9l7FhoF1Xm?5Y< z8Ye(g|GmbTBGC1Wqki5H@+EX5qt8QM5BU|kiP7oMHyD=)-OSJz2yzS9ioT`3+QxXb zp>Hx?J?M7Ek&nH_IP$%>88-;J1H6lJs88Nwu!|_<2MnzR7i-OjrF{p0)80<<4`4J=Hq5B!^R0{bqBR+$E!l-wkpN3HV4=|eina(4q zFAp;KD{3J>521dgIsr|7^hF5uG5Idg%b;Wrg8KU_248FQ9CQRqeSehEWcOnX_D_X8 z&WNng6O76XO<_bfXext!R(vh+Dwd zjH0?<2%)j?4Wr3tE`~IRe#_{B&@{$Te}2cfy3k9EqW=A!!9K8%KQI(CkUui2JM?jY8w}8l$Mr*F$)+B`P2F=OP z+@Nk{@O6J|ou+c({H;i%9|^qznunq11NNq3=t0oD49(FhAEO_G=4bG)dkO5o#lZf( zP!WdW4zv!8u?m{VxYAJADMm+V0mhYqQdz)JyQnV!`G}(S10$e?89{BuoDzfjuP8$? zA$+mTJP0k$(EFb%!DuR@Bt!3l>UKs`dGJMo-VGJi6=*7(>IdjOQQg64x=&e#<^WZW z(HL8*e8^d71xAx!RAlg1Izm+n*#)f(s-W#;JMvdRaaL80p%|g69?}F_gP}E$!rVd7 zvkqTi$I$mfYcq!Y{Z7VF-Rm&;NoGL1EBX!a4TZoyWekd8sfHnap^X?? zi>k&Ud!TnQ^ggAiu0T_NHf88thT;N*ZVPSB(ELa1UP6BjC4VBw@2LH-m7_kRvIwdp z*#o$CP%4X*XwNw6vkm~h=BPh9fzI%y5zu=XL+$Fqn2}KOKVmoN7V-?VJL9OW)OW-J z(33Hzp}iOfTdLk6FG2f&`@m8_eMf8o_cP{sXupvC(EcIRCj&yxKnI4H1+dn#&v*> zVKkjHj?w6MMScJ@oiiRhiZu1j1V&R|JjOWk>xm5hRZpQNF^+tEGNY-yDU7CjKEdeu z(5c`_)RX#*{0!*Np-(aTE9i6vf7Kz>(~Q0box$L*t^~!P30DV7wg;{rbT;G2$LBDP ze0DD5$d~6at}2x31bhp9mT}}S&oPGjo@`Gn1msV^!IrV#l=mrE6mlH;LI~A)F{3dy z)Dp&Fyv6(szUFA0Eeq)ZeKDjLbUEX?Kvyu1?6s1?zXBsDZW3cAbQR+oLSF%|qI@c6 zHRBpV*MPNv%3jCVQqb2JNA_CJ*fP)!jI9ja$hfi4*BLh&x`}b{Iegy`!`6UqW^66! z7O+(a?+A1o6IT)XCgXhw-Oj{SfWF1VQ8{ljan+$az&k?3Rf4_?-p9AQC7>TLL2>9# z#*!^|F_y;WZYHiObPvEigR{_mOk7RqM@(Eb=zb=y3iM+pt}OHuCaye`Y!BkfK@Tti z<$uNmlz)&38bCj10_yWa4F1J@L3<=bKz)9gv8m86!B>dGpMp}~0ec>Ll(8qE#~4fJ zQky|QenrPX&=i`&1O=f~F0j;=lZ>S{(mjBsemKnp&7stHARxb@{sIBbKj)aB36$yt z0vdN;GeINh1@H~>Deofq7HP6?8skyFe#Zo*p_jlfIEU=}D-%#Xe*@{ruZ8(SFc#%% z$ynS+D<+r)#Yu$4eY9aL?oDxOBA5;JnP4U~6BEpUIwruJLGv;Z%zU`0Lvkw6{%h zv{)>y7?k=Q$Xd`vjG_Laeh0D-bTMP%p-UKwW6>IwFj=85G8EgOwJ1SrM|#&MOg1PT z1GL`NE5XaizXiIA5oC*37#Tz-)%T#+bIy_26~n z4}oq1Zy^0Bl+N3NH1*R~#(fOk#u&Qin_xS9=@aN%jG_C#9a0Ut1H6NBs4nk<50Ksn z-N~3e&|M*Cpt}L`%!kmujG?}#-@u?wdS3|H>m$Zc9rrVa@~E!B(REb6Pm!j+CBHw2 za*9GfXAIrv5aY-<=sZAwC!&usj?Ov8IJ)i<_#Wqc1^t1c_g(!XL-W18dnx8NMplL94JilB7g7drM4QTys?f_IT__iq@ zasgU_q4#VoKxTR`;y?qw*oleh$^9t3>KR1f(TN_8TxfSL?FD@?5r_>ig1 z=oV12F`%_stPN-f+(nqW4EeID$9NK2pV6nGR4(wy*T}BGv(QEWec+K#-36K;O@7pr z@yNfLfxD4UenIuP2X&zSZ^_U*vT4P*b1F!ZdW`HSX1dWSRZ8G5%e9Y9B% zpAFiHQH!CS8TShGUdFA0c426|ny!q8jxgOAHwD_Aaj!yqFzyLxPsXEh(2H?Xp}iTm z8rp|(PeSiw+!|?#;u1AVcZNT z`8seLphH8**B)dv*_ry3puQi@c;v$)7&jC85aTvNM>1{}bQGh>=HnUnBJ@$l?SYbw zK~M<#7?_MST}SN_CGkEy?i?*R1?a4$irUx0@>$WUJZ+EX&rexRE|sm;Xg zfa*=02UJ(!sE*GtZY6Yf2wgje@ot6A4Y>n4FXSS0KI5p2XBqc8^f`w1;3-}Z;|J)1 zkn+%lAwNPFGqi_q$nS}tz*0uM0$mpJJCynqh}BT)3*suEaY8f$D;SUJKy4%L1}`&S z9_T8@QM>6Lz-@xQ$~fu+x(9G?K-Vyi`eQBQHbd7jj{4>`#%+PFXXNM54UDIu8$+r? zslR}C8MsYYs4?Ec7#mV!h@dBdD*)9)M!H<`5&M zK)+xpu44{|+yniRanzSzF>WFB2;-<9%1?UOJQGcg|w1lQIUOwna zMplEKV(>5i2#VPe^i4$UIeG^6B3qqhXK26Le9btr=LJR%hknC2vMr5g z;1)x_WgOW#jiGn6*c|j7=9Zt4zQoY`njxP6^iF4fVD#@$8rwks0{w|`G`8t_;K+CA z8sHv*(s{tGfd0z3rBJ#zaO7WfKcW<%dl1w<@(Ccxp4S*pL9a7z88n@7@J+8YL-&iX z#dzrR1i|26)xy7O!MF#Y`51?OPN>V+B2e@-Vd1L@jX-0hi$j|+_IBvqjJ*SjF-h2R zP}rWZ6`*YyTM^n0v`2nbXeY+P?g>2@+YQ>2vG+rJf!;WO0CWLki$P&a!j^RfWi+G-~$1b0lOu@Ho*xYB7zY)p^~9}o(OD7h&oWsP`p247y+9^ zJceTb5zB~rP@kcFfJi1rzy=Y=P`o}8FamuaiDM}K9?8rI*dqdeAk@9kct&)9W@S_t zXf{T4gxdk%sY~c5d)#Q8H#^L@-Si$l==lw96XYj z5%AkcK8E7rk^GDp0)=xEY9tgLO^7F#7oeE48>L>g&46M zn#@qVHBy)nE1*RfioZsRGGZyT7(;R5NO4B2gqC0^MjI)~2>4&*c1FDc#rP-0ZfI#n zy$&tINa}BlH-ch?k+O`WzAwj6>@ZTEkz|Jo48;*66&d>OFH(t7-#{xflKQU-qb@=* zRtZUcS&dQOLaQ^9`n3i_F~3MnMpB>EVkmAGsm)00zdIR<-$lspfuz19Uk4P=i;#Z< zN$so8X!5}ZjHI?UWHk9>BSuoY8#9{v>Mn-9H#QD8KHXvxeZG90~BM63}fV*(BTZl-6A6xxgGitLvgqW*$L3M zIT5lApqN}_G$Y@Jj$tT17a7aQJ^k$a(!Fcfc!jA!IW&_@}HKSd@mazFGj zhGJ0>>LWnk;6$ijfc_FneF5YrP-;KW$D!m0Kz<4(+XH<9I+c;1L7!ywN$5029)v!{ z=u^BC`m!afCfZ|dS@*6;VR1xwO zKr!aXd`720pJj9!^f`vUp@=-sD5}>&2LJlIh%90xolAZO@K>rLLcRqAT}%E1==+Wc z`4AAfpvxHgrX%tqBXUDuV(8nG$Z|&Hfv#W_wQ(gQZiA9<0Yz;je*z*elza#%2PMA& zA|G@$qo@zpFd{#c`UTK;G?8@-y}U(UV-zlrtY_$@BC>&@Z)zeN84-oP&d|3tkxh(9 zgucPZbm(SAJOJIos8P_ZjG(c#jiG(!2#qBmFvcR=8QOD>yv2xN(6<>i7P^BG!=djm z>S5@+j2HoZkD>kQ$oq_V2>Jm-`__@2j2H>s#i;Sn-HbQ{-NVp+VT8sW5MMw)WN7~| zvX7A+pfnDFE(P7s$d1sD8C@DmV+_bn&`%j%26}*zouM?YfW8BIkfCpjBcC(6ER^a8 z=sV*G)d}cw(8COUcO3bW(dD6EF|r%<2%{@Nk2115^cbTnLXR`D2lNC(`%94&M)riJ zGPLIuImyUgP#W_ zv_BEK$Vjr$w+!u9MA8`g&N1>GLwgsION=D@f6wR*&>t8{ZT*qablp#kq;~(zP%J-k znUT~7zc3WvkNnC=>W|+TiuXsZFpg~WJLCF7uQHBobd7QML$5QAY?RKpeu9Rfh+>|g z_+=FHLNqHf3PQ7iTak}370m+@kpD0=5fnlGL}*b^8u{p-Xc+)|nirt3U$hqT;S15) zpgwF@1KJ2QgO8z}(H5W`&ff;@z)09F3O^>~dFZ{2gx#WD840^ZyMg;~EyhHY${LI` z>KYxw7&`s{qUn%?gVrMLvgI=LySv-j$~*bG&+iLuuGKs z2vE!>I)-tlpko<|b3`9z@VE3LO7)nG^D#!EQ^3hIvIDj&#t)DS26-xbxKA>1(^e{MrGEYOv#vphcN_GVS`Zr3p1i=%~ z6aZUMEGouR#{=v9XHe4^KwU^?_V6QFMs(d@)H#1?*r zW&^jNKFCbW&UmjwQ5NCBKN53-Takv3B<5m*C^R<{6oBRdx8WSvEio_1hxDh={2+of z?375?01q}vr28eI4w;~IJ@7U|3o_m|Xdxz``zJGA3KVT4JoHs!5hkGe!bb`FBeV>- z18J&5Sx_G5AB0u_m5@FRt;__}2US2-)tG?VTAlG`Lu)YJ^U#`1fPP7=#RSv` zwV8nW@=hi||0dR9f~?TGjE6CnSdX#rvBdg}w;0-h@s>gxf<~y@d}w3Fn+v^*@n%7r zFc$uh*c3Fw`53E-&6xnUNxYj0$ZjpbJ+M33swES&f)Z_zPj+j^cwa!fGWI9v5XMsb z9$+l>{V>L&u8G6J2=qVI@gXn@Y1AokG#HCC?veN~7>6|Kk@yH>%R|RAb|~~w#!}f6 z0Q}dY+{B5DMY)L>w~3RGKL$D(pf4@TO~e>WoQ5=xC1Q*vVjNg1V+Lc}LuWF!GnD!f z*gnu_7&{m`n{f|9=P;J~gKY3D&hG(zj462>b7CA$KzEOZ&; zRfWFDcqO1OFgqKUBP%opeq@VeEDU@D-K=7com_qFse87RmLj>UCnsKpliTd z)TbQuHO4CnUC&U=HE{#jh;vFpUuV2Kpqm-5Fmwy!B}2C|UIFNvj8_S|o$+X_zr}b} zpl>r?b?6QzXbC;S1a$6ECZO_;f#dk@aUk?O6HpnKnBY|G^Ba*QVT(gZYsOblkFlRa zEn^Qt9plrvIT-va{30nAFfzjWF^_Xq1ugyQD;rg!^WPqWy%u1&Tf*4CTX4 zNwBkTps*|9`_QtCfbS+%V{A?+#t&gXgSKGo9w>Y(3H@aELtBB?NTVN;+JKHozX$CE zx*)w5+Lf{B!=!HD5j@MjhCapw)CU+}gzFD|987`_2hb-N3tJ@3VEjzbnT($oI*akM zK%Zg!e9+m9PklLu@iAW|%>@`oKK04VU=`BTuj{~TNcV(pVEi7?jf@X}PJ$m2es?JR zEomFlXmiphj2{O*!1&pq2N^#P^f)+y^ZP); ze6}Fw8~PYyVq2n39Ep#&Dw}(E-_#L3|OTtIJ3x3S_@WDc8ClR2Z3*lHH*ukF) z-2`x+KNyPsCw%x_@?wB?__d&`81F6Udd9B+g`W^U=96TUL->`TZ!kW_Q!@O7h~>jy zlF4T%ALD`W%R@0o5q@Rp4#uwteTVU@Lf>a%`5zz+V);9f27W#0F2-*F-OE_?W%7rN zUlWS95`GQnS;pG}{f_bb3xP{w_>G|+us9!cLE-9*4<9Mqo$>F2_GbJh(0%}A1Vy3f zL&C>(MRG8HS!iy?F9&VQ`1PT^7{4iWJeY!Or$SLTB8Z3n%=iv^nei$A7sjWue`Wm4 z(BBvz6JiC2qIUM|H1n8#8C<2QsV5QmI;P_!qd z1kx9vCBa>YgB66L-wCxD+LTdmLt!&Q?SwXG)LtmsMkvf%DHsnaosmZWreM4g8sjvj z3!^(h(N01?1nm#TV;+ESq&x~HBfT67TcylIn(jM`@lHaY0dtVw6*`ykdP1o^%a9NI zq@dpkOYM6Jpgfxwx&o|3x)t#-<5m>Y*WeeDfG_?a`H)T80)Q;U?AJWf3k1`hRNjb(? zYR7SK0_RXWQW(1mnhMZ2mfCR&oI#rEewMM+Z$AR~dK~PRYMJ00DEyuXE6b+XFoS&_8}6l&%4B zEum=tNtBn#gQB1R7i;GMUq$f+>Y2R_5?V+?=nw)4MQR9L5?TmFdXW~YbOa$(fzUx} zLN9`V3Mf^GA|OQt>_R4)cFQ^DQua1Vo!zb89rSWL3jAXt_l~v zx}ov{ahgwTtB}O#jr@R3jRkxbwouRC+z7KU4)7UaVNkalrJw?3E#xyATJSURccZ0+ zV*sB?@Dy!f5T9c$oW#+MaTc!ae5P2qRQ}^FT>s;9f`#h}pA+F}^d~lMOto++TTO%M z$ZztQ3bXk6Pd;Z`xNh+|$HIl*Z_Kv{?#!pE=Q7gYSTU{fZ1^KK2b|Dme@SrTdkI!pYd&aKOpunSJYPmoDmgux@w9C||vEQS~04LA$ehygp4gxb&+ z2EiP77Bb*N_=&kY>Fi-p84`fDruNP3^I#La1|P%EU=l+`;1M9+9GhVu(1skBfbX4@ z>1+hOVJbWeZ@|~^Cw8gRCkf%zfSy_?0*g;yU## zNf6fsy#*KHE`FN<^fUQrfBE_Yv7L|K^V62{(>C%y3SD6o5R>_dr~F3%9~YpV7bpfb z0Y4Vt`T~<79bSSr;Y%R43+9DLs1F@s7(4@8;1GNUmxU-Kp%~Nz(iNgUg-*g*xF$ph zHV-KYwSoGD41%e!8uq|3I0rX`DC~slFc*mT!tV(Y+5-9r5$1w2K))B(9)>_Ftbx7U zMnlhV$}f@^(7On~7g4_tgPE`nUIE%c5$qL#y&|wz1on!+UJ=+Uf^s8h4@JvDJ?H?m z)1ueGEkvRnst8nr zL`a4SuoSidwpM3{N?~h#R;M(db%1>JJ@QF>QeS=ljJl}%AZ2V&0*Ko(#BCYkR_}G= z=Q2A1d#U}{GPhW}2SXGzgw8MmX2S+J03X1QaF4ZFC{%%FfGs1jrCzUY`5x{tKMsLP z&;)wGQ}7IIfkW^aTxRYpArk6CM;Hb(VI7dJ>U;1#++l7_p4BQr6X*ftS?w7is#DkM z)U`VGtd3o(cZVU63fQVfeIV}|ZUyKS7X#Qb4qL`y%Q`l|mUUtQThm*BP#^$I~n zNPx#-3@m{60DbBsuTKorr|sAOM2H6H)d0O3pjQL*YJgr1_P}ZQ7XIcoN&$$5#?TF( zgn6(DUW1R}XE1qfX%VOfiI5BvU@2^aV{i^`@Jdr``Y7@F=(CU^L}TK%G5uv@`pd@j zmyK7#E;uPfJm1ChT|D2#^IbgO#lHZ*2$4`1+5^5xz&8nNU@yD_-@!k;4z(cA=QJ4$ z3t=k|mraPvCd6e^;<9ORs0EbMlyaISfDhpZAzI;+R`{e9K51P5X2S*`Zd!i;SA}R}gAx!6 zZD1fg4Ww&Bx;CV1^CR35qAfOTTLB&gVy^8dAcop*gja#IZAshqo)C|PLKSERJpemC zh8^3D6rw%4w2y|y&<*H+I>f_CAvz9*>9891z-jmvs6!{N>r@)>eWzVQbnXDRgy=F^ zh^~w&UGZ<%DX<(~hPU7%{2@fQ#lSV)v0e8qLOfm+s=@2FFi=xle9g_qh~!pkDfzeI-qw?^zMn?J--E0h+gR3D;gR>7x++!-cI1U z-s=Hh^{E6+044h1<37YgAL6*rUU)}{Wc;Wk?rdloLur9iYymsPic5JnC7X9;2wolMW~WHK8S(0>;TF{}N(!FhoH^=mq0o z5o`crYD{%FE5uXn;2hi#{OMYt%(25^2GDnmqrNG$m6Qdr7WTora0&3+_(D(-sL%Mv zVG^L*gji?=ePO&16Ulobc~7K16De~d^_fV0CN2l;JrR3PB+rR|3o)qxl!pe;3D9Q} z`b=64yMVNlF2Wx|Ob&u_K-$Tqojev6!d5se#1!g0<#~7$K7-3VA}pa8V7I5y@#+3B z8L<1)+l81~3aSDzI2GGXy#T*+gIC}^_)>_}j(~i|S|IJrL?E7K;@er+ zXBPFHwFL0ZEYi)!H?t$5J`g*ziJjTR&TRUr*{=#Q2fxng3gkUEjmJBapdVn{dFuBa zZ~`c69(v8s17#rrIsmb>fOuGNUWjK30(m|2t`G|gK|R1Oi^yxy5g``y{o)uPz88~j zG5%gW7tm!fx-9-kh$Z-c$voHuuL1sD@)KkVu`~=Gf#$FjUI6l6M*Ws8hW~+Eh_rCP zrfJwT4V$KogJ%F6r5yrvP9tsFJt5LV0G-m&DZM+4hWUU_=?CBg_z_6E9Nm^zfhN!c z$Zt9Mt)Q+eDgriH@i>eD>ac?LvtmEI50`*=TbU2aLnG({T)*-&AXZjMC@7Q3$N4R631A=VFpFW|Zm8yrvqVxbKTgr{Kzyaed70lhc;0_gHAWjvb#_~BXn z@+^5iOBoyS#m449IU6?%v8ga%<4xFj({peTsM99$*_0*3b6tTtJx6^vmw`?|{A|7@ z#1``3f}UI3!XTImD`6L$gstk2q zmBxH_1AgB>1<-N-%Rqm%|04XsE3|`v{^~#j=mh9}U?Gt1!257Th>Rep46R`}%!0M> zI(!P`|Ed$P=c~tscr7mwYX`CIL2P&M8f5a??Ufw)c?5{J!_?<6@phPaJ4`(eQ;);c z<1p79A)g~-02?169*$7X5z0C8t3L81-YCjv4M>81Fdd$S!*Bs;Lr24)7Ic6oVGe8s z@;Z7}h+}PGF3^UL9e@wud-z9)pu7yOd&o@1$6w7G#?QMA7Ss0=D~-& zvb->Kh7o`bKVAVl;Rt*##3w}nyM3|*4#3+&d`j7$z6KuyWqe8*pS6U(FcD}spB)$C z47#4_4JmM*Z9`)C^G=W<#M#>Lst{k;0sno0F6Z$1c?qrH0$dT|LIpU@Yd4w$v2c;! zFVfB~VvDcZ0I~9Q5=?_tKtJ{k_4($Y5Z^uq=<*%;efK8!4_dL>L2=_MfD|JuV4pp2il2=PlkK;K_h!yaCN&=h9DZ6PjG?v-$82)Bf|ik+?n z0kL@vJ6^*+*Rjj>FWIxD4th^|KA&p<-EN@gjduY1-1r6l5#qNL$bge@7Jh<1h4|eG z_~Z9#K%IXd0Am4NZrXr+Z<6m#^1VsEH_7)V`Q9Yoo8=rh>MeN-AA5iX}l$#Ce^(Q+0 z6$K4pA}nJco4Wl?-TqF5kA(P#`u)T8w@Gt56}AHT-u_#NJ29|Bh`WynaSwg&H38b? zz5ehN%!G7!9&QMciSIIrx6Dt0Jl*)rT^)!G_dvjpZhYq^*38mC8#0L_a}^MaCic&= zLl~5Y+JK%}kHc`F&1WqI+H=-^_!zDTDV$Im>Oeaf3R7Se?1uN?dm*KSO3)m7!&F!c z``|6OA*4|NqMt z*?)wL#P*Tvg)CPF@J~6)j-s5X?}RLm&B|ko@+Sb@Di9wPssVXbz#bLQt-=a;5e~w8 z@D*H#EFq&qfU=|Gpe6JG?%hOBgJrMYbgKja+0DWSHz+_ki z*eYf(ya}Jf|A4ww#HJNXKvjr`jxZ3$1NEr54v3|SN8uCr9!w!CwSpG_{VP#^<;UOy zA*<8?^sTa0$VbrWk)=RvRV@nml08dV?Ipm6)vgFxo%&YCX4U5a`c)^78pKeI1n2~V zfLN)q0I+$DU2qJzzQzymr;s(VaZU2CSsCzu&34ca#sc})Tn#(mF#IfJEd#g*P>UNNW-Jzz4d zgF}G5>S2%i=up2fl!GOJf9i8xee7KSBlrfcgImZ3`C%wb0_@O$vKpKK(lpEq#7o05 zfF2F;)&DDvs8=K6z0s2}9k5@cJwiTO9%jQ2LN*Qs>el!<$P_Z3pX1Rj9^DhDSHe?( zkD3sVP11yHS_i%svKe_d+brk?0oyeveT#8m3Ykc}C9Z+(K-)-s2Z-^+Uw~L`>4FG| zfx6HVy2B8d42xhR5JxS~37JG(B$b5*Kz))%!&*22__!5%w(1G^y>$gZx7LH>UBsp(XT%iLeZ| z!*MtdzX{pL1!bTvw1**(3Tt35yaV6CKSCxKgcyj2?l2nW!*g&@$iB;9yO8}-U@;Ku z{oa7Ha81bmb|?whp+ENTKM1D6O4tP_;cNI)$N~AFJT!tXFcRj#vycHF!cUMXwt1b(x;77>Fx+Qsu~vqe(ZKbfag&I(P-%gYShLBcT}7gjPVhF{B$qx-q01L%K04-5nvH3V}+{1bVF^S~317lZA;*OP>BeElakS}ijGyC{z>9DczJTjO zrqIq)=xb8&SqeT&!DlJ>ECrvX9DontN4O*8c=8-y3CMH&K%ngLYhW+noALN|e3pNSWpdoaI5ik?hz+N~7U%^cwr&6b>)M+ZZPHhiEfcj4*?NriEeFwgSe}tS? z5Ga2dGl#=Q*e&EN^qj@mF{?E&KFy-f zn8g@1>s`12zX~}!9I684&h8FF03ByP1C%rSXSglooJXKBOat_v^EP}TO6o>t4Y85OW?aT#PJ&HxOT0O>!M&PV25?T3%R}@d<6d!lKoe?fij=P4$n3P^4myW z8y5+=2|I5(DCBe0;kiwKO*WIqX19=ACc?);ZhboUJ-H!`LdrWU#bY`_tN)5zKk7S#`Z5C7jkDb5L-Jt!Z08ox5C+KWOS#6Jgj2>E&^ArILg6wv!n6X*d?!Aw{OufTioJ&^BV z7nA{F>@amZ+#m46VdC!yc^xSRlz9Zb-pB`}djp#v<>#a2p%JtN>Tq8(+TN68geKSOV1L?W6DoTowFTYG?qhfqYJ)_eu0V zC7~El$5TxJoliXjTY&nVBHgLWK$)lW0%=cI0(^Cvcsos;oF>21q&rQzcliFD9)KS2 z%!loO4c_?${t@zB7eqiR+!6A<1P{dJd(`#)xj=sJU*pfjP_GZr>4T0iP{pLPL0KO=5FYX#FG1HKaS z48Ax+%%5ooLtzpu1inAR_n-6q=Z^w;fBp`93Do=ZJ3^i%ud~R{u7Nk;MwI_^Ymlq`T6|oLSCTW7j_BxC2{fPRv|As0b5;M3Ev3$ z71w_C0#NqX#LL&~;G&S<1Ow^5nFZeq`7N>h-2k{LI-Rrez!8< zW5FMuhcF=4{`^76zg&Qhf06bt{P-8~@Ym10R^$;F3|#;BA@~a1LjKbe@Zs%dfIhdE z!9HNzxg!8S-@zAmHo|*C-YozvV6Bk%^1&1#GtnWFSjzldNH=A>xAQscpk7pp-Fs}7_O!+v2e0P^r4s0!#3-VfHo zKEOsr;-Cj)2qOX?Mo{O78jt`Tfbc>EU=n;JjN*AA z8PK8lCO8O}fNM$=2Yg+kGhnk4lv@HjmH1T{CDEm1f0zOb`3vbuWXrx~$b z@95|_qbnq0Wvq;n(a|xnPNz$TqGWBO)YnG&cg>Tf9;;ivPW$5KM~Q~paprC{TS}+D zj-9yEHgmDuylq=nmX(of0{ifW5W>a#{-2O3KKU@O+<8MB4UjMN$*;=txyrA|4|0o~t z!`>SCJmB(2y6P%&X&HHy+*!n9NoUZK?|CFe)Xr*(bSXcno{}4NQF)D=y^DR4d+0V} zZRUX7dKu@^+ZnF5cgE)0+gSEJFwE;Q+7286!3=lbnhx>t4>_)=;)}(IDS$;Mnz^Ag`WLjVDr&U5*PIu^7TG*qfFkj zqV1x7=31H5VOHDu>E?dNnGpv@^_$kZZ~^<{TgQ*d7`9_x|2EUxZjK-KSpRt_t_yal z?zo9koNjEfRxrctr4$N~uhHFZhPfzE&hYcRmWNpt@TL!Lnsv=QD(s>Wjqef_3l?QW zB@rt`Xk6_&vCKBgIm5$4|64U1tzX}}SF?m~|Gl>Mo~_re-rlrj(!Fi}sp8rLRNQL_ z@9)Ibl&ouxp^l#g=T;o$${TUD3zv_rRoaM%s%ArXd{VebY^}yd-Pi&)TfyVz7dv;# zD#xFCZ}W(-V}%|YJ+IZg3)53C%xyJq^kapNg^t+#Uf3bo{n~Ze{czLNSI0e;(xKu4 z^Um>O=AHSK+K+ELHe*IJ;*m0)Eu5=}$2dYe1Vhy-5)qEiF;2O(Z*t7mpa%C+Y=UVQ( zVieV3lta#FIW$17!p_Pk{Q>D$c+;zLefh@(>2hgjx*Tn}rjDVyH?M@hm?l>E7i-B* z#_DbE;WFYs80wg-?d^=0|IIqY%owbYY{iVMWiK2aX4s?3RWo8@OWR^=)hQfT+rX6t z3|CZRoAKUpS!L%=^NXA@bDq#qF%vd?^Lvh;51B7ryJqgtLDP0TK_gwqk4vX7XuPJ! zB~EQhL$zo}TUuPW;K)#%z&Hebn59Va>3RBLt)(ycjCHH^TvEFV~sr}-LtA5>sKt9?+} zRs(gk)gjPDhu#Wt((0*HeRU6J$UaEp1{fIsNSlCx_bz z9lU05r7f#Ef8RiqVO#93Wo&d0G|sDGr}z#t-wrD`dn;{=zN!@GRq?&o7TM%i(ytVE1LUeXTk>>oxn^7YV(kM@6TUP!gYEc=w9Q`$1mr_?;g3C zlr5j-;yX`F3<;F8I861(Dc5Izx+7IC-t^gn(KAr5bh*g7PI=jPor^__CswswExNS6 zlrzPr?*PBP!Bxa}K6$GE`6CaISIs6@iy6xv)x2`0r%xXiGs+Ko7J$BVs~K5M?X7gT zTF}$22E?XpYLtAz{j>R@KSp&=dCV87#PvLCR>F#Fwi@D?U}@|nGE|MZ16E_EtZ2^BSy9zX^$qOYVwV?KEIglF~Pc%BuElK0f|S%ts$q~EDm8GR+! zl76;tmGO%u_slE(tC%d0{5754zlyo)k#E&<|0?E|NB**w>-lZg94hLjfuoOV%ouS= zR_}+_z|-V?HMJU@KlrmZ>IXGaUwSL}JY7kBES6W#BRyS7hHb99zA@W9O*zNd;bx01 z>ld@DT_r52MhaV85hgTs;zD9W!kkg%qM4g{E-e;PCb)C_v6aip)<5RW#Y}$A#;id` znO{tux=ccD#d}jKI5%tX$ zMx#yU^{~UT`&BgsOPqCJWYXA9Rpy&_P8>7uE*NoYUhuZ(51GHOLVs2ETY8Gau~If( znz7l%hMb<9wRWz=T6O<#CT9U!+K&g!(02S6jcr+SwSS5>{NDBdT!TrvRqGls>9V$D zv{eJ-E9D&Bn^a%5w zdhK7{Jxq>2v&|iv&F5Tev4byO>xzInFvE9Im++#JyZ)>-Ogqo?3}b7Wj-K+POy?=GF> zshxf8+O_wF2G&uHxMo*vk%EFrrOeSM%YdA02mIY~<{zFc@9&ng88E{<;rI;IJXHx! zv_EG}m&_AR;WMl&xP_gB9A; zRP&CGj-&NPd8!bmM;p&@<8+Q*@!d3PL1rhC7WC~myZN!FzX}`k_0~7s!^v}`u`)By zlgpYnU-qOi%RT*tub=e#T~8G73%@hiJG)a@jM$QMqTpPsCkk6~P86J*^+3KQ=S0Ev zwI$CvQE>g~k>{8wxGs6*IVK9OUp?}GiGtG8)lhGDZjo*s$G+`OFLgd(E)Jq1D{+KfBeVRpY z-5F?kP@4Ig>I||zLEc|a$SwI*^N8*wiXea1$|n(n1UJ1=l+=HjLyU&Hz3J4>8(E>c ztDzf|V|7<%$**ROx4If~DHb5VB1-B~&miyOmj^A+8mZ+x|76s0spW(>{{mXh^Ik?p zJrG;+>sghx{3i0afP7YFEmd+6Znl-L`t)=LZ8)gHX*F7=>j|FDB`Ey?+N|(s!w!!j z{kc1@nFn;KKOk@JQj0n(pRBCWy3~>6zr&xNzs8+M%h#E`=ixo936%|%p=dyVc1K0HF1Woqxl7%e>}4#wX@&nP-Q6){Y(brmKaYPOO{O z71iW|(z1qVw`;j>T9*8(6;|HN2_Ge=Y4xCKiQeWARisDiqG_GfL1N{u7E3x8c1)uE zx$9uFmkN-pre&qSk##&ku5Qa(^6OdF4n>Y~S)|2V>CLvbC;fT`ZIEMh zuv+QW>_q27kms(?3QM1?yUN4?`d`Z$6rhi4Tvk3=f;qR(%04^LxDI*r=@n=<%KcQ^ zjmBgDNH>Nn(h64JGTZ)z4%RF3Wo7?>Wp;=qH-6S~|1!IhM@}I6#|C~3_sEr|PIX(! zYP8N$9{C?y?z>s&7^54*6{Cfg`$kXXsxf%wR^R_X`)j-1=dY|r$iLH#;fk*@gn7FW zwU?zzko|SZ)<%RbL3cOk@3XL)FItw@aZF z+}Op({t5f_T!#(|mMvRguXyjmxnIeVEA7XXk7W?$su}Ki?K35RlN>$r;HE~XSY)MF zB^geYKIiX@c4~eQ!*zK@Awk__9JevpGS{;^TLh;>>PlHB_KD&3;B&aW1f8ZgTxtA8p-e7-DDo_)$y207%;EAl-{&RjeoAMQ$hsH2g)Pl0mwZZGJ4Dx%cM&IWa3WLI-;HBnWk zMK~_PR`(#cDR&(EO#b|{`wOGl|IEB!I?i~es}qf9+|}H|c){IU>wkh9tXR~}ohsJ; ziRw@5S~CSlFUM7Vn^5Omr%sUwS2TxED8R*frLfVm;ld@2Th|&?$LwQ_`PmeQnYTt6 zvzo+)J+ioLXah4o^Als&XJ!Reie-QNqpYFlA@=f=>hP?B!ZVH;+_<}Y&buF0UYkzs zesRac8f}%S)kT=vl3$kXyr#C7cNX=^gR4uPw$)YZ6__5APE;n%GT8zwujT|gpBPI{ zIi9%zYH>9nb| z)Gfw%$39QhF(}Jg%y$cd}$@TLgDb>T-}NDx|h`>&=DztJ%~Imhg}aSwN1? zA6cPX{>lZ)@vj^%3m%a+n=BMwy=uh*=7AT@Jq4nx)+kokuo;HqOlFtJfhpr^R(opv z;BvNI92x3dZNjX1@hz6keX@M!Zd>Q55wp`<)tfc<>6)3lEw4G}X-AZ|9ARwr#xQbv zY^mj}8$EKfmr_1J{-VclCtS__^57N@Yon8>(CM8jA8&c2%k_6pOm@aN*i*&2zBOs6 z?K>=@ZaEoPNHXbibqRK?Ft8qUzM)X0}0# zB8Jq-gD5>LI65D9VI*I&b*i}djnUJ0PHULCkBEA* z+MJEuIxkPDU_0cCAr&h;kWUO{_pwjT;hod~->YuON`KvR98L9=oz<++l5>Y@yC>#O zIE(05ROw@^eCV-T*lYS@Pq!Yv_v)j6C$`k@X0mIV?s`6&ky0JysVb<~#X3%Z(3v>ItbuaP`*)fBbdc|!*4>QBi!<9LB1 zULJXH6Y&<;=?zQdTSZ|}PS6v}IF2mD*3y#=HubA<4ue2S9m!BUkQW69NE znS1%%zuXs44zqDfUk*d+g}PO5b*R!Kzad`tr}xW)qh%q#T#q%n-t>e4cJikWx^CzV z0#E+uefi%E%qP!tUU_i&Y;s@v;N(C%sWq5K&!b*B^Rxgvp^w!A=k(iZ!{OK8pAUyK z$K)!1|T?_m@j^_3Pznt~tsl-khpFw1)v41RA04fARY}XzIJ^iF}t@jmaj` zbM();>4`kUN-voK`&VOG*Id6_a-*%5dsbs?%;xdP2Wh!yr;hZ0dgL9n+_M@(p6QW4 zuI1KE&vL5UQb#L2ak0c?J?ia-NN;1t9Oapj|4Yx~$d}2J4iD*XZ1py8&dm5Pz08BG zr=v2!%2t%BvBhdl**aaFZnXA>|GI$Rb!W}*M3=r!jT!thhk;!kycdz)`m*Oy&U0QY zGMu{`dJ9BtgV-WYnrApRR_&xLYhFsbIAzMkG+8#ue>eSzZ2#@|vi;#5OHKDHdrbGT z9f5b&85!++C@n%yjy=Z({ig#t9>|Q?-1GgYQJ40ZYh=F(X-%4>Pc+|moJl=5ckYE5 z+dD3K{P9H{?tY<0LzS0~&xxu=tix96c4^7gj7-Z}93Z!D4Cu0L`YwfJ8RR?3mt6x} z16ggJd4>m*a?!+g%&gH(ewxWfYYvqtg2jX5G}Q}M0Tg)9$bV=sdr{s~g1MCxaKh-m zvb@#TlLM-$njbBwrrLav6Xk3j?renXuuf6k9s9u%Yy$`x8xav26%*s~y4rKRD(CdT zICFYi<=dyXRzCKS@RnyTHZWhiSa1EmMz~VM?CNZSBI-O*9Tee7jVR8Da*stFRIH=V z4aUaF9McpTu_P?clZ5`GXJ*cWxnUunt$(l&HOvf zeQ3_qojYsSG>p}DhbK;c)iJ79Dc(b^*&P?jI^7?sBjn-<%bGS#BT|j^=Gt?TU9yRv z&Yk<=xR+X_cJDE})!i?Q=iL20Ui(!C?F>)QuC#)dl`(}9w3hs`TwqQ42=ajR96I{R z7vuqQ2JI>Vay4k{e1fvXF9CAZC@gup^p@Kzuv{9;E1u;Day@BK>D3AwxtcTx??Q1= z;C0B4@;%!$=4P%d%CzI}JfM_opQehGkC@kF*x-lM(Ma6tE}DDoq)5r){uQl<39`B^sn*Jz ze9@;A-+n zsZkT-M zo}4mtS)Y1^Diw_D+b(fb{KQdD9T+Jgje z)HutL?8tCdRo3DBC?1g0r}FthMlj5?<#PUQ;|X)^vhPw;zgu?w`mz^aT=u$LQ`js# z;wgL6w?Eo!7A`pO(5xA!#%xWx8&r_sbc|+=SN3;x@L2K{;x}#l0rZ3c>2dnxfb`1gR{C`L zoF~2K(7#Wo57XjJ0dlp-wer6q{tl3v|94350L-y0Qql$@&G$u^ByG9 z`m1?{m4BA(?$N(jp#I99p83R(Ksnp9_dN2XKskQtWyw|k0s7w*_X6srHUX@1fA`rf zd%5X`%b$;5e-1DI?#t(IEq_VJPhgw`$^+vnK%OHX6(?3czBpk~O*8PySrn>xNK)lHvV+lAxLyH1Jr~m|E!P*Hq`G z2SA=;2`@d7lr@>|p8Z zvX*`yXuXyjt+iLFN1)t!)##k7{EA^c0$`4Ng%`+>u((Oi^w>2vxi$7YLmLY(b9eK>+>}pjV&8lHa5P-SLfYd8BM-2?^SM4 zt4yWRu?-(F@7S6wv6VJYCid%{5Z}9hqHL6TWpU=~a!TVKJsLOc(JS6m55Vd2^aSOM z$1iW|dRlT-A4{GltX{D`XXxgmpGVFu!mheksc&|1tPhYU*^XIqt3C`5DjzGj8jy}# ze~}^CqANs&*Vi(p)?=00R%%yWMt|l0+8AIPW0vW-V$9f8kIC;d)7-osK(M3jSj-{v!RHhaBQMBx7|**=_aGCI z-lfm@kp2gq-uHAuo-umI;ew3?J^A>aLdY}7Bd@IGzGD!sgX*}0ykJbza_hK5P7|$^ zwE|3ZpKDBxXgEGq#~r8-=ep>ZmY7!DKCotts)5egAbWA0dN;DLFU1nf#n^eEzl>l* zf^}TDdha|{X=z*L+!lKJLOtwo6UdY&OPQq7Vz9ce14~Xn&M&h z@ar2qR=lBu&|`XnQq>z*@YL0FxPoK1vRpO0TAXM{ly<0DsG1$yi#|4ULQ3kY-lJ`0 zGB4WmWnS#Pe8LmGljBNOuM}FWk=ZtW;Fy8&bL}tgn18QRNSnU>JI8VpaCOc6b^ zezsk*kHiM5i&V#Y)f$Z@nrDrMx78v|-G63Rs(`_6Se>}I0yfWjO`adVdvM#-_3Z{t zf3)_{b{)pHEWEqQ@_pUvEt}i@m3*OH<|PbTm=xT)Z;#?lo@(A`Xz>o~pX@iOMY*8- z1V4^Z zp2(@IEtNygX)LB1-t?}4M1oEqTv2eoRmsg>woATzxXY;0>%qIejuf3=z;%_p*C`tX z$h%~dH};iNIZ*B^ho=I?e7{}+^5uTHJtQFiD#CiME~maa{X_J9#Cu&1eXC}Z$NTiH z7AW`W<#b;A*k?tj;YsZbO8d+&{mUoiucUe{?zDMxrVV>Mq%3Y986Xd^w9}i5E9kmx zkeh`#*O!)?oJl6z$i8x``|{5c3;lA(=Uy$H*IVe+W;c73Uz^}T2GcZOF3=g-gKwT9DqqmsI3y(#Mt zdwKgLt7S^PN5NEyZ4%!;Nkjod-34Z)93IPrp`4S4@EhAa`zywFPs<#b`e^N8k9B-1 zDTJ1}x4XAx&W|6s$ZDBQ$IvqEle|qcU%|-A&K3h|*K8Y8|A`dcG>z}qR_sL6oZ7-_ znt7w!#s@S_OzKok&1)K8D5*y%1Da&^V4`cI!8vcy`E>DjJ+YClI-ZTX?Db^Q9O~(b z)GZjbk7upIt-T~qKgI0c7FEC|FAU!^xZC0#o$Ot@6ll8ii6@q~>$d)hW+U5&?=3#{ z^^_@ZP7NYvlBI8Hx z89ea0UQbNj)ie6B`c=EmY}ILbKO?Twq$Uk#pWD!;QkR78J?Ok-Uu@^;)}ls?a6ewX zb9{NrGR|8nBn{@m41d?sNacwo*3d5%<$DcxJU8q)$_NK?|Fk(K{aICy9Qx|?57GA#?{zu!t(r|v&+E~*TA!RRlPl*Qe{$^o)!A+R!6fTb))M(PMgY*(cBXb5T91N4`ojjrNwRdR|K&T+Sh$!i%!5_W`m239Gb(RWijIlmRxZcx^mep*(SuV@a{`;vi^FFn zhKB9QC=l9w#;C<#OrG?`_F+p}CN3F1WO2)+B}H5HDYKg+qF!Er%>aSaOckjO`=TTyUzO`dda z>xi^gNy|nJTiUAiGBb7k(#U!R3zo^(r5y$&Lw{al)}N z!;Vg${^lqnQx5b$D!UQ$X)`&Gdd)|mza-TAqc#!fI5R;0obFOKG3^hKGwt80r~RAQ zbjxglBf8z^1CTO&^-1W2XS-O0GQkQ&X3Kp#A9{W&T^ z2h6uD6}3}6V}V-e`y0u_OlQ05E^2GxzVlF{F&f-0;#J~dhTpGrR<{TF z+EkJrQ?#5hrM(_g^z_dk<;eS5(^SS3+8k|Mr)NwV;gN@ClVA2uOL!4YKzjO$1K#Ow z9<|Tt$;W2ef2K9*2+dO$d9ZcfmwO;_daEZqGTc9CJS+_C9~o0J_t>ks*BVpr20hfQ zvuri;ZC;ppA>U@-I^aPeVOcQG=j<$68uP**v)|&MYfpZhL4A z`!#4^sqxg_9T#<5F0ba>JjZ=2pL4>gX{o25YQJ_w(timXbE@6n*~NG|JD1bNWQ3Pq z>n=EvF+C>xm>$mFbfnAM?*^`7jg+ldQ>Xs8w#V)zT{54w*}Ak?@Wc~oZM$t4+;n8S z@IA$+9%NuQIC}0}sn?Wi>pi)%zg~0h?!lU~Mwb~$o{=C?FAoM|dzJ@0foBc(iFm-K z9*4*|+&@^Qc}C_!w^I)IH^uy8{(sa@t!R~g>UCB}eXT7~k9d~+isZ1WuRgx?w2}J_QEl)56YRG*eC$?o}6tL2W? zozr6nty~zz19E2+@9fWAFD$k0!O7!%x%j0)M=dG6L|`7v#Su@hG9*yWonh+^u$r;> z?WkrBl*!`MX9;G+f%&D&A>Ljm;5zlXst$f#^&-i#^Nnuz>+0fVRY&}(eRXgJKk@*1 z)ogOL^wpLNuI801Eq!)YFRQ}qdWjvNt)B02-TnT`5mjojthHCI;r`T?ZD_KC*i>t3 zpP;w=J*yhMg{Dr1)b+l_B5>>5sH0lO59?HuwGGO2dz=(b%L_cN(Dr6ly($ip<=T(ru_FCRCp-Wh;0#&0sx3%BuKdWhY-t~@r zT|3_6jo|88JL4Qz+VsPnbSjfIw8ayx`bzEnYH=l_7o!j;{n%|!d&ej!ky-ElyutG?*m;05RNvSfrM{dg_U(uoD zHH6cc_nHcpc*{9lKacC3!Q7RD*wQo1xPx8*VT zT6fHe_SSmk7iZ0-V(RMAF;3l1;Z~FXgrb^saw^g0IlahxLiM`P7&!ISNm0v-wwyd* z-tLgl7BffAJ2zEr>&;9I4cR;En@V7EvJ{km$}GS3hNFq`H)_XB3Alz>FIs6 zoST%M{3pnvx#qJX_xxEs2IQm1G?hQwV<}i&bjrGEuV5b~$_Gv)nCC@RBS5SmV6WTr zel(7)c3k_*jM+b>PdPQS@av(Ejc(OvWw)+t`p2kM)AuZZ)ff zm~jtR$+-7&x3up1?X*vAYMlA^Lp7AD?b%B)nJJ=TQti%gWJm3Fa}zRzS0!T;*(oEV zOzhFrr*`&zb^iDcGg_;+IVs7@K&{+3fYR`@FHlIPGplEY2sZFk*3xwF$^84ZPV{ z6>-I>I;i{X-Wyx7A+|S4*FOFOY=Pp8TiW8$tH zKap-KR-UNGi~YKHNoUa4-IwfSPo`Pv7vXKnuqWT!s&Zw%(-e8Mp;Ehy{pyGZ+U;YW z&{VJJw46EYvs(Tt@|Jq8ZON~iTeSQf@{)SSuI0`f`tPD=Wqrk)s3LvZM02*1Quy|c zwt@cEo{SB=S6OmKM59s8tLPCcXAMWUn^dTi9zm_Eu4Y;Lqtr!a`Q%O>zEm}NoxB?O zq{@$-w8lRnFQCUgE0^n8%!EmQ9(jp?^eeL%5Rw1IyQ)_Cbmd)DYkblGO_9>0Q_EaR zUg0UZI(q)%FM;<}ak7i_?^1GQeFIBt6qJ?4o(?`jU%Awm-Wjx-_a)$G-c~gwpk!@X zqRyeCB2|6s(^f?za?JCynJhcwZ*KF4#)cH3@mY@;Y?fRS;$qzrK-pSuF5SmN&SFOqk_q{!RiTm9UNBZ z@EpDGOcr54V5^z(bSf0Aj)kdRT=fiOYRtZ!;D!czZ*L%+qsLVy9<1${S|k+6fJ+#n*p1y7h>f)HZQa z>&}y#Zj}kut2{cWnjC7r96zpSZMoHIWL6=%=ueduamEkY;Rms9eeGCnUDd_pDyNIl z@?5Hrd&N}4_F3|> zn5RHxMO~n-mUmy1c1lljSu*-!1l3z7>E2q)gS@@6R?si!?Kb{i+1dxS(x=OgR&VOt zPqp&DF8a`JY@(#O*mjyY@tP}~_x@lo#@&bskW z-5zNp`bedrq-tteQAA+0FOioCNIgCsftq(2yfYi0s?)zY9_h5H0#EoK+g!%`L<1hl zbWPJQ@;7*_PuQA~iGI9KMtU2To=tcs%<8-hkKsjy*qWKn&d+pAn%gJo$u`li)qk{f z&63S(%fHXPW^`#XsZ*ONNsn|Gm0&KF3#zv)9b39`W8#LepVug`AkSgl+)eOg9^ zdZkL|FJCk<&-}^ui}yM|7H_w&bulK1FfLSY^^P`vqrnqnVd^cpO1j>XCM)TP1?p{i zO4`$sa+uQ@JpMyTgdw%52-Bwv)QaBwL}WOBBu>vgb!dd!a_W^beq+0OLtD1*8X4B0 zaka+HYs-%Y2dHX1{^*om?Nj30%<5dZQT3M1s#kezxqEX?{Z+*+_x*-DTz>Z0Ur;<-TS<_m$A@Q|Xoako(;yqHy11zEYH1 z+w1GxxG&W&$9>k{$sq`JP&sut=n0;U0pI_QuT2?7MSF5)(l)Eda1OGMRkz)_)u|o; zQ42WlvyLO^^PY`(`ZY*=AJ3jH!K_j7zj9-|aQA z(dsE(rZnyH@+@}&f53OoIrm<>B4CSM z!CnzDHY`+;CdGz`h*+>7N)e=}G!+rBU`1?TZ(xa0G)5D95@Qn0OErl}OyWySc?P+M z?>BqSEhr}bzwdi_2xrco-PzgM+1Z)dS(*^gn^$s4q`=AY#Np6tRk%EvhSdU&3r-X+ zlUieIXEc$#XcNFv^_(eyY$Pvi{uP|C`A3+PsO6LH@dOB6TAHLk&;r>)h@nhL4*7{p z@Nq462x+x>wW=z4zWo}j7nwj_Kt^r0jj35n>kC+g8(?1Ss!(9ntzO{plXR;AZn$}_ z+m_+@lFQ3l4b{GnR*lvVGc&$qZL6*GdtbKPRds$5r+-@gq!G1ZS^X|qX^gb7e$F@r zhOTW$`kH6-`WLWx-X`_JfDm_(1&us0(sCoGO09{5Ik(V34#l=vg!9Qj&IJFFji{|W zf-daaU8P$TzZ+>8q2U#N(AI#wnWQ8pHo#hUm*rkae31yGv=%JJC ztwgO9YFIX9Z;;cRMWcmODACKp_Kh32dxjwuOij@`5q#j<)v)o_8dqlw0Nea9E$!jv zg#~VI1q*oum?`WF$Bd62lB@D|EPpU8_U-BV$dRa+2z_`*oNC{`hprYQ;v0nl3)xpsvY&|Jp+S+dUV_u7(DUq z@k+SX3*8X^ij#Cw$B*Cf_I~!n7^3yhT_SEH zSs)bB=zdvROD*sdeq}Cgs$HkMpIlpMb1c_b8eJof^&^ETN-gj`N0{2|qQJ@OUrXEY zG;7Umz|*YaU#)q|0wa>q%FsS;XfADn*0-NwhSCh;w82a<$CO1-@UII0g)KDKO?D== z?%&L{=zAEU^*dWYO=atcnki#Z!+8Y?Yn%mLfX6~U0~IzkjVV0QlWM_(e9l*Dh4*|3 zyjKl4e9lTg;d55&A$&}uNuQIuQw{v%Pv~JZ>ND+Pgx{Mm^*s^6SZIT%9>XS~| z)Urd%{%t13wd&ByzDFqKGogSeNCgm-6UIa^E<;QNwdXQ~76c1+qlJTp;l(Liuaae0 zz}p*#tKM?9y;k@NH9>~LKIZc2n(t~YpJ5z!623Zf`OKG;&#F=0%${r5CUgC!X|Ah3 zMr)LqB--g*Dq`N1qwD`HCB;Wof3b|0NJnAG`t^nduOt> z%lkvJJjP_f#MwkhGU!e?2%T}nIcY=XrY4&zHx$o3F}um^6LX7;i%COgkG5}TcZ-WT zGX>ZA#f+5rP}&2eNZ&+cHsCxgA0|M{&~vDMh={jB4lL!`RMjh5bi~Hm7>}Prk?@V! zAd9ekQp(FLLh=y^Z#XXWunf#6JCWM1G3XK|ChQMhp>@%Qjaw`qG06Xn1~J`wgaw@Q zD#4XdHPcZhB&b#5AdGqi559eVpe2v;MhI7qG6($G9Fc7-f|p_3^+mk0MO0r|RS)Gf zlM=j4x((u5jgHlUn5oZMPpw}G6EVl^)~xd{NWDPaSHUqz+ZL0+7^G}zl%Yg8X8r3Q zf|R9G7J<~W|1n7UrGeBh3sUCB58)$eJ{c@w->_ADuWC>~awZiWY0z#*gpE^^IwLy! zrNRJvC}^g@P6~BK$Bs3)@Od7@zi+IX5ue*vzF4EB2~<8`v-%oaN-{#!XbqOSQ{XsY z3#JStX61gZKG5=eSN|%^fCwbBD}|$TC`hy6jA!P~JOMBVUDAxF(FrD|4pbexxP z4jt(&2Z{YrMH33#z{ps1q>%tq>x-3M&&@sJ5s36f@SG;qY`~%;aFy!nKHnTLMo&6Q zvyly$CwNv;Xd&A_4~!5xmJbOZV&~9A-@aeFDMuFyKTeq3Q4sbsQE)5GQ+72%f%3I6 zV#|0Eg9dHkv5@wC+v}S+*bSaOMEE(c)J+iCenIFsd4i$2BAof4iIft6{4-*0Lw^9a z63f9?EF?6lDl8R;m`MzOt&kl;pMYNj9G9 zZO!_>Ctrm*(s|aDkjTP{t=rxaB6xjFGq?H&@k7X$9!eNR7boSONZ9W!bLFX_g*M2P zr{qQxS}iJDY;&+hMnvpt|A4cb*3W%2e$mbuEq6DMnj4WZDSZE?f(7rk7@Z#0EhIR? zb+ET}&(4K$Ve0~1m-u!c8Xn>{+^c!l&V`G@H%~yH=3?Fu4pwxHI?Wp^CpGB7$rjE( zTIydBEtZ?Ret$qfUF&A`>UHQE_X;?2d3Tn{t^@ipfguC1z}CGo+M#B&t@$O;JS_&{ zE5fS(%2>-&m--KB({)UXQSBEFagGS?@i8lS1=Lxc{U0vs(YM*+4kMjrd9s-IUj=VW z2R`I{n#@#}>;8P2JO{^aNU`tCg_{S%gNee+Bfc2^r5Edg9miPS+Oj;ngvzmYrGA=l zN*5 zUc=o&rVT^$kTz1H_94wYlK`g7hqf4 zRxF$BG5huGxaf6KK=R3HQw}98J!;pe(UmQ6XXlizidtB3z%zE#%(WSFo*kohaj(oS zwClhZIz{J!P@;T`N)}A$)~5N^TcrFSO5fR>Q{Ns6I~Ct;U^lm!k#6C~&kxm4gq_-1VtG9V2q=pT?i^X@Q{6EisEd(1X_F2{-Ar6|=v#bT7&vKw}khjivYu1W=xl0#^ z=FNEC7YeX`4y#9q>Wdj~)9iS&_B+qCf1OQvt{Hg>!urOCL`=_pORLj9-y{&1eS}47 zlZv>rXn?j-xn9%P`TK8(7`edJarop7evT_P1_ZA3EBEc=oHyFDplH&{RSV8Nngw&(vrl-)u*hMqIdh~T&)%6ZI>gO8df3{i@WMdxGfwAj z@?XL#$O+D9wEY!TmmN=h1jIaqQJl{PG{<;#%-^+Ti~N`5=H;r_D!d9VGw~`VmsV^7 zwQv@3tT5_1b`pLA3(b3ujUaw)u*E6T&Vm?RLSnq$7R_ER2C1!0x-jhVIUK&xwi-Gn zWCKkK^F=KWO)7+hILA!i&{PYq-DBL(3~k3;nzWz{E|hJc=d3l=LY~rm749LtWro93 zdY|Ccx2Wg(2B!M+-k#9fd!RlRb-*ojR1r$p|F35*amkl{6HS9N->SxQk#^Y>D{NawVqpNQ(g%8d-#_B8(K4o5tJI2ve$wyB5|_ zzmO)5`o*E#wZNe|)pLllH{nF6$1`19raG+^1@YLVj>X7z`oEf(hz}FsBM1trYETa&*8#$4~e;SQV9fCV7pGcP;Nbv`y#LtGW-L7S*d) zRM_yYWF|69nw36m_=;IVN!1Rg9$V9rW-M6i-=Ty5+67gzviN8za*b?6MiXCc)YNL8 z*$7TG`yuRDwaO4sTOW)B*1vD~xJ0$|Rw2BW(v)q*0vGOP;JDO#tTolm zT?7jBr&^&;YK8unL)X>lI~*nkQ@wn*$0>!Qd2cA#Q7Etl*4PB|DO4oSw1neWU8oB8 zu!NIyK!H=M7I^fzW!x1~@9a@6w40#_EFEG?g(ES9bC zxsp_)B|cTWU#&R{X0CyT1sm3co2Clv@y_%Uw9o9Wpj7T>_t!kM5NMYxd$j` z3I(b%vx()Dnjs_A;jdZR$mBJl3X>y32JMlLPN`X)JkQmv#Z$b!7LA{F-Ru4tow(7Qn>VrnJHArEST^vfbAZ18Q(t+SyUUD+iK`bLj^I5GB-#DwsS~5)i zSwb^)_7agviYj%Rr6|>H8baf2q!uk$MwKHbX~gS*G@=k#MrUv&Ps|}Cxu!(IbQBB8 z9<7nZ9z|sQWPNJIRS7e*z!xyq!k7(Df?eI347t>o0YumoPbDQi7^5L@lX5G8m zcD3o&y;(j&P+CM7iZ{!1*bj1xnTG9K4YSz~&_gP{i}#HW-oMLB)&V8nG@qAP(tuK) z=12Ty3S2>{M(-`?V9v%Y{mue_l5=|tl-i8|N-Tg->ShC!de{ObH2(u3Wr&hnupbnp zI4yC=`)UuiFjzol5zews!sc_v{%I<=68D8*)*0h4m`hu%lnE`Xy}x z{MiP>e&L4Dad5u|B^@RO$q9l_)eV{>NJvK03)-BuQ}bq+TF3})!L@uxn$z3bX_Cil z?fHZoz$e_Vmu1_IQ&W8Vy2(-7w2c~$^_wC^7-rKHqWM?(Y~muPM(8K6pL6F!rT5>^ z!U(9=NBb2FpHR1$vduL*PV{>AQbfe`eyyFOLOMD(Yt**EAm{cC2UPj|6K+yyHukiV zlqU$ydw0mLZ1_@C6{I*RL^@GGJF}M)MCw%4d&oaEEVj+eeCGYq)=Y$EeO1;X2RB=Nf*>$_e32~ zPR7;YOB*?oFthcYy5L{-uxyV!@;*zjAKaeB+?Sn&PKM7}lJJco#Mu?Gt8GAm6x8wy zCT@L5G*e=k4z?-6BEwG6-LON5%@pnHm#(e)rhX}gLM;yB%G96wqcj(*U8$OCTd6Us zv4#pv)=rC5J23FrigTUD8;Xr}vN>yuej4IF+f?)6v>BK;e0p<&;} zEVv-haEA#DoMsfW9m67r4Hz@QYia7tmC>UIIQMSVu2*a4?jgnoPROBs=1lS#)4a6j z{D`qWt?D;f-n=DmF-W?M78{d5ujz0kTN$inWXH3G&1|wo7_MOKhY)MwWR$e*ps_tO zskpYqNrjn-!8MYQ&ovImT(gAJj8)+4H0c)Q&F}&v9OinAoT+?1>&NF>ioDtqj_}Q4 z&@BWFR!AuZ=N9;d5U|O1vep_e>OnV+fgm0BUA#D%r4@g+XwhfIavDpET;t)fCQ?qL zQ##U47JDf!{_Vvj!xxW3qE-xiIcmiL4T{_cp%5`EIU5`*w0G#PnmcF}ebdr=k#4J@ zQ(AT%Sf>F($Lm77XO*HPuV_FOmr713q245jzbqnu{`p2$1U=`lA^&i3ocKEKMJ#)j zTjSsyk#H7?W6EcP5}!jR(;TU5RS$7RRez~PX{oRsKe?r)O6Rr1@d>&{fz;G}?eL1K zC+I&;+jPghM0Zds6BQfqmmTM7WCOQ_21nI)fTN%batAV7hD^ki|Eq&-CM)eRVQi~b zUgLYoNs^c0KuE#xCLO&)2C?DtTf?Fzb!_6kW0nxMj=kB--=(=N|Qg=1^#^q!w>e*1*7jRdAfCMhM@?kIhNt7z_nl0JOeXoi_VPO^XKl& zU4QNgI5Xw;f$W2~Lkz3WoYCHqQ};hB4G1WGwx2DNSDpWE=FIQTv$)FMpe!8I+N0Ws z{A38`Dx!+vY~hE#vFX-h-gdAW*c9T3XE?C6Y1-T>g+;~h37mG~k9F(*I1wJSJDx=u ze%N~^?&Gz2AIF_8DIL>s_fKm!SD%fHJX^ha%}={Kjwxk5d$PJWcK?{1{NwKH^8b3t zcQJ`!u+b%>M`GVASiGl zfWy_RNi~`}tyFjvhnsIg(5dk89IjqXit>$A_*4!z-;gj(52gStwSRbT**|(en!i{3DL1WS z*-!g==%+?s_o0+(tiMKo@x4^YzgPQ>?=AaH?^p2mYX9-xvj6mcHGi+tL2iovn(2V| z;urkAN)P_tOb_#WbjtK0Kf)F80E7H{Zxc zEZKzwfGtl1E5WjAFC^k3)9XU>sXM}ui)Qu5iBpTF3eDx~x%&>tAH9|L<+3yb2E9FYkycEJYoR+P zaB9k0@zgd~0uuDhO7N4fHR;{4VcR;LI}WiM6W#BtZ@NzlwHwmWUe~5k``%XayTUNx zVCVK)ZHZOGaWRG}L7W=dpwR)XwtZ(qkl}50F3jRQ)|DwdS$XlC0+$S2#{m`ij&d9> z9-$-(Zdw7fL68sj-=-vXn7blfBgDxBxxk5J^FI6E;0X?+mX2Sx-l~0{p`BUP`-TH{ zj&1sMvG4oZ06XoSXOpteruOlfKR7ME%_#qw>48#7<*uMu-!>il_Uq8rH#Sg_Legop z*od4*2qJ-8K(q(@ZsV?98|5Qd%H)AFD^_H^ zGq)r!BPmm@&KWXaq_ih=^vQ{@d}7WaJ*|EH*=@OhF<$^d1vp40eu%JcGL#Ja`Y{hM zmFjz7lF}V*7j->>R}(rL0l^CbSW|2$EhYG)gQyd9;fZ!52X<@dAnq+W{cQIR(aP|y zeZ$_a@tXnUb}0ZKuSs(Tr4rwjwpp1(J_ z99Jh<(^<*-B=Gh1-34$L8iEETQF2SLNC#?&@2sIUbQ;){>{nP&nJ{b2q-L!Rf4qO$ zajbJk$38R1oVaN)wDnsVv%im%iBa?N1gPnWU*<*n-NSs)`PiL>|{(n{t z8sCsV*}0Q-yRrNx>xvP%cQY|&`@Mw=?`@CSc7Nf*`*9Uf+gRJnms#6w+vEqAFUt?M z9gTW#!-n^wlz-)1-SWRv(0}T03HoL1ubZ?O{S|3a=(}?+s8760*F7?t6!@VSzz!l# zxv@{4?W_>KspyD9HcZ>UxVZ8**cP`VK7Gtal*p~X0!1L(LsZj*=XtaA)hWmA9IS-= ztzRWnoXb!8Y+BeitX_rCGvdR&xI@focm6oHvqQZMcWEjB(;{HHgfNv5Uqx&$xpTHx zn}G0F;4bX9>J17XOI4<(Ez>MfQX0|}#k$n=n+3dS`L7DGB;rnqJdj=6o$v3CriGq# zTAPRgVL{W_3Ku4}1UHx}BbCNjn-(OJR&M<)wj!WlTEeFhAz#Z?6>?AMkhq4b^<~HA zWsUIlJUiH%U92qAdUrG`uev``b$t@UL|x^lyh4!93sS}37Zi#TRXG(fUixg5x0gmK zRL}+EH-s2aY|oJ;;}1|V-G_@ia8aou$vbrjXIG=xhAm$wC4Rlt@Kps1&)@HzGfj@7 z;gbXBzMH+~b~Kv&usAy?aWwG<>J8!TxlwzXE7BwUR(2e?rkfwsG)eIqEivcX+7UCK zYeT`k4t6$Rk}ek0*p^RXR(s{)UEPEFH1HEXH@rK#Uax@cNNI9ytrZX2yiUAbTy_6 z7~JJ6LV`dg7QjE)b95SxXOfM$yb>cPTwip(c(zSN6Ym9Co|&JdqR z@wJ!>toy-(^2hQKq1?tL_2j~_v3^~%nC`+wrqAl?x7d5p$<$G2<^8|>B=0{fya67Q zC{{{OSAx^-Pq`GF>|vMyxQvkE(>ohmf8kQ_BXU?og%t8nJG&9Z#X5PV^10=P2<^h{7Y{ZmP{Mef^2PvjPz8;s4Tef3ajvRFucf@~vgvnL=K4bGg z*7opRF>!pV>;6SIGcw+fM>3He#H-b%(tE_Kb(8tr@Ymes5(4l#G=1%*MhaZQmIZuA zvY%8Yb!3dRAgRvt*CUp!DI7=GD>~rv6^xA77?e18koCB*@LF*cAEC@K8TV3B?q!V0 zq!F^l2pQ%HWj01qj7mMZ$a^tHNdEic1-TL<6gxJiJZ&@|8B$Px(x(9f%w-;GNqZ-* zB0?(?bULV{DpIx`bt*QUYn~N>?VDC3MEU8ajz3T^U{=xil+W|>zerhh`EU8R6<^42 zoHzu0Gw-D?`~AR1X7iuavDuSDvc?2#p6!@Ex&Ok^gX|{GwCymWr^mIwR+jvgo%zew zV!7%{hRd5Q_@`gk__N0d_0#gxHQ~p8%$mDBXwsgAi#{uJ?E0E*n=Mvttw2T_oPKvg zC%RJNJ*|QJ~QSFu1QQ>NJjfk9?c+LyCC1&7k2#54R5g~`OgZ($FEO4zh>p9 zsVN^X_e-BWw)th7sGYZ--96rPm~)R_JBn=1vI##QXRY3yb5y=@eRoOyjgK<3rv-NMlyi+U6h}#SbpZ|IL@?S6L65 zT9LDg5Lw}yp(1iS?gHyd*xDRrj@0^wpE9$VuHpjIXB%6m`FC7?`0gAqC23_#p-||w zUR-bv02cFs4NSPWjE$23dB?%~r1l#dTKRRC*(K#}OZoXQ5EB zJE6Al>QY?*u`=7Lu(ALPDke4^9vi9ag}>TTL_OWZ&WzIut5tIch#f>9H?8$}tN5;A zZy#ZjT(J>a_l>22$-ym}bV&d5fM@a_SL2gDUpFhqW8~`S*{fYgtk!19pUr2T|Nec| zlWj%6We*PO5pw6f;N)SK2cKrv4x%3S90>t=UnHkKC|I~XVo-yTy8Q=MHtgfSd}?^U&)A%) zQ_?3mG+1(Avu;q6;j?zMSkJWk4>D<;|MEiD)rQ+bf5%nn!86`mp896wgsj|wLVv^U z4417d$Afkp6EX!#v}EdWP!BdC6TH0%zMMg&s0F@)<<<&M*TBSGOZg1VJGH_yUs68H zvb=tx37>2R@nEdq0=|;DnBf-vT0^%yQhDr(866Ph{l%V{&Ex$x_3tI`P5peydsBa3 z^4`?%m%KOi|0VBDbfEX9{+jVO(c>lWUq+Ypny=0DB8LU13ywahr+SH6M(!}TR)d-p zKdx|(LQNBuU;M5Hcad{NZSCx2S~p|uLb5z_6W93r$BxK} zPs|&YhNEMT6VJ?=aeVQDe6Ko=`f&$ySKNqkj1Mz!g zw53>Wqg+IyU`Prjp8JKni-OmExpe85>jKk)doFoWw&havn$hi$y*g;+v;j-MSRcN6 zjMwUE({jAVMNbXI(Prj1|zwBF%?0|NU6 z4Db%?9x$wH=ql_y`ua}oHEW&EsHGG8_)HA*9vw&*mvO{6k1kRrm_L4Vgr#;sxRsfd zMuZ6?vhF%GgLm45Fqo*5Vvv0f%VW!D)WDa{DoBK{fU^oe^J_Uxgf*sNCS1rRt}wT# z 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

R*jRJ1?^iQ`xYAuqn>-7iqg^r+ zmgz?BmL>l1Z7N%q9(OaS_hH~ja4c8=P68JLH72n%q{by*hHQ0g@@+#ImT9qQgssRj zEJcciDVLLSIVqQuaycoNlX5vJtJSQWl*>uEoRro4UQWv8 zq+G7;wN{XF1u0jMas??@ka7hnSCDcADOZqk1u0jMas??@ka7hnSCDcADOX^>vVznr zNWFs8D@eV9)GKuAm84!t>XoEkN$QoPUPU#Ae^-5B&B=t&CuO#(K zQm-WSN>Z;R^-5B&B=t&C-@tl^MO9@_RasM2wp5iRRlQ!m!ZBveRMu0CHvrm%@fLU& zu-+((smfBSvXiQ;q$(S!%0jBh?#!seT2Q5J0PIqB>F&slyHnX8ZJsOHZEZ7OVl~`k z*AwfAQ?ZBGe8*xBaVpjjr(z3nDwYtZI{m^k7#0twV(D-yRyng63tb#HQQeAuh#Noj zL*g;C&ABl1sIk6HubG!g{x$Rt7L_% z)b;J7^(<^*DN9(&4wkaJD$776zzUYKffc9eaK|d^tI86VvV*1cPGtj2S-?{EuaxyG zbc8Brm6)%rUn$#H%JLPt$7x2&a+TPnQ!UGSuIzx>><7U^fM=8HT>$KjOvTzrc3W1wBT! zFZPX_R}u@q4X|^}SgT~LRWjB(USYQ0_GNKxe-rJz1pJlu@2C$(VfDWfoBx%@I)FBe zwfzdC9yEYP&;&NH&Y&f-5|^_Qm$MR=vl5rH5|?AoyquM|+;|PV4*m_^0O&1i&Z6Xk{r^*~wK_a-&S%d>;Qg@Fw^Wd<3wtVq#wf>#hRZ zuEN4@iG|e?>pXBi_!ZzWk#zyM5d0ck1TF@bfJ?z;JUOZakAQao)>9a)LNY3q#aqdU zl#DjXXp=p`bwVgc75IWtbP^zKW$9Mgxm8wfvB)a0$11#|KoM93Rsd|&h_S4ZC9L-) z=vyV|TP5gQCFolv=vyV|TP5gQCFolv=vyUdb0z3oCG>9<{aZ!#@=+i26x)Suc67;ze`d4XRCG=|v{aS*4SAu?5Lcdm_3x{aHnSR?(kT z^k$f@jcT3F+>u*GR%iBs9(R8}}G_?UGo zxDCL|EO?m(FSG6hD(&UqZU8s4;AR%w%z~R)a5L)xuoAFVTiDmMeh;v%X|Zm1$i!GR z+hdB#KBclw>7oxP%aqD4rHf9WY*M;c0(1jqX;MrPSRyyF(2ub~&evE|jhn$OfR)VP z4GQBna67mIRDinxZx(%x}j%vllX1TIhuI!a7YvsyTxw2Glj~7$z{lFB!I}~bFMrRKN4r4Xm6O00C zg+5+{*i8vFuhdvDSF5$RyVmPN-o+emWc8kE(pM&ZW&ROx51ZV>=9_?f#QX@LH<{>7 z))C;B;B0UXI1ii;eg#Uw1>i#PYe0Wk^oK=%SoDWQf7qupy6JNme~JAL$4}``#vuRo zu1cbBN%Sq53-Uluz%}wJa2`bCNl%YG6p*xhJJCvb2l%YG6vE~%A<`lB#6td5V8hB9eJOY}@BJyS~0l+rVm^h_l^Q%TQM z(leFxOeH;2Ne>j$1I6?}F+EUB4;0e_#q>ZiJy1*!6w?F6^guB^P)rXL(*woyKruZ~ zOb-;(1I4VbRjjXAegf>xup(4h{{ZaESny-(W$+4k6);P&{#LR6RQ9!)|ykfMGj5(J&)>ZuD#^JzGi7R?@ST^k^|XT1<}?vtC!Z zSA*YxYrys32EdBT+FeEutKKc8cgyJAVtTijb-apoyozzxD zp`eC>8VYJC@DFA^aK6Du3!Mz4GaYAwTVFhom>n7L%}dG9E<>afRTW` zIlW|d1azqh;1C@<&5%jMtM1NKqYfP zC38R}b3i3?KqYfPC38R}b3i3?KqYfPC38R}7S}5+&atpw$;c^V3VL?C2|J-l$~WP^@gY`x2=2hw}_d@C-`mQFaD3l7h^KmA4~yLK_Q?<1~oD$gFzV#%3x3i zgEAPD!JrHVWiTj%K^Y9nV9W)Ff_dODa5y*u90`sBM}zsG2>b*b1C9m9f#bmf@KbOC zI1!u#iowa?XW$g@b8sr4MU92vbg&4V0TzQ2a3(k({0ek&U*A~4HTQsf!F}L<@Bml| zD#0r7d+;E52&@JVgGaz0z@y-gU=64OkAXjd$HAY$6W}l4N$?a{3!VngfM>yTtjy1Y zb$$_h$4{f9K8;@bG>@Y6#CAa;P+z%R?kDq?E?sB+}Mm0R^lQ(ho7$J z@giG2f6e!c0vowoQb87A7ISw61Hf*8yTIKY3y^HXN=l&v(d&J}Z(0Ao699Of_ zKMMW`Y5{G>U0TFlTEtyiB;LkyeieMK%tQ+`*>Qx|mBH)Eu%BOLS-=Kpc$NbKzy%&i z2I$EaI7YBv06jn^I0(!DGr=KX7MKI(fTR=n+t&iPiOni*-vC&}?VG?7 z0B^Q`3s!D`@DPA!*sSO_E4mGzv8%vi;7{Oj@MrJ@_zQRvJO$Q*r@=GeS@0Y{ z`?1k}Y_uO6?Z-SascZo9W-&bM% zzDhDKuzX*I-TNw;1%hA#n9REkQ#l?8X5gp(?6;Rl%FTL~!g`j%dNz*rY#i&^ICgJK z*}W}g_qLSX+fsPiGWm|b0j{=8zRMAvn_a5mSktcxe5Uu>$FZwh%C2rH>s<=#T?*Fq ztK3t;X@LI1s(zLGOK>(g2VBJ3$ejc4FN61&!TZbL{blg}GI)O(yuZw)O|iFMg|+=E zZ0%QJX}`*S0sPe;$ND(VttP)3P>a8TV-uPc?STFLD%Q;u*3A^G@K<@{j|KiJ?{07p zxEI_99su+eHutNrxL<|6{VJ~!9+~Zz!iaeL2L^HlD(3*i0WJ7>?^;e zt^N+i>hBO^m^sWGZj3SaFy|U$&3V`XF2WA*;l@wQW6cG|G1?CB0&NHQB5eowQf&wL za`SrgdgDs00pDqqVGZ~R<9hQ+Yy#hpP2eWuQS)!+E5={U*UZ<9=dcO=}WV~p8h?U^KYWu*o*avoudSxHj_?xm1Z2Uvn2R7bN_JNJJwSC~Awh!D(+XwEg z?F09<&a}=nhiNOp!?l&*J+zhJk=jb|f!a#&@m9U{H}eGRAJ(hpDb`z930`DULVLP- zx3(30zdgr34J*N?+l#OXyx6|Le9>NNFE#&e-)!G({=;5oFEjsX-)=8A|7EYhhVZ|! zA^e8<0XBr+vHIBWNyF-k-C)n!Tc*lx)&!X$Gp$K7TV`AP%RHH9O_BLB-hW&63y3YpsyHQQm0H zmP_SQYmU5G-fYdqGVp`eq4H7rCu^R3O0Knzmh0pTR*`&3)>_BNM%idBkpGgeSU;7o z$=9rt%phVPvxi9LdS3n>vYF)0@flY#YwRiV;{JuRpRt<`dGgV z>=oF{Iys)OAzGs~m_$cs^^{c?ASO6~77Jx4de2E3%i(Cr}z*oAd zSOC6WTL8Yv?dA5dmbm@h!PZhN0FSfoa3{FatoyJ4JkR=rdjxiXpTG`qvGuh3bL;@u zU%sij z9IOWy8!NF?5W-GD2rC64_BKQ8=Y_B#6~a0}2-^f9W|$Dq2}4*V2=V{9Ld-QGaRfLL z90iUB^Fa~#2{;BE3-}L+Sf>nOn=*uD$`E!bLs+E@iIYGvI2rs5oC1ChP6eldh2V6s z2%G^HgA#Bi_ysr%{1Ti4E&>;WOTeYzGH^LiZ!)ME@^)|sr~r3@yTEdAH&_Ag0r!IY z!2RF>uoA#Uun`c#LO=)$k|FFzhOizP!ZJVz%aI}MMuxB&8Ddrr3I5Nd;J-{_Eixo{ z9}`QFA?!qk1pj9e8<8O_M25suU@dqWJOkE&7XUl8ScD7-c4?WxLs)YUi6*cC{0+Pf zHnPjy4WxtaAOrLOnIKEdH-a3qK@P|Td7vlA2faXV&<7NNT|i&Z5A+ASf&pMRFc9nx z27$p~2p9^6f#F~T*aM6NdxBA5FQDEO*_-1Sun!mu#)0u*0+2cHrl;13#Y~`1$O>&u0gIzB!ql@F`#_C_dEEA2Jz7d|e;n z+xie+*0Z9sk77(-}kh-NyJG|L}W`vCq|`Z{n!u>%9LM+3}yM-l2@G1BV zd=9<r6oZ^96Z`@5e)YJs#rQ@eo##LfAwKS+q0ukV05P z3R&0jl)?i^APo!wLjmKCZ^J`;86M)h@Q}?o<7vixo3X_cjrmw)3}G`U#FLHrSPcsC zU3iGE!bA3SFauByo_NexPdp@J9!o(X>;#3d5)_j36BdF(k++vZSO^LQNH?$_AYCj2 zsV5}!u?-aByWxNO-sOAZ z5ML9A_?9@tm&75yBM$Kuamein@&Wl{>nDVzpO9Msb^(1sKQI{34%qeyVc93dlbZQF zshRK2#eXRH2{;BE3&Kxt^t-SzPj9BW#e|&uuo1iqRQ|Lv z-_3@wg&5+?S>EQ~FrM{F{O8VptP|(4nzZ-pUHg7qP*N?=zPs={wf4&{#12P2tBz;k zJMvf0y8=7#H??l-iOsL(4HSN@+fT`+);a31qkeu>{cN39X8qyOH2n=5c-y7r=g+P$ z@{g%s&>{)@_~xVCpZ|Kj9v6Oj{X!MVud6>(AOBH*E;8O{Ko-qgowW1wUuszCzh14& z!;?MzW8b{Z&wr=7e>A*J;8q0cI<6n&udN=DC~CA0D$L(h&-)ym_|31sIyP3K_KOYQ z7Qg>`t_G0DsN;d5IGDSFJxfD)+0_ALl={ z{-o%6^vJcRM*Zp-dref%wTq+vmgD-fl|P!dAG5M_rVlz>w`hL;nCh8Pe|ty&v-UzL zXTmzPA3q$jzBD#QqV`ka9oM(+4IM}IQSZ8q{)Rf<#|!`Pf6iBPkzd`{dQK=_Z~C*> zvonr3d3{=VY&1@c#)>_*ZbN_Z`b_GX7mcIR_RH26=yR2KJL4oAfBgV|IPrN4PyP7z zO8>U#HjUJwsJ?mJtMKsJRsT;Nztw%G%GRcQH&@T~tE!LmPa~{jY-Yt%G0}PY#Uizgq9Qkr6yXU z`ji$)*r&%%n>w_P748%N-%X8TYW1SFp@}5bXC;zuy}Yzv>)(6?)ITb2JwBrH@BDGe zcfN$%SHP(aiP!RNOjXTu+j!}AUlz?fcC1C`xb04SXD84}{-kuY&i|^f=zQEA6!XHm zJK|HSK0p4vt7p|0b@hrJ7e7J!O|8CWyV5VHUg^JFT_*~vH>h;9zEM#9GV8!i&FL0Y z-`wnvc(iU_S$&7dtX>fpMtX7QoHXAb1=Smy!wRZbYk&9ZHTpQU`iZEn6JGnnG1dK~ z^LoAd8Or!Fc(FN+h)1tast#NEHK$)*+p_j<<(dwIBW>0kXMRM7(e2kPjd-fxpzO`{ zZ|}ZA$4ao~lSESW*RlHzTDR+6Q-H40tn;s;*nsv~Y*!9C&{9W+T zer;pd42YFAkKI@y{`UG$Me~oZ8R@U8nMhwA=c99L*=aRHV(YP`zBVb^jw)OXtU1~* zs(symrl}EY7zuy+npV1SY{c06M#)v)x(roy&qU+-{g5*zw)RtX*jgMFuIjL*yk?B{ zqdm4RLo^MwAF;KcZnK+f*fH(IFVqr8w=3<}BI%sZKcvRvkWeEa$G#xFM6){p86TG=H9T6>muzM=6#$qx2vCjbIl`Be^}BI z7CqPTV)h*3NB^ao$2v;OFRXd8 z!|()kXfJ+rjtz&`HuI|JSczWezuqt{>fh1FcK7#obVhOMHAe1$7GZmwza_@E<%{1N z+Ll~n@7b+i-*j1goTjT|&-H#xWX~p6-Wh+Qz3q<5=D%L^T-$VOUW%1{6aCcD`V?P> zux=Gw9`Lt^!F6<6uuiP|pF zwXtdIYtgs#F;=H54a!`x+amVy)q_>8bauY6w7x{W^jZT;LQ^&ZU=D-Q+9~X%-JyYAx z&SGtZ14fUnE^Ozt{W^A#PG|?-f9;g(+ZA_E*F*)0sek+Jm*~QFWUar~{$76X-cH@N zRot2H-8)LVlizD}l!RYbmk}2}ur5c4x(=T&`K5KeqdY4*U&W>IO`Pa*B~G|a9TG>2 zlWx7-+qe(gK{=xZQ0*7>clPLG8z36Dh11sckIE7qyP{Way*;y-hlu-ruV`vdy&-LD+6Z9ab>cFAjerOdZPW zj){%0-V1NN-$0xbRGjwOQD4*gdyU?87e&*JyAHiP@{RT4xPD>=jy;cF+xn>Lu;uHz zMXlxP99takN9C%Ywydi=OUuim(k}KBbqJ?Pn{BGQCYnlz$4>NS) zg`F6avC+5LzqRLm(K^JoS!`yB+E2+Ytb3w4TC`1+AlBa;A>!#MtaaQ3=dHifNf6~n zQ3oyXZM9!(ouY#x#RbK!NpT@Ro^-YaV(ry*bdAqdr_6sf3dDk zAJ^1v_$JHk|GSPAI{Mo-yE*!5?B;}1Y45%CXddBXhjw$~qb4|8KOj~Z%d=wT?fGN% zllJ0tULI>Vr~Rv%xkrL1-}kZpeJ_84E)iI%+{wSjyo3cv_#VpokqP7eDC|-T@ILIA z=moK3y7j%Q_Xx&CV|MKreYYX@s!r(^rLkAE<4>dyw-fc-61Q%`8=EjUKlvk4Rb_r4i1XGQ{j*XuAZFZ`(S&mO6A49vkMDwKE6AM(Tt= z+-9AKsG`K!q3XCj*U#!qitX9Ii+%T8+v4bioy0`>slBPT^7Uu8mHp5p+y5>653NOZ ze0`c`Y+IV^FWI*1)K%-t{P*H*9r>@XzcV&H=Dg?P&Nr+tk3En2x$p0d`nUDiIxn40 z@Ab1-OUlO?T} zTFW{*_cyGsj}7;qS^xLg^DXK<#J%EWDtv42Na{F|>!R;Xbmp4aykq@-S@=5=Wh1op zPo*0TZymP1=ZWebn`Z0wiw%qVx0XgUUhGlnTYkg(SJm^FEpM-XOY~a5N%@=C^Iq%2 zd+Eoo=Rdy2UYXdxwK!V~Z=I@-pflHOO}FYy8m;qg@4WRhp7!E(ByVot4kE>tnftBz z8AiwZgY{k8i|-)+t^{p~(f>NSZb!6TN9phEuvk5@t9vm!vh3~U-JU!)@7OxT%6~|H ze^q@_v_2g@JLMan^k}?q^SFf_&)9eU+siw4j%Y8=_gT(2SHWoc!#2Cs`-Sy=qcZ%% zl%H?P2e!zz*B5<9Bzo*jdR=|*u`_A@|BJKLZ|XYPBz9XZ%I)C)KbEK>V>|cEH_@fvd)e0B zVfx-z@P|@?wsvj1TC>>sqBESbtLblV{O@9!wzp0@^ZFg7m+#Cvd|&eZp>z80OFtwk zr@y#HZPTVJEjqcV;Us<7yJG+tMZt*_<$(MXz)cQzf$wYI&ust)aieN*z* zb&BSr+Ar!)bZqasL~+}f@m^Z*`gXn%4POj&0 zv&9x2S$~v#hg#sr|9kh1bjG&W3+}KWJHWm|hw1+q_>HxI|4~E#_7iUy z6b_3X+mHF33K`HaVn>uGv2C?)$FAFsDF2UXeBWE3L4T{gO|=L#T(M2pCA_HNnuOvw zVdJTBr(HPtCjXCA7mD#?)aDjwn28Pfxy`3PdUkRPG#t5;3-JFm`-Wq7M8(_NYlux- z?E!Z6y@lB8fAnvE3nc2N)-v_|OROE%Xs+$O-x+&-be}gWQ^yv&DAAO*?HEmS+pg~1 zRb73Tb9>A5|L%Sm)^I}SdLmJ*hEozrzd2dMq8*iX)Mivy)ANsMIIHzF{>csJx0d}- z&bRk|`k}PLw^NUXi@u$F;&bs|-_XH+OnlrQ&uD?~2+xgvgXDk4njZO<$bYA84@G6| z{cqXm|Daa7**t-38W?8<4y#2Qto@h6lKm%WP?%1FILc@#Oo1*_h zlYZ|*WH#_MQ1sW(^7MRkR2Drpyd3p4A2S=?X!f_`VO(x(C+Hg&NAvEiJ*;oMKtF^G zW;J~HL#W11tHTeV{bKiKe&mTeZ6s|q8lHl8EzxKW*Yn&2WZU6Je>9Oa^-`@XbZ5=P+ zc?aVrVf_C$!p3>ws>a)j*EaBC=aKI~d-yrufkOOPgztO{H19Ed%O&;eHbmYf{gyNC zVs`#(jmLHIl1@kW?{CuI3dLUF|Gn{~*z*>D*Wah}Uue`CO*oClvt!aKZSk{Kyxw?8 z?78yu{}0PVX5)3O<=c9`L;pWvOd(>w^K5GutL=BLJ3^0W@7<1=Vr|_!zj4{tBXu03 zvpPs;{^urfzLEd;*>MfN@1gPk@ALa!o7nREuf?A2Sik>N+qs|rmHJ%bTpC|WEdOpx zwGGAc4$3&bvA%=Q?_^NxJ(sqHY+Bk@w(}(Z^2S$pUhY5U+`0vJ$hz4&(H(TY{rj-W zZfxUQJE%C{nPj&0|EYaj73i#eqi?G`J13u}B!5#=dVJnZL6Ovy-z*#0q<1BWH&Ds^ zSDS`)R)1_xLwPcrMv=y(I9czev2A2=5n4|CmzoM&&bsc~rZRUuo5WZAxG&ZzTg-_Y zuSL2u*MW}4Pe)-brQf+e|D6u(@Mks6)}_m8I($c!lKr8gglT0pEl4QuNSM`hW^-^> z)5$UZh0Xr(z8a z`(d#m-z~j9wq!pXf75N>g}SKc-qB~@>VG?_U&3);*|aHfG2Tb(4#w^f|2o%)ft?fh4_7IO!MYt3y3rS%OZ<5#ua({7zrXX3QJ=DT$M zO+Pd7*EBu8E%jCZ8+%)>`~P~4zp80nqPoLTY7)tJoGko5X5!OJBx{MWBc5Bdq_&fM zp{9TCq@2I;Z2iq0WYMjIuucT|?=`*NI-37h)7!0O`aG}c{ejpbwwZCG?9gys`E+`^;f2uB3l1SBZ)fySPjzXx` zND@IE$Hq<8DcIo_Fl&*dMarORK3zgni)DYP2{Oi<1HPk%9*W0g-~r3)V+WP4O*hwSw@JGVxSnpM6rER^ zn2bc|(PiqMP1U0-C6dndgeK`aW(XU9n!Ywo$4@uah!SFmmaDv^?ib3A-_>oHE~<2> zx-ZhizFMwERI09>&^1ldB|(NfUDqYu{D<-zZ{RZCaZ`0mrfGk=C_$2~$GIIDA+wOA ziGy&HBX`yz_&s6crip`b=^yQ%jvLT@5sdWma3tx58bzr>-HGYOgSbh$HK+%EPv_-@ z$AB3gEo$5fv8xVMqutegly1C@-;VTkCjNBu3EVUh(*BolQ-!)V-CTxiM9SY?NsLv> z6_4Y(I!>ByjXJ_G5FJT3aLWfzv+hVDO25RX+q5~sgd;i<4+QM;!2_F zo+i|Ml^kvv^Dp?j>6S?nqwy!{`zuZ4Yrl=_>KxPb*htfDD}=i1U0v&R<8}O=PAyFo z;wK;6fbKO{r;{8mp{ZuDbn{=hsk)cDMcU^zE&rW%|B0Iv87-6Wchl{h93Eq)8Wl-; zY^UmL1G<%x^f(OYTrzYYB}L|cbzi6HdU=sCd@~Xexy$y(?+6>$)NPfd+b2n<;OhE% zI(I|2K$1|och~n_x~XRAq{tn2GJaRLNjF^{SI;NibUNL1Yowdy#7Q^)gPW>L;)U-- zvskCIkCv-ZWawH*eQmmVE|L`8hG}9dei3PBH9}0Cj;V7rwLC-TCG?DxrTs}_guY76 z3AVo1q;{z{l5RS$WMSh^H&ski`-L7q%zQ{Z-BwcfNV@qj5;t9vbF2!nbXxRpKkt@&~)S9$~6b$3O!q<=@gR0E?T1Q z(=^@tNs((akaQDjeoNE&rbbfxvyQK3Hlf$b6x~PZ=95~YMtHhet^I1QNY!^iy3vT= z)A2o>Z@Qu8=d{RrG*pMGx+FzfChS)uP3ja(J!X=$){&&|krX{^N$r>6G)*<1B5I$rW!fv<}F&%8&~LYC3GK2-AYoI zC*4&2m8@II6ievoCB(g5JST1v>%>O!xRGSki&uZvMvn9bwDNznUkR)n=1fZvM^u#Jt`7%>3GX%-muH%(a$h zC7CZ-DORRgV+E~g=HIO8)&kB?w0>djX8qDS#~NduXRWrzS@qW6tTU{CSeuc4ZM)VI zJIPM7?zGeGbn6~_usy=M*B)t)vsT#?>l6E7`wh#t-;$a3a2b?&c9G1Nz3t=WZgMyK1UX#p zVV@{R$x-&na*P~f|4fdP6YW#vesZ$CP!`Gq?M3n+InzEK-fCYdZ{id^*Gs%9(+25IB zf94$Q9BhB#gq)DQ*_q?avH$BF>YQtT>HNz1we0I$;#?+ob5=R4#>?%pLoa4X#h<)`jy_hGr&t#Yg6m+s^4lkzKft^2$q+!x$h$8p!Y4Nj{2 zuDi)eNBI;^hG%+dP7g2L%XSL9d@tV_=oNSc&h8$6&LD5JH`*EOP4Xr=L%gZpRA;Dn zfOmj1%$x1ab%uLKc}F=Ty&|v3+0#4CTj-4P7I}-D(cXF9#m?T|rCymc$@`7>8>i5_ z*1Og@z`NdC;!N{?>n(K-_HOZ(Ifr-^UWF6#?(*()W_u5KE1e^})!u67DDM$(jdQg3 znD@AItoIl11!sX*>(x4^d5vD9v(S6T`_wtz+w5(2&P&Qp%686A8kn@Z^Q)vilSVlg zBu`47M;oI>zOlD43IBe^nZh=HVVsBm zeB)R6OASh8Twz=(vWzmL4F6TeZ$%Gdsj(FQ?~Dr4xy!g4cZKn+@Qmk-b)uK?f>A5_ z8g)jK$TT(>Z;3SHU1JmO2gV1u9~xhZJmV`fPXx@KW=~<6`DS1I{mlJ@X-+mL3&WgZ z9*8^LT!7@K=1;|*YHq@Rsd=dwVqRumM(E|{<@m2KufqQu^EbHHnAak?&ioxQmzj4W zxy!suxaM;6ZsC|K%vDH!Z$2cto2$)7L_hNn=A-!kX#SDVHRfaZ|71Rn{LkiJ@IPrj zDULFqGM_@S)_exZv*vRm-+bO&NBkGe7m&PYzDVd_&A;NWHmeD(GaGOl%_j2toB1!? zm(7<&AM+LS4g7DKZ*uKh<_GvcG(W-rsrfbjEv7FlOIR=&%e2t6tRyQ*=7c98GocW*xu7-G}@!>(a86<_vWfG_827l*!ytRSbHpzarQVQG!4#a?VL7QNNlga1ss6#oVG1)|u# z(Ecs)S&c+ctwy-4Mz|~N`*9zz9}xT7m3F0Y>{a$d#9VE!Ce4TKhso;^`w`@Su%AQz zyuA*|3-$}*82d&0-y+q1!+t{~*>Bo!ibL$T?6-;Yj{OetH`*J;VEbKrljvi=XTQf) z@7tdc`lys;YdRo!jz`8L?3BOb~GeE;Yvq3A|wM61|mHPPmn1x zh4`s5RdknWvK#(%nJxy)?y@_f84`Xjd&nO6Gi9boky$c`KU?M?&y_uipD*)8lI$gW ziP^HZ>@D_G>#oRB>#o>Ut+@Ed$?>=om5-6{pX8s!ETtJBK{G(IPOcLp_Ht636yl^hsp0@9&4D+fqom{S?sO+kwv$bq9H*xk;p98{Vye^2=_Rt9eojAe zG#bo6G2GeR87u}mL!2SvFlVSU6#p=17;^r^kIjem?Y2LC?JKH^wstTR@e;EZ?1i|Nh;XM#A_$4Fj4Fp*f+30F%JtICVryy zD*WhG!bY!REeL!V_)zo+d=&Tu|EGb^gcbNa@VQ6{d=dCUIDyT9&B(tDd?^M5z6yLT z3IhKN{7;MwYzb@;Lj!)m7b9ILn;42_W{cfj>B7z2WH&_^ZmOFqY&Xqi-FLIxEc`(? z2Y;@cC$in1Za@6}-9g9)yTkAgcSnjI?w;-_(aqhuxagRlQoO_(;?jG+h5dGYr zx+ma2(LGV*yC=CP3CAsVPa)3FUFKf*boUorb(VV${&U#?N^vi6e@&c=+%n<1SGiY- zB=>6f8nKIet$Us5<6iIHh~y@BiRkH;ySH%dGIts2-|F5)nw4%P?kaZ`HT}K&d+PY0 z`yl0C?XE`tu=_9)v{kNp+R_c{E}yU*i)!F>Vu zMfXK9%>AqTk{IAtyEUS~t#xbhuXoprecXDtUZlGXZUgcrw+VNXyGa;I>lHRyFZ|qd zJV*5K0-lFI$zulgQoIzA;-z}*>3C@#t0$Uqcd@(I!|NfYd0Ac-E?TlUNa@F-o6?Vo zGr^l6^3jsfoxI83{#=WejC{5?n>6QmbHx7MTyHKCG-g84m~l_@P7~5w=+Wj%Z$@&S zcRq10@P3VZk#`a0yx6;#TrTx46`9^;9vYW-g?9z+mEM(HTjrq+dRKesT}s0iy}awZ z>$vI$?*`l(y&JjqCU1#IL+Ac2ah7^ZN#_>t7DAVK%kZOr<1Y8^rVJ~*6_nu~?>_tw zcn{#N^j4A!8o5Y9BY%Y2{lWVKaUS*lh~!V+<4FGE(No@2-qZM>^`6E5ocEmQ<2~;^ zk7S+q0{No56TiW0B=le2zmUJ;N*qL0zf=!Zr=3C-R^v!8-CKh>Bjjy4L768vYP z>j$*1KNwyAd?dd@)K%Ca^n9pdOCV2p`CY4}2g}GzaLXIe@7-z)(0qZ`!gC zOkgNXU1k1P_MLNNAJUB=R&j$kW`w zgd4nqL#7Y4$dYwr*D*jIB8!!B&aH;UN1it>%0u!A*QOw_Dn z5Uhjs(w+zF=%!gmmS!C%YSwX_W*zC8Zy1_g^wjL4r)C#DHM{7k*@d)M+AGC5F&x8y zT|5GFXyFxx<`t)DUeR6iiewnYTO!^54~!y7Gm5=6qu32b@jf;B!2SR(@uB^p$g@AP zKSJ^`>>>|#@frTl;TR4aV>AB$+W*D>rTr!Tuk5eze{FwFn*X!^hh&Sr1&I$EF*O@W z(rm=gY-BghM)uZh#L{fUgpCAX5w7Ivu1u2b?#pC2$r#N^Jk3cAI0>31Y@~-UG!HS^ z=L;gqmf1*hU?W3eBe`6gC-aafPLixS$?=+#Owyd>R5;0SagrPXGdWo82%;q`esa3z zCkJVMGEMW70h*so)%;|ZW+rE7W-?VygPBazoMfuzB(pRpDbbwdXKDu#t(_f2Ty_v~ z6(`BioTP{5BpI5M6lhM8p*cwp%}Fw(;v@x9v5`W}Mh?|%q)@YwLp2+jtl7x^nvG1> zY-E4UMkZ@EvcF~{d&%W;Ir*|HiOa4eu3{r+O2tM7NX14*Xf~1o8>u44$KWK#Xiid~ zIZ1}*Bn6t2jMtoGAI(X|YECjmi+CD`Tdi@#r>KYqneMreLALi3XmnxE{Y z`N;sa?<=NjelkGwlfLkiO(G}oUf?~^C-4DWC0lcqOt{KNA`h$pU820yM569qr7FX80-##y<}_llBwBCD(qz(oMF5>UhLscfXTR;$qdp=W_QhG z@->r5(M+bFW-=yBW**$&2$+nanM{ApWOmU^#?(y4(@bUvOy)$`QIx|J!-W)wG2t+0 zaqTbRFiD!jq{na=OLLfH&0z*=4zru)Fv*(31T}}TG>7rvFt>{SZUs!n(M%?wnM|)3 zCX*k-VGPY-OwC~o_b+f5Q*)S}F&w668xAv8bC}_p!wl0LX1L}sg_^@;Xbv-sJ^Od5 zsp2pPXb#gC4r7WOln(ZlJT{O-w&pXL@R@+fgU=+PXD7pIk~FJHht=>LTl1Mcy$mly zOwg?62rtvi#ILx`5nj;C#;+KTt)2sjEX{LLHP6XZ&jHZXc@99Dis7VdhLfimj?@gt zjA1zGF$~Al3?~VOQz}wnI2TazU&C_-Yo60v^PE)8bMn2*J@&gb&*=@%DT9IV41tm; zhBJs~2-k~=n&I@;3@24HoD|J%@?*G7zUDUhJX?T;!f;kl=6k$*X_Nb4IC+}k7%>bd zSu-3%Gn^#NaFV?>-WoBKrx1^Em0~-FW;>>4JBIfRY{%4WXE)7u4A{;(G0J-Z&Xc1# zPqKPmL0+4@_bB;C-p3+ebDLpsn}K4lq}^dS12n@a&mZC zV-;8ftr0v;oMcV2bF5itsK;0*SqrVRtWxVT>l$>-+pHDVD(g|}3F|ql27U5Xw8jsu z%{CJXJJGuzd+aHT?W2!Z?&9N*J>#d=6~`a{^M!Wl@n??M!@34{q;(VSp4QE{qpUk{ z_p(;tj<#0f?rp8c9b>J*-N$<3_%rtyVLgMphxH=vNUIKaPiq72DC^|~rxc%PFIaHu zX+O8#IAPJT$J^JOa@z5ySR1wbp>{t%wdCiEtgla7wBS_xl!ctw<{7GN?it0Wo?xe) zamI)}>`dH|b{_7Yb^-1vdjRfU_7L3B_DI~l?J>Ax?1^WT9CwC2<&2VrXV}vhtJG$l z{nJIKp=FL{+hL@z_TO)Afql`zhs-FjH_Sfd-~u^B_pgyZOwkHqWPct$mq)@sIrq*D zpSxGXTscpFLv1RfRXq|um-E8MwEH9S`y=wg@Ua_vPC9;iI6YYu2_M#c)Zy$Z==>y4 zY*c)-)988l_(b@eT}XZIu)?T#fgzFOCE+7`Us}%2R`@tOay%tckGk+V<LdZlqsQNJFLLUJ^bgUmZSX@?=BFvv~5MjyaL^qx~z# zhUIcviW!V_;$Um=wv^S`MV}zg;*1B!@+L>=qif`gDtGqJRQ+eTU*jI^*|;;=0W-uQtOqw9B&lvTyH$?q23hSdEN}%!_@AUINUoL_XwSG5ACLDH&wed9ihD6hdV*L`)jv* zU@?*@0afFvx*ZAwsy7Y@s4+7wa5C!%n%bw1qs3T# zK1odGXp7-ugxHf^9-hc>r%Bx!9`9+W_faz056TgFBA-2?0``FVivePw7$m}DdqUdH zX{*xKrfo>u#EVp4cFXK`Tl%@(FG@@4Hn7_x={Iy=me#x5_--$z-{1Yw)LCgGy3I

!+-?iHy`H`{?S!=5pudjt1=3|0 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⁣dUi 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

=;034=|=!0qfLoVZ{M)EKH3o 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 literal 0 HcmV?d00001 diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 2301b63b..349b9083 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1,4 +1,12 @@ { + "nowPlayingVolume": "Volume", + "@nowPlayingVolume": { + "description": "System volume control label in the Mornye player" + }, + "appearanceStyle": "App Style", + "@appearanceStyle": { + "description": "Appearance section for choosing the app visual style" + }, "discordRichPresence": "Discord Rich Presence", "discordPresenceDescription": "Share the track playing in the built-in player on Discord. Requires Discord to be installed and signed in.", "discordPresenceMissing": "Install Discord and sign in, then turn this setting off and on again.", @@ -6601,6 +6609,9 @@ "@metadataCopyField": { "description": "Metadata menu action that copies the selected key and value" }, + "mornyeShowTabs": "Show tabs", + "mornyeSearch": "Search", + "mornyeContinuePlaying": "Continue Playing", "metadataCopyAll": "Copy all metadata", "@metadataCopyAll": { "description": "Metadata menu action that copies every visible key and value" @@ -6613,6 +6624,15 @@ "@optionsEmbeddedCoverSizeDescription": { "description": "Description shown in the embedded cover size picker" }, + "mornyeFavorite": "Favorite", + "mornyeGoToArtist": "Go to Artist", + "@mornyeFavorite": { + "description": "Compact context menu action that adds a track to Loved" + }, + "mornyeFavorited": "Favorited", + "@mornyeFavorited": { + "description": "Selected state of the compact favorite action" + }, "optionsEmbeddedCoverSizeOriginal": "Original resolution", "@optionsEmbeddedCoverSizeOriginal": { "description": "Option that preserves the provider artwork at its original resolution" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index edad0b4e..16f4e6c3 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -1,4 +1,12 @@ { + "nowPlayingVolume": "Volume", + "@nowPlayingVolume": { + "description": "Label kontrol volume sistem di pemutar Mornye" + }, + "appearanceStyle": "Gaya Aplikasi", + "@appearanceStyle": { + "description": "Bagian tampilan untuk memilih gaya visual aplikasi" + }, "discordRichPresence": "Discord Rich Presence", "discordPresenceDescription": "Bagikan lagu yang diputar di pemutar bawaan ke Discord. Discord harus terpasang dan sudah login.", "discordPresenceMissing": "Pasang Discord dan login, lalu matikan dan nyalakan kembali pengaturan ini.", @@ -6230,5 +6238,11 @@ "nowPlayingRepeatAll": "Repeat all", "nowPlayingRepeatOne": "Repeat one", "queueNetworkFailedOffline": "{count} downloads failed while offline", - "libraryFilterMetadataMissingIsrc": "Missing ISRC" + "libraryFilterMetadataMissingIsrc": "Missing ISRC", + "mornyeShowTabs": "Tampilkan tab", + "mornyeSearch": "Cari", + "mornyeContinuePlaying": "Putar Berikutnya", + "mornyeFavorite": "Favorit", + "mornyeGoToArtist": "Buka Artis", + "mornyeFavorited": "Difavoritkan" } diff --git a/lib/main.dart b/lib/main.dart index d8b6f61b..4c133297 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'package:crypto/crypto.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -37,6 +38,11 @@ void main() { runZonedGuarded( () async { WidgetsFlutterBinding.ensureInitialized(); + LicenseRegistry.addLicense(() async* { + yield LicenseEntryWithLineBreaks(const [ + 'Inter', + ], await rootBundle.loadString('assets/fonts/Inter-LICENSE.txt')); + }); _startupBenchmark?.observeFirstFrame(); final previousOnError = FlutterError.onError; diff --git a/lib/models/theme_settings.dart b/lib/models/theme_settings.dart index f2a6154c..46cc2f93 100644 --- a/lib/models/theme_settings.dart +++ b/lib/models/theme_settings.dart @@ -4,6 +4,15 @@ const String kThemeModeKey = 'theme_mode'; const String kUseDynamicColorKey = 'use_dynamic_color'; const String kSeedColorKey = 'seed_color'; const String kUseAmoledKey = 'use_amoled'; +const String kThemeStyleKey = 'theme_style'; + +enum AppThemeStyle { material, mornye } + +AppThemeStyle themeStyleFromString(String? value) => + AppThemeStyle.values.firstWhere( + (style) => style.name == value, + orElse: () => AppThemeStyle.material, + ); /// Default Spotify green color for fallback const int kDefaultSeedColor = 0xFF1DB954; @@ -13,12 +22,14 @@ class ThemeSettings { final bool useDynamicColor; final int seedColorValue; final bool useAmoled; + final AppThemeStyle style; const ThemeSettings({ this.themeMode = ThemeMode.system, this.useDynamicColor = true, this.seedColorValue = kDefaultSeedColor, this.useAmoled = false, + this.style = AppThemeStyle.material, }); Color get seedColor => Color(seedColorValue); @@ -28,12 +39,14 @@ class ThemeSettings { bool? useDynamicColor, int? seedColorValue, bool? useAmoled, + AppThemeStyle? style, }) { return ThemeSettings( themeMode: themeMode ?? this.themeMode, useDynamicColor: useDynamicColor ?? this.useDynamicColor, seedColorValue: seedColorValue ?? this.seedColorValue, useAmoled: useAmoled ?? this.useAmoled, + style: style ?? this.style, ); } @@ -42,6 +55,7 @@ class ThemeSettings { kUseDynamicColorKey: useDynamicColor, kSeedColorKey: seedColorValue, kUseAmoledKey: useAmoled, + kThemeStyleKey: style.name, }; factory ThemeSettings.fromJson(Map json) { @@ -50,6 +64,7 @@ class ThemeSettings { useDynamicColor: json[kUseDynamicColorKey] as bool? ?? true, seedColorValue: json[kSeedColorKey] as int? ?? kDefaultSeedColor, useAmoled: json[kUseAmoledKey] as bool? ?? false, + style: themeStyleFromString(json[kThemeStyleKey] as String?), ); } @@ -60,7 +75,8 @@ class ThemeSettings { other.themeMode == themeMode && other.useDynamicColor == useDynamicColor && other.seedColorValue == seedColorValue && - other.useAmoled == useAmoled; + other.useAmoled == useAmoled && + other.style == style; } @override @@ -68,7 +84,8 @@ class ThemeSettings { themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ - useAmoled.hashCode; + useAmoled.hashCode ^ + style.hashCode; } ThemeMode themeModeFromString(String? value) { diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 0d6ad0a5..64f263ea 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -194,12 +194,18 @@ class ExtensionNotifier extends Notifier { lyricsFetchOptions: snapshot.lyricsFetchOptions, allowedDirectories: allowedDirectories, ); - await loadExtensions(extensionsDir); + final loaded = await loadExtensions(extensionsDir); final loadError = state.error; - if (loadError != null) throw StateError(loadError); + if (!loaded) { + throw StateError(loadError ?? 'Failed to load installed extensions'); + } await loadProviderPriority(); await loadMetadataProviderPriority(); - state = state.copyWith(isInitialized: true, isLoading: false); + state = state.copyWith( + isInitialized: true, + isLoading: false, + error: loadError, + ); await _syncDownloadDirectory(ref.read(settingsProvider)); _log.i('Extension system initialized'); } catch (e) { @@ -234,17 +240,32 @@ class ExtensionNotifier extends Notifier { } } - Future loadExtensions(String dirPath) async { + Future loadExtensions(String dirPath) async { state = state.copyWith(isLoading: true, error: null); try { final result = await PlatformBridge.loadExtensionsFromDir(dirPath); _log.d('Load extensions result: $result'); + final loadErrors = (result['errors'] as List? ?? const []) + .whereType() + .where((error) => error.trim().isNotEmpty) + .toList(); await refreshExtensions(); - state = state.copyWith(isLoading: false, error: state.error); + final refreshError = state.error; + state = state.copyWith( + isLoading: false, + error: + refreshError ?? (loadErrors.isEmpty ? null : loadErrors.join('\n')), + ); + // Native loading can succeed overall while individual packages fail. + // Keep working extensions usable, but do not treat an unreadable install + // directory as an empty, successfully initialized extension system. + return refreshError == null && + (loadErrors.isEmpty || state.extensions.isNotEmpty); } catch (e) { _log.e('Failed to load extensions: $e'); state = state.copyWith(isLoading: false, error: e.toString()); + return false; } } diff --git a/lib/providers/music_player_provider.dart b/lib/providers/music_player_provider.dart index 98d6f13e..b0f58be7 100644 --- a/lib/providers/music_player_provider.dart +++ b/lib/providers/music_player_provider.dart @@ -1,5 +1,7 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/models/unified_library_item.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/music_player_service.dart'; @@ -8,6 +10,49 @@ final currentMediaItemProvider = StreamProvider((ref) { return musicPlayerMediaItemEvents(); }); +/// Use the Library's identity for favorites, including restored queues and +/// playback started from a file path rather than a Library row ID. +final playerCollectionTrackProvider = FutureProvider.autoDispose + .family((ref, item) async { + final source = item.extras?['source']?.toString() ?? ''; + if (source.isNotEmpty) { + final history = await ref + .read(downloadHistoryProvider.notifier) + .getByFilePathAsync(source); + if (history != null) { + return UnifiedLibraryItem.fromDownloadHistory(history).toTrack(); + } + } + final database = LibraryDatabase.instance; + final row = await database.getById(item.id); + if (row != null) { + return UnifiedLibraryItem.fromLocalLibrary( + LocalLibraryItem.fromJson(row), + ).toTrack(); + } + if (source.isNotEmpty) { + final matches = await database.findByTrackAndArtist( + item.title, + item.artist ?? '', + ); + for (final row in matches) { + final local = LocalLibraryItem.fromJson(row); + if (local.filePath == source) { + return UnifiedLibraryItem.fromLocalLibrary(local).toTrack(); + } + } + } + return Track( + id: item.id, + name: item.title, + artistName: item.artist ?? '', + albumName: item.album ?? '', + coverUrl: item.artUri?.toString(), + duration: item.duration?.inSeconds ?? 0, + source: 'local', + ); + }); + final playbackStateProvider = StreamProvider((ref) { return musicPlayerPlaybackStateEvents(); }); diff --git a/lib/providers/player_motion_artwork_provider.dart b/lib/providers/player_motion_artwork_provider.dart new file mode 100644 index 00000000..5e6338e9 --- /dev/null +++ b/lib/providers/player_motion_artwork_provider.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; +import 'package:spotiflac_android/utils/ttl_cache.dart'; + +typedef PlayerArtworkAlbum = ({String album, String artist}); + +final _motionCache = TtlCache>( + const Duration(minutes: 10), + maxEntries: 32, +); + +/// Resolve the same album motion artwork used by collection headers. Cache by +/// album so advancing through its tracks does not repeat metadata requests. +final playerMotionArtworkProvider = FutureProvider.autoDispose + .family((ref, album) { + if (album.album.trim().isEmpty || album.artist.trim().isEmpty) { + return null; + } + final extensions = ref.watch(extensionProvider); + final preferred = ref.watch( + settingsProvider.select((s) => s.searchProvider), + ); + final enabled = extensions.extensions + .where((extension) => extension.enabled && extension.hasCustomSearch) + .map((extension) => extension.id) + .toSet(); + final providers = { + if (preferred != null && enabled.contains(preferred)) preferred, + ...extensions.metadataProviderPriority.where(enabled.contains), + ...enabled, + }.toList(); + if (providers.isEmpty) return null; + final key = jsonEncode([album.album, album.artist, providers]); + final cached = _motionCache.get(key); + if (cached != null) return cached; + final request = findPlayerMotionArtwork( + album: album, + providerIds: providers, + search: (provider, query) => PlatformBridge.customSearchWithExtension( + provider, + query, + options: {'filter': 'album', 'limit': 8}, + ).timeout(const Duration(seconds: 6)), + loadAlbum: (provider, id) => PlatformBridge.getProviderMetadata( + provider, + 'album', + id, + ).timeout(const Duration(seconds: 6)), + ); + _motionCache.set(key, request); + return request; + }); + +Future findPlayerMotionArtwork({ + required PlayerArtworkAlbum album, + required List providerIds, + required Future>> Function( + String provider, + String query, + ) + search, + required Future> Function(String provider, String id) + loadAlbum, +}) async { + String normalized(String value) => + value.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' '); + for (final provider in providerIds) { + try { + final results = await search(provider, '${album.album} ${album.artist}'); + for (final result in results) { + final name = (result['name'] ?? result['album_name'] ?? '').toString(); + final artist = + (result['artists'] ?? + result['artist'] ?? + result['artist_name'] ?? + '') + .toString(); + // Never attach a similarly named album's video to the playing track. + if (normalized(name) != normalized(album.album) || + normalized(artist) != normalized(album.artist)) { + continue; + } + final direct = normalizeRemoteHttpUrl( + result['header_video']?.toString(), + ); + if (direct != null) return direct; + final id = (result['id'] ?? result['album_id'] ?? '').toString(); + if (id.isEmpty) continue; + final metadata = await loadAlbum(provider, id); + final info = metadata['album_info']; + final video = normalizeRemoteHttpUrl( + ((info is Map ? info['header_video'] : null) ?? + metadata['header_video']) + ?.toString(), + ); + if (video != null) return video; + } + } catch (_) { + // Offline, unsupported providers and expired links keep the static cover. + } + } + return null; +} diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index eeb36ec0..15331079 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -13,6 +13,7 @@ ThemeSettings loadBootstrapThemeSettings(SharedPreferences prefs) { useDynamicColor: prefs.getBool(kUseDynamicColorKey) ?? true, seedColorValue: prefs.getInt(kSeedColorKey) ?? kDefaultSeedColor, useAmoled: prefs.getBool(kUseAmoledKey) ?? false, + style: themeStyleFromString(prefs.getString(kThemeStyleKey)), ); } @@ -42,6 +43,7 @@ class ThemeNotifier extends Notifier { useDynamicColor: useDynamic ?? true, seedColorValue: seedColor ?? kDefaultSeedColor, useAmoled: useAmoled ?? false, + style: themeStyleFromString(prefs.getString(kThemeStyleKey)), ); } catch (e) { debugPrint('Error loading theme settings: $e'); @@ -55,6 +57,7 @@ class ThemeNotifier extends Notifier { await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor); await prefs.setInt(kSeedColorKey, state.seedColorValue); await prefs.setBool(kUseAmoledKey, state.useAmoled); + await prefs.setString(kThemeStyleKey, state.style.name); } catch (e) { debugPrint('Error saving theme settings: $e'); } @@ -65,6 +68,11 @@ class ThemeNotifier extends Notifier { await _saveToStorage(); } + Future setStyle(AppThemeStyle style) async { + state = state.copyWith(style: style); + await _saveToStorage(); + } + Future setUseDynamicColor(bool value) async { state = state.copyWith(useDynamicColor: value); await _saveToStorage(); diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 457a8ac0..d875dde5 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -562,7 +562,7 @@ class TrackNotifier extends Notifier { id: data['id'] as String? ?? '', name: data['name'] as String? ?? '', releaseDate: data['release_date'] as String? ?? '', - totalTracks: data['total_tracks'] as int? ?? 0, + totalTracks: int.tryParse(data['total_tracks']?.toString() ?? '') ?? 0, coverUrl: normalizeCoverReference( (data['cover_url'] ?? data['images'])?.toString(), ), diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index f94b853b..98145a9f 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/screens/track_history_snapshot.dart'; import 'package:spotiflac_android/widgets/collection_scaffold.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -18,7 +20,6 @@ import 'package:spotiflac_android/screens/selection_mode_mixin.dart'; import 'package:spotiflac_android/widgets/error_card.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; -import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/provider_resource_ids.dart'; import 'package:spotiflac_android/utils/ttl_cache.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; @@ -32,6 +33,7 @@ import 'package:spotiflac_android/widgets/track_detail_actions.dart'; import 'package:spotiflac_android/widgets/selection_action_button.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/widgets/downloadable_cover.dart'; +import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; class _AlbumCache { static final _cache = TtlCache>( @@ -274,6 +276,10 @@ class _AlbumScreenState extends ConsumerState Widget _buildHeaderMeta(BuildContext context, String? releaseDate) { final items = []; + final genre = _tracks?.firstOrNull?.genre?.trim(); + if (context.isMornye && genre != null && genre.isNotEmpty) { + items.add(HeaderMetaItem(genre)); + } final year = _releaseYear(releaseDate); if (year != null) items.add(HeaderMetaItem(year)); items.addAll(_audioTraitInline()); @@ -340,11 +346,24 @@ class _AlbumScreenState extends ConsumerState @override Widget build(BuildContext context) { + if (context.isMornye) { + return MornyeArtistSurface( + imageSource: + widget.coverUrl ?? _headerImageUrl ?? widget.headerImageUrl, + neutralActions: true, + child: Builder(builder: _buildPage), + ); + } + return _buildPage(context); + } + + Widget _buildPage(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final tracks = _tracks ?? []; final pageBackgroundColor = colorScheme.surface; - final bottomInset = context.navBarBottomInset; - final bottomPadding = MediaQuery.paddingOf(context).bottom; + final bottomPadding = isSelectionMode + ? MediaQuery.paddingOf(context).bottom + : 0.0; pruneSelection({ for (var index = 0; index < tracks.length; index++) @@ -356,7 +375,6 @@ class _AlbumScreenState extends ConsumerState isSelectionMode: isSelectionMode, onExitSelectionMode: exitSelectionMode, backgroundColor: pageBackgroundColor, - bottomInset: bottomInset, selectionBar: _buildSelectionBottomBar( context, colorScheme, @@ -366,10 +384,12 @@ class _AlbumScreenState extends ConsumerState appBar: _buildAppBar(context, colorScheme, pageBackgroundColor), slivers: [ if (_isLoading) - const SliverToBoxAdapter( + SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.all(16), - child: AlbumTrackListSkeleton(itemCount: 10), + padding: context.isMornye + ? EdgeInsets.zero + : const EdgeInsets.all(16), + child: const AlbumTrackListSkeleton(itemCount: 10), ), ), if (_error != null) @@ -410,7 +430,7 @@ class _AlbumScreenState extends ConsumerState motionUrl.trim().isNotEmpty && Uri.tryParse(motionUrl)?.hasAuthority == true; final coverThumbUrl = widget.coverUrl ?? _headerImageUrl; - final showSquareCover = !hasMotion; + final showSquareCover = context.isMornye || !hasMotion; final expandedHeight = calculateExpandedHeight(context); final cacheWidth = coverCacheWidthForViewport(context); final headerBgUrl = @@ -439,6 +459,8 @@ class _AlbumScreenState extends ConsumerState return AlbumDetailHeader( title: widget.albumName, + immersive: context.isMornye, + squareArtwork: !hasMotion && headerBgUrl == coverThumbUrl, expandedHeight: expandedHeight, showTitleInAppBar: showTitleInAppBar, backgroundColor: pageBackgroundColor, @@ -446,7 +468,9 @@ class _AlbumScreenState extends ConsumerState paletteSource: coverThumbUrl, blurAndScrimBackground: showSquareCover, coverBuilder: showSquareCover - ? (context, coverSize) => coverThumbUrl != null + ? (context, coverSize) => context.isMornye && hasMotion + ? background + : coverThumbUrl != null ? DownloadableCover( coverUrl: highResCoverUrl(coverThumbUrl) ?? coverThumbUrl, baseName: [ @@ -490,8 +514,10 @@ class _AlbumScreenState extends ConsumerState extensionId: widget.extensionId, style: TextStyle( color: colorScheme.primary, - fontSize: 16, - fontWeight: FontWeight.w600, + fontSize: context.isMornye ? 20 : 16, + fontWeight: context.isMornye + ? FontWeight.w400 + : FontWeight.w600, ), textAlign: TextAlign.center, maxLines: 1, @@ -501,6 +527,29 @@ class _AlbumScreenState extends ConsumerState meta: _buildHeaderMeta(context, releaseDate), actions: isSelectionMode ? null + : context.isMornye + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildLoveAllButton(), + const SizedBox(width: 16), + Flexible( + child: SizedBox( + width: 172, + child: HeaderFilledButton( + icon: CupertinoIcons.arrow_down_circle_fill, + label: context.l10n.dialogDownload, + tonal: true, + onPressed: tracks.isEmpty + ? null + : () => _downloadAll(context), + ), + ), + ), + const SizedBox(width: 16), + _buildAddToPlaylistButton(context), + ], + ) : Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -580,6 +629,11 @@ class _AlbumScreenState extends ConsumerState forceQualityPicker: forceQualityPicker, ), clickableArtist: true, + showQualityBadges: + !context.isMornye || + !_audioTraits.any( + (trait) => trait.toLowerCase().contains('lossless'), + ), isSelectionMode: isSelectionMode, isSelected: selectedIds.contains(selectionId), onToggleSelection: () => toggleSelection(selectionId), @@ -708,6 +762,7 @@ class _AlbumScreenState extends ConsumerState return HeaderCircleButton( icon: allLoved ? Icons.favorite : Icons.favorite_border, + tonal: true, iconColor: allLoved ? Theme.of(context).colorScheme.error : null, tooltip: allLoved ? context.l10n.trackOptionRemoveFromLoved @@ -721,6 +776,7 @@ class _AlbumScreenState extends ConsumerState Widget _buildAddToPlaylistButton(BuildContext context) { return HeaderCircleButton( icon: Icons.add, + tonal: true, tooltip: context.l10n.tooltipAddToPlaylist, onPressed: _tracks == null || _tracks!.isEmpty ? null diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index a821270d..a385e252 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -1,4 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/screens/track_history_snapshot.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/theme/cover_palette.dart'; @@ -480,6 +486,17 @@ class _ArtistScreenState extends ConsumerState @override Widget build(BuildContext context) { + if (context.isMornye) { + return MornyeArtistSurface( + imageSource: + _headerImageUrl ?? widget.headerImageUrl ?? widget.coverUrl, + child: Builder(builder: _buildPage), + ); + } + return _buildPage(context); + } + + Widget _buildPage(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final albums = _albums ?? []; _ensureAlbumBuckets(albums); @@ -487,7 +504,6 @@ class _ArtistScreenState extends ConsumerState final albumsOnly = _albumsOnlyBucket; final singles = _singlesBucket; final compilations = _compilationsBucket; - final bottomInset = context.navBarBottomInset; final hasDiscography = !_isLoadingDiscography && _error == null && albums.isNotEmpty; @@ -513,7 +529,7 @@ class _ArtistScreenState extends ConsumerState CustomScrollView( controller: _scrollController, slivers: [ - _buildHeader( + ..._buildHeader( context, colorScheme, albums: albums, @@ -523,10 +539,11 @@ class _ArtistScreenState extends ConsumerState SliverToBoxAdapter( child: ArtistScreenSkeleton( showCoverHeader: + !context.isMornye && (_headerImageUrl ?? - widget.headerImageUrl ?? - widget.coverUrl) == - null, + widget.headerImageUrl ?? + widget.coverUrl) == + null, showPopularSection: !widget.artistId.startsWith('deezer:') && !widget.artistId.startsWith('qobuz:') && @@ -545,13 +562,18 @@ class _ArtistScreenState extends ConsumerState ), ), if (!_isLoadingDiscography && _error == null) ...[ + if (context.isMornye && albums.isNotEmpty && !isSelectionMode) + SliverToBoxAdapter( + child: _buildMornyeFeaturedAlbum(context, albums.first), + ), if (_topTracks != null && _topTracks!.isNotEmpty) SliverToBoxAdapter( - child: _buildPopularSection(colorScheme), + child: _buildPopularSection(context, colorScheme), ), if (releases.isNotEmpty) SliverToBoxAdapter( child: _buildAlbumSection( + context, context.l10n.artistReleases, releases, colorScheme, @@ -560,6 +582,7 @@ class _ArtistScreenState extends ConsumerState if (albumsOnly.isNotEmpty) SliverToBoxAdapter( child: _buildAlbumSection( + context, context.l10n.artistAlbums, albumsOnly, colorScheme, @@ -568,6 +591,7 @@ class _ArtistScreenState extends ConsumerState if (singles.isNotEmpty) SliverToBoxAdapter( child: _buildAlbumSection( + context, context.l10n.artistSingles, singles, colorScheme, @@ -577,6 +601,7 @@ class _ArtistScreenState extends ConsumerState if (compilations.isNotEmpty) SliverToBoxAdapter( child: _buildAlbumSection( + context, context.l10n.artistCompilations, compilations, colorScheme, @@ -586,7 +611,7 @@ class _ArtistScreenState extends ConsumerState SliverToBoxAdapter( child: SizedBox(height: isSelectionMode ? 120 : 32), ), - SliverToBoxAdapter(child: SizedBox(height: bottomInset)), + const NavBarSliverSpacer(), ], ), ], @@ -695,87 +720,108 @@ class _ArtistScreenState extends ConsumerState (sum, a) => sum + a.totalTracks, ); final singleTracks = singles.fold(0, (sum, a) => sum + a.totalTracks); + final pageContext = context; + + Widget buildSheet(BuildContext context) => AppBottomSheet( + maxHeightFactor: 0.85, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Row( + children: [ + if (!context.isMornye) ...[ + Icon(Icons.download, color: colorScheme.primary), + const SizedBox(width: 12), + ], + Expanded( + child: Text( + context.l10n.discographyDownload, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: context.isMornye + ? FontWeight.w600 + : FontWeight.bold, + ), + ), + ), + ], + ), + ), + const Divider(height: 1), + if (albums.isNotEmpty) + _DiscographyOptionTile( + icon: Icons.library_music, + title: context.l10n.discographyDownloadAll, + subtitle: context.l10n.discographyDownloadAllSubtitle( + totalTracks, + albums.length, + ), + onTap: () { + Navigator.pop(context); + _downloadAlbums(pageContext, albums); + }, + ), + if (albumsOnly.isNotEmpty) + _DiscographyOptionTile( + icon: Icons.album, + title: context.l10n.discographyAlbumsOnly, + subtitle: context.l10n.discographyAlbumsOnlySubtitle( + albumTracks, + albumsOnly.length, + ), + onTap: () { + Navigator.pop(context); + _downloadAlbums(pageContext, albumsOnly); + }, + ), + if (singles.isNotEmpty) + _DiscographyOptionTile( + icon: Icons.music_note, + title: context.l10n.discographySinglesOnly, + subtitle: context.l10n.discographySinglesOnlySubtitle( + singleTracks, + singles.length, + ), + onTap: () { + Navigator.pop(context); + _downloadAlbums(pageContext, singles); + }, + ), + _DiscographyOptionTile( + icon: Icons.checklist, + title: context.l10n.discographySelectAlbums, + subtitle: context.l10n.discographySelectAlbumsSubtitle, + showDivider: false, + onTap: () { + Navigator.pop(context); + enterSelectionMode(albums.first.id); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); showModalBottomSheet( context: context, useRootNavigator: true, - backgroundColor: colorScheme.surfaceContainerHigh, - builder: (context) => SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const AppSheetHandle(), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), - child: Row( - children: [ - Icon(Icons.download, color: colorScheme.primary), - const SizedBox(width: 12), - Text( - context.l10n.discographyDownload, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), + isScrollControlled: true, + backgroundColor: context.isMornye + ? Colors.transparent + : colorScheme.surfaceContainerHigh, + builder: (sheetContext) => sheetContext.isMornye + ? Theme( + data: MornyeTheme.build(Brightness.dark), + child: MornyeGlassPanel( + tintOpacity: 0.78, + child: Builder(builder: buildSheet), ), - const Divider(height: 1), - if (albums.isNotEmpty) - _DiscographyOptionTile( - icon: Icons.library_music, - title: context.l10n.discographyDownloadAll, - subtitle: context.l10n.discographyDownloadAllSubtitle( - totalTracks, - albums.length, - ), - onTap: () { - Navigator.pop(context); - _downloadAlbums(context, albums); - }, - ), - if (albumsOnly.isNotEmpty) - _DiscographyOptionTile( - icon: Icons.album, - title: context.l10n.discographyAlbumsOnly, - subtitle: context.l10n.discographyAlbumsOnlySubtitle( - albumTracks, - albumsOnly.length, - ), - onTap: () { - Navigator.pop(context); - _downloadAlbums(context, albumsOnly); - }, - ), - if (singles.isNotEmpty) - _DiscographyOptionTile( - icon: Icons.music_note, - title: context.l10n.discographySinglesOnly, - subtitle: context.l10n.discographySinglesOnlySubtitle( - singleTracks, - singles.length, - ), - onTap: () { - Navigator.pop(context); - _downloadAlbums(context, singles); - }, - ), - _DiscographyOptionTile( - icon: Icons.checklist, - title: context.l10n.discographySelectAlbums, - subtitle: context.l10n.discographySelectAlbumsSubtitle, - onTap: () { - Navigator.pop(context); - enterSelectionMode(albums.first.id); - }, - ), - const SizedBox(height: 8), - ], - ), - ), - ), + ) + : buildSheet(sheetContext), ); } @@ -846,7 +892,7 @@ class _ArtistScreenState extends ConsumerState } final progressDialogKey = GlobalKey<_FetchingProgressDialogState>(); - showDialog( + showAppDialog( context: context, barrierDismissible: false, builder: (ctx) => _FetchingProgressDialog( diff --git a/lib/screens/artist_screen_widgets.dart b/lib/screens/artist_screen_widgets.dart index ab5d4914..fd4cd190 100644 --- a/lib/screens/artist_screen_widgets.dart +++ b/lib/screens/artist_screen_widgets.dart @@ -2,7 +2,7 @@ part of 'artist_screen.dart'; extension _ArtistScreenSections on _ArtistScreenState { - Widget _buildHeader( + List _buildHeader( BuildContext context, ColorScheme colorScheme, { required List albums, @@ -50,22 +50,92 @@ extension _ArtistScreenSections on _ArtistScreenState { ), ); - return CoverPaletteBuilder( - imageSource: hasValidImage ? imageUrl : null, - builder: (context, headerScheme) => _buildArtistAppBar( - context, - colorScheme, - headerScheme, - albums: albums, - hasDiscography: hasDiscography, - imageUrl: imageUrl, - hasValidImage: hasValidImage, - headerVideoUrl: headerVideoUrl, - hasMotionBanner: hasMotionBanner, - listenersText: listenersText, - isFavoriteArtist: isFavoriteArtist, + if (context.isMornye) { + final artwork = hasValidImage + ? DownloadableCover( + coverUrl: imageUrl, + baseName: widget.artistName, + child: CachedCoverImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + memCacheWidth: 800, + errorWidget: (_, _, _) => + const Center(child: Icon(CupertinoIcons.person, size: 80)), + ), + ) + : const Center(child: Icon(CupertinoIcons.person, size: 80)); + return MornyeArtistHeader( + name: widget.artistName, + listeners: listenersText, + showTitle: _showTitleInAppBar, + artwork: hasMotionBanner + ? MotionHeaderBanner(videoUrl: headerVideoUrl, fallback: artwork) + : artwork, + actions: isSelectionMode + ? const [] + : [ + HeaderCircleButton( + icon: isFavoriteArtist + ? CupertinoIcons.heart_fill + : CupertinoIcons.heart, + iconColor: isFavoriteArtist + ? colorScheme.primary + : Colors.white, + tooltip: isFavoriteArtist + ? context.l10n.artistOptionRemoveFromFavorites + : context.l10n.artistOptionAddToFavorites, + onPressed: () => _toggleFavoriteArtist(context), + ), + if (_isLoadingDiscography) + const ShimmerLoading( + child: SkeletonBox(width: 60, height: 60, borderRadius: 30), + ) + else if (hasDiscography) + CupertinoButton( + padding: EdgeInsets.zero, + borderRadius: BorderRadius.circular(30), + color: Colors.white, + onPressed: () => + _showDiscographyOptions(context, colorScheme, albums), + child: SizedBox.square( + dimension: 60, + child: Semantics( + label: context.l10n.discographyDownload, + child: const Icon( + CupertinoIcons.arrow_down, + size: 26, + color: Colors.black, + ), + ), + ), + ), + HeaderCircleButton( + icon: CupertinoIcons.ellipsis, + tooltip: context.l10n.openInOtherServices, + onPressed: () => _showShareSheet(context), + ), + ], + ).buildSlivers(context); + } + return [ + CoverPaletteBuilder( + imageSource: hasValidImage ? imageUrl : null, + builder: (context, headerScheme) => _buildArtistAppBar( + context, + colorScheme, + headerScheme, + albums: albums, + hasDiscography: hasDiscography, + imageUrl: imageUrl, + hasValidImage: hasValidImage, + headerVideoUrl: headerVideoUrl, + hasMotionBanner: hasMotionBanner, + listenersText: listenersText, + isFavoriteArtist: isFavoriteArtist, + ), ), - ); + ]; } Widget _buildArtistAppBar( @@ -305,7 +375,82 @@ extension _ArtistScreenSections on _ArtistScreenState { ); } - Widget _buildPopularSection(ColorScheme colorScheme) { + Widget _buildMornyeFeaturedAlbum(BuildContext context, ArtistAlbum album) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 28, 20, 0), + child: Material( + color: Colors.white.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => _navigateToAlbum(album), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(5), + child: SizedBox.square( + dimension: 76, + child: album.coverUrl != null + ? CachedCoverImage( + imageUrl: album.coverUrl!, + fit: BoxFit.cover, + ) + : const Icon(CupertinoIcons.square_stack, size: 32), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (album.releaseDate.isNotEmpty) + Text( + album.releaseDate.split('-').first, + style: TextStyle( + fontSize: 12, + color: Colors.white.withValues(alpha: 0.7), + ), + ), + const SizedBox(height: 4), + Text( + album.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + if (album.totalTracks > 0) ...[ + const SizedBox(height: 4), + Text( + context.l10n.tracksCount(album.totalTracks), + style: TextStyle( + fontSize: 12, + color: Colors.white.withValues(alpha: 0.7), + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + Icon( + CupertinoIcons.chevron_forward, + size: 14, + color: Colors.white.withValues(alpha: 0.7), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildPopularSection(BuildContext context, ColorScheme colorScheme) { if (_topTracks == null || _topTracks!.isEmpty) { return const SizedBox.shrink(); } @@ -425,7 +570,7 @@ extension _ArtistScreenSections on _ArtistScreenState { final isQueued = queueItem != null; - return InkWell( + final row = InkWell( onTap: () => _handlePopularTrackTap( track, isQueued: isQueued, @@ -439,20 +584,22 @@ extension _ArtistScreenSections on _ArtistScreenState { hasLocalPlaybackCandidate: isInHistory || isInLocalLibrary, ), child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 10, 8), + padding: EdgeInsets.fromLTRB(context.isMornye ? 20 : 16, 8, 10, 8), child: Row( children: [ - SizedBox( - width: 24, - child: Text( - '$rank', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, + if (!context.isMornye) ...[ + SizedBox( + width: 24, + child: Text( + '$rank', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, ), - ), - const SizedBox(width: 12), + const SizedBox(width: 12), + ], track.coverUrl != null ? DownloadableCover( coverUrl: track.coverUrl, @@ -499,7 +646,10 @@ extension _ArtistScreenSections on _ArtistScreenState { Text( track.name, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, + fontSize: context.isMornye ? 17 : null, + fontWeight: context.isMornye + ? FontWeight.w400 + : FontWeight.w500, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -520,6 +670,7 @@ extension _ArtistScreenSections on _ArtistScreenState { style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: colorScheme.onSurfaceVariant, + fontSize: context.isMornye ? 15 : null, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -543,6 +694,22 @@ extension _ArtistScreenSections on _ArtistScreenState { ), ), ); + if (!context.isMornye) return row; + return Stack( + children: [ + row, + Positioned( + left: 80, + right: 20, + bottom: 0, + child: Divider( + height: 1, + thickness: 0.5, + color: Colors.white.withValues(alpha: 0.12), + ), + ), + ], + ); }, ); } @@ -607,6 +774,7 @@ extension _ArtistScreenSections on _ArtistScreenState { } Widget _buildAlbumSection( + BuildContext context, String title, List albums, ColorScheme colorScheme, { @@ -638,6 +806,7 @@ extension _ArtistScreenSections on _ArtistScreenState { return KeyedSubtree( key: ValueKey(album.id), child: _buildAlbumCard( + context, album, colorScheme, tileSize: tileSize, @@ -653,6 +822,7 @@ extension _ArtistScreenSections on _ArtistScreenState { } Widget _buildAlbumCard( + BuildContext context, ArtistAlbum album, ColorScheme colorScheme, { required double tileSize, @@ -866,17 +1036,62 @@ class _DiscographyOptionTile extends StatelessWidget { final String title; final String subtitle; final VoidCallback onTap; + final bool showDivider; const _DiscographyOptionTile({ required this.icon, required this.title, required this.subtitle, required this.onTap, + this.showDivider = true, }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + if (context.isMornye) { + final textTheme = Theme.of(context).textTheme; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CupertinoButton( + padding: const EdgeInsets.symmetric(vertical: 16), + onPressed: onTap, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: textTheme.bodyLarge), + const SizedBox(height: 4), + Text( + subtitle, + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withValues( + alpha: 0.72, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 16), + Icon( + mornyeIconFor(icon), + size: 22, + color: colorScheme.onSurface, + ), + ], + ), + ), + if (showDivider) const Divider(height: 0.5, thickness: 0.5), + ], + ), + ); + } return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), leading: Container( @@ -937,7 +1152,7 @@ class _FetchingProgressDialogState extends State<_FetchingProgressDialog> { final colorScheme = Theme.of(context).colorScheme; final progress = _total > 0 ? _current / _total : 0.0; - return AlertDialog( + return AppAlertDialog( backgroundColor: colorScheme.surfaceContainerHigh, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), content: Column( @@ -985,7 +1200,7 @@ class _FetchingProgressDialogState extends State<_FetchingProgressDialog> { ], ), actions: [ - TextButton( + AppDialogAction( onPressed: widget.onCancel, child: Text(context.l10n.dialogCancel), ), diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 9ed61c07..52cd60e0 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/theme/cover_palette.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -16,7 +17,6 @@ import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart'; import 'package:spotiflac_android/utils/cover_art_utils.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; -import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; @@ -34,6 +34,7 @@ import 'package:spotiflac_android/widgets/selection_action_button.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/widgets/disc_separator_chip.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; +import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; class DownloadedAlbumScreen extends ConsumerStatefulWidget { final String albumName; @@ -250,13 +251,6 @@ class _DownloadedAlbumScreenState extends ConsumerState @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final qualityLabelMode = ref.watch( - settingsProvider.select((s) => s.libraryQualityLabelMode), - ); - final bottomPadding = MediaQuery.paddingOf(context).bottom; - final bottomInset = context.navBarBottomInset; - final tracksValue = ref.watch( downloadedAlbumTracksProvider( DownloadedAlbumTracksRequest( @@ -286,6 +280,25 @@ class _DownloadedAlbumScreenState extends ConsumerState pruneSelection(tracks.map((t) => t.id).toSet()); + if (context.isMornye) { + return MornyeArtistSurface( + imageSource: _resolveAlbumEmbeddedCoverPath(tracks) ?? widget.coverUrl, + neutralActions: true, + child: Builder(builder: (context) => _buildPage(context, tracks)), + ); + } + return _buildPage(context, tracks); + } + + Widget _buildPage(BuildContext context, List tracks) { + final colorScheme = Theme.of(context).colorScheme; + final qualityLabelMode = ref.watch( + settingsProvider.select((s) => s.libraryQualityLabelMode), + ); + final bottomPadding = isSelectionMode + ? MediaQuery.paddingOf(context).bottom + : 0.0; + return CollectionScaffold( scrollController: scrollController, isSelectionMode: isSelectionMode, @@ -298,7 +311,6 @@ class _DownloadedAlbumScreenState extends ConsumerState tracks, bottomPadding, ), - bottomInset: bottomInset, ); } @@ -363,6 +375,7 @@ class _DownloadedAlbumScreenState extends ConsumerState return AlbumDetailHeader( title: widget.albumName, + immersive: context.isMornye, expandedHeight: expandedHeight, showTitleInAppBar: showTitleInAppBar, background: background, @@ -379,9 +392,11 @@ class _DownloadedAlbumScreenState extends ConsumerState subtitle: Text( widget.artistName, style: TextStyle( - color: HeaderPalette.of(context).onSurfaceVariant, - fontSize: 16, - fontWeight: FontWeight.w600, + color: context.isMornye + ? colorScheme.primary + : HeaderPalette.of(context).onSurfaceVariant, + fontSize: context.isMornye ? 20 : 16, + fontWeight: context.isMornye ? FontWeight.w400 : FontWeight.w600, ), textAlign: TextAlign.center, maxLines: 1, diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 8b38b91d..9633c0d5 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -4,7 +4,14 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:spotiflac_android/screens/track_history_snapshot.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; +import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -26,6 +33,7 @@ import 'package:spotiflac_android/services/csv_import_service.dart'; import 'package:spotiflac_android/services/cover_download_service.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/extension_auth_launcher.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; @@ -42,7 +50,7 @@ import 'package:spotiflac_android/widgets/cached_cover_image.dart'; import 'package:spotiflac_android/widgets/error_card.dart'; import 'package:spotiflac_android/widgets/in_library_badge.dart'; import 'package:spotiflac_android/widgets/preview_button.dart'; -import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/widgets/app_search_field.dart'; import 'package:spotiflac_android/widgets/view_queue_snackbar_action.dart'; import 'package:spotiflac_android/widgets/downloadable_cover.dart'; @@ -64,6 +72,7 @@ class _HomeTabState extends ConsumerState final _historySnapshot = TrackHistorySnapshot(); final _urlController = TextEditingController(); final FocusNode _searchFocusNode = FocusNode(); + final _homeScrollController = ScrollController(); String? _lastSearchQuery; String? _activeSearchInput; bool _isResettingSearchSurface = false; @@ -157,6 +166,9 @@ class _HomeTabState extends ConsumerState super.initState(); _urlController.addListener(_onSearchChanged); _searchFocusNode.addListener(_onSearchFocusChanged); + ShellNavigationService.homeSearchRequests.addListener( + _focusSearchFromShell, + ); // Run an initial fetch check in case extensions were already initialized // before HomeTab was mounted (e.g. auto-installed during first setup). @@ -227,6 +239,10 @@ class _HomeTabState extends ConsumerState @override void dispose() { + ShellNavigationService.homeSearchRequests.removeListener( + _focusSearchFromShell, + ); + _homeScrollController.dispose(); _liveSearchDebounce?.cancel(); _trackStateSub.close(); _extensionInitSub.close(); @@ -336,6 +352,14 @@ class _HomeTabState extends ConsumerState } } + void _focusSearchFromShell() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (_homeScrollController.hasClients) _homeScrollController.jumpTo(0); + _searchFocusNode.requestFocus(); + }); + } + void _onTrackStateChanged(TrackState? previous, TrackState next) { if (previous != null && !next.hasContent && @@ -743,7 +767,6 @@ class _HomeTabState extends ConsumerState trackProvider.select((s) => s.isShowingRecentAccess), ); final screenHeight = MediaQuery.sizeOf(context).height; - final bottomInset = context.navBarBottomInset; final hasHistoryItems = ref.watch( _homeHistoryPreviewProvider.select((items) => items.isNotEmpty), ); @@ -833,6 +856,7 @@ class _HomeTabState extends ConsumerState onRefresh: () => ref.read(exploreProvider.notifier).refresh(), notificationPredicate: (notification) => showExplore, child: CustomScrollView( + controller: _homeScrollController, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, slivers: [ AppSliverHeader.tabRoot(title: context.l10n.homeTitle), @@ -860,7 +884,7 @@ class _HomeTabState extends ConsumerState 16, (hasResults || showExplore) ? 8 : 16, ), - child: _buildSearchBar(colorScheme), + child: _buildSearchBar(), ), ), @@ -1044,7 +1068,7 @@ class _HomeTabState extends ConsumerState ); }, ), - SliverToBoxAdapter(child: SizedBox(height: bottomInset)), + const NavBarSliverSpacer(), ], ), ), @@ -1096,6 +1120,11 @@ class _HomeTabState extends ConsumerState ); } + // The Mornye style uses the navigation title as the page identity. Keep + // the search surface directly beneath it instead of repeating app branding + // in the content area. + if (context.isMornye) return const SizedBox.shrink(); + return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( diff --git a/lib/screens/home_tab_import.dart b/lib/screens/home_tab_import.dart index b25850e1..845af69f 100644 --- a/lib/screens/home_tab_import.dart +++ b/lib/screens/home_tab_import.dart @@ -17,7 +17,7 @@ extension _HomeTabCsvImport on _HomeTabState { if (progressDialogInitialized || !mounted) return; progressDialogInitialized = true; progressDialogVisible = true; - showDialog( + showAppDialog( context: this.context, useRootNavigator: false, barrierDismissible: false, @@ -25,7 +25,7 @@ extension _HomeTabCsvImport on _HomeTabState { builder: (dialogCtx, setState) { progressDialogContext = dialogCtx; setDialogState = setState; - return AlertDialog( + return AppAlertDialog( content: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -88,13 +88,13 @@ extension _HomeTabCsvImport on _HomeTabState { final l10n = this.context.l10n; - final options = await showDialog<_CsvImportOptions>( + final options = await showAppDialog<_CsvImportOptions>( context: this.context, useRootNavigator: false, builder: (dialogCtx) { var skipDownloaded = true; return StatefulBuilder( - builder: (dialogCtx, setDialogState) => AlertDialog( + builder: (dialogCtx, setDialogState) => AppAlertDialog( title: Text(l10n.dialogImportPlaylistTitle), content: Column( mainAxisSize: MainAxisSize.min, @@ -102,20 +102,30 @@ extension _HomeTabCsvImport on _HomeTabState { children: [ Text(l10n.dialogImportPlaylistMessage(tracks.length)), const SizedBox(height: 12), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - title: Text(l10n.homeSkipAlreadyDownloaded), - value: skipDownloaded, - onChanged: (value) { - setDialogState(() { - skipDownloaded = value ?? true; - }); - }, - ), + if (dialogCtx.isMornye) + AppSwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.homeSkipAlreadyDownloaded), + value: skipDownloaded, + onChanged: (value) => setDialogState(() { + skipDownloaded = value; + }), + ) + else + CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.homeSkipAlreadyDownloaded), + value: skipDownloaded, + onChanged: (value) { + setDialogState(() { + skipDownloaded = value ?? true; + }); + }, + ), ], ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop( dialogCtx, const _CsvImportOptions( @@ -125,7 +135,8 @@ extension _HomeTabCsvImport on _HomeTabState { ), child: Text(l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, onPressed: () => Navigator.pop( dialogCtx, _CsvImportOptions( diff --git a/lib/screens/home_tab_recent.dart b/lib/screens/home_tab_recent.dart index 29d2d040..fb985976 100644 --- a/lib/screens/home_tab_recent.dart +++ b/lib/screens/home_tab_recent.dart @@ -25,18 +25,20 @@ extension _HomeTabRecentUI on _HomeTabState { TextButton( onPressed: () async { // Clearing history is not undoable, so ask first. - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (dialogContext) => AlertDialog( + builder: (dialogContext) => AppAlertDialog( title: Text(dialogContext.l10n.dialogClearAll), content: Text(dialogContext.l10n.recentClearAllMessage), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.of(dialogContext).pop(false), child: Text(dialogContext.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.of(dialogContext).pop(true), child: Text(dialogContext.l10n.dialogClearAll), diff --git a/lib/screens/home_tab_search_results.dart b/lib/screens/home_tab_search_results.dart index 7302d145..c0a95899 100644 --- a/lib/screens/home_tab_search_results.dart +++ b/lib/screens/home_tab_search_results.dart @@ -177,14 +177,18 @@ extension _HomeTabSearchResultsUI on _HomeTabState { void _showSortOptions(ColorScheme colorScheme) { var tempSort = _searchSortOption; + final mornye = context.isMornye; showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, - backgroundColor: colorScheme.surfaceContainerLow, + backgroundColor: mornye + ? Colors.transparent + : colorScheme.surfaceContainerLow, + elevation: mornye ? 0 : null, builder: (ctx) => StatefulBuilder( builder: (ctx, setSheetState) { - return SafeArea( + final content = SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: Column( @@ -214,6 +218,14 @@ extension _HomeTabSearchResultsUI on _HomeTabState { spacing: 8, runSpacing: 8, children: HomeSearchSortOption.values.map((option) { + if (context.isMornye) { + return MornyeFilterChip( + label: _sortOptionLabel(option), + selected: tempSort == option, + glass: false, + onTap: () => setSheetState(() => tempSort = option), + ); + } return FilterChip( label: Text(_sortOptionLabel(option)), selected: tempSort == option, @@ -242,6 +254,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState { ), ), ); + return mornye ? MornyeGlassPanel.overlay(child: content) : content; }, ), ); @@ -452,22 +465,31 @@ extension _HomeTabSearchResultsUI on _HomeTabState { colorScheme.surface, ) : colorScheme.surfaceContainerHighest; + final mornye = context.isMornye; return [ SliverToBoxAdapter( child: Padding( // Aligned with the clamped result list below on wide screens. padding: - EdgeInsets.fromLTRB(16, 8, 8, 8) + + (mornye + ? const EdgeInsets.fromLTRB(20, 16, 12, 10) + : const EdgeInsets.fromLTRB(16, 8, 8, 8)) + EdgeInsets.symmetric(horizontal: wideListInset(context)), child: Row( children: [ Expanded( child: Text( title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: mornye + ? Theme.of(context).textTheme.titleLarge?.copyWith( + fontSize: 20, + fontWeight: FontWeight.w700, + color: colorScheme.onSurface, + ) + : Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ), if (showSortButton) @@ -476,7 +498,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState { child: TextButton.icon( onPressed: () => _showSortOptions(colorScheme), icon: Icon( - Icons.swap_vert, + mornye ? mornyeIconFor(Icons.swap_vert) : Icons.swap_vert, size: 18, color: _searchSortOption != HomeSearchSortOption.defaultOrder @@ -511,6 +533,15 @@ extension _HomeTabSearchResultsUI on _HomeTabState { delegate: SliverChildBuilderDelegate((context, index) { final isFirst = index == 0; final isLast = index == itemCount - 1; + if (mornye) { + return Padding( + padding: EdgeInsets.fromLTRB(16, 0, 16, isLast ? 0 : 8), + child: MornyeGlassPanel.overlay( + radius: 24, + child: itemBuilder(index, false), + ), + ); + } return StaggeredListItem( index: index, child: Container( @@ -675,6 +706,33 @@ extension _HomeTabSearchResultsUI on _HomeTabState { String? selectedFilter, ColorScheme colorScheme, ) { + Widget filterChip(String id, String label, {IconData? icon}) { + void select() { + ref.read(trackProvider.notifier).setSearchFilter(id); + _triggerSearchWithFilter(id); + } + + return context.isMornye + ? MornyeGlassPanel.overlay( + radius: 24, + child: MornyeFilterChip( + label: label, + selected: selectedFilter == id, + icon: icon == null ? null : mornyeIconFor(icon), + glass: false, + tonal: selectedFilter == id, + onTap: select, + ), + ) + : FilterChip( + label: Text(label), + selected: selectedFilter == id, + onSelected: (_) => select(), + showCheckmark: false, + avatar: icon == null ? null : Icon(icon, size: 18), + ); + } + return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: SingleChildScrollView( @@ -683,31 +741,17 @@ extension _HomeTabSearchResultsUI on _HomeTabState { children: [ Padding( padding: const EdgeInsets.only(right: 8), - child: FilterChip( - label: Text(context.l10n.historyFilterAll), - selected: selectedFilter == 'all', - onSelected: (_) { - ref.read(trackProvider.notifier).setSearchFilter('all'); - _triggerSearchWithFilter('all'); - }, - showCheckmark: false, - ), + child: filterChip('all', context.l10n.historyFilterAll), ), ...filters.map((filter) { - final isSelected = selectedFilter == filter.id; return Padding( padding: const EdgeInsets.only(right: 8), - child: FilterChip( - label: Text(filter.label ?? filter.id), - selected: isSelected, - onSelected: (_) { - ref.read(trackProvider.notifier).setSearchFilter(filter.id); - _triggerSearchWithFilter(filter.id); - }, - showCheckmark: false, - avatar: filter.icon != null - ? Icon(_getFilterIcon(filter.icon!), size: 18) - : null, + child: filterChip( + filter.id, + filter.label ?? filter.id, + icon: filter.icon == null + ? null + : _getFilterIcon(filter.icon!), ), ); }), @@ -747,75 +791,53 @@ extension _HomeTabSearchResultsUI on _HomeTabState { _performSearch(text, filterOverride: filter); } - Widget _buildSearchBar(ColorScheme colorScheme) { + Widget _buildSearchBar() { final hasText = _urlController.text.isNotEmpty; - return TextField( + return AppSearchField( controller: _urlController, focusNode: _searchFocusNode, - autofocus: false, - decoration: InputDecoration( - hintText: _getSearchHint(), - filled: true, - fillColor: settingsGroupColor(context), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide(color: colorScheme.outlineVariant), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide(color: colorScheme.outlineVariant), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide(color: colorScheme.primary, width: 2), - ), - prefixIcon: _SearchProviderDropdown( - onProviderChanged: () { - _lastSearchQuery = null; - ref.read(trackProvider.notifier).setSearchFilter(null); - setState(() {}); - final text = _urlController.text.trim(); - if (text.isNotEmpty && - text.length >= _HomeTabState._minLiveSearchChars) { - _performSearch(text); - } - }, - ), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (hasText) - IconButton( - icon: const Icon(Icons.clear), - onPressed: _clearAndRefresh, - tooltip: context.l10n.dialogClear, - ) - else ...[ - IconButton( - icon: const Icon(Icons.file_upload_outlined), - onPressed: _isCsvImporting - ? null - : () => _importCsv(context, ref), - tooltip: context.l10n.homeImportCsvTooltip, - ), - IconButton( - icon: const Icon(Icons.paste), - onPressed: _pasteFromClipboard, - tooltip: context.l10n.actionPaste, - ), - ], + hintText: _getSearchHint(), + clearTooltip: context.l10n.dialogClear, + onClear: _clearAndRefresh, + prefixIcon: _SearchProviderDropdown( + onProviderChanged: () { + _lastSearchQuery = null; + ref.read(trackProvider.notifier).setSearchFilter(null); + setState(() {}); + final text = _urlController.text.trim(); + if (text.isNotEmpty && + text.length >= _HomeTabState._minLiveSearchChars) { + _performSearch(text); + } + }, + ), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (hasText) + IconButton( + icon: const Icon(Icons.clear), + onPressed: _clearAndRefresh, + tooltip: context.l10n.dialogClear, + ) + else ...[ + IconButton( + icon: const Icon(Icons.file_upload_outlined), + onPressed: _isCsvImporting + ? null + : () => _importCsv(context, ref), + tooltip: context.l10n.homeImportCsvTooltip, + ), + IconButton( + icon: const Icon(Icons.paste), + onPressed: _pasteFromClipboard, + tooltip: context.l10n.actionPaste, + ), ], - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 16, - ), + ], ), onSubmitted: (_) => _onSearchSubmitted(), - onTapOutside: (_) { - FocusScope.of(context).unfocus(); - }, ); } diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index a08afdfd..f0bb1d5e 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -272,7 +272,12 @@ class _TrackItemWithStatus extends ConsumerWidget { splashColor: colorScheme.primary.withValues(alpha: 0.12), highlightColor: colorScheme.primary.withValues(alpha: 0.08), child: Padding( - padding: const EdgeInsets.fromLTRB(12, 10, 6, 10), + padding: EdgeInsets.fromLTRB( + 12, + context.isMornye ? 12 : 10, + 6, + context.isMornye ? 12 : 10, + ), child: Row( children: [ Semantics( @@ -314,7 +319,9 @@ class _TrackItemWithStatus extends ConsumerWidget { Text( track.name, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, + fontWeight: context.isMornye + ? FontWeight.w600 + : FontWeight.w500, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -485,7 +492,10 @@ class _CollectionItemWidget extends StatelessWidget { splashColor: colorScheme.primary.withValues(alpha: 0.12), highlightColor: colorScheme.primary.withValues(alpha: 0.08), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: context.isMornye ? 12 : 10, + ), child: Row( children: [ cover, @@ -497,7 +507,9 @@ class _CollectionItemWidget extends StatelessWidget { Text( item.name, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, + fontWeight: context.isMornye + ? FontWeight.w600 + : FontWeight.w500, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -521,9 +533,11 @@ class _CollectionItemWidget extends StatelessWidget { ), ), Icon( - Icons.chevron_right, + context.isMornye + ? mornyeIconFor(Icons.chevron_right) + : Icons.chevron_right, color: colorScheme.onSurfaceVariant, - size: 24, + size: context.isMornye ? 18 : 24, ), ], ), @@ -686,6 +700,8 @@ class _LoadingOrErrorScaffold extends StatelessWidget { final String? error; final Widget loadingBody; final VoidCallback onRetry; + final bool collectionHeader; + final String? coverUrl; const _LoadingOrErrorScaffold({ required this.title, @@ -693,13 +709,62 @@ class _LoadingOrErrorScaffold extends StatelessWidget { required this.error, required this.loadingBody, required this.onRetry, + this.collectionHeader = false, + this.coverUrl, }); @override Widget build(BuildContext context) { if (isLoading) { + if (context.isMornye && collectionHeader) { + return MornyeArtistSurface( + imageSource: coverUrl, + neutralActions: true, + child: Builder( + builder: (context) => Scaffold( + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + elevation: 0, + leadingWidth: 68, + leading: Padding( + padding: const EdgeInsets.only(left: 12), + child: HeaderCircleButton( + icon: Icons.arrow_back, + tooltip: MaterialLocalizations.of( + context, + ).backButtonTooltip, + onPressed: () => Navigator.pop(context), + ), + ), + ), + body: MediaQuery.removePadding( + context: context, + removeTop: true, + child: loadingBody, + ), + ), + ), + ); + } return Scaffold( - appBar: AppBar(title: Text(title)), + appBar: AppBar( + title: Text(title), + leadingWidth: context.isMornye ? 68 : null, + leading: context.isMornye + ? Padding( + padding: const EdgeInsets.only(left: 12), + child: HeaderCircleButton( + icon: Icons.arrow_back, + tooltip: MaterialLocalizations.of( + context, + ).backButtonTooltip, + onPressed: () => Navigator.pop(context), + ), + ) + : null, + ), body: loadingBody, ); } @@ -780,6 +845,52 @@ class _ArtistLoadingScaffold extends StatelessWidget { @override Widget build(BuildContext context) { + if (context.isMornye) { + return MornyeArtistSurface( + imageSource: coverUrl, + child: Builder( + builder: (context) => Scaffold( + body: CustomScrollView( + slivers: [ + ...MornyeArtistHeader( + name: artistName, + showTitle: false, + artwork: coverUrl != null && coverUrl!.isNotEmpty + ? CachedCoverImage( + imageUrl: coverUrl!, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + memCacheWidth: 800, + ) + : const ShimmerLoading( + child: SkeletonBox( + width: double.infinity, + height: 342, + borderRadius: 0, + ), + ), + actions: const [ + ShimmerLoading( + child: Row( + spacing: 24, + children: [ + SkeletonBox(width: 44, height: 44, borderRadius: 22), + SkeletonBox(width: 60, height: 60, borderRadius: 30), + SkeletonBox(width: 44, height: 44, borderRadius: 22), + ], + ), + ), + ], + ).buildSlivers(context), + const SliverToBoxAdapter( + child: ArtistScreenSkeleton(showCoverHeader: false), + ), + ], + ), + ), + ), + ); + } final colorScheme = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final edgeInset = detailHeaderEdgeInset(context); @@ -1039,6 +1150,8 @@ class _ExtensionAlbumScreenState extends ConsumerState { if (_isLoading || _error != null) { return _LoadingOrErrorScaffold( title: widget.albumName, + collectionHeader: true, + coverUrl: widget.coverUrl, isLoading: _isLoading, error: _error, loadingBody: const AlbumTrackListSkeleton( @@ -1173,6 +1286,8 @@ class _ExtensionPlaylistScreenState return SkeletonCrossfade( child: _LoadingOrErrorScaffold( title: widget.playlistName, + collectionHeader: true, + coverUrl: widget.coverUrl, isLoading: _isLoading, error: _error, loadingBody: const TrackListSkeleton( @@ -1302,7 +1417,7 @@ class _ExtensionArtistScreenState extends ConsumerState name: (data['name'] ?? '').toString(), artists: (data['artists'] ?? '').toString(), releaseDate: (data['release_date'] ?? '').toString(), - totalTracks: data['total_tracks'] as int? ?? 0, + totalTracks: int.tryParse(data['total_tracks']?.toString() ?? '') ?? 0, coverUrl: normalizeCoverReference(data['cover_url']?.toString()), albumType: (data['album_type'] ?? 'album').toString(), providerId: (data['provider_id'] ?? widget.extensionId).toString(), diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 9e628a9e..a52e433c 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -7,6 +7,9 @@ import 'package:spotiflac_android/widgets/collection_scaffold.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/widgets/track_card.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus, XFile; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; @@ -24,7 +27,6 @@ import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; -import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/cover_art_utils.dart'; import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart'; import 'package:spotiflac_android/screens/selection_mode_mixin.dart'; @@ -306,14 +308,14 @@ class _LibraryTracksFolderScreenState downloadHistoryVisibleBatchExistsProvider(_folderData.historyRequest), ); - final bottomPadding = MediaQuery.paddingOf(context).bottom; - final bottomInset = context.navBarBottomInset; + final bottomPadding = isSelectionMode + ? MediaQuery.paddingOf(context).bottom + : 0.0; return CollectionScaffold( scrollController: scrollController, isSelectionMode: isSelectionMode, onExitSelectionMode: exitSelectionMode, - bottomInset: bottomInset, selectionBar: _buildSelectionBottomBar( context, colorScheme, @@ -762,7 +764,7 @@ class _LibraryTracksFolderScreenState void _showCoverOptionsSheet(BuildContext context, bool hasCustomCover) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -772,23 +774,28 @@ class _LibraryTracksFolderScreenState children: [ const AppSheetHandle(), const SizedBox(height: 16), - ListTile( + AppSheetOption( contentPadding: const EdgeInsets.symmetric( horizontal: 24, vertical: 4, ), - leading: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.image_outlined, - color: colorScheme.onPrimaryContainer, - ), - ), + leading: context.isMornye + ? Icon( + mornyeIconFor(Icons.image_outlined), + color: colorScheme.primary, + ) + : Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_outlined, + color: colorScheme.onPrimaryContainer, + ), + ), title: Text(context.l10n.collectionPlaylistChangeCover), onTap: () { Navigator.pop(sheetContext); @@ -796,23 +803,28 @@ class _LibraryTracksFolderScreenState }, ), if (hasCustomCover) - ListTile( + AppSheetOption( contentPadding: const EdgeInsets.symmetric( horizontal: 24, vertical: 4, ), - leading: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: colorScheme.errorContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.delete_outline, - color: colorScheme.onErrorContainer, - ), - ), + leading: context.isMornye + ? Icon( + mornyeIconFor(Icons.delete_outline), + color: colorScheme.primary, + ) + : Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.delete_outline, + color: colorScheme.onErrorContainer, + ), + ), title: Text(context.l10n.collectionPlaylistRemoveCover), onTap: () { Navigator.pop(sheetContext); @@ -834,10 +846,10 @@ class _LibraryTracksFolderScreenState final controller = TextEditingController(text: currentName); final formKey = GlobalKey(); - final nextName = await showDialog( + final nextName = await showAppDialog( context: context, builder: (dialogContext) { - return AlertDialog( + return AppAlertDialog( title: Text(dialogContext.l10n.collectionRenamePlaylist), content: Form( key: formKey, @@ -861,11 +873,12 @@ class _LibraryTracksFolderScreenState ), ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.of(dialogContext).pop(), child: Text(dialogContext.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, onPressed: () { if (formKey.currentState?.validate() != true) return; Navigator.of(dialogContext).pop(controller.text.trim()); diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 9c19a6ac..c70fad3f 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/services/local_track_batch_actions.dart'; import 'package:spotiflac_android/theme/cover_palette.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -11,7 +12,6 @@ import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart'; import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; -import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/batch_track_actions.dart'; import 'package:spotiflac_android/models/unified_library_item.dart'; @@ -29,6 +29,7 @@ import 'package:spotiflac_android/widgets/selection_action_button.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/widgets/disc_separator_chip.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; +import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; class LocalAlbumScreen extends ConsumerStatefulWidget { final String albumName; @@ -164,12 +165,24 @@ class _LocalAlbumScreenState extends ConsumerState @override Widget build(BuildContext context) { + if (context.isMornye) { + return MornyeArtistSurface( + imageSource: widget.coverPath, + neutralActions: true, + child: Builder(builder: _buildPage), + ); + } + return _buildPage(context); + } + + Widget _buildPage(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final qualityLabelMode = ref.watch( settingsProvider.select((s) => s.libraryQualityLabelMode), ); - final bottomPadding = MediaQuery.paddingOf(context).bottom; - final bottomInset = context.navBarBottomInset; + final bottomPadding = isSelectionMode + ? MediaQuery.paddingOf(context).bottom + : 0.0; final tracks = _sortedTracksCache; if (tracks.isEmpty) { @@ -193,7 +206,6 @@ class _LocalAlbumScreenState extends ConsumerState tracks, bottomPadding, ), - bottomInset: bottomInset, ); } @@ -225,6 +237,7 @@ class _LocalAlbumScreenState extends ConsumerState return AlbumDetailHeader( title: widget.albumName, + immersive: context.isMornye, expandedHeight: expandedHeight, showTitleInAppBar: showTitleInAppBar, background: background, @@ -258,9 +271,11 @@ class _LocalAlbumScreenState extends ConsumerState subtitle: Text( widget.artistName, style: TextStyle( - color: HeaderPalette.of(context).onSurfaceVariant, - fontSize: 16, - fontWeight: FontWeight.w600, + color: context.isMornye + ? colorScheme.primary + : HeaderPalette.of(context).onSurfaceVariant, + fontSize: context.isMornye ? 20 : 16, + fontWeight: context.isMornye ? FontWeight.w400 : FontWeight.w600, ), textAlign: TextAlign.center, maxLines: 1, diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 51ef9bb5..bf1d9088 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'dart:ui' show ImageFilter; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -30,6 +32,8 @@ import 'package:spotiflac_android/widgets/update_dialog.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/mini_player.dart'; +import 'package:spotiflac_android/widgets/mornye_bottom_bar.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; @@ -46,6 +50,7 @@ class MainShell extends ConsumerStatefulWidget { class _MainShellState extends ConsumerState with SingleTickerProviderStateMixin, WidgetsBindingObserver { int _currentIndex = 0; + final _mornyeChrome = MornyeChromeController(); // Preserves the PageView element (and its kept-alive tabs) when the body // structure swaps between rail and bottom-bar layouts on rotation. final GlobalKey _pageViewKey = GlobalKey(); @@ -163,7 +168,7 @@ class _MainShellState extends ConsumerState _safRepairDialogVisible = true; try { - await showDialog( + await showAppDialog( context: context, barrierDismissible: false, builder: (dialogContext) { @@ -172,7 +177,7 @@ class _MainShellState extends ConsumerState builder: (context, setDialogState) { return PopScope( canPop: false, - child: AlertDialog( + child: AppAlertDialog( icon: Icon( Icons.folder_off_outlined, color: Theme.of(context).colorScheme.error, @@ -180,7 +185,7 @@ class _MainShellState extends ConsumerState title: Text(context.l10n.downloadFolderAccessLostTitle), content: Text(context.l10n.downloadFolderAccessLostSubtitle), actions: [ - TextButton( + AppDialogAction( onPressed: isPickingFolder ? null : () { @@ -194,7 +199,8 @@ class _MainShellState extends ConsumerState }, child: Text(context.l10n.storageAutomaticFolder), ), - FilledButton( + AppDialogAction( + filled: true, onPressed: isPickingFolder ? null : () async { @@ -393,10 +399,10 @@ class _MainShellState extends ConsumerState final colorScheme = Theme.of(context).colorScheme; - showDialog( + showAppDialog( context: context, barrierDismissible: false, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( icon: Icon( Icons.folder_special_outlined, size: 32, @@ -417,11 +423,12 @@ class _MainShellState extends ConsumerState ), ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(ctx), child: Text(context.l10n.updateLater), ), - FilledButton( + AppDialogAction( + filled: true, onPressed: () async { Navigator.pop(ctx); final result = await PlatformBridge.pickSafTree(); @@ -459,6 +466,7 @@ class _MainShellState extends ConsumerState _shareSubscription?.cancel(); _pageController.dispose(); _tabJumpTransitionController.dispose(); + _mornyeChrome.dispose(); super.dispose(); } @@ -489,6 +497,7 @@ class _MainShellState extends ConsumerState } void _onNavTap(int index) { + _mornyeChrome.expand(); if (index == 0 && _currentIndex == 0) { _resetHomeToMain(); return; @@ -513,7 +522,12 @@ class _MainShellState extends ConsumerState // Jump directly when skipping intermediate tabs to avoid // sliding through them. For those jumps, keep a short fade-in // so the transition still feels intentional. - if (isNonAdjacentJump) { + if (context.isMornye) { + // The glass pill owns the tab transition. Sliding/fading the whole + // page at the same time continuously invalidates its live backdrop. + _tabJumpTransitionController.value = 1; + _pageController.jumpToPage(index); + } else if (isNonAdjacentJump) { _pageController.jumpToPage(index); _tabJumpTransitionController.forward(from: 0); } else { @@ -527,6 +541,7 @@ class _MainShellState extends ConsumerState } void _onPageChanged(int index) { + _mornyeChrome.expand(); if (_currentIndex != index) { ref.read(previewPlayerProvider.notifier).stop(); setState(() => _currentIndex = index); @@ -695,7 +710,9 @@ class _MainShellState extends ConsumerState final l10n = context.l10n; final destinations = [ NavigationDestination( - icon: const Icon(Icons.home_outlined), + icon: Icon( + context.isMornye ? CupertinoIcons.house_fill : Icons.home_outlined, + ), selectedIcon: BouncingIcon(child: const Icon(Icons.home)), label: l10n.navHome, ), @@ -705,7 +722,11 @@ class _MainShellState extends ConsumerState child: Badge( isLabelVisible: queueState > 0, label: Text('$queueState'), - child: const Icon(Icons.library_music_outlined), + child: Icon( + context.isMornye + ? CupertinoIcons.square_stack_fill + : Icons.library_music_outlined, + ), ), ), selectedIcon: SlidingIcon( @@ -727,7 +748,11 @@ class _MainShellState extends ConsumerState child: Badge( isLabelVisible: repoUpdatesCount > 0, label: Text('$repoUpdatesCount'), - child: const Icon(Icons.extension_outlined), + child: Icon( + context.isMornye + ? CupertinoIcons.square_grid_2x2 + : Icons.extension_outlined, + ), ), ), selectedIcon: BouncingIcon( @@ -743,7 +768,9 @@ class _MainShellState extends ConsumerState label: l10n.navStore, ), NavigationDestination( - icon: const Icon(Icons.settings_outlined), + icon: Icon( + context.isMornye ? CupertinoIcons.gear : Icons.settings_outlined, + ), selectedIcon: SpinIcon(child: const Icon(Icons.settings)), label: l10n.navSettings, ), @@ -765,6 +792,11 @@ class _MainShellState extends ConsumerState // desktop-width windows keeps all primary destinations at the reachable // bottom edge on iPad and large Android tablets. final useNavigationRail = useNavigationRailForWidth(screenSize.width); + final canMinimizeChrome = + context.isMornye && + !useNavigationRail && + MediaQuery.viewInsetsOf(context).bottom == 0 && + MediaQuery.textScalerOf(context).scale(15) <= 20; final pageView = KeyedSubtree( key: _pageViewKey, @@ -782,7 +814,15 @@ class _MainShellState extends ConsumerState key: ValueKey('page-$index'), child: TickerMode( enabled: index == _currentIndex, - child: tabs[index], + child: NotificationListener( + onNotification: (notification) { + if (canMinimizeChrome && index == _currentIndex) { + return _mornyeChrome.handleScroll(notification); + } + return false; + }, + child: tabs[index], + ), ), ), ), @@ -858,6 +898,45 @@ class _MainShellState extends ConsumerState : pageView, bottomNavigationBar: Builder( builder: (context) { + if (context.isMornye) { + return ListenableBuilder( + listenable: Listenable.merge([ + ShellNavigationService.chromeBrightness, + ShellNavigationService.chromeSurface, + ]), + builder: (context, child) => Theme( + data: ShellNavigationService.chromeBrightness.value == null + ? Theme.of(context) + : MornyeTheme.build( + ShellNavigationService.chromeBrightness.value!, + chromeSurface: + ShellNavigationService.chromeSurface.value, + ), + child: child!, + ), + child: SafeArea( + top: false, + minimum: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: useNavigationRail + ? const MiniPlayer() + : ValueListenableBuilder( + valueListenable: _mornyeChrome, + builder: (context, collapsed, _) => MornyeBottomBar( + collapsed: canMinimizeChrome && collapsed, + destinations: destinations, + selectedIndex: _currentIndex.clamp(0, maxIndex), + onSelected: _onNavTap, + onExpand: _mornyeChrome.expand, + onSearch: + ShellNavigationService.requestHomeSearch, + blurEnabled: + !ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider), + ), + ), + ), + ); + } final bottomBar = Column( mainAxisSize: MainAxisSize.min, children: [ @@ -931,6 +1010,9 @@ class _TabNavigator extends StatefulWidget { } class _TabNavigatorState extends State<_TabNavigator> { + late final ShellChromeObserver _chromeObserver = ShellChromeObserver( + widget.navigatorKey, + ); // Nested navigators get no HeroController from MaterialApp; without one, // Hero widgets on routes pushed inside a tab never fly. final HeroController _heroController = @@ -938,6 +1020,7 @@ class _TabNavigatorState extends State<_TabNavigator> { @override void dispose() { + _chromeObserver.detach(); _heroController.dispose(); super.dispose(); } @@ -947,6 +1030,7 @@ class _TabNavigatorState extends State<_TabNavigator> { return Navigator( key: widget.navigatorKey, observers: [ + _chromeObserver, if (widget.heroAnimationsEnabled) _heroController, ...widget.observers, ], diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index f997b44f..916346e7 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -1,16 +1,21 @@ import 'dart:async'; +import 'dart:ui' show BoxHeightStyle, ImageFilter; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; import 'package:flutter/rendering.dart' show ScrollDirection; +import 'package:flutter/services.dart' show SystemUiOverlayStyle; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/download_history_provider.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/music_player_service.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; @@ -24,6 +29,18 @@ import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; import 'package:spotiflac_android/widgets/playback_seek_slider.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/widgets/mornye_volume_control.dart'; +import 'package:spotiflac_android/widgets/mornye_player_queue.dart'; +import 'package:spotiflac_android/widgets/mornye_player_background.dart'; +import 'package:spotiflac_android/widgets/mornye_playback_button.dart'; +import 'package:spotiflac_android/widgets/mornye_playback_time.dart'; +import 'package:spotiflac_android/widgets/mornye_player_actions_sheet.dart'; +import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; +import 'package:spotiflac_android/widgets/mornye_landscape_player.dart'; +import 'package:spotiflac_android/widgets/mornye_player_artwork.dart'; +import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart'; +import 'package:spotiflac_android/widgets/mornye_player_details_sheet.dart'; +import 'package:spotiflac_android/widgets/mornye_player_favorite_button.dart'; final _log = AppLogger('NowPlaying'); @@ -37,8 +54,13 @@ const kNowPlayingArtworkHeroTag = 'now-playing-artwork'; class NowPlayingRoute extends PageRoute { NowPlayingRoute() : super(fullscreenDialog: true); - bool _dragging = false; - bool _popViaDrag = false; + bool _interactiveTransition = false; + int _dragGeneration = 0; + + // Keep the previous page painted where a drag exposes it. The player itself + // fills the screen, including the status bar and bottom safe area. + @override + bool get opaque => false; @override Color? get barrierColor => null; @@ -56,8 +78,14 @@ class NowPlayingRoute extends PageRoute { Duration get reverseTransitionDuration => const Duration(milliseconds: 300); void startDrag() { - _dragging = true; + _dragGeneration++; controller?.stop(); + if (!_interactiveTransition && controller != null) { + controller!.value = Easing.emphasizedDecelerate.transform( + controller!.value, + ); + } + _interactiveTransition = true; changedInternalState(); } @@ -66,22 +94,34 @@ class NowPlayingRoute extends PageRoute { } void endDrag(DragEndDetails details, double pageHeight) { - _dragging = false; - changedInternalState(); final velocity = (details.primaryVelocity ?? 0) / pageHeight; final value = controller?.value ?? 1.0; if (velocity > 1.0 || (velocity >= 0 && value < 0.7)) { - _popViaDrag = true; navigator?.pop(); } else { - controller?.fling(); + _settleOpen(); } } void cancelDrag() { - _dragging = false; - changedInternalState(); - controller?.fling(); + _settleOpen(); + } + + void _settleOpen() { + final generation = _dragGeneration; + // Keep the same linear position mapping until settling finishes. Switching + // to the entrance curve on release jumps away from the finger's position. + controller + ?.animateTo( + 1, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + ) + .whenCompleteOrCancel(() { + if (generation != _dragGeneration || !isCurrent) return; + _interactiveTransition = false; + changedInternalState(); + }); } @override @@ -101,27 +141,17 @@ class NowPlayingRoute extends PageRoute { Widget child, ) { final reversing = animation.status == AnimationStatus.reverse; - // Button/back pop: the sheet stays put and fades out where it is, so the - // Hero cover is the only thing traveling down to the mini player. A drag - // dismissal instead keeps sliding from the finger's position, dissolving - // on the way down. - final inPlacePop = reversing && !_popViaDrag; - // Linear while dragging so the page tracks the finger 1:1. - final slide = _dragging + // Mornye's full-bleed artwork travels with its panel. Material keeps the + // independent cover flight back to the mini-player on button/back pop. + final inPlacePop = + reversing && !_interactiveTransition && !context.isMornye; + final slide = _interactiveTransition ? animation - : CurvedAnimation( - parent: animation, - curve: Easing.emphasizedDecelerate, - reverseCurve: Easing.emphasizedAccelerate, - ); + : animation.drive(CurveTween(curve: Easing.emphasizedDecelerate)); final position = inPlacePop ? const AlwaysStoppedAnimation(Offset.zero) : slide.drive(Tween(begin: const Offset(0, 1), end: Offset.zero)); - final opacity = inPlacePop - ? CurvedAnimation(parent: animation, curve: const Interval(0.3, 1.0)) - : reversing - ? CurvedAnimation(parent: animation, curve: const Interval(0.0, 0.5)) - : const AlwaysStoppedAnimation(1.0); + final opacity = inPlacePop ? animation : kAlwaysCompleteAnimation; return SlideTransition( position: position, child: FadeTransition(opacity: opacity, child: child), @@ -184,6 +214,7 @@ class _NowPlayingScreenState extends ConsumerState { ParsedLyrics _lyrics = ParsedLyrics.empty; bool _loadingMeta = false; int _currentPage = 0; + bool _landscapeLyrics = false; bool _bottomDragForwarding = false; double _bottomDragTotal = 0; bool _queueSheetShowing = false; @@ -198,7 +229,7 @@ class _NowPlayingScreenState extends ConsumerState { // When automatic playback advances while Lyrics is already visible, // onPageChanged will not run again. Inspect an unresolved SAF URI now // instead of leaving the new track with an empty Lyrics page. - inspectUnresolvedContentUri: _currentPage == 1, + inspectUnresolvedContentUri: _currentPage == 1 || _landscapeLyrics, ), ); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -207,6 +238,21 @@ class _NowPlayingScreenState extends ConsumerState { }); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final size = MediaQuery.sizeOf(context); + final landscapeLyrics = context.isMornye && size.width > size.height; + if (landscapeLyrics == _landscapeLyrics) return; + _landscapeLyrics = landscapeLyrics; + if (landscapeLyrics) { + _loadMetadataForItem( + ref.read(currentMediaItemProvider).value, + inspectUnresolvedContentUri: true, + ); + } + } + @override void dispose() { _mediaItemSub?.close(); @@ -340,7 +386,10 @@ class _NowPlayingScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; + final mornye = context.isMornye; + final colorScheme = mornye + ? MornyeTheme.build(Brightness.dark).colorScheme + : Theme.of(context).colorScheme; final mediaItem = ref.watch(currentMediaItemProvider).value; final controller = ref.read(musicPlayerControllerProvider); @@ -358,48 +407,81 @@ class _NowPlayingScreenState extends ConsumerState { } final source = mediaItem.extras?['source']?.toString() ?? ''; + final motionArtwork = mornye && !MediaQuery.disableAnimationsOf(context) + ? ref + .watch( + playerMotionArtworkProvider(( + album: mediaItem.album ?? '', + artist: mediaItem.artist ?? '', + )), + ) + .value + : null; - // Fade the page content in during the last 60% of the slide-up so only - // the sheet surface and the flying Hero artwork move together; without - // this the title/controls slide separately and look detached from the - // cover. On pop/drag the content fades out first, then the cover flies. + // The Mornye route moves the entire player together. A second, + // delayed content fade would make dismissal appear to pause partway down. final routeAnimation = ModalRoute.of(context)?.animation ?? kAlwaysCompleteAnimation; - final contentOpacity = CurvedAnimation( - parent: routeAnimation, - curve: const Interval(0.4, 1.0, curve: Curves.easeOut), - ); + final contentOpacity = mornye + ? kAlwaysCompleteAnimation + : routeAnimation.drive( + CurveTween(curve: const Interval(0.4, 1.0, curve: Curves.easeOut)), + ); - return Scaffold( - backgroundColor: colorScheme.surface, + final player = Scaffold( + backgroundColor: mornye ? Colors.transparent : colorScheme.surface, appBar: _FadingAppBar( opacity: contentOpacity, child: AppBar( - backgroundColor: colorScheme.surface, + backgroundColor: mornye ? Colors.transparent : colorScheme.surface, + foregroundColor: colorScheme.onSurface, surfaceTintColor: Colors.transparent, - title: Text(context.l10n.nowPlayingTitle), + systemOverlayStyle: mornye ? SystemUiOverlayStyle.light : null, + toolbarHeight: mornye ? 36 : kToolbarHeight, + automaticallyImplyLeading: false, + title: mornye + ? IconButton( + tooltip: context.l10n.nowPlayingMinimize, + onPressed: () => Navigator.of(context).maybePop(), + icon: Container( + width: 36, + height: 5, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(3), + ), + ), + ) + : Text(context.l10n.nowPlayingTitle), centerTitle: true, - leading: IconButton( - tooltip: context.l10n.nowPlayingMinimize, - icon: const Icon(Icons.keyboard_arrow_down), - onPressed: () => Navigator.of(context).maybePop(), - ), - actions: [ - IconButton( - tooltip: context.l10n.nowPlayingUpNext, - icon: const Icon(Icons.queue_music), - onPressed: () => _showQueueSheet(colorScheme), - ), - IconButton( - tooltip: MaterialLocalizations.of(context).moreButtonTooltip, - icon: const Icon(Icons.more_vert), - onPressed: () => _showMoreActions( - mediaItem: mediaItem, - source: source, - colorScheme: colorScheme, - ), - ), - ], + leading: mornye + ? null + : IconButton( + tooltip: context.l10n.nowPlayingMinimize, + icon: const Icon(Icons.keyboard_arrow_down), + onPressed: () => Navigator.of(context).maybePop(), + ), + actions: mornye + ? null + : [ + IconButton( + tooltip: context.l10n.nowPlayingUpNext, + icon: const Icon(Icons.queue_music), + onPressed: () => _showQueueSheet(colorScheme), + ), + IconButton( + tooltip: MaterialLocalizations.of( + context, + ).moreButtonTooltip, + icon: const Icon(Icons.more_vert), + onPressed: () => _showMoreActions( + context: context, + mediaItem: mediaItem, + source: source, + colorScheme: colorScheme, + ), + ), + ], ), ), body: FadeTransition( @@ -408,39 +490,87 @@ class _NowPlayingScreenState extends ConsumerState { child: Column( children: [ Expanded( - child: PageView( - controller: _pageController, - onPageChanged: (page) { - if (_currentPage != page) { - setState(() => _currentPage = page); - } - if (page == 1) { - _loadMetadataForItem( - ref.read(currentMediaItemProvider).value, - inspectUnresolvedContentUri: true, - ); - } - }, - children: [ - _playerPage(mediaItem, controller, colorScheme), - _lyricsSection(colorScheme, isActive: _currentPage == 1), - ], - ), + child: mornye + ? _mornyePlayerPage( + mediaItem, + controller, + colorScheme, + squareArtwork: motionArtwork == null, + ) + : PageView( + controller: _pageController, + onPageChanged: (page) { + if (_currentPage != page) { + setState(() => _currentPage = page); + } + if (page == 1) { + _loadMetadataForItem( + ref.read(currentMediaItemProvider).value, + inspectUnresolvedContentUri: true, + ); + } + }, + children: [ + _playerPage(mediaItem, controller, colorScheme), + _lyricsSection( + colorScheme, + isActive: _currentPage == 1, + ), + ], + ), ), _queueSwipeRegion( colorScheme, Column( mainAxisSize: MainAxisSize.min, children: [ - _PageTabBar( - controller: _pageController, - colorScheme: colorScheme, - labels: [ - context.l10n.nowPlayingTabPlayer, - context.l10n.nowPlayingTabLyrics, - ], - ), - const SizedBox(height: 8), + if (mornye && !_landscapeLyrics) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + tooltip: _currentPage == 1 + ? context.l10n.nowPlayingTabPlayer + : context.l10n.nowPlayingTabLyrics, + isSelected: _currentPage == 1, + color: Colors.white, + style: IconButton.styleFrom( + backgroundColor: _currentPage == 1 + ? Colors.white.withValues(alpha: 0.16) + : Colors.transparent, + ), + icon: const Icon(CupertinoIcons.quote_bubble), + onPressed: _toggleMornyeLyrics, + ), + IconButton( + tooltip: context.l10n.nowPlayingUpNext, + color: Colors.white, + isSelected: _currentPage == 2, + style: IconButton.styleFrom( + backgroundColor: _currentPage == 2 + ? Colors.white.withValues(alpha: 0.16) + : Colors.transparent, + ), + icon: const Icon(CupertinoIcons.list_bullet), + onPressed: () => setState( + () => _currentPage = _currentPage == 2 ? 0 : 2, + ), + ), + ], + ), + ) + else if (!mornye) + _PageTabBar( + controller: _pageController, + colorScheme: colorScheme, + labels: [ + context.l10n.nowPlayingTabPlayer, + context.l10n.nowPlayingTabLyrics, + ], + ), + if (!_landscapeLyrics) const SizedBox(height: 8), ], ), ), @@ -449,6 +579,36 @@ class _NowPlayingScreenState extends ConsumerState { ), ), ); + if (!mornye) return player; + return HeroMode( + // A route Hero renders outside the panel's gradient and slide, briefly + // exposing a detached rectangle. Keep Mornye artwork inside its panel. + enabled: false, + child: Theme( + data: MornyeTheme.build(Brightness.dark), + child: Stack( + fit: StackFit.expand, + children: [ + RepaintBoundary( + child: MornyePlayerBackground( + artUri: mediaItem.artUri, + squareArtwork: motionArtwork == null, + artwork: !_landscapeLyrics && _currentPage == 0 + ? Hero( + tag: kNowPlayingArtworkHeroTag, + child: MornyePlayerArtwork( + mediaItem: mediaItem, + videoUrl: motionArtwork, + ), + ) + : null, + ), + ), + player, + ], + ), + ), + ); } /// Swipe up (player content or bottom tab strip) opens the queue sheet; a @@ -608,6 +768,313 @@ class _NowPlayingScreenState extends ConsumerState { ); } + void _toggleMornyeLyrics() { + setState(() => _currentPage = _currentPage == 1 ? 0 : 1); + if (_currentPage == 1) { + _loadMetadataForItem( + ref.read(currentMediaItemProvider).value, + inspectUnresolvedContentUri: true, + ); + } + } + + Widget _mornyePlayerPage( + MediaItem mediaItem, + MusicPlayerController controller, + ColorScheme colorScheme, { + required bool squareArtwork, + }) { + final showLyrics = _landscapeLyrics || _currentPage == 1; + final showQueue = !_landscapeLyrics && _currentPage == 2; + final compactStage = showLyrics || showQueue; + final motion = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 380); + return LayoutBuilder( + builder: (context, constraints) { + final screenSize = MediaQuery.sizeOf(context); + final landscape = screenSize.width > screenSize.height; + final scale = MediaQuery.textScalerOf(context).scale(17) / 17; + Widget stage({bool artworkOnly = false}) => LayoutBuilder( + builder: (context, stage) { + final compact = compactStage && !artworkOnly; + final artSize = (stage.maxWidth - 56).clamp( + 0.0, + (stage.maxHeight - 24).clamp(0.0, 360.0), + ); + return Stack( + children: [ + Positioned.fill( + top: 76, + child: IgnorePointer( + ignoring: !compact, + child: ExcludeSemantics( + excluding: !compact, + child: AnimatedSwitcher( + duration: motion, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.035), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ), + layoutBuilder: (currentChild, previousChildren) => + Stack( + fit: StackFit.expand, + children: [ + for (final child in previousChildren) + IgnorePointer( + child: ExcludeSemantics(child: child), + ), + ?currentChild, + ], + ), + // Keep the outgoing panel until it fades out; + // closing the queue must never substitute lyrics. + child: compact + ? KeyedSubtree( + key: ValueKey(_currentPage), + child: showQueue + ? MornyePlayerQueue( + colorScheme: colorScheme, + onShuffleLibrary: () => + _shuffleLibrary(controller), + ) + : _lyricsSection( + colorScheme, + isActive: showLyrics, + ), + ) + : null, + ), + ), + ), + ), + Positioned( + top: artworkOnly ? (stage.maxHeight - artSize) / 2 : 8, + left: artworkOnly ? (stage.maxWidth - artSize) / 2 : 28, + width: artworkOnly ? artSize : 48, + height: artworkOnly ? artSize : 48, + child: HeroMode( + enabled: artworkOnly || compactStage, + child: AnimatedSwitcher( + duration: motion, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + layoutBuilder: (current, previous) => Stack( + fit: StackFit.expand, + children: [ + for (final child in previous) + HeroMode( + enabled: false, + child: IgnorePointer(child: child), + ), + ?current, + ], + ), + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: Tween( + begin: 0.85, + end: 1.0, + ).animate(animation), + child: child, + ), + ), + child: !artworkOnly && !compactStage + ? null + : _artworkDragRegion( + context, + Hero( + tag: kNowPlayingArtworkHeroTag, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: PlayerArtwork( + artUri: mediaItem.artUri?.toString(), + colorScheme: colorScheme, + cacheWidth: + (360 * + MediaQuery.devicePixelRatioOf( + context, + )) + .round(), + ), + ), + ), + ), + ), + ), + ), + Positioned( + left: 88, + right: 28, + top: 4, + child: IgnorePointer( + ignoring: !compact, + child: ExcludeSemantics( + excluding: !compact, + child: AnimatedOpacity( + opacity: compact ? 1 : 0, + duration: motion, + child: _trackHeader( + mediaItem, + colorScheme, + compact: true, + ), + ), + ), + ), + ), + ], + ); + }, + ); + + final volumeGap = + 8 + + (squareArtwork + ? (constraints.maxHeight - 480).clamp(0.0, 48.0) + : 0); + // Share the spare space above and below the transport row so it sits + // between the timeline and volume without moving either slider. + final transportShift = landscape + ? 0.0 + : ((volumeGap - 16) / 2).clamp(0.0, 20.0); + Widget controls() => _PlaybackControls( + mediaId: mediaItem.id, + duration: mediaItem.duration ?? Duration.zero, + controller: controller, + colorScheme: colorScheme, + qualityLabel: _qualityLabel(), + compact: landscape, + transportTopPadding: 16 + transportShift, + ); + + if (landscape) { + return MornyeLandscapePlayer( + artwork: stage(artworkOnly: true), + header: _trackHeader(mediaItem, colorScheme, compact: true), + lyrics: _lyricsSection(colorScheme, isActive: true), + queue: MornyePlayerQueue( + colorScheme: colorScheme, + onShuffleLibrary: () => _shuffleLibrary(controller), + ), + controls: controls(), + volume: const MornyeVolumeControl(), + ); + } + + Widget content() => Column( + children: [ + Expanded(child: stage()), + AnimatedSize( + duration: motion, + curve: Curves.easeInOutCubic, + child: compactStage + ? const SizedBox(width: double.infinity) + : Padding( + padding: const EdgeInsets.fromLTRB(28, 12, 28, 8), + child: _trackHeader(mediaItem, colorScheme), + ), + ), + controls(), + SizedBox(height: volumeGap - transportShift), + const MornyeVolumeControl(), + const SizedBox(height: 8), + ], + ); + + // Controls keep their intrinsic height. Small portrait screens and + // large accessibility text can scroll instead of clipping the volume. + final minHeight = 480.0 + (scale - 1).clamp(0, 3) * 120; + if (constraints.maxHeight < minHeight) { + return SingleChildScrollView( + child: SizedBox(height: minHeight, child: content()), + ); + } + return content(); + }, + ); + } + + Widget _trackHeader( + MediaItem mediaItem, + ColorScheme colorScheme, { + bool compact = false, + }) { + return Builder( + builder: (context) => Row( + children: [ + Expanded( + child: Builder( + builder: (titleContext) => InkWell( + borderRadius: BorderRadius.circular(8), + onTap: + (mediaItem.artist ?? '').trim().isEmpty && + (mediaItem.album ?? '').trim().isEmpty + ? null + : () => _showTrackNavigationMenu(titleContext, mediaItem), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ExplicitTrackTitle( + title: mediaItem.title, + explicit: _isExplicit(mediaItem), + maxLines: compact ? 1 : 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: compact ? 15 : 22, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + mediaItem.artist ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: compact ? 13 : 20, + color: Colors.white70, + ), + ), + ], + ), + ), + ), + ), + MornyePlayerFavoriteButton( + key: ValueKey(mediaItem.id), + mediaItem: mediaItem, + compact: compact, + ), + Builder( + builder: (buttonContext) => IconButton( + tooltip: MaterialLocalizations.of(context).moreButtonTooltip, + color: colorScheme.onSurface, + icon: const Icon(CupertinoIcons.ellipsis), + onPressed: () => _showMoreActions( + context: context, + mediaItem: mediaItem, + source: mediaItem.extras?['source']?.toString() ?? '', + colorScheme: colorScheme, + anchor: mornyeMenuAnchor(buttonContext), + ), + ), + ), + ], + ), + ); + } + /// Title/artist, seek slider, and transport buttons — shared by the /// portrait column and the landscape right pane. List _metadataAndControls( @@ -632,29 +1099,37 @@ class _NowPlayingScreenState extends ConsumerState { child: child, ), ), - child: Column( + child: Row( key: ValueKey(mediaItem.id), children: [ - ExplicitTrackTitle( - title: mediaItem.title, - explicit: _isExplicit(mediaItem), - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ExplicitTrackTitle( + title: mediaItem.title, + explicit: _isExplicit(mediaItem), + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6), + Text( + mediaItem.artist ?? '', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 6), - Text( - mediaItem.artist ?? '', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, ), ], ), @@ -663,6 +1138,7 @@ class _NowPlayingScreenState extends ConsumerState { const SizedBox(height: 24), _PlaybackControls( key: ValueKey(mediaItem.id), + mediaId: mediaItem.id, duration: mediaItem.duration ?? Duration.zero, controller: controller, colorScheme: colorScheme, @@ -732,10 +1208,37 @@ class _NowPlayingScreenState extends ConsumerState { } } + Future _showTrackNavigationMenu( + BuildContext titleContext, + MediaItem mediaItem, + ) async { + final action = await showMornyeContextMenu( + context: titleContext, + builder: (_) => MornyePlayerNavigationMenu(mediaItem: mediaItem), + ); + if (!mounted) return; + if (action == 'album') { + await _goToCurrentAlbum( + mediaItem: mediaItem, + source: mediaItem.extras?['source']?.toString() ?? '', + ); + } else if (action == 'artist') { + final track = ref.read(playerCollectionTrackProvider(mediaItem)).value; + await navigateToArtist( + context, + artistName: mediaItem.artist ?? '', + artistId: track?.artistId, + extensionId: track?.source, + ); + } + } + Future _showMoreActions({ + required BuildContext context, required MediaItem mediaItem, required String source, required ColorScheme colorScheme, + Rect? anchor, }) async { final controller = ref.read(musicPlayerControllerProvider); final sleepTimerEndsAt = controller.sleepTimerEndsAt; @@ -746,58 +1249,69 @@ class _NowPlayingScreenState extends ConsumerState { context, ).formatTimeOfDay(TimeOfDay.fromDateTime(sleepTimerEndsAt)), ); - final action = await showAppBottomSheet( - context: context, - useRootNavigator: true, - backgroundColor: colorScheme.surfaceContainerHigh, - title: mediaItem.title, - subtitle: mediaItem.artist, - builder: (sheetContext) => Padding( - padding: const EdgeInsets.only(bottom: 16), - child: SettingsGroup( - children: [ - if ((mediaItem.album ?? '').trim().isNotEmpty) - SettingsItem( - icon: Icons.album_outlined, - title: sheetContext.l10n.homeGoToAlbum, - onTap: () => Navigator.of(sheetContext).pop('album'), + final action = context.isMornye + ? await showMornyeContextMenu( + context: context, + anchor: anchor, + builder: (_) => MornyePlayerActionsSheet( + mediaItem: mediaItem, + sleepTimerSubtitle: sleepTimerSubtitle, + ), + ) + : await showAppBottomSheet( + context: context, + useRootNavigator: true, + backgroundColor: colorScheme.surfaceContainerHigh, + title: mediaItem.title, + subtitle: mediaItem.artist, + builder: (sheetContext) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: SettingsGroup( + children: [ + if ((mediaItem.album ?? '').trim().isNotEmpty) + SettingsItem( + icon: Icons.album_outlined, + title: sheetContext.l10n.homeGoToAlbum, + onTap: () => Navigator.of(sheetContext).pop('album'), + ), + SettingsItem( + icon: Icons.info_outline, + title: sheetContext.l10n.nowPlayingDetails, + onTap: () => Navigator.of(sheetContext).pop('details'), + ), + SettingsItem( + icon: Icons.bedtime_outlined, + title: sheetContext.l10n.nowPlayingSleepTimer, + subtitle: sleepTimerSubtitle, + onTap: () => Navigator.of(sheetContext).pop('sleepTimer'), + ), + SettingsItem( + icon: Icons.open_in_new, + title: sheetContext.l10n.nowPlayingOpenInExternalPlayer, + trailing: Icon( + Icons.open_in_new, + size: 18, + color: Theme.of( + sheetContext, + ).colorScheme.onSurfaceVariant, + ), + showDivider: false, + onTap: () => Navigator.of(sheetContext).pop('external'), + ), + ], ), - SettingsItem( - icon: Icons.info_outline, - title: sheetContext.l10n.nowPlayingDetails, - onTap: () => Navigator.of(sheetContext).pop('details'), ), - SettingsItem( - icon: Icons.bedtime_outlined, - title: sheetContext.l10n.nowPlayingSleepTimer, - subtitle: sleepTimerSubtitle, - onTap: () => Navigator.of(sheetContext).pop('sleepTimer'), - ), - SettingsItem( - icon: Icons.open_in_new, - title: sheetContext.l10n.nowPlayingOpenInExternalPlayer, - trailing: Icon( - Icons.open_in_new, - size: 18, - color: Theme.of(sheetContext).colorScheme.onSurfaceVariant, - ), - showDivider: false, - onTap: () => Navigator.of(sheetContext).pop('external'), - ), - ], - ), - ), - ); - if (!mounted) return; + ); + if (!mounted || !context.mounted) return; switch (action) { case 'album': await _goToCurrentAlbum(mediaItem: mediaItem, source: source); break; case 'details': - _showDetailsSheet(colorScheme); + _showDetailsSheet(context, colorScheme); break; case 'sleepTimer': - await _showSleepTimerSheet(controller, colorScheme); + await _showSleepTimerSheet(context, controller, colorScheme); break; case 'external': await _openExternally(source); @@ -806,6 +1320,7 @@ class _NowPlayingScreenState extends ConsumerState { } Future _showSleepTimerSheet( + BuildContext context, MusicPlayerController controller, ColorScheme colorScheme, ) async { @@ -838,7 +1353,7 @@ class _NowPlayingScreenState extends ConsumerState { ), ), ); - if (!mounted || selection == null) return; + if (!mounted || !context.mounted || selection == null) return; if (selection == 'off') { controller.cancelSleepTimer(); @@ -970,6 +1485,10 @@ class _NowPlayingScreenState extends ConsumerState { } void _showQueueSheet(ColorScheme colorScheme) { + if (context.isMornye) { + setState(() => _currentPage = 2); + return; + } if (_queueSheetShowing) return; _queueSheetShowing = true; showModalBottomSheet( @@ -1154,34 +1673,55 @@ class _NowPlayingScreenState extends ConsumerState { ).whenComplete(() => _queueSheetShowing = false); } - void _showDetailsSheet(ColorScheme colorScheme) { - final meta = _metadata; + void _showDetailsSheet(BuildContext context, ColorScheme colorScheme) { + final mornye = context.isMornye; + final mediaItem = ref.read(currentMediaItemProvider).value; + final fallback = { + if (mediaItem != null) ...{ + 'title': mediaItem.title, + 'artist': mediaItem.artist ?? '', + 'album': mediaItem.album ?? '', + ...playbackAudioMetadataFromMediaItem(mediaItem), + }, + ...?_metadata, + }; + final source = mediaItem?.extras?['source']?.toString() ?? ''; + final resolved = mediaItem?.extras?['resolvedSource']?.toString().trim(); + final path = resolved != null && resolved.isNotEmpty ? resolved : source; + // Restored Android SAF items initially carry only queue metadata. Read + // their tags on demand, even if playback has not resolved a local copy. + final metadataFuture = path.isEmpty || _loadedMetadataPath == path + ? Future.value(fallback) + : readPlaybackFileMetadataWithRetry(path) + .then((meta) => mergePlaybackFileMetadata(fallback, meta)) + .catchError((Object error) { + _log.w('Failed to read details metadata: $error'); + return fallback; + }); showModalBottomSheet( context: context, isScrollControlled: true, - showDragHandle: true, - backgroundColor: colorScheme.surfaceContainerHigh, + useRootNavigator: mornye, + showDragHandle: !mornye, + backgroundColor: mornye + ? Colors.transparent + : colorScheme.surfaceContainerHigh, builder: (context) { return DraggableScrollableSheet( expand: false, - initialChildSize: 0.6, + initialChildSize: mornye ? 0.75 : 0.6, maxChildSize: 0.9, minChildSize: 0.4, builder: (context, scrollController) { - if (meta == null) { - return Center( - child: Text( - context.l10n.nowPlayingNoMetadata, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ); - } - return _MetadataList( - meta: meta, - colorScheme: colorScheme, - scrollController: scrollController, + return FutureBuilder>( + future: metadataFuture, + initialData: fallback, + builder: (context, snapshot) => _MetadataList( + meta: snapshot.data ?? fallback, + colorScheme: colorScheme, + scrollController: scrollController, + mediaItem: mediaItem, + ), ); }, ); @@ -1191,24 +1731,35 @@ class _NowPlayingScreenState extends ConsumerState { } class _PlaybackControls extends ConsumerWidget { + final String mediaId; final Duration duration; final MusicPlayerController controller; final ColorScheme colorScheme; final String? qualityLabel; + final bool compact; + final double transportTopPadding; const _PlaybackControls({ super.key, + required this.mediaId, required this.duration, required this.controller, required this.colorScheme, required this.qualityLabel, + this.compact = false, + this.transportTopPadding = 16, }); @override Widget build(BuildContext context, WidgetRef ref) { + final mornye = context.isMornye; final position = ref.watch(playbackPositionProvider); + final elapsedSeconds = position.inSeconds; final isPlaying = ref.watch(playbackPlayingProvider); final isLoading = ref.watch(playbackLoadingProvider); + final timeStyle = Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant); final shuffleOn = ref.watch( playbackStateProvider.select( (s) => s.value?.shuffleMode == AudioServiceShuffleMode.all, @@ -1228,11 +1779,11 @@ class _PlaybackControls extends ConsumerWidget { SliderTheme( data: SliderThemeData( trackHeight: 4, - activeTrackColor: colorScheme.primary, + activeTrackColor: mornye ? Colors.white : colorScheme.primary, inactiveTrackColor: colorScheme.onSurface.withValues( alpha: 0.18, ), - thumbColor: colorScheme.primary, + thumbColor: mornye ? Colors.white : colorScheme.primary, // A 7dp thumb was hard to grab; 10dp with a 24dp overlay // gives the drag gesture a full-size target. thumbShape: const RoundSliderThumbShape( @@ -1243,6 +1794,7 @@ class _PlaybackControls extends ConsumerWidget { ), ), child: PlaybackSeekSlider( + key: ValueKey(mediaId), position: position, duration: duration, onSeek: controller.seek, @@ -1252,12 +1804,14 @@ class _PlaybackControls extends ConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ - Text( - formatClock(position.inSeconds), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), + if (mornye) + MornyePlaybackTime( + key: ValueKey('elapsed:$mediaId'), + seconds: elapsedSeconds, + style: timeStyle, + ) + else + Text(formatClock(elapsedSeconds), style: timeStyle), Expanded( child: Center( child: _QualityBadge( @@ -1266,93 +1820,140 @@ class _PlaybackControls extends ConsumerWidget { ), ), ), - Text( - formatClock(duration.inSeconds), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), + if (mornye) + MornyePlaybackTime( + key: ValueKey('remaining:$mediaId'), + // Subtract whole seconds so both labels roll together, + // even when the track duration includes milliseconds. + seconds: (duration.inSeconds - elapsedSeconds).clamp( + 0, + duration.inSeconds, + ), + remaining: true, + style: timeStyle, + ) + else + Text(formatClock(duration.inSeconds), style: timeStyle), ], ), ), ], ), ), - const SizedBox(height: 16), + SizedBox(height: compact ? 8 : transportTopPadding), Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: mornye + ? MainAxisAlignment.spaceEvenly + : MainAxisAlignment.center, children: [ - IconButton( - iconSize: 24, - tooltip: shuffleOn - ? context.l10n.nowPlayingShuffleOn - : context.l10n.nowPlayingPlayInOrder, - color: shuffleOn - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - icon: const Icon(Icons.shuffle), - onPressed: () => controller.setShuffle(!shuffleOn), - ), - const SizedBox(width: 8), - IconButton( - iconSize: 44, - tooltip: context.l10n.nowPlayingPreviousTrack, - icon: const Icon(Icons.skip_previous), - onPressed: controller.previous, - ), - const SizedBox(width: 20), - Container( - decoration: BoxDecoration( - color: colorScheme.primary, - shape: BoxShape.circle, + if (!mornye) + IconButton( + iconSize: 24, + tooltip: shuffleOn + ? context.l10n.nowPlayingShuffleOn + : context.l10n.nowPlayingPlayInOrder, + color: shuffleOn + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + icon: const Icon(Icons.shuffle), + onPressed: () => controller.setShuffle(!shuffleOn), ), - child: IconButton( + if (!mornye) const SizedBox(width: 8), + if (mornye) + MornyePlaybackButton( + icon: CupertinoIcons.backward_fill, + color: colorScheme.onSurface, + tooltip: context.l10n.nowPlayingPreviousTrack, + onPressed: controller.previous, + ) + else + IconButton( iconSize: 44, - padding: const EdgeInsets.all(12), - color: colorScheme.onPrimary, + color: colorScheme.onSurface, + tooltip: context.l10n.nowPlayingPreviousTrack, + icon: const Icon(Icons.skip_previous), + onPressed: controller.previous, + ), + SizedBox(width: mornye ? 12 : 20), + if (mornye) + MornyePlaybackButton( + icon: isPlaying + ? CupertinoIcons.pause_fill + : CupertinoIcons.play_fill, tooltip: isPlaying ? context.l10n.actionPause : context.l10n.tooltipPlay, - icon: isLoading - ? const SizedBox.square( - dimension: 32, - child: CircularProgressIndicator(strokeWidth: 3), - ) - : Icon(isPlaying ? Icons.pause : Icons.play_arrow), - onPressed: isLoading - ? null - : () => controller.togglePlayPause(isPlaying), + color: colorScheme.onSurface, + iconSize: 44, + padding: const EdgeInsets.all(12), + loading: isLoading, + onPressed: () => controller.togglePlayPause(isPlaying), + ) + else + Container( + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + ), + child: IconButton( + iconSize: 44, + padding: const EdgeInsets.all(12), + color: colorScheme.onPrimary, + tooltip: isPlaying + ? context.l10n.actionPause + : context.l10n.tooltipPlay, + icon: isLoading + ? const SizedBox.square( + dimension: 32, + child: CircularProgressIndicator(strokeWidth: 3), + ) + : Icon(isPlaying ? Icons.pause : Icons.play_arrow), + onPressed: isLoading + ? null + : () => controller.togglePlayPause(isPlaying), + ), ), - ), - const SizedBox(width: 20), - IconButton( - iconSize: 44, - tooltip: context.l10n.nowPlayingNextTrack, - icon: const Icon(Icons.skip_next), - onPressed: controller.next, - ), - const SizedBox(width: 8), - IconButton( - iconSize: 24, - tooltip: switch (repeatMode) { - AudioServiceRepeatMode.one => context.l10n.nowPlayingRepeatOne, - AudioServiceRepeatMode.none => context.l10n.nowPlayingRepeatOff, - _ => context.l10n.nowPlayingRepeatAll, - }, - color: repeatMode == AudioServiceRepeatMode.none - ? colorScheme.onSurfaceVariant - : colorScheme.primary, - icon: Icon( - repeatMode == AudioServiceRepeatMode.one - ? Icons.repeat_one - : Icons.repeat, + SizedBox(width: mornye ? 12 : 20), + if (mornye) + MornyePlaybackButton( + icon: CupertinoIcons.forward_fill, + color: colorScheme.onSurface, + tooltip: context.l10n.nowPlayingNextTrack, + onPressed: controller.next, + ) + else + IconButton( + iconSize: 44, + color: colorScheme.onSurface, + tooltip: context.l10n.nowPlayingNextTrack, + icon: const Icon(Icons.skip_next), + onPressed: controller.next, + ), + if (!mornye) const SizedBox(width: 8), + if (!mornye) + IconButton( + iconSize: 24, + tooltip: switch (repeatMode) { + AudioServiceRepeatMode.one => + context.l10n.nowPlayingRepeatOne, + AudioServiceRepeatMode.none => + context.l10n.nowPlayingRepeatOff, + _ => context.l10n.nowPlayingRepeatAll, + }, + color: repeatMode == AudioServiceRepeatMode.none + ? colorScheme.onSurfaceVariant + : colorScheme.primary, + icon: Icon( + repeatMode == AudioServiceRepeatMode.one + ? Icons.repeat_one + : Icons.repeat, + ), + onPressed: () => controller.setRepeatMode(switch (repeatMode) { + AudioServiceRepeatMode.none => AudioServiceRepeatMode.all, + AudioServiceRepeatMode.all => AudioServiceRepeatMode.one, + _ => AudioServiceRepeatMode.none, + }), ), - onPressed: () => controller.setRepeatMode(switch (repeatMode) { - AudioServiceRepeatMode.none => AudioServiceRepeatMode.all, - AudioServiceRepeatMode.all => AudioServiceRepeatMode.one, - _ => AudioServiceRepeatMode.none, - }), - ), ], ), ], @@ -1387,6 +1988,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { Duration _activeTransitionPosition = Duration.zero; bool _playing = false; bool _loading = false; + bool _hasStarted = false; bool _userScrolling = false; static const double _estimatedLyricExtent = 64; @@ -1394,6 +1996,11 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { void initState() { super.initState(); _resetLineKeys(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); _syncPositionSubscription(); } @@ -1410,6 +2017,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { } void _resetLineKeys() { + _hasStarted = false; _lineKeys = List.generate( widget.lyrics.lines.length, (index) => GlobalKey(debugLabel: 'lyric-line-$index'), @@ -1430,7 +2038,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { final position = ref.read(playbackPositionProvider); _playing = ref.read(playbackPlayingProvider); _loading = ref.read(playbackLoadingProvider); - _active = LyricsParser.activeIndex(widget.lyrics.lines, position); + _active = _activeIndexAt(position); _activeTransitionPosition = position; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) unawaited(_maybeAutoScroll(_active)); @@ -1439,7 +2047,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { _positionSubscription = ref.listenManual( playbackPositionProvider, (previous, next) { - final active = LyricsParser.activeIndex(widget.lyrics.lines, next); + final active = _activeIndexAt(next); if (active != _active) _setActiveLine(active, position: next); _scheduleNextLine(next); }, @@ -1449,17 +2057,37 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { next, ) { _playing = next; - _scheduleNextLine(ref.read(playbackPositionProvider)); + final position = ref.read(playbackPositionProvider); + _setActiveLine(_activeIndexAt(position), position: position); + _scheduleNextLine(position); }); _loadingSubscription = ref.listenManual(playbackLoadingProvider, ( previous, next, ) { _loading = next; - _scheduleNextLine(ref.read(playbackPositionProvider)); + final position = ref.read(playbackPositionProvider); + _setActiveLine(_activeIndexAt(position), position: position); + _scheduleNextLine(position); }); } + int _activeIndexAt(Duration position) { + if (context.isMornye) { + // Read one transport snapshot: playing/loading derived providers can + // notify separately during the same playback event. + final playback = ref.read(playbackStateProvider).value; + if (playback?.processingState == AudioProcessingState.loading || + playback?.processingState == AudioProcessingState.buffering) { + return -1; + } + _hasStarted = + _hasStarted || playback?.playing == true || position > Duration.zero; + if (!_hasStarted) return -1; + } + return LyricsParser.activeIndex(widget.lyrics.lines, position); + } + void _setActiveLine(int active, {required Duration position}) { if (!mounted || active == _active) return; setState(() { @@ -1552,6 +2180,16 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { Widget build(BuildContext context) { final lines = widget.lyrics.lines; final active = _active; + final mornye = context.isMornye; + final highContrast = MediaQuery.highContrastOf(context); + final blurLyrics = + mornye && + !highContrast && + (!ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider)); + final motion = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 280); return NotificationListener( onNotification: (notification) { @@ -1579,7 +2217,9 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { final isActive = index == active; final isPast = index < active; - final color = isActive + final color = mornye + ? Colors.white + : isActive ? widget.colorScheme.onSurface : isPast ? widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.5) @@ -1600,14 +2240,15 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { } else { content = Text( text, - textAlign: TextAlign.center, + textAlign: mornye ? TextAlign.start : TextAlign.center, style: - (isActive + (mornye || isActive ? Theme.of(context).textTheme.headlineSmall : Theme.of(context).textTheme.titleLarge) ?.copyWith( - height: 1.4, - fontWeight: isActive + height: mornye ? 1.3 : 1.4, + fontSize: mornye ? 28 : null, + fontWeight: mornye || isActive ? FontWeight.bold : FontWeight.w500, color: color, @@ -1621,23 +2262,49 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { switchOutCurve: Curves.easeInCubic, child: KeyedSubtree( key: ValueKey(isActive && line.hasWordTiming), - child: content, + child: mornye + ? SizedBox(width: double.infinity, child: content) + : content, ), ); + if (mornye) { + final distance = (index - active).abs(); + final sigma = !blurLyrics || isActive + ? 0.0 + : active < 0 + ? 2.8 + : distance == 1 + ? 0.6 + : distance == 2 + ? 1.8 + : 2.8; + content = TweenAnimationBuilder( + tween: Tween(end: sigma), + duration: motion, + child: RepaintBoundary(child: content), + builder: (context, value, child) => ImageFiltered( + enabled: value > 0, + imageFilter: ImageFilter.blur(sigmaX: value, sigmaY: value), + child: child, + ), + ); + } return Padding( key: _lineKeys[index], - padding: const EdgeInsets.symmetric(vertical: 10), + padding: EdgeInsets.symmetric(vertical: mornye ? 16 : 10), child: GestureDetector( onTap: () => ref.read(musicPlayerControllerProvider).seek(line.time), child: AnimatedScale( - scale: isActive ? 1.0 : 0.96, + scale: mornye || isActive ? 1.0 : 0.96, alignment: Alignment.center, duration: const Duration(milliseconds: 280), curve: Curves.easeOutCubic, child: AnimatedOpacity( - opacity: isActive ? 1.0 : (isPast ? 0.55 : 0.85), + opacity: mornye + ? (isActive || highContrast ? 1 : 0.48) + : (isActive ? 1.0 : (isPast ? 0.55 : 0.85)), duration: const Duration(milliseconds: 280), child: content, ), @@ -1805,9 +2472,10 @@ class _WordHighlightedLyricLineState Widget _buildHighlightedLine(BuildContext context) { final highlightedColor = widget.colorScheme.onSurface; - final pendingColor = widget.colorScheme.onSurfaceVariant.withValues( - alpha: 0.6, - ); + final mornye = context.isMornye; + final pendingColor = mornye + ? Colors.white.withValues(alpha: 0.4) + : widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.6); final segments = []; final starts = []; final ends = []; @@ -1825,7 +2493,12 @@ class _WordHighlightedLyricLineState currentPosition: _currentPosition, repaint: _animationClock, style: (Theme.of(context).textTheme.headlineSmall ?? const TextStyle()) - .copyWith(height: 1.4, fontWeight: FontWeight.bold), + .copyWith( + fontSize: mornye ? 28 : null, + height: mornye ? 1.3 : 1.4, + fontWeight: FontWeight.bold, + ), + textAlign: mornye ? TextAlign.start : TextAlign.center, pendingColor: pendingColor, highlightedColor: highlightedColor, semanticsLabel: widget.line.text, @@ -1840,6 +2513,7 @@ class _SweepingTimedLyricText extends StatefulWidget { final Duration Function() currentPosition; final Listenable repaint; final TextStyle style; + final TextAlign textAlign; final Color pendingColor; final Color highlightedColor; final String semanticsLabel; @@ -1851,6 +2525,7 @@ class _SweepingTimedLyricText extends StatefulWidget { required this.currentPosition, required this.repaint, required this.style, + required this.textAlign, required this.pendingColor, required this.highlightedColor, required this.semanticsLabel, @@ -1866,6 +2541,7 @@ class _SweepingTimedLyricTextState extends State<_SweepingTimedLyricText> { TextPainter? _highlightedPainter; String? _cachedText; TextStyle? _cachedStyle; + TextAlign? _cachedAlignment; TextDirection? _cachedDirection; TextScaler? _cachedScaler; Locale? _cachedLocale; @@ -1880,6 +2556,7 @@ class _SweepingTimedLyricTextState extends State<_SweepingTimedLyricText> { ) { if (_cachedText == text && _cachedStyle == widget.style && + _cachedAlignment == widget.textAlign && _cachedDirection == textDirection && _cachedScaler == textScaler && _cachedLocale == locale && @@ -1895,7 +2572,7 @@ class _SweepingTimedLyricTextState extends State<_SweepingTimedLyricText> { text: text, style: widget.style.copyWith(color: widget.pendingColor), ), - textAlign: TextAlign.center, + textAlign: widget.textAlign, textDirection: textDirection, textScaler: textScaler, locale: locale, @@ -1905,13 +2582,14 @@ class _SweepingTimedLyricTextState extends State<_SweepingTimedLyricText> { text: text, style: widget.style.copyWith(color: widget.highlightedColor), ), - textAlign: TextAlign.center, + textAlign: widget.textAlign, textDirection: textDirection, textScaler: textScaler, locale: locale, ); _cachedText = text; _cachedStyle = widget.style; + _cachedAlignment = widget.textAlign; _cachedDirection = textDirection; _cachedScaler = textScaler; _cachedLocale = locale; @@ -1953,18 +2631,26 @@ class _SweepingTimedLyricTextState extends State<_SweepingTimedLyricText> { ? constraints.maxWidth : pendingPainter.width; final height = pendingPainter.height; - final segmentBoxes = >[]; + final segmentBoxes = >[]; var segmentOffset = 0; for (final segment in widget.segments) { final segmentEnd = segmentOffset + segment.length; - segmentBoxes.add( - highlightedPainter.getBoxesForSelection( - TextSelection( - baseOffset: segmentOffset, - extentOffset: segmentEnd, - ), - ), - ); + final boxes = + highlightedPainter + .getBoxesForSelection( + TextSelection( + baseOffset: segmentOffset, + extentOffset: segmentEnd, + ), + boxHeightStyle: BoxHeightStyle.max, + ) + .map((box) => box.toRect()) + .toList() + ..sort((a, b) { + final row = a.top.compareTo(b.top); + return row == 0 ? a.left.compareTo(b.left) : row; + }); + segmentBoxes.add(boxes); segmentOffset = segmentEnd; } @@ -1989,7 +2675,7 @@ class _SweepingTimedLyricTextState extends State<_SweepingTimedLyricText> { } class _TimedLyricSweepPainter extends CustomPainter { - final List> segmentBoxes; + final List> segmentBoxes; final List starts; final List ends; final Duration Function() currentPosition; @@ -2022,13 +2708,21 @@ class _TimedLyricSweepPainter extends CustomPainter { ) : 0.0; if (value > 0) { - for (final box in segmentBoxes[index]) { - final rect = box.toRect(); - if (value >= 1) { - completedPath.addRect(rect); + final boxes = segmentBoxes[index]; + // Font fallback and wrapping can split one timed segment into several + // boxes. Consume its progress once, instead of lighting every box at + // the same time (which starts highlights in the middle of the text). + var revealWidth = + boxes.fold(0, (width, box) => width + box.width) * value; + for (final box in boxes) { + if (revealWidth <= 0) break; + if (box.width <= 0) continue; + if (revealWidth >= box.width) { + completedPath.addRect(box); } else { - partialBoxes.add((rect, value)); + partialBoxes.add((box, revealWidth / box.width)); } + revealWidth -= box.width; } } } @@ -2089,11 +2783,13 @@ class _MetadataList extends StatelessWidget { final Map meta; final ColorScheme colorScheme; final ScrollController scrollController; + final MediaItem? mediaItem; const _MetadataList({ required this.meta, required this.colorScheme, required this.scrollController, + this.mediaItem, }); @override @@ -2127,6 +2823,14 @@ class _MetadataList extends StatelessWidget { ), ].where((r) => r.$2.trim().isNotEmpty && r.$2 != '0').toList(); + if (context.isMornye) { + return MornyePlayerDetailsSheet( + rows: rows, + scrollController: scrollController, + mediaItem: mediaItem, + ); + } + final textTheme = Theme.of(context).textTheme; return ListView( @@ -2309,10 +3013,12 @@ class _QualityBadge extends StatelessWidget { if (text == null || text.isEmpty) return const SizedBox.shrink(); return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(10), - ), + decoration: context.isMornye + ? null + : BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(10), + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index a353fd65..b5036ebe 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; import 'package:spotiflac_android/screens/track_history_snapshot.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/screens/selection_mode_mixin.dart'; @@ -15,7 +16,6 @@ import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/cover_art_utils.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; -import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/provider_resource_ids.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; @@ -26,6 +26,8 @@ import 'package:spotiflac_android/widgets/track_detail_actions.dart'; import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart'; import 'package:spotiflac_android/widgets/error_card.dart'; import 'package:spotiflac_android/widgets/downloadable_cover.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; class PlaylistScreen extends ConsumerStatefulWidget { final String playlistName; @@ -253,8 +255,21 @@ class _PlaylistScreenState extends ConsumerState @override Widget build(BuildContext context) { + if (context.isMornye) { + return MornyeArtistSurface( + imageSource: _coverUrl, + neutralActions: true, + child: Builder(builder: _buildPage), + ); + } + return _buildPage(context); + } + + Widget _buildPage(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final bottomPadding = MediaQuery.paddingOf(context).bottom; + final bottomPadding = isSelectionMode + ? MediaQuery.paddingOf(context).bottom + : 0.0; pruneSelection({ for (var index = 0; index < _tracks.length; index++) @@ -265,7 +280,6 @@ class _PlaylistScreenState extends ConsumerState scrollController: scrollController, isSelectionMode: isSelectionMode, onExitSelectionMode: exitSelectionMode, - bottomInset: context.navBarBottomInset, selectionBar: _buildSelectionBottomBar( context, colorScheme, @@ -310,6 +324,8 @@ class _PlaylistScreenState extends ConsumerState return AlbumDetailHeader( title: _playlistName, + immersive: context.isMornye, + squareArtwork: !hasMotion, expandedHeight: expandedHeight, showTitleInAppBar: showTitleInAppBar, background: hasMotion @@ -343,11 +359,24 @@ class _PlaylistScreenState extends ConsumerState actions: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - _buildLoveAllButton(), - const SizedBox(width: 12), - Flexible(child: _buildDownloadAllCenterButton(context)), - const SizedBox(width: 12), - _buildAddToPlaylistButton(context), + if (context.isMornye) ...[ + _buildLoveAllButton(), + const SizedBox(width: 16), + Flexible( + child: SizedBox( + width: 172, + child: _buildDownloadAllCenterButton(context), + ), + ), + const SizedBox(width: 16), + _buildAddToPlaylistButton(context), + ] else ...[ + _buildLoveAllButton(), + const SizedBox(width: 12), + Flexible(child: _buildDownloadAllCenterButton(context)), + const SizedBox(width: 12), + _buildAddToPlaylistButton(context), + ], ], ), ); @@ -363,10 +392,12 @@ class _PlaylistScreenState extends ConsumerState Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) { if (_isLoading) { - return const SliverToBoxAdapter( + return SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.all(16), - child: TrackListSkeleton(itemCount: 8), + padding: context.isMornye + ? EdgeInsets.zero + : const EdgeInsets.all(16), + child: const TrackListSkeleton(itemCount: 8), ), ); } @@ -488,6 +519,7 @@ class _PlaylistScreenState extends ConsumerState return HeaderCircleButton( icon: allLoved ? Icons.favorite : Icons.favorite_border, + tonal: true, iconColor: allLoved ? Theme.of(context).colorScheme.error : null, tooltip: allLoved ? context.l10n.trackOptionRemoveFromLoved @@ -498,8 +530,13 @@ class _PlaylistScreenState extends ConsumerState Widget _buildDownloadAllCenterButton(BuildContext context) { return HeaderFilledButton( - icon: Icons.download_rounded, - label: context.l10n.downloadAllCount(_tracks.length), + icon: context.isMornye + ? CupertinoIcons.arrow_down_circle_fill + : Icons.download_rounded, + tonal: true, + label: context.isMornye + ? context.l10n.dialogDownload + : context.l10n.downloadAllCount(_tracks.length), onPressed: _tracks.isEmpty ? null : () => _confirmDownloadAll(context), ); } @@ -507,6 +544,7 @@ class _PlaylistScreenState extends ConsumerState Widget _buildAddToPlaylistButton(BuildContext context) { return HeaderCircleButton( icon: Icons.playlist_add, + tonal: true, tooltip: context.l10n.tooltipAddToPlaylist, onPressed: _tracks.isEmpty ? null diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index e04c813a..64f66cb8 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -2,13 +2,19 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_choice_chip.dart'; import 'package:spotiflac_android/services/local_track_batch_actions.dart'; import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/widgets/error_card.dart'; import 'package:spotiflac_android/widgets/track_card.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; +import 'package:spotiflac_android/widgets/app_search_field.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:share_plus/share_plus.dart'; @@ -30,6 +36,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/local_track_redownload_service.dart'; @@ -775,7 +782,7 @@ class _QueueTabState extends ConsumerState { String? tempMetadata = _filterMetadata; String tempSortMode = _sortMode; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, @@ -831,13 +838,13 @@ class _QueueTabState extends ConsumerState { Wrap( spacing: 8, children: [ - FilterChip( + AppChoiceChip( label: Text(context.l10n.libraryFilterAll), selected: tempSource == null, onSelected: (_) => setSheetState(() => tempSource = null), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterDownloaded, ), @@ -846,7 +853,7 @@ class _QueueTabState extends ConsumerState { () => tempSource = 'downloaded', ), ), - FilterChip( + AppChoiceChip( label: Text(context.l10n.libraryFilterLocal), selected: tempSource == 'local', onSelected: (_) => @@ -865,13 +872,13 @@ class _QueueTabState extends ConsumerState { Wrap( spacing: 8, children: [ - FilterChip( + AppChoiceChip( label: Text(context.l10n.libraryFilterAll), selected: tempQuality == null, onSelected: (_) => setSheetState(() => tempQuality = null), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterQualityHiRes, ), @@ -879,7 +886,7 @@ class _QueueTabState extends ConsumerState { onSelected: (_) => setSheetState(() => tempQuality = 'hires'), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterQualityCD, ), @@ -887,7 +894,7 @@ class _QueueTabState extends ConsumerState { onSelected: (_) => setSheetState(() => tempQuality = 'cd'), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterQualityLossy, ), @@ -908,7 +915,7 @@ class _QueueTabState extends ConsumerState { Wrap( spacing: 8, children: [ - FilterChip( + AppChoiceChip( label: Text(context.l10n.libraryFilterAll), selected: tempFormat == null, onSelected: (_) => @@ -916,7 +923,7 @@ class _QueueTabState extends ConsumerState { ), for (final format in availableFormats.toList()..sort()) - FilterChip( + AppChoiceChip( label: Text(format.toUpperCase()), selected: tempFormat == format, onSelected: (_) => @@ -936,13 +943,13 @@ class _QueueTabState extends ConsumerState { spacing: 8, runSpacing: 8, children: [ - FilterChip( + AppChoiceChip( label: Text(context.l10n.libraryFilterAll), selected: tempMetadata == null, onSelected: (_) => setSheetState(() => tempMetadata = null), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterMetadataComplete, ), @@ -951,7 +958,7 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'complete', ), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterMetadataMissingAny, ), @@ -960,7 +967,7 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'missing-any', ), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterMetadataMissingYear, ), @@ -969,7 +976,7 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'missing-year', ), ), - FilterChip( + AppChoiceChip( label: Text( context .l10n @@ -980,7 +987,7 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'missing-genre', ), ), - FilterChip( + AppChoiceChip( label: Text( context .l10n @@ -992,7 +999,7 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'missing-album-artist', ), ), - FilterChip( + AppChoiceChip( label: Text( context .l10n @@ -1004,7 +1011,7 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'missing-track-number', ), ), - FilterChip( + AppChoiceChip( label: Text( context .l10n @@ -1015,7 +1022,7 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'missing-disc-number', ), ), - FilterChip( + AppChoiceChip( label: Text( context .l10n @@ -1026,7 +1033,7 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'missing-artist', ), ), - FilterChip( + AppChoiceChip( label: Text( context .l10n @@ -1038,7 +1045,7 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'incorrect-isrc-format', ), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterMetadataMissingIsrc, ), @@ -1047,7 +1054,7 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'missing-isrc', ), ), - FilterChip( + AppChoiceChip( label: Text( context .l10n @@ -1058,7 +1065,7 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'missing-label', ), ), - FilterChip( + AppChoiceChip( label: Text( context .l10n @@ -1082,7 +1089,7 @@ class _QueueTabState extends ConsumerState { Wrap( spacing: 8, children: [ - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterSortLatest, ), @@ -1091,7 +1098,7 @@ class _QueueTabState extends ConsumerState { () => tempSortMode = 'latest', ), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterSortOldest, ), @@ -1100,33 +1107,33 @@ class _QueueTabState extends ConsumerState { () => tempSortMode = 'oldest', ), ), - FilterChip( + AppChoiceChip( label: Text(context.l10n.searchSortTitleAZ), selected: tempSortMode == 'a-z', onSelected: (_) => setSheetState(() => tempSortMode = 'a-z'), ), - FilterChip( + AppChoiceChip( label: Text(context.l10n.searchSortTitleZA), selected: tempSortMode == 'z-a', onSelected: (_) => setSheetState(() => tempSortMode = 'z-a'), ), - FilterChip( + AppChoiceChip( label: Text(context.l10n.searchSortArtistAZ), selected: tempSortMode == 'artist-asc', onSelected: (_) => setSheetState( () => tempSortMode = 'artist-asc', ), ), - FilterChip( + AppChoiceChip( label: Text(context.l10n.searchSortArtistZA), selected: tempSortMode == 'artist-desc', onSelected: (_) => setSheetState( () => tempSortMode = 'artist-desc', ), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterSortAlbumAsc, ), @@ -1135,7 +1142,7 @@ class _QueueTabState extends ConsumerState { () => tempSortMode = 'album-asc', ), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterSortAlbumDesc, ), @@ -1144,21 +1151,21 @@ class _QueueTabState extends ConsumerState { () => tempSortMode = 'album-desc', ), ), - FilterChip( + AppChoiceChip( label: Text(context.l10n.searchSortDateNewest), selected: tempSortMode == 'release-newest', onSelected: (_) => setSheetState( () => tempSortMode = 'release-newest', ), ), - FilterChip( + AppChoiceChip( label: Text(context.l10n.searchSortDateOldest), selected: tempSortMode == 'release-oldest', onSelected: (_) => setSheetState( () => tempSortMode = 'release-oldest', ), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterSortGenreAsc, ), @@ -1167,7 +1174,7 @@ class _QueueTabState extends ConsumerState { () => tempSortMode = 'genre-asc', ), ), - FilterChip( + AppChoiceChip( label: Text( context.l10n.libraryFilterSortGenreDesc, ), @@ -1182,7 +1189,8 @@ class _QueueTabState extends ConsumerState { SizedBox( width: double.infinity, - child: FilledButton( + child: AppDialogAction( + filled: true, onPressed: () { setState(() { _filterSource = tempSource; @@ -1443,12 +1451,11 @@ class _QueueTabState extends ConsumerState { isLibraryPageLoading: isLibraryPageLoading, ); - final bottomPadding = MediaQuery.paddingOf(context).bottom; - final bottomInset = context.navBarBottomInset; final selectionItems = getFilterData( historyFilterMode, ).filteredUnifiedItems; if (_isSelectionMode || _isPlaylistSelectionMode) { + final bottomPadding = MediaQuery.paddingOf(context).bottom; WidgetsBinding.instance.addPostFrameCallback((_) { if (_isSelectionMode) { _syncSelectionOverlay( @@ -1494,53 +1501,14 @@ class _QueueTabState extends ConsumerState { padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: GestureDetector( onTap: () {}, - child: TextField( + child: AppSearchField( controller: _searchController, focusNode: _searchFocusNode, - autofocus: false, - canRequestFocus: true, - decoration: InputDecoration( - hintText: context.l10n.historySearchHint, - prefixIcon: const Icon(Icons.search), - suffixIcon: _searchQuery.isNotEmpty - ? IconButton( - tooltip: context.l10n.dialogClear, - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - _clearSearch(); - FocusScope.of(context).unfocus(); - }, - ) - : null, - filled: true, - fillColor: settingsGroupColor(context), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide( - color: colorScheme.outlineVariant, - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide( - color: colorScheme.outlineVariant, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide( - color: colorScheme.primary, - width: 2, - ), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 16, - ), - ), + hintText: context.l10n.historySearchHint, + clearTooltip: context.l10n.dialogClear, onChanged: _onSearchChanged, - onTapOutside: (_) { + onClear: () { + _clearSearch(); FocusScope.of(context).unfocus(); }, ), @@ -1567,6 +1535,9 @@ class _QueueTabState extends ConsumerState { size: 48, child: SingleChildScrollView( scrollDirection: Axis.horizontal, + padding: context.isMornye + ? const EdgeInsets.symmetric(vertical: 4) + : EdgeInsets.zero, child: Row( children: [ _FilterChip( @@ -1635,7 +1606,6 @@ class _QueueTabState extends ConsumerState { : false, isPageLoading: isLibraryPageLoading, inMemoryHistoryItems: inMemoryHistoryItems, - bottomInset: bottomInset, ); }, ), @@ -1753,17 +1723,18 @@ class _QueueTabState extends ConsumerState { BuildContext context, DownloadItem item, ) async { - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(context.l10n.cancelDownloadTitle), content: Text(context.l10n.cancelDownloadContent(item.track.name)), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.of(ctx).pop(false), child: Text(context.l10n.cancelDownloadKeep), ), - TextButton( + AppDialogAction( + isDestructive: true, onPressed: () => Navigator.of(ctx).pop(true), child: Text(context.l10n.dialogCancel), ), @@ -1799,9 +1770,9 @@ class _QueueTabState extends ConsumerState { : (item.errorMessage.trim().isNotEmpty ? _localizedDownloadError(context, item.errorMessage) : context.l10n.updateDownloadFailed); - final action = await showDialog( + final action = await showAppDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(title), content: SingleChildScrollView( child: Column( @@ -1823,22 +1794,25 @@ class _QueueTabState extends ConsumerState { ), ), actions: [ - TextButton( + AppDialogAction( + isDestructive: true, onPressed: () => Navigator.of(ctx).pop('remove'), style: TextButton.styleFrom(foregroundColor: colorScheme.error), child: Text(context.l10n.dialogRemove), ), - TextButton( + AppDialogAction( onPressed: () => Navigator.of(ctx).pop(), child: Text(context.l10n.dialogCancel), ), if (isFolderAccessLost) - FilledButton( + AppDialogAction( + filled: true, onPressed: () => Navigator.of(ctx).pop('reselect'), child: Text(context.l10n.downloadFolderReselect), ) else - FilledButton( + AppDialogAction( + filled: true, onPressed: () => Navigator.of(ctx).pop('retry'), child: Text(context.l10n.dialogRetry), ), diff --git a/lib/screens/queue_tab_filter_widgets.dart b/lib/screens/queue_tab_filter_widgets.dart index e79cd7d5..9f786121 100644 --- a/lib/screens/queue_tab_filter_widgets.dart +++ b/lib/screens/queue_tab_filter_widgets.dart @@ -42,7 +42,6 @@ extension _QueueTabFilterWidgets on _QueueTabState { required bool hasMoreLibrary, required bool isPageLoading, required List inMemoryHistoryItems, - double bottomInset = 0, }) { final historyItems = filterData.historyItems; final showFilteringIndicator = filterData.showFilteringIndicator; @@ -749,7 +748,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { SliverToBoxAdapter( child: SizedBox(height: _isSelectionMode ? 100 : 16), ), - SliverToBoxAdapter(child: SizedBox(height: bottomInset)), + const NavBarSliverSpacer(), ], ); @@ -777,17 +776,19 @@ extension _QueueTabFilterWidgets on _QueueTabState { WidgetRef ref, ColorScheme colorScheme, ) async { - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(context.l10n.queueClearAll), content: Text(context.l10n.queueClearAllMessage), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(ctx, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.pop(ctx, true), style: FilledButton.styleFrom(backgroundColor: colorScheme.error), child: Text(context.l10n.dialogClear), diff --git a/lib/screens/queue_tab_item_widgets.dart b/lib/screens/queue_tab_item_widgets.dart index 8b910086..450f044e 100644 --- a/lib/screens/queue_tab_item_widgets.dart +++ b/lib/screens/queue_tab_item_widgets.dart @@ -101,7 +101,8 @@ extension _QueueTabItemWidgets on _QueueTabState { SizedBox( width: double.infinity, - child: FilledButton.icon( + child: AppActionButton( + isDestructive: true, onPressed: selectedCount > 0 ? () => _deleteSelected(unifiedItems) : null, @@ -148,19 +149,20 @@ extension _QueueTabItemWidgets on _QueueTabState { direction: DismissDirection.endToStart, confirmDismiss: isActive ? (_) async { - return await showDialog( + return await showAppDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(context.l10n.cancelDownloadTitle), content: Text( context.l10n.cancelDownloadContent(item.track.name), ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.of(ctx).pop(false), child: Text(context.l10n.cancelDownloadKeep), ), - TextButton( + AppDialogAction( + isDestructive: true, onPressed: () => Navigator.of(ctx).pop(true), child: Text(context.l10n.dialogCancel), ), diff --git a/lib/screens/queue_tab_selection.dart b/lib/screens/queue_tab_selection.dart index 321a70fa..b674c047 100644 --- a/lib/screens/queue_tab_selection.dart +++ b/lib/screens/queue_tab_selection.dart @@ -181,9 +181,9 @@ extension _QueueTabSelectionActions on _QueueTabState { return; } - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(ctx.l10n.dialogDownloadAllTitle), content: Text( ctx.l10n.dialogDownloadPlaylistsMessage( @@ -192,11 +192,12 @@ extension _QueueTabSelectionActions on _QueueTabState { ), ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(ctx, false), child: Text(ctx.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, onPressed: () => Navigator.pop(ctx, true), child: Text(ctx.l10n.dialogDownload), ), @@ -279,17 +280,20 @@ extension _QueueTabSelectionActions on _QueueTabState { Future _deleteSelectedPlaylists(BuildContext context) async { final count = _selectedPlaylistIds.length; - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(ctx.l10n.collectionDeletePlaylist), content: Text(ctx.l10n.collectionDeletePlaylistsMessage(count)), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(ctx, false), child: Text(ctx.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.pop(ctx, true), style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, @@ -400,17 +404,20 @@ extension _QueueTabSelectionActions on _QueueTabState { Future _deleteSelected(List allItems) async { final count = _selectedIds.length; - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(context.l10n.dialogDeleteSelectedTitle), content: Text(context.l10n.dialogDeleteSelectedMessage(count)), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(ctx, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.pop(ctx, true), style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, diff --git a/lib/screens/queue_tab_widgets.dart b/lib/screens/queue_tab_widgets.dart index 7ef342a0..62bc9292 100644 --- a/lib/screens/queue_tab_widgets.dart +++ b/lib/screens/queue_tab_widgets.dart @@ -42,7 +42,7 @@ class _CollectionEntry { _CollectionEntry._(_CollectionEntryType.playlist, index); } -class _FilterChip extends StatelessWidget { +class _FilterChip extends ConsumerWidget { final String label; final int count; final bool isSelected; @@ -56,36 +56,89 @@ class _FilterChip extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; + final mornye = context.isMornye; - return FilterChip( - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(label), - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( + final content = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: mornye + ? Theme.of(context).textTheme.bodyMedium?.copyWith( + color: isSelected + ? colorScheme.onSurface + : colorScheme.onSurfaceVariant, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + ) + : null, + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.2) + : colorScheme.outline.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + count.toString(), + style: TextStyle( + fontSize: 11, color: isSelected - ? colorScheme.primary.withValues(alpha: 0.2) - : colorScheme.outline.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(10), + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, ), - child: Text( - count.toString(), - style: TextStyle( - fontSize: 11, - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, + ), + ), + ], + ); + + if (mornye) { + return Semantics( + button: true, + selected: isSelected, + label: '$label, $count', + excludeSemantics: true, + onTap: onTap, + child: MornyeGlass( + radius: 22, + blurEnabled: + !ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(22), + child: AnimatedContainer( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : context.tokens.motionFast, + constraints: const BoxConstraints(minHeight: 44), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.10) + : Colors.transparent, + borderRadius: BorderRadius.circular(22), + ), + child: content, ), ), ), - ], - ), + ), + ); + } + + return FilterChip( + label: content, selected: isSelected, onSelected: (_) => onTap(), showCheckmark: false, diff --git a/lib/screens/repo/extension_details_screen.dart b/lib/screens/repo/extension_details_screen.dart index f947a80c..60cccfe9 100644 --- a/lib/screens/repo/extension_details_screen.dart +++ b/lib/screens/repo/extension_details_screen.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_snack_bar.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; @@ -7,6 +9,10 @@ import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/services/extension_storage_service.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/widgets/extension_repo_card.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; class ExtensionDetailsScreen extends ConsumerStatefulWidget { final RepoExtension extension; @@ -130,7 +136,9 @@ class _ExtensionDetailsScreenState padding: EdgeInsets.only(left: edgeInset), child: IconButton( tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: const Icon(Icons.arrow_back), + icon: Icon( + context.isMornye ? CupertinoIcons.chevron_back : Icons.arrow_back, + ), onPressed: () => Navigator.pop(context), ), ), @@ -145,7 +153,9 @@ class _ExtensionDetailsScreenState return Container( color: colorScheme.surfaceContainerHighest, child: Icon( - _getCategoryIcon(ext.category), + context.isMornye + ? mornyeIconFor(_getCategoryIcon(ext.category)) + : _getCategoryIcon(ext.category), size: size, color: colorScheme.onSurfaceVariant, ), @@ -163,12 +173,8 @@ class _ExtensionDetailsScreenState padding: const EdgeInsets.all(16) + EdgeInsets.symmetric(horizontal: wideListInset(context)), - child: Card( - elevation: 0, + child: ExtensionRepoCard( color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -232,7 +238,11 @@ class _ExtensionDetailsScreenState if (ext.hasUpdate) FilledButton.icon( onPressed: () => _updateExtension(ext), - icon: const Icon(Icons.update), + icon: Icon( + context.isMornye + ? CupertinoIcons.arrow_clockwise + : Icons.update, + ), label: Text( '${context.l10n.storeUpdate} v${ext.version}', ), @@ -249,7 +259,11 @@ class _ExtensionDetailsScreenState Expanded( child: OutlinedButton.icon( onPressed: null, - icon: const Icon(Icons.check), + icon: Icon( + context.isMornye + ? CupertinoIcons.check_mark + : Icons.check, + ), label: Text(context.l10n.storeInstalled), style: OutlinedButton.styleFrom( minimumSize: const Size(0, 52), @@ -262,7 +276,11 @@ class _ExtensionDetailsScreenState const SizedBox(width: 12), IconButton.filled( onPressed: () => _uninstallExtension(ext), - icon: const Icon(Icons.delete_outline), + icon: Icon( + context.isMornye + ? CupertinoIcons.trash + : Icons.delete_outline, + ), style: IconButton.styleFrom( backgroundColor: colorScheme.errorContainer, foregroundColor: colorScheme.onErrorContainer, @@ -278,7 +296,11 @@ class _ExtensionDetailsScreenState else FilledButton.icon( onPressed: () => _installExtension(ext), - icon: const Icon(Icons.download), + icon: Icon( + context.isMornye + ? CupertinoIcons.arrow_down_circle + : Icons.download, + ), label: Text(context.l10n.storeInstall), style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(52), @@ -309,7 +331,11 @@ class _ExtensionDetailsScreenState EdgeInsets.symmetric(horizontal: wideListInset(context)), child: Row( children: [ - Icon(icon, size: 20, color: colorScheme.primary), + Icon( + context.isMornye ? mornyeIconFor(icon) : icon, + size: 20, + color: colorScheme.primary, + ), const SizedBox(width: 8), Text( title, @@ -360,17 +386,30 @@ class _ExtensionDetailsScreenState runSpacing: 8, children: ext.tags .map( - (tag) => Chip( - label: Text(tag), - backgroundColor: colorScheme.surfaceContainer, - labelStyle: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - side: BorderSide.none, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - ), + (tag) => context.isMornye + ? ExtensionRepoCard( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Text( + tag, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ) + : Chip( + label: Text(tag), + backgroundColor: colorScheme.surfaceContainer, + labelStyle: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + side: BorderSide.none, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), ) .toList(), ), @@ -388,12 +427,9 @@ class _ExtensionDetailsScreenState const EdgeInsets.symmetric(horizontal: 16, vertical: 8) + EdgeInsets.symmetric(horizontal: wideListInset(context)), sliver: SliverToBoxAdapter( - child: Card( - elevation: 0, + child: ExtensionRepoCard( color: colorScheme.surfaceContainer, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + radius: 16, child: Column( children: [ _MetadataRow( @@ -437,12 +473,9 @@ class _ExtensionDetailsScreenState const EdgeInsets.symmetric(horizontal: 16, vertical: 8) + EdgeInsets.symmetric(horizontal: wideListInset(context)), sliver: SliverToBoxAdapter( - child: Card( - elevation: 0, + child: ExtensionRepoCard( color: colorScheme.surfaceContainer, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + radius: 16, child: Column( children: [ _CapabilityRow( @@ -544,15 +577,14 @@ class _ExtensionDetailsScreenState .installExtension(ext.id, tempDir.path, storage.extensionsDir); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - success - ? context.l10n.snackbarExtensionInstalled(ext.displayName) - : context.l10n.snackbarFailedToInstall, - ), - behavior: SnackBarBehavior.floating, + showAppSnackBar( + context, + content: Text( + success + ? context.l10n.snackbarExtensionInstalled(ext.displayName) + : context.l10n.snackbarFailedToInstall, ), + behavior: SnackBarBehavior.floating, ); } } @@ -565,38 +597,39 @@ class _ExtensionDetailsScreenState .updateExtension(ext.id, tempDir.path); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - success - ? context.l10n.snackbarExtensionUpdated(ext.displayName) - : context.l10n.snackbarFailedToUpdate, - ), - behavior: SnackBarBehavior.floating, + showAppSnackBar( + context, + content: Text( + success + ? context.l10n.snackbarExtensionUpdated(ext.displayName) + : context.l10n.snackbarFailedToUpdate, ), + behavior: SnackBarBehavior.floating, ); } } Future _uninstallExtension(RepoExtension ext) async { - final confirm = await showDialog( + final confirm = await showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.dialogUninstallExtension), content: Text( context.l10n.dialogUninstallExtensionMessage(ext.displayName), ), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel), ), - TextButton( + AppDialogAction( + isDestructive: true, onPressed: () => Navigator.pop(context, true), - child: Text( - context.l10n.dialogUninstall, - style: TextStyle(color: Theme.of(context).colorScheme.error), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, ), + child: Text(context.l10n.dialogUninstall), ), ], ), @@ -735,7 +768,7 @@ class _CapabilityRow extends StatelessWidget { child: Row( children: [ Icon( - icon, + context.isMornye ? mornyeIconFor(icon) : icon, size: 20, color: enabled ? colorScheme.primary : colorScheme.outline, ), @@ -747,7 +780,11 @@ class _CapabilityRow extends StatelessWidget { ), ), Icon( - enabled ? Icons.check_circle : Icons.cancel_outlined, + context.isMornye + ? (enabled + ? CupertinoIcons.check_mark_circled + : CupertinoIcons.clear_circled) + : (enabled ? Icons.check_circle : Icons.cancel_outlined), size: 20, color: enabled ? colorScheme.primary : colorScheme.outline, ), diff --git a/lib/screens/repo_tab.dart b/lib/screens/repo_tab.dart index 5f97de80..7a1cd932 100644 --- a/lib/screens/repo_tab.dart +++ b/lib/screens/repo_tab.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_snack_bar.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons, CupertinoTextField; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:spotiflac_android/widgets/extension_row.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/widgets/app_search_field.dart'; @@ -12,6 +15,11 @@ import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/screens/repo/extension_details_screen.dart'; import 'package:spotiflac_android/services/extension_storage_service.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/extension_repo_card.dart'; class RepoTab extends ConsumerStatefulWidget { const RepoTab({super.key}); @@ -80,7 +88,6 @@ class _RepoTabState extends ConsumerState { ); } final colorScheme = Theme.of(context).colorScheme; - final bottomInset = context.navBarBottomInset; return Scaffold( body: RefreshIndicator( @@ -93,7 +100,9 @@ class _RepoTabState extends ConsumerState { actions: [ if (hasRegistryUrl) IconButton( - icon: const Icon(Icons.link), + icon: Icon( + context.isMornye ? CupertinoIcons.link : Icons.link, + ), tooltip: context.l10n.storeChangeRepoTooltip, onPressed: () => _showChangeRepoDialog(registryUrl), ), @@ -103,7 +112,10 @@ class _RepoTabState extends ConsumerState { if (!hasRegistryUrl) SliverFillRemaining( hasScrollBody: false, - child: _buildSetupRepoState(colorScheme, error), + child: Builder( + builder: (context) => + _buildSetupRepoState(context, colorScheme, error), + ), ) else ...[ SliverToBoxAdapter( @@ -220,34 +232,65 @@ class _RepoTabState extends ConsumerState { ), ), - SliverToBoxAdapter( - child: SettingsGroup( - children: filteredExtensions.asMap().entries.map((entry) { - final index = entry.key; - final ext = entry.value; - return _ExtensionItem( - extension: ext, - showDivider: index < filteredExtensions.length - 1, - isDownloading: downloadingId == ext.id, - onInstall: () => _installExtension(ext), - onUpdate: () => _updateExtension(ext), - onTap: () => _showExtensionDetails(ext), - ); - }).toList(), + if (context.isMornye) + SliverToBoxAdapter( + child: ExtensionRepoCard( + margin: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: filteredExtensions.asMap().entries.map(( + entry, + ) { + final index = entry.key; + final ext = entry.value; + return _ExtensionItem( + extension: ext, + showDivider: index < filteredExtensions.length - 1, + isDownloading: downloadingId == ext.id, + onInstall: () => _installExtension(ext), + onUpdate: () => _updateExtension(ext), + onTap: () => _showExtensionDetails(ext), + ); + }).toList(), + ), + ), + ) + else + SliverToBoxAdapter( + child: SettingsGroup( + children: filteredExtensions.asMap().entries.map((entry) { + final index = entry.key; + final ext = entry.value; + return _ExtensionItem( + extension: ext, + showDivider: index < filteredExtensions.length - 1, + isDownloading: downloadingId == ext.id, + onInstall: () => _installExtension(ext), + onUpdate: () => _updateExtension(ext), + onTap: () => _showExtensionDetails(ext), + ); + }).toList(), + ), ), - ), const SliverToBoxAdapter(child: SizedBox(height: 16)), ], ], - SliverToBoxAdapter(child: SizedBox(height: bottomInset)), + const NavBarSliverSpacer(), ], ), ), ); } - Widget _buildSetupRepoState(ColorScheme colorScheme, String? error) { + Widget _buildSetupRepoState( + BuildContext context, + ColorScheme colorScheme, + String? error, + ) { // Bottom padding keeps the content optically centered in the area // visible above the translucent nav bar. return Padding( @@ -358,9 +401,9 @@ class _RepoTabState extends ConsumerState { void _showChangeRepoDialog(String currentUrl) { final changeUrlController = TextEditingController(text: currentUrl); - showDialog( + showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.storeRepoDialogTitle), content: Column( mainAxisSize: MainAxisSize.min, @@ -383,47 +426,57 @@ class _RepoTabState extends ConsumerState { overflow: TextOverflow.ellipsis, ), const SizedBox(height: 16), - TextField( - controller: changeUrlController, - decoration: InputDecoration( - hintText: context.l10n.storeRepoUrlHint, - labelText: context.l10n.storeNewRepoUrlLabel, - prefixIcon: const Icon(Icons.link), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.outlineVariant, + if (context.isMornye) + CupertinoTextField( + controller: changeUrlController, + placeholder: context.l10n.storeNewRepoUrlLabel, + keyboardType: TextInputType.url, + autocorrect: false, + padding: const EdgeInsets.all(12), + ) + else + TextField( + controller: changeUrlController, + decoration: InputDecoration( + hintText: context.l10n.storeRepoUrlHint, + labelText: context.l10n.storeNewRepoUrlLabel, + prefixIcon: const Icon(Icons.link), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + ), + filled: true, + fillColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, ), ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.outlineVariant, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary, - width: 2, - ), - ), - filled: true, - fillColor: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 16, - ), + keyboardType: TextInputType.url, + autocorrect: false, ), - keyboardType: TextInputType.url, - autocorrect: false, - ), ], ), actions: [ - TextButton( + AppDialogAction( + isDestructive: true, onPressed: () { Navigator.of(context).pop(); ref.read(repoProvider.notifier).removeRegistryUrl(); @@ -433,11 +486,13 @@ class _RepoTabState extends ConsumerState { ), child: Text(context.l10n.dialogRemove), ), - TextButton( + AppDialogAction( onPressed: () => Navigator.of(context).pop(), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDefault: true, onPressed: () { final newUrl = changeUrlController.text.trim(); Navigator.of(context).pop(); @@ -540,15 +595,14 @@ class _RepoTabState extends ConsumerState { .installExtension(ext.id, tempDir.path, storage.extensionsDir); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - success - ? context.l10n.snackbarExtensionInstalledEnable(ext.displayName) - : context.l10n.snackbarFailedToInstallNamed(ext.displayName), - ), - behavior: SnackBarBehavior.floating, + showAppSnackBar( + context, + content: Text( + success + ? context.l10n.snackbarExtensionInstalledEnable(ext.displayName) + : context.l10n.snackbarFailedToInstallNamed(ext.displayName), ), + behavior: SnackBarBehavior.floating, ); } } @@ -561,24 +615,23 @@ class _RepoTabState extends ConsumerState { .updateExtension(ext.id, tempDir.path); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - success - ? context.l10n.snackbarExtensionUpdatedVersion( - ext.displayName, - ext.version, - ) - : context.l10n.snackbarFailedToUpdateNamed(ext.displayName), - ), - behavior: SnackBarBehavior.floating, + showAppSnackBar( + context, + content: Text( + success + ? context.l10n.snackbarExtensionUpdatedVersion( + ext.displayName, + ext.version, + ) + : context.l10n.snackbarFailedToUpdateNamed(ext.displayName), ), + behavior: SnackBarBehavior.floating, ); } } } -class _CategoryChip extends StatelessWidget { +class _CategoryChip extends ConsumerWidget { final String label; final IconData icon; final bool isSelected; @@ -592,8 +645,64 @@ class _CategoryChip extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; + if (context.isMornye) { + return Semantics( + button: true, + selected: isSelected, + label: label, + onTap: onTap, + excludeSemantics: true, + child: MornyeGlass.navigation( + radius: 24, + blurEnabled: + !ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(24), + child: Container( + constraints: const BoxConstraints(minHeight: 44), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.10) + : Colors.transparent, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + mornyeIconFor(icon), + size: 18, + color: isSelected + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: isSelected + ? colorScheme.primary + : colorScheme.onSurface, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w400, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } return FilterChip( label: Row( mainAxisSize: MainAxisSize.min, @@ -654,7 +763,9 @@ class _ExtensionItem extends StatelessWidget { onTap: onTap, avatar: ExtensionAvatar( imageUrl: extension.iconUrl, - fallbackIcon: _getCategoryIcon(extension.category), + fallbackIcon: context.isMornye + ? mornyeIconFor(_getCategoryIcon(extension.category)) + : _getCategoryIcon(extension.category), background: extension.isInstalled ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, @@ -727,6 +838,7 @@ class _ExtensionItem extends StatelessWidget { ? FilledButton.tonal( onPressed: onUpdate, style: FilledButton.styleFrom( + shape: context.isMornye ? const StadiumBorder() : null, padding: const EdgeInsets.symmetric(horizontal: 12), minimumSize: Size(0, context.tokens.minTouchTarget), ), @@ -736,13 +848,20 @@ class _ExtensionItem extends StatelessWidget { ? OutlinedButton( onPressed: null, style: OutlinedButton.styleFrom( + shape: context.isMornye ? const StadiumBorder() : null, padding: const EdgeInsets.symmetric(horizontal: 12), minimumSize: Size(0, context.tokens.minTouchTarget), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.check, size: 16, color: colorScheme.outline), + Icon( + context.isMornye + ? CupertinoIcons.check_mark + : Icons.check, + size: 16, + color: colorScheme.outline, + ), SizedBox(width: tokens.gapXs), Text( context.l10n.storeInstalled, @@ -754,6 +873,13 @@ class _ExtensionItem extends StatelessWidget { : FilledButton( onPressed: onInstall, style: FilledButton.styleFrom( + shape: context.isMornye ? const StadiumBorder() : null, + backgroundColor: context.isMornye + ? colorScheme.primary.withValues(alpha: 0.12) + : null, + foregroundColor: context.isMornye + ? colorScheme.primary + : null, padding: const EdgeInsets.symmetric(horizontal: 12), minimumSize: Size(0, context.tokens.minTouchTarget), ), diff --git a/lib/screens/settings/app_settings_page.dart b/lib/screens/settings/app_settings_page.dart index cfdf821a..7986eb12 100644 --- a/lib/screens/settings/app_settings_page.dart +++ b/lib/screens/settings/app_settings_page.dart @@ -1,4 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart' show CupertinoActivityIndicator; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; @@ -147,17 +151,19 @@ class AppSettingsPage extends ConsumerWidget { WidgetRef ref, ColorScheme colorScheme, ) { - showDialog( + showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.dialogDisableHistoryTitle), content: Text(context.l10n.dialogDisableHistoryMessage), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(context), child: Text(context.l10n.dialogCancel), ), - TextButton( + AppDialogAction( + isDestructive: true, onPressed: () { ref.read(downloadHistoryProvider.notifier).clearHistory(); ref.read(settingsProvider.notifier).setSaveDownloadHistory(false); @@ -166,10 +172,8 @@ class AppSettingsPage extends ConsumerWidget { SnackBar(content: Text(context.l10n.snackbarHistoryCleared)), ); }, - child: Text( - context.l10n.dialogDisableAndClear, - style: TextStyle(color: colorScheme.error), - ), + style: TextButton.styleFrom(foregroundColor: colorScheme.error), + child: Text(context.l10n.dialogDisableAndClear), ), ], ), @@ -181,17 +185,19 @@ class AppSettingsPage extends ConsumerWidget { WidgetRef ref, ColorScheme colorScheme, ) { - showDialog( + showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.dialogClearHistoryTitle), content: Text(context.l10n.dialogClearHistoryMessage), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(context), child: Text(context.l10n.dialogCancel), ), - TextButton( + AppDialogAction( + isDestructive: true, onPressed: () { ref.read(downloadHistoryProvider.notifier).clearHistory(); Navigator.pop(context); @@ -199,10 +205,8 @@ class AppSettingsPage extends ConsumerWidget { SnackBar(content: Text(context.l10n.snackbarHistoryCleared)), ); }, - child: Text( - context.l10n.dialogClear, - style: TextStyle(color: colorScheme.error), - ), + style: TextButton.styleFrom(foregroundColor: colorScheme.error), + child: Text(context.l10n.dialogClear), ), ], ), @@ -213,15 +217,18 @@ class AppSettingsPage extends ConsumerWidget { BuildContext context, WidgetRef ref, ) async { - showDialog( + showAppDialog( context: context, barrierDismissible: false, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( content: Row( children: [ - const CircularProgressIndicator(), + if (context.isMornye) + const CupertinoActivityIndicator() + else + const CircularProgressIndicator(), const SizedBox(width: 16), - Text(context.l10n.cleanupOrphanedDownloads), + Expanded(child: Text(context.l10n.cleanupOrphanedDownloads)), ], ), ), @@ -275,8 +282,12 @@ class _UpdateChannelSelector extends StatelessWidget { Row( children: [ Icon( - Icons.new_releases, - color: colorScheme.onSurfaceVariant, + context.isMornye + ? mornyeIconFor(Icons.new_releases) + : Icons.new_releases, + color: context.isMornye + ? colorScheme.primary + : colorScheme.onSurfaceVariant, size: 24, ), const SizedBox(width: 16), @@ -378,8 +389,12 @@ class _VerificationBrowserModeSelector extends StatelessWidget { Row( children: [ Icon( - Icons.open_in_browser, - color: colorScheme.onSurfaceVariant, + context.isMornye + ? mornyeIconFor(Icons.open_in_browser) + : Icons.open_in_browser, + color: context.isMornye + ? colorScheme.primary + : colorScheme.onSurfaceVariant, size: 24, ), const SizedBox(width: 16), diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 180f3cf8..07469055 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -1,9 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/constants/language_choices.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/theme_provider.dart'; +import 'package:spotiflac_android/models/theme_settings.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; @@ -23,6 +28,71 @@ class AppearanceSettingsPage extends ConsumerWidget { slivers: [ AppSliverHeader.page(title: context.l10n.appearanceTitle), + SliverToBoxAdapter( + child: SettingsSectionHeader(title: context.l10n.appearanceStyle), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + for (final style in AppThemeStyle.values) + ListTile( + title: Wrap( + spacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + style == AppThemeStyle.mornye + ? 'Mornye' + : 'Material', + ), + if (style == AppThemeStyle.mornye) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, + vertical: 2, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'Beta', + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + leading: Icon( + context.isMornye + ? mornyeIconFor( + style == AppThemeStyle.mornye + ? Icons.music_note_outlined + : Icons.palette_outlined, + ) + : style == AppThemeStyle.mornye + ? Icons.music_note_outlined + : Icons.palette_outlined, + ), + trailing: themeSettings.style == style + ? Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.primary, + ) + : null, + selected: themeSettings.style == style, + onTap: () => + ref.read(themeProvider.notifier).setStyle(style), + ), + ], + ), + ), + SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric( @@ -33,37 +103,39 @@ class AppearanceSettingsPage extends ConsumerWidget { ), ), - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.sectionColor), - ), - - SliverToBoxAdapter( - child: SettingsGroup( - children: [ - SettingsSwitchItem( - icon: Icons.wallpaper, - title: context.l10n.appearanceDynamicColor, - subtitle: context.l10n.appearanceDynamicColorSubtitle, - value: themeSettings.useDynamicColor, - onChanged: (value) => ref - .read(themeProvider.notifier) - .setUseDynamicColor(value), - showDivider: false, - ), - ], - ), - ), - if (!themeSettings.useDynamicColor) + if (themeSettings.style == AppThemeStyle.material) ...[ SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: _ColorPalettePicker( - currentColor: themeSettings.seedColorValue, - onColorSelected: (color) => - ref.read(themeProvider.notifier).setSeedColor(color), - ), + child: SettingsSectionHeader(title: context.l10n.sectionColor), + ), + + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsSwitchItem( + icon: Icons.wallpaper, + title: context.l10n.appearanceDynamicColor, + subtitle: context.l10n.appearanceDynamicColorSubtitle, + value: themeSettings.useDynamicColor, + onChanged: (value) => ref + .read(themeProvider.notifier) + .setUseDynamicColor(value), + showDivider: false, + ), + ], ), ), + if (!themeSettings.useDynamicColor) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: _ColorPalettePicker( + currentColor: themeSettings.seedColorValue, + onColorSelected: (color) => + ref.read(themeProvider.notifier).setSeedColor(color), + ), + ), + ), + ], SliverToBoxAdapter( child: SettingsSectionHeader(title: context.l10n.sectionTheme), @@ -76,7 +148,8 @@ class AppearanceSettingsPage extends ConsumerWidget { onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode), ), - if (Theme.of(context).brightness == Brightness.dark) + if (themeSettings.style == AppThemeStyle.material && + Theme.of(context).brightness == Brightness.dark) SettingsSwitchItem( icon: Icons.brightness_2, title: context.l10n.appearanceAmoledDark, @@ -164,6 +237,63 @@ class _ThemePreviewCard extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; + if (context.isMornye) { + return ExcludeSemantics( + child: IgnorePointer( + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.navLibrary, + style: Theme.of(context).textTheme.headlineLarge, + ), + const SizedBox(height: 16), + Row( + children: [ + Icon( + Icons.library_music_outlined, + color: colorScheme.primary, + ), + const SizedBox(width: 16), + Text( + 'SpotiFLAC Mobile', + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + const SizedBox(height: 20), + MornyeTabBar( + destinations: [ + NavigationDestination( + icon: const Icon(Icons.home_outlined), + label: context.l10n.navHome, + ), + NavigationDestination( + icon: const Icon(Icons.library_music_outlined), + label: context.l10n.navLibrary, + ), + NavigationDestination( + icon: const Icon(Icons.settings_outlined), + label: context.l10n.navSettings, + ), + ], + selectedIndex: 1, + onSelected: (_) {}, + blurEnabled: false, + ), + ], + ), + ), + ), + ); + } + return RepaintBoundary( child: LayoutBuilder( builder: (context, constraints) { @@ -178,7 +308,7 @@ class _ThemePreviewCard extends StatelessWidget { ); return Container( - constraints: BoxConstraints(minHeight: previewHeight), + height: previewHeight, width: double.infinity, decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, @@ -452,6 +582,21 @@ class _ThemeModeSelector extends StatelessWidget { @override Widget build(BuildContext context) { + if (context.isMornye) { + final labels = { + ThemeMode.system: context.l10n.appearanceThemeSystem, + ThemeMode.light: context.l10n.appearanceThemeLight, + ThemeMode.dark: context.l10n.appearanceThemeDark, + }; + return Padding( + padding: const EdgeInsets.all(12), + child: MornyeSegmentedControl( + labels: labels.values.toList(), + selectedIndex: labels.keys.toList().indexOf(currentMode), + onChanged: (index) => onChanged(labels.keys.elementAt(index)), + ), + ); + } return Padding( padding: const EdgeInsets.all(12), child: Row( @@ -577,7 +722,7 @@ class _LanguageSelector extends StatelessWidget { void _showLanguagePicker(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surface, @@ -602,7 +747,7 @@ class _LanguageSelector extends StatelessWidget { itemBuilder: (context, index) { final lang = supportedLanguageChoices[index]; final isSelected = currentLocale == lang.$1; - return ListTile( + return AppSheetOption( leading: Icon( lang.$3, color: isSelected diff --git a/lib/screens/settings/backup_restore_page.dart b/lib/screens/settings/backup_restore_page.dart index 73de81a2..a1b90827 100644 --- a/lib/screens/settings/backup_restore_page.dart +++ b/lib/screens/settings/backup_restore_page.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus, XFile; import 'package:spotiflac_android/l10n/l10n.dart'; @@ -176,11 +177,11 @@ class _BackupRestorePageState extends ConsumerState { Future _confirmRestore(BackupBundle bundle) { final l10n = context.l10n; - return showDialog( + return showAppDialog( context: context, builder: (dialogContext) { final theme = Theme.of(dialogContext); - return AlertDialog( + return AppAlertDialog( title: Text(l10n.backupRestoreConfirmTitle), content: Column( mainAxisSize: MainAxisSize.min, @@ -233,11 +234,14 @@ class _BackupRestorePageState extends ConsumerState { ], ), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.of(dialogContext).pop(false), child: Text(l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.of(dialogContext).pop(true), child: Text(l10n.backupRestoreConfirmButton), ), diff --git a/lib/screens/settings/cache_management_page.dart b/lib/screens/settings/cache_management_page.dart index a4766361..8b5ce546 100644 --- a/lib/screens/settings/cache_management_page.dart +++ b/lib/screens/settings/cache_management_page.dart @@ -2,6 +2,9 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -238,17 +241,20 @@ class _CacheManagementPageState extends ConsumerState { } Future _confirmClear(String target) async { - final confirm = await showDialog( + final confirm = await showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.cacheClearConfirmTitle), content: Text(context.l10n.cacheClearConfirmMessage(target)), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.dialogClear), ), @@ -259,17 +265,20 @@ class _CacheManagementPageState extends ConsumerState { } Future _confirmClearAll() async { - final confirm = await showDialog( + final confirm = await showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.cacheClearAllConfirmTitle), content: Text(context.l10n.cacheClearAllConfirmMessage), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.dialogClear), ), @@ -381,9 +390,28 @@ class _CacheManagementPageState extends ConsumerState { ); } + Widget _overviewSurface({required Widget child}) { + final scheme = Theme.of(context).colorScheme; + final mornye = context.isMornye; + final content = Padding(padding: const EdgeInsets.all(16), child: child); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: DecoratedBox( + decoration: BoxDecoration( + color: mornye + ? scheme.surfaceContainer + : scheme.primaryContainer.withValues(alpha: 0.28), + borderRadius: BorderRadius.circular(mornye ? 28 : 18), + ), + child: content, + ), + ); + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final mornye = context.isMornye; final overview = _overview; return Scaffold( @@ -406,13 +434,7 @@ class _CacheManagementPageState extends ConsumerState { ) else ...[ SliverToBoxAdapter( - child: Container( - margin: const EdgeInsets.fromLTRB(16, 16, 16, 4), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colorScheme.primaryContainer.withValues(alpha: 0.28), - borderRadius: BorderRadius.circular(18), - ), + child: _overviewSurface( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -420,7 +442,9 @@ class _CacheManagementPageState extends ConsumerState { context.l10n.cacheSummaryTitle, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, - color: colorScheme.onPrimaryContainer, + color: mornye + ? colorScheme.onSurface + : colorScheme.onPrimaryContainer, ), ), const SizedBox(height: 6), @@ -429,16 +453,20 @@ class _CacheManagementPageState extends ConsumerState { formatBytes(overview.totalKnownDiskCacheBytes), ), style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: colorScheme.onPrimaryContainer, + color: mornye + ? colorScheme.onSurface + : colorScheme.onPrimaryContainer, ), ), const SizedBox(height: 2), Text( context.l10n.cacheSummarySubtitle, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onPrimaryContainer.withValues( - alpha: 0.85, - ), + color: mornye + ? colorScheme.onSurfaceVariant + : colorScheme.onPrimaryContainer.withValues( + alpha: 0.85, + ), ), ), const SizedBox(height: 12), @@ -446,7 +474,12 @@ class _CacheManagementPageState extends ConsumerState { spacing: 8, runSpacing: 8, children: [ - FilledButton.tonalIcon( + AppActionButton( + isDestructive: true, + style: FilledButton.styleFrom( + backgroundColor: colorScheme.secondaryContainer, + foregroundColor: colorScheme.onSecondaryContainer, + ), onPressed: _isBusy ? null : () async { @@ -465,7 +498,9 @@ class _CacheManagementPageState extends ConsumerState { icon: const Icon(Icons.delete_sweep_outlined), label: Text(context.l10n.cacheClearAll), ), - OutlinedButton.icon( + AppActionButton( + outlined: true, + glass: false, onPressed: _isBusy ? null : _refreshOverview, icon: const Icon(Icons.refresh), label: Text(context.l10n.cacheRefreshStats), diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 042453cf..3e384e77 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -10,6 +10,7 @@ import 'package:spotiflac_android/utils/audio_format_utils.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; +import 'package:spotiflac_android/widgets/app_search_field.dart'; class DownloadSettingsPage extends ConsumerStatefulWidget { const DownloadSettingsPage({super.key}); @@ -403,7 +404,7 @@ class _DownloadSettingsPageState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ for (final format in autoConvertFormats) - ListTile( + AppSheetOption( leading: Icon( format == 'opus' ? Icons.graphic_eq @@ -451,7 +452,7 @@ class _DownloadSettingsPageState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ for (final bitrate in autoConvertBitrates) - ListTile( + AppSheetOption( leading: const Icon(Icons.speed_outlined), title: Text(bitrate.replaceAll('k', ' kbps')), trailing: normalizedCurrent == bitrate @@ -479,7 +480,7 @@ class _DownloadSettingsPageState extends ConsumerState { String current, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -506,7 +507,7 @@ class _DownloadSettingsPageState extends ConsumerState { ), ), ), - ListTile( + AppSheetOption( leading: const Icon(Icons.signal_cellular_alt), title: Text(context.l10n.settingsDownloadNetworkAny), subtitle: Text(context.l10n.downloadNetworkAnySubtitle), @@ -520,7 +521,7 @@ class _DownloadSettingsPageState extends ConsumerState { Navigator.pop(context); }, ), - ListTile( + AppSheetOption( leading: const Icon(Icons.wifi), title: Text(context.l10n.settingsDownloadNetworkWifiOnly), subtitle: Text(context.l10n.downloadNetworkWifiOnlySubtitle), @@ -547,7 +548,7 @@ class _DownloadSettingsPageState extends ConsumerState { int current, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -575,7 +576,7 @@ class _DownloadSettingsPageState extends ConsumerState { ), ), for (final count in const [1, 2, 3]) - ListTile( + AppSheetOption( leading: Icon( count == 1 ? Icons.looks_one_outlined @@ -797,7 +798,7 @@ class _DownloadSettingsPageState extends ConsumerState { ]; final colorScheme = Theme.of(context).colorScheme; final normalizedCurrent = current.trim().toUpperCase(); - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -883,25 +884,12 @@ class _RegionPickerSheetState extends State<_RegionPickerSheet> { ), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: TextField( + child: AppSearchField( controller: _searchController, - autofocus: false, - textInputAction: TextInputAction.search, onChanged: (value) => setState(() => _query = value), - decoration: InputDecoration( - hintText: MaterialLocalizations.of(context).searchFieldLabel, - prefixIcon: const Icon(Icons.search), - suffixIcon: _query.isEmpty - ? null - : IconButton( - tooltip: context.l10n.dialogClear, - icon: const Icon(Icons.close), - onPressed: () { - _searchController.clear(); - setState(() => _query = ''); - }, - ), - ), + hintText: MaterialLocalizations.of(context).searchFieldLabel, + clearTooltip: context.l10n.dialogClear, + onClear: () => setState(() => _query = ''), ), ), Expanded( @@ -920,7 +908,7 @@ class _RegionPickerSheetState extends State<_RegionPickerSheet> { final code = matches[index]; final isSelected = code == widget.selected; final countryName = widget.countryNameOf(context, code); - return ListTile( + return AppSheetOption( title: Text(countryName != code ? countryName : code), subtitle: countryName != code ? Text(code) : null, trailing: isSelected diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 7d081e23..73cad455 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -1,6 +1,14 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons, CupertinoTextField; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; +import 'package:spotiflac_android/widgets/app_snack_bar.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; @@ -94,14 +102,7 @@ class _ExtensionDetailPageState extends ConsumerState { SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues( - alpha: 0.3, - ), - borderRadius: BorderRadius.circular(20), - ), + child: _ExtensionOverviewSurface( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -111,7 +112,9 @@ class _ExtensionDetailPageState extends ConsumerState { width: 56, height: 56, decoration: BoxDecoration( - color: hasError + color: context.isMornye + ? MornyeTheme.controlFill(context) + : hasError ? colorScheme.errorContainer : colorScheme.primaryContainer, borderRadius: BorderRadius.circular(16), @@ -128,7 +131,13 @@ class _ExtensionDetailPageState extends ConsumerState { fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) => Icon( - hasError + context.isMornye + ? (hasError + ? CupertinoIcons + .exclamationmark_circle + : CupertinoIcons + .square_grid_2x2) + : hasError ? Icons.error_outline : Icons.extension, size: 28, @@ -140,7 +149,12 @@ class _ExtensionDetailPageState extends ConsumerState { ), ) : Icon( - hasError + context.isMornye + ? (hasError + ? CupertinoIcons + .exclamationmark_circle + : CupertinoIcons.square_grid_2x2) + : hasError ? Icons.error_outline : Icons.extension, size: 28, @@ -169,8 +183,9 @@ class _ExtensionDetailPageState extends ConsumerState { ], ), ), - Switch( + AppSwitch( value: extension.enabled, + semanticLabel: extension.displayName, onChanged: hasError ? null : (enabled) => ref @@ -191,16 +206,21 @@ class _ExtensionDetailPageState extends ConsumerState { ), ], const SizedBox(height: 16), + if (context.isMornye) + Divider( + color: MornyeTheme.metadataDividerColor(context), + ), _InfoRow( label: context.l10n.extensionId, value: extension.id, ), - _InfoRow( - label: context.l10n.extensionsVersion( - extension.version, + if (!context.isMornye) + _InfoRow( + label: context.l10n.extensionsVersion( + extension.version, + ), + value: '', ), - value: '', - ), if (hasError && extension.errorMessage != null) _InfoRow( label: context.l10n.extensionError, @@ -232,6 +252,7 @@ class _ExtensionDetailPageState extends ConsumerState { else SliverToBoxAdapter( child: SettingsGroup( + glass: true, children: extension.settings.asMap().entries.map((entry) { final index = entry.key; final setting = entry.value; @@ -257,6 +278,7 @@ class _ExtensionDetailPageState extends ConsumerState { ), SliverToBoxAdapter( child: SettingsGroup( + glass: true, children: [ _HealthSummaryItem( status: healthStatus, @@ -286,6 +308,7 @@ class _ExtensionDetailPageState extends ConsumerState { ), SliverToBoxAdapter( child: SettingsGroup( + glass: true, children: [ _URLHandlerInfo(patterns: extension.urlHandler!.patterns), ], @@ -302,6 +325,7 @@ class _ExtensionDetailPageState extends ConsumerState { ), SliverToBoxAdapter( child: SettingsGroup( + glass: true, children: extension.qualityOptions.asMap().entries.map(( entry, ) { @@ -325,6 +349,7 @@ class _ExtensionDetailPageState extends ConsumerState { ), SliverToBoxAdapter( child: SettingsGroup( + glass: true, children: extension.postProcessing!.hooks.asMap().entries.map( (entry) { final index = entry.key; @@ -348,6 +373,7 @@ class _ExtensionDetailPageState extends ConsumerState { ), SliverToBoxAdapter( child: SettingsGroup( + glass: true, children: extension.permissions.asMap().entries.map((entry) { final index = entry.key; final permission = entry.value; @@ -367,6 +393,7 @@ class _ExtensionDetailPageState extends ConsumerState { ), SliverToBoxAdapter( child: SettingsGroup( + glass: true, children: [ _CapabilityItem( icon: Icons.search, @@ -437,10 +464,10 @@ class _ExtensionDetailPageState extends ConsumerState { SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), - child: OutlinedButton.icon( + child: AppDialogAction( onPressed: () => _confirmRemove(context), - icon: const Icon(Icons.delete_outline), - label: Text(context.l10n.extensionRemoveButton), + isDestructive: true, + outlined: true, style: OutlinedButton.styleFrom( foregroundColor: colorScheme.error, side: BorderSide(color: colorScheme.error), @@ -449,6 +476,18 @@ class _ExtensionDetailPageState extends ConsumerState { borderRadius: BorderRadius.circular(16), ), ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + context.isMornye + ? CupertinoIcons.trash + : Icons.delete_outline, + ), + const SizedBox(width: 8), + Flexible(child: Text(context.l10n.extensionRemoveButton)), + ], + ), ), ), ), @@ -492,17 +531,20 @@ class _ExtensionDetailPageState extends ConsumerState { Future _confirmRemove(BuildContext context) async { final colorScheme = Theme.of(context).colorScheme; - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.dialogRemoveExtension), content: Text(context.l10n.dialogRemoveExtensionMessage), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.pop(context, true), style: FilledButton.styleFrom(backgroundColor: colorScheme.error), child: Text(context.l10n.dialogRemove), @@ -523,6 +565,44 @@ class _ExtensionDetailPageState extends ConsumerState { } } +class _ExtensionOverviewSurface extends StatelessWidget { + const _ExtensionOverviewSurface({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final content = Padding(padding: const EdgeInsets.all(20), child: child); + if (context.isMornye) return MornyeGlassPanel(child: content); + return DecoratedBox( + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(20), + ), + child: content, + ); + } +} + +class _ExtensionDivider extends StatelessWidget { + const _ExtensionDivider({this.indent = 56}); + + final double indent; + + @override + Widget build(BuildContext context) => Divider( + height: 1, + thickness: context.isMornye ? 0.5 : 1, + indent: indent, + endIndent: 16, + color: context.isMornye + ? MornyeTheme.metadataDividerColor(context) + : Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.3), + ); +} + /// Long OAuth URLs: selectable text so users can copy without relying on snackbars. class _OauthLoginLinkPreview extends StatelessWidget { final String? value; @@ -626,8 +706,10 @@ class _CapabilityItem extends StatelessWidget { child: Row( children: [ Icon( - icon, - color: enabled ? colorScheme.primary : colorScheme.outline, + context.isMornye ? mornyeIconFor(icon) : icon, + color: context.isMornye || enabled + ? colorScheme.primary + : colorScheme.outline, ), const SizedBox(width: 16), Expanded( @@ -648,20 +730,19 @@ class _CapabilityItem extends StatelessWidget { ), ), Icon( - enabled ? Icons.check_circle : Icons.cancel_outlined, - color: enabled ? colorScheme.primary : colorScheme.outline, + context.isMornye + ? (enabled + ? CupertinoIcons.check_mark_circled_solid + : CupertinoIcons.xmark_circle) + : (enabled ? Icons.check_circle : Icons.cancel_outlined), + color: context.isMornye || enabled + ? colorScheme.primary + : colorScheme.outline, ), ], ), ), - if (showDivider) - Divider( - height: 1, - thickness: 1, - indent: 56, - endIndent: 16, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), + if (showDivider) const _ExtensionDivider(), ], ); } @@ -680,7 +761,15 @@ Color _healthStatusColor(ColorScheme colorScheme, String status) { } } -IconData _healthStatusIcon(String status) { +IconData _healthStatusIcon(BuildContext context, String status) { + if (context.isMornye) { + return switch (status) { + 'online' => CupertinoIcons.check_mark_circled_solid, + 'degraded' => CupertinoIcons.exclamationmark_triangle, + 'offline' => CupertinoIcons.exclamationmark_circle, + _ => CupertinoIcons.question_circle, + }; + } switch (status) { case 'online': return Icons.check_circle; @@ -732,7 +821,7 @@ class _HealthSummaryItem extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ - Icon(_healthStatusIcon(statusValue), color: color), + Icon(_healthStatusIcon(context, statusValue), color: color), const SizedBox(width: 16), Expanded( child: Column( @@ -773,18 +862,16 @@ class _HealthSummaryItem extends StatelessWidget { color: colorScheme.primary, ), ) - : const Icon(Icons.refresh), + : Icon( + context.isMornye + ? CupertinoIcons.refresh + : Icons.refresh, + ), ), ], ), ), - Divider( - height: 1, - thickness: 1, - indent: 56, - endIndent: 16, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), + const _ExtensionDivider(), ], ); } @@ -822,7 +909,7 @@ class _HealthCheckItem extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(_healthStatusIcon(check.status), color: color), + Icon(_healthStatusIcon(context, check.status), color: color), const SizedBox(width: 16), Expanded( child: Column( @@ -858,14 +945,7 @@ class _HealthCheckItem extends StatelessWidget { ], ), ), - if (showDivider) - Divider( - height: 1, - thickness: 1, - indent: 56, - endIndent: 16, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), + if (showDivider) const _ExtensionDivider(), ], ); } @@ -899,7 +979,12 @@ class _PermissionItem extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ - Icon(icon, color: colorScheme.onSurfaceVariant), + Icon( + context.isMornye ? mornyeIconFor(icon) : icon, + color: context.isMornye + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), const SizedBox(width: 16), Expanded( child: Text( @@ -910,14 +995,7 @@ class _PermissionItem extends StatelessWidget { ], ), ), - if (showDivider) - Divider( - height: 1, - thickness: 1, - indent: 56, - endIndent: 16, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), + if (showDivider) const _ExtensionDivider(), ], ); } @@ -954,20 +1032,49 @@ class _SettingItemState extends State<_SettingItem> { Widget trailing; switch (widget.setting.type) { case 'boolean': - trailing = Switch( + trailing = AppSwitch( value: widget.value as bool? ?? false, + semanticLabel: widget.setting.label, onChanged: widget.onChanged, ); break; case 'select': - trailing = DropdownButton( - value: widget.value as String?, - items: widget.setting.options?.map((opt) { - return DropdownMenuItem(value: opt, child: Text(opt)); - }).toList(), - onChanged: widget.onChanged, - underline: const SizedBox(), - ); + trailing = context.isMornye + ? ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.42, + ), + child: TextButton( + onPressed: widget.setting.options?.isNotEmpty == true + ? _showOptions + : null, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + widget.value as String? ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + const Icon( + CupertinoIcons.chevron_up_chevron_down, + size: 16, + ), + ], + ), + ), + ) + : DropdownButton( + value: widget.value as String?, + items: widget.setting.options?.map((opt) { + return DropdownMenuItem(value: opt, child: Text(opt)); + }).toList(), + onChanged: widget.onChanged, + underline: const SizedBox(), + ); break; case 'button': trailing = _isLoading @@ -983,7 +1090,7 @@ class _SettingItemState extends State<_SettingItem> { break; default: trailing = Icon( - Icons.chevron_right, + context.isMornye ? CupertinoIcons.chevron_right : Icons.chevron_right, color: colorScheme.onSurfaceVariant, ); } @@ -1015,14 +1122,7 @@ class _SettingItemState extends State<_SettingItem> { ], ), ), - if (widget.showDivider) - Divider( - height: 1, - thickness: 1, - indent: 16, - endIndent: 16, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), + if (widget.showDivider) const _ExtensionDivider(indent: 16), ], ); } @@ -1079,22 +1179,40 @@ class _SettingItemState extends State<_SettingItem> { ), ), ), - if (widget.showDivider) - Divider( - height: 1, - thickness: 1, - indent: 16, - endIndent: 16, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), + if (widget.showDivider) const _ExtensionDivider(indent: 16), ], ); } + Future _showOptions() async { + final value = await showAppBottomSheet( + context: context, + title: widget.setting.label, + maxHeightFactor: 0.7, + builder: (sheetContext) => SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final option in widget.setting.options ?? []) + AppSheetOption( + title: Text(option), + trailing: option == widget.value + ? const Icon(CupertinoIcons.check_mark) + : null, + onTap: () => Navigator.pop(sheetContext, option), + ), + ], + ), + ), + ); + if (mounted && value != null) widget.onChanged(value); + } + Future _invokeAction(BuildContext context) async { if (widget.setting.action == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarNoActionDefined)), + showAppSnackBar( + context, + content: Text(context.l10n.snackbarNoActionDefined), ); return; } @@ -1121,13 +1239,12 @@ class _SettingItemState extends State<_SettingItem> { payload['error'] as String? ?? result['error'] as String? ?? context.l10n.extensionActionFailed; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.friendlyError( - error, - fallback: context.l10n.extensionActionFailed, - ), + showAppSnackBar( + context, + content: Text( + context.friendlyError( + error, + fallback: context.l10n.extensionActionFailed, ), ), ); @@ -1143,29 +1260,25 @@ class _SettingItemState extends State<_SettingItem> { browserMode: 'external_first', ); if (!launched && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarError('Could not open browser'), - ), + showAppSnackBar( + context, + content: Text( + context.l10n.snackbarError('Could not open browser'), ), ); } } final message = payload['message'] as String?; if (message != null && message.isNotEmpty && context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + showAppSnackBar(context, content: Text(message)); } } } } catch (e) { if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarError(context.friendlyError(e))), - ), + showAppSnackBar( + context, + content: Text(context.l10n.snackbarError(context.friendlyError(e))), ); } } finally { @@ -1181,34 +1294,51 @@ class _SettingItemState extends State<_SettingItem> { ); final colorScheme = Theme.of(context).colorScheme; - showDialog( + showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(widget.setting.label), - content: TextField( - controller: controller, - keyboardType: widget.setting.type == 'number' - ? TextInputType.number - : TextInputType.text, - decoration: InputDecoration( - hintText: - widget.setting.description ?? context.l10n.extensionEnterValue, - filled: true, - fillColor: colorScheme.surfaceContainerHighest.withValues( - alpha: 0.3, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - ), - ), + content: context.isMornye + ? Padding( + padding: const EdgeInsets.only(top: 12), + child: CupertinoTextField( + controller: controller, + keyboardType: widget.setting.type == 'number' + ? TextInputType.number + : TextInputType.text, + placeholder: + widget.setting.description ?? + context.l10n.extensionEnterValue, + padding: const EdgeInsets.all(12), + ), + ) + : TextField( + controller: controller, + keyboardType: widget.setting.type == 'number' + ? TextInputType.number + : TextInputType.text, + decoration: InputDecoration( + hintText: + widget.setting.description ?? + context.l10n.extensionEnterValue, + filled: true, + fillColor: colorScheme.surfaceContainerHighest.withValues( + alpha: 0.3, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(context), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDefault: true, onPressed: () { final newValue = widget.setting.type == 'number' ? num.tryParse(controller.text) @@ -1220,7 +1350,7 @@ class _SettingItemState extends State<_SettingItem> { ), ], ), - ); + ).then((_) => controller.dispose()); } } @@ -1245,12 +1375,18 @@ class _PostProcessingHookItem extends StatelessWidget { width: 40, height: 40, decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, + color: context.isMornye + ? Colors.transparent + : colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(10), ), child: Icon( - Icons.auto_fix_high, - color: colorScheme.onTertiaryContainer, + context.isMornye + ? CupertinoIcons.wand_stars + : Icons.auto_fix_high, + color: context.isMornye + ? colorScheme.primary + : colorScheme.onTertiaryContainer, size: 20, ), ), @@ -1322,14 +1458,7 @@ class _PostProcessingHookItem extends StatelessWidget { ], ), ), - if (showDivider) - Divider( - height: 1, - thickness: 1, - indent: 72, - endIndent: 16, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), + if (showDivider) const _ExtensionDivider(indent: 72), ], ); } @@ -1355,12 +1484,16 @@ class _URLHandlerInfo extends StatelessWidget { width: 48, height: 48, decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, + color: context.isMornye + ? Colors.transparent + : colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(12), ), child: Icon( - Icons.link, - color: colorScheme.onTertiaryContainer, + context.isMornye ? CupertinoIcons.link : Icons.link, + color: context.isMornye + ? colorScheme.primary + : colorScheme.onTertiaryContainer, size: 24, ), ), @@ -1467,12 +1600,18 @@ class _QualityOptionItem extends StatelessWidget { width: 40, height: 40, decoration: BoxDecoration( - color: colorScheme.secondaryContainer, + color: context.isMornye + ? Colors.transparent + : colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(10), ), child: Icon( - Icons.high_quality, - color: colorScheme.onSecondaryContainer, + context.isMornye + ? CupertinoIcons.waveform + : Icons.high_quality, + color: context.isMornye + ? colorScheme.primary + : colorScheme.onSecondaryContainer, size: 20, ), ), @@ -1497,14 +1636,16 @@ class _QualityOptionItem extends StatelessWidget { ), ), ], - const SizedBox(height: 4), - Text( - quality.id, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.primary, - fontFamily: 'monospace', + if (!context.isMornye) ...[ + const SizedBox(height: 4), + Text( + quality.id, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.primary, + fontFamily: 'monospace', + ), ), - ), + ], ], ), ), @@ -1530,14 +1671,7 @@ class _QualityOptionItem extends StatelessWidget { ], ), ), - if (showDivider) - Divider( - height: 1, - thickness: 1, - indent: 72, - endIndent: 16, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), + if (showDivider) const _ExtensionDivider(indent: 72), ], ); } diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index 1f6f0c7d..3a20397b 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -1,5 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_snack_bar.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; import 'package:spotiflac_android/widgets/extension_row.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:file_picker/file_picker.dart'; @@ -14,6 +16,9 @@ import 'package:spotiflac_android/screens/settings/extension_detail_page.dart'; import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart'; import 'package:spotiflac_android/screens/settings/provider_priority_page.dart'; import 'package:spotiflac_android/services/extension_storage_service.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; @@ -51,6 +56,17 @@ class _ExtensionsPageState extends ConsumerState { Widget build(BuildContext context) { final extState = ref.watch(extensionProvider); final colorScheme = Theme.of(context).colorScheme; + final installButton = AppActionButton( + onPressed: _installExtension, + icon: const Icon(Icons.add), + label: Text(context.l10n.extensionsInstallButton), + outlined: context.isMornye, + glass: false, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + ); return PopScope( canPop: true, @@ -186,23 +202,19 @@ class _ExtensionsPageState extends ConsumerState { label: context.l10n.extensionsInstallButton, child: Padding( padding: const EdgeInsets.all(16), - child: FilledButton.icon( - onPressed: _installExtension, - icon: const Icon(Icons.add), - label: Text(context.l10n.extensionsInstallButton), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - ), + child: context.isMornye + ? MornyeGlassPanel.overlay( + radius: 28, + child: installButton, + ) + : installButton, ), ), ), SliverToBoxAdapter( child: SettingsInfoCard( + glass: true, icon: Icons.info_outline, tone: SettingsInfoTone.warning, message: context.l10n.extensionsInfoTip, @@ -226,8 +238,9 @@ class _ExtensionsPageState extends ConsumerState { }); if (hasInvalidFile) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarSelectExtFile)), + showAppSnackBar( + context, + content: Text(context.l10n.snackbarSelectExtFile), ); } return; @@ -265,9 +278,7 @@ class _ExtensionsPageState extends ConsumerState { final message = _getInstallResultMessage(installResult); ref.read(extensionProvider.notifier).clearError(); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + showAppSnackBar(context, content: Text(message)); } } finally { try { @@ -366,8 +377,9 @@ class _ExtensionItem extends StatelessWidget { : serviceHealthColor ?? colorScheme.onSurfaceVariant, ), ), - trailing: Switch( + trailing: AppSwitch( value: extension.enabled, + semanticLabel: extension.displayName, onChanged: hasError ? null : onToggle, ), ); @@ -412,6 +424,27 @@ class _DownloadPriorityItem extends ConsumerWidget { (e) => e.enabled && e.hasDownloadProvider, ); + if (context.isMornye) { + return Opacity( + opacity: hasDownloadExtensions ? 1 : 0.5, + child: SettingsItem( + icon: Icons.download, + title: context.l10n.extensionsDownloadPriority, + subtitle: hasDownloadExtensions + ? context.l10n.extensionsDownloadPrioritySubtitle + : context.l10n.extensionsNoDownloadProvider, + onTap: hasDownloadExtensions + ? () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const ProviderPriorityPage(), + ), + ) + : null, + ), + ); + } + final content = InkWell( onTap: hasDownloadExtensions ? () => Navigator.push( @@ -483,6 +516,27 @@ class _MetadataPriorityItem extends ConsumerWidget { (e) => e.enabled && e.hasMetadataProvider, ); + if (context.isMornye) { + return Opacity( + opacity: hasMetadataExtensions ? 1 : 0.5, + child: SettingsItem( + icon: Icons.search, + title: context.l10n.extensionsMetadataPriority, + subtitle: hasMetadataExtensions + ? context.l10n.extensionsMetadataPrioritySubtitle + : context.l10n.extensionsNoMetadataProvider, + onTap: hasMetadataExtensions + ? () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const MetadataProviderPriorityPage(), + ), + ) + : null, + ), + ); + } + final content = InkWell( onTap: hasMetadataExtensions ? () => Navigator.push( @@ -554,6 +608,27 @@ class _DownloadFallbackItem extends ConsumerWidget { (e) => e.enabled && e.hasDownloadProvider, ); + if (context.isMornye) { + return Opacity( + opacity: hasDownloadExtensions ? 1 : 0.5, + child: SettingsItem( + icon: Icons.alt_route, + title: context.l10n.extensionsFallbackTitle, + subtitle: hasDownloadExtensions + ? context.l10n.extensionsFallbackSubtitle + : context.l10n.extensionsNoDownloadProvider, + onTap: hasDownloadExtensions + ? () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const DownloadFallbackExtensionsPage(), + ), + ) + : null, + ), + ); + } + final content = InkWell( onTap: hasDownloadExtensions ? () => Navigator.push( @@ -640,6 +715,27 @@ class _SearchProviderSelector extends ConsumerWidget { currentProviderName = ext?.displayName ?? resolvedProviderId; } + if (context.isMornye) { + return Opacity( + opacity: hasAnyProvider ? 1 : 0.5, + child: SettingsItem( + icon: Icons.manage_search, + title: context.l10n.extensionsSearchProvider, + subtitle: hasAnyProvider + ? currentProviderName + : context.l10n.extensionsNoCustomSearch, + onTap: hasAnyProvider + ? () => _showSearchProviderPicker( + context, + ref, + settings, + searchProviders, + ) + : null, + ), + ); + } + final content = Column( mainAxisSize: MainAxisSize.min, children: [ @@ -795,6 +891,23 @@ class _HomeFeedProviderSelector extends ConsumerWidget { currentProviderName = ext?.displayName ?? settings.homeFeedProvider!; } + if (context.isMornye) { + return SettingsItem( + icon: Icons.explore_outlined, + title: context.l10n.extensionsHomeFeedProvider, + subtitle: !hasAnyProvider && !homeFeedDisabled + ? context.l10n.extensionsNoHomeFeedExtensions + : currentProviderName, + showDivider: false, + onTap: () => _showHomeFeedProviderPicker( + context, + ref, + settings, + homeFeedProviders, + ), + ); + } + final content = Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/screens/settings/files_settings_page.dart b/lib/screens/settings/files_settings_page.dart index aee04866..5a570de3 100644 --- a/lib/screens/settings/files_settings_page.dart +++ b/lib/screens/settings/files_settings_page.dart @@ -1,6 +1,10 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/app_choice_chip.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -82,17 +86,19 @@ class _FilesSettingsPageState extends ConsumerState { if (mounted) setState(() => _hasAllFilesAccess = true); } else if (status.isPermanentlyDenied) { if (mounted) { - final shouldOpen = await showDialog( + final shouldOpen = await showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.setupStorageAccessRequired), content: Text(context.l10n.allFilesAccessDeniedMessage), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDefault: true, onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.setupOpenSettings), ), @@ -486,7 +492,7 @@ class _FilesSettingsPageState extends ConsumerState { final settings = ref.read(settingsProvider); final isSafMode = settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -513,7 +519,7 @@ class _FilesSettingsPageState extends ConsumerState { ), ), ), - ListTile( + AppSheetOption( leading: Icon(Icons.folder_special, color: colorScheme.primary), title: Text(context.l10n.storageAutomaticFolder), subtitle: Text(context.l10n.storageAutomaticDownloadFolder), @@ -527,7 +533,7 @@ class _FilesSettingsPageState extends ConsumerState { notifier.setDownloadTreeUri(''); }, ), - ListTile( + AppSheetOption( leading: Icon(Icons.folder_open, color: colorScheme.primary), title: Text(context.l10n.storageSafRecommended), subtitle: Text(context.l10n.storageDownloadFolderHint), @@ -547,7 +553,7 @@ class _FilesSettingsPageState extends ConsumerState { void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -574,7 +580,7 @@ class _FilesSettingsPageState extends ConsumerState { ), ), ), - ListTile( + AppSheetOption( leading: Icon(Icons.folder_special, color: colorScheme.primary), title: Text(context.l10n.setupAppDocumentsFolder), subtitle: Text(context.l10n.setupAppDocumentsFolderSubtitle), @@ -587,7 +593,7 @@ class _FilesSettingsPageState extends ConsumerState { if (ctx.mounted) Navigator.pop(ctx); }, ), - ListTile( + AppSheetOption( leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant), title: Text(context.l10n.setupChooseFromFiles), subtitle: Text(context.l10n.setupChooseFromFilesSubtitle), @@ -665,7 +671,7 @@ class _FilesSettingsPageState extends ConsumerState { final save = onSave ?? ref.read(settingsProvider.notifier).setFilenameFormat; - showModalBottomSheet( + showAppModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, @@ -685,7 +691,7 @@ class _FilesSettingsPageState extends ConsumerState { String current, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -755,7 +761,7 @@ class _FilesSettingsPageState extends ConsumerState { Icons.person_outline_outlined, ), ]) - ListTile( + AppSheetOption( leading: Icon(option.$4), title: Text(option.$2), subtitle: Text(option.$3), @@ -782,7 +788,7 @@ class _FilesSettingsPageState extends ConsumerState { String current, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -883,7 +889,7 @@ class _FolderOption extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return ListTile( + return AppSheetOption( contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), title: Text(title), subtitle: Column( @@ -997,6 +1003,13 @@ class _FilenameFormatEditorSheetState } Widget _tagChip(ColorScheme colorScheme, String tag) { + if (context.isMornye) { + return AppChoiceChip( + label: Text(tag), + selected: false, + onSelected: (_) => _insertTag(tag), + ); + } return ActionChip( label: Text(tag), onPressed: () => _insertTag(tag), @@ -1082,7 +1095,7 @@ class _FilenameFormatEditorSheetState .toList(), ), const SizedBox(height: 12), - SwitchListTile( + AppSwitchListTile( value: _showAdvancedTags, onChanged: (value) => setState(() => _showAdvancedTags = value), @@ -1106,7 +1119,7 @@ class _FilenameFormatEditorSheetState Row( children: [ Expanded( - child: TextButton( + child: AppDialogAction( onPressed: () => Navigator.pop(context), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), @@ -1120,7 +1133,8 @@ class _FilenameFormatEditorSheetState const SizedBox(width: 12), Expanded( flex: 2, - child: FilledButton( + child: AppDialogAction( + filled: true, onPressed: () { widget.onSave(_controller.text); Navigator.pop(context); diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 0f653811..33ca9d34 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -1,5 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:file_picker/file_picker.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -86,17 +88,19 @@ class _LibrarySettingsPageState extends ConsumerState { return true; } else if (status.isPermanentlyDenied) { if (mounted) { - final shouldOpen = await showDialog( + final shouldOpen = await showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.libraryStorageAccessRequired), content: Text(context.l10n.libraryStorageAccessMessage), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDefault: true, onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.setupOpenSettings), ), @@ -201,17 +205,20 @@ class _LibrarySettingsPageState extends ConsumerState { } Future _clearLibrary() async { - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.libraryClearConfirmTitle), content: Text(context.l10n.libraryClearConfirmMessage), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.pop(context, true), style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, @@ -246,17 +253,20 @@ class _LibrarySettingsPageState extends ConsumerState { } Future _removeSource(LocalLibrarySource source) async { - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.libraryRemoveFolder), content: Text(context.l10n.libraryRemoveFolderMessage), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.pop(context, true), style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, @@ -291,7 +301,7 @@ class _LibrarySettingsPageState extends ConsumerState { void _showAutoScanPicker(BuildContext context, String current) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -397,7 +407,7 @@ class _LibrarySettingsPageState extends ConsumerState { ('singles', Icons.music_note, context.l10n.historyFilterSingles), ('playlists', Icons.queue_music, context.l10n.searchPlaylists), ]; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -484,7 +494,7 @@ class _LibrarySettingsPageState extends ConsumerState { context.l10n.libraryQualityLabelFileFormat, ), ]; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -1008,21 +1018,21 @@ class _LibrarySourceSettingsItem extends StatelessWidget { builder: (context) => Column( mainAxisSize: MainAxisSize.min, children: [ - ListTile( + AppSheetOption( enabled: canScan, leading: const Icon(Icons.refresh_rounded), title: Text(context.l10n.libraryScan), subtitle: Text(context.l10n.libraryScanSubtitle), onTap: canScan ? () => Navigator.pop(context, 'scan') : null, ), - ListTile( + AppSheetOption( enabled: canScan, leading: const Icon(Icons.sync_rounded), title: Text(context.l10n.libraryForceFullScan), subtitle: Text(context.l10n.libraryForceFullScanSubtitle), onTap: canScan ? () => Navigator.pop(context, 'full_scan') : null, ), - ListTile( + AppSheetOption( leading: Icon( Icons.delete_outline, color: Theme.of(context).colorScheme.error, @@ -1099,7 +1109,9 @@ class _LibrarySourceSettingsItem extends StatelessWidget { determinate: totalFiles > 0 && !isFinalizing, ), ), - Switch.adaptive( + AppSwitch( + adaptive: true, + semanticLabel: title, value: source.enabled, onChanged: enabled ? onEnabledChanged : null, ), @@ -1531,7 +1543,7 @@ class _AutoScanOption extends StatelessWidget { @override Widget build(BuildContext context) { - return ListTile( + return AppSheetOption( leading: Icon(icon), title: Text(title), trailing: selected ? Icon(Icons.check, color: colorScheme.primary) : null, diff --git a/lib/screens/settings/log_screen.dart b/lib/screens/settings/log_screen.dart index d5471ef9..1f9241dc 100644 --- a/lib/screens/settings/log_screen.dart +++ b/lib/screens/settings/log_screen.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:flutter/services.dart'; import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; +import 'package:spotiflac_android/widgets/app_search_field.dart'; import 'package:spotiflac_android/utils/ordered_range_selection.dart'; final RegExp _domainPattern = RegExp( @@ -197,17 +199,20 @@ class _LogScreenState extends State { } void _clearLogs() { - showDialog( + showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.logClearLogsTitle), content: Text(context.l10n.logClearLogsMessage), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(context), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () { _selectionMode = false; _selectedEntries.clear(); @@ -426,43 +431,13 @@ class _LogScreenState extends State { horizontal: 20, vertical: 12, ), - child: Row( - children: [ - Icon(Icons.search, color: colorScheme.onSurfaceVariant), - const SizedBox(width: 16), - Expanded( - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: context.l10n.logSearchHint, - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - filled: true, - fillColor: colorScheme.surfaceContainerHighest, - suffixIcon: _searchQuery.isNotEmpty - ? IconButton( - tooltip: context.l10n.logClearSearch, - icon: const Icon(Icons.clear, size: 20), - onPressed: () { - _searchController.clear(); - setState(() => _searchQuery = ''); - }, - ) - : null, - ), - onChanged: (value) { - setState(() => _searchQuery = value); - }, - ), - ), - ], + child: AppSearchField( + controller: _searchController, + hintText: context.l10n.logSearchHint, + clearTooltip: context.l10n.logClearSearch, + onClear: () => setState(() => _searchQuery = ''), + onChanged: (value) => + setState(() => _searchQuery = value), ), ), ], diff --git a/lib/screens/settings/lyrics_provider_priority_page.dart b/lib/screens/settings/lyrics_provider_priority_page.dart index e13a1238..41860362 100644 --- a/lib/screens/settings/lyrics_provider_priority_page.dart +++ b/lib/screens/settings/lyrics_provider_priority_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; @@ -114,14 +115,10 @@ class _LyricsProviderPriorityPageState iconColor: Theme.of(context).colorScheme.primary, name: info.name, subtitle: info.description, - trailing: SizedBox( - height: 32, - child: FittedBox( - child: Switch( - value: true, - onChanged: (_) => _disableProvider(id), - ), - ), + trailing: AppSwitch( + value: true, + semanticLabel: info.name, + onChanged: (_) => _disableProvider(id), ), ); }, @@ -346,11 +343,10 @@ class _DisabledProviderItem extends StatelessWidget { ], ), ), - SizedBox( - height: 32, - child: FittedBox( - child: Switch(value: false, onChanged: (_) => onToggle()), - ), + AppSwitch( + value: false, + semanticLabel: info.name, + onChanged: (_) => onToggle(), ), ], ), diff --git a/lib/screens/settings/lyrics_settings_page.dart b/lib/screens/settings/lyrics_settings_page.dart index 0fafb17d..01773d9a 100644 --- a/lib/screens/settings/lyrics_settings_page.dart +++ b/lib/screens/settings/lyrics_settings_page.dart @@ -6,6 +6,9 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/settings/lyrics_provider_priority_page.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; class LyricsSettingsPage extends ConsumerWidget { const LyricsSettingsPage({super.key}); @@ -214,7 +217,7 @@ class LyricsSettingsPage extends ConsumerWidget { String current, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -241,7 +244,7 @@ class LyricsSettingsPage extends ConsumerWidget { ), ), ), - ListTile( + AppSheetOption( leading: const Icon(Icons.audiotrack), title: Text(context.l10n.lyricsModeEmbed), subtitle: Text(context.l10n.lyricsModeEmbedSubtitle), @@ -251,7 +254,7 @@ class LyricsSettingsPage extends ConsumerWidget { Navigator.pop(context); }, ), - ListTile( + AppSheetOption( leading: const Icon(Icons.insert_drive_file_outlined), title: Text(context.l10n.lyricsModeExternal), subtitle: Text(context.l10n.lyricsModeExternalSubtitle), @@ -261,7 +264,7 @@ class LyricsSettingsPage extends ConsumerWidget { Navigator.pop(context); }, ), - ListTile( + AppSheetOption( leading: const Icon(Icons.library_music_outlined), title: Text(context.l10n.lyricsModeBoth), subtitle: Text(context.l10n.lyricsModeBothSubtitle), @@ -286,7 +289,7 @@ class LyricsSettingsPage extends ConsumerWidget { final colorScheme = Theme.of(context).colorScheme; final controller = TextEditingController(text: currentLanguage); - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -325,38 +328,55 @@ class LyricsSettingsPage extends ConsumerWidget { ), ), const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.dialogCancel), - ), - const SizedBox(width: 8), - TextButton( - onPressed: () { - ref - .read(settingsProvider.notifier) - .setMusixmatchLanguage(''); - Navigator.pop(context); - }, - child: Text(context.l10n.downloadMusixmatchAuto), - ), - const SizedBox(width: 8), - FilledButton( - onPressed: () { - final normalized = controller.text - .trim() - .toLowerCase() - .replaceAll(RegExp(r'[^a-z0-9\-_]'), ''); - ref - .read(settingsProvider.notifier) - .setMusixmatchLanguage(normalized); - Navigator.pop(context); - }, - child: Text(context.l10n.dialogSave), - ), - ], + Builder( + builder: (context) { + final actions = [ + AppDialogAction( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.dialogCancel), + ), + AppDialogAction( + onPressed: () { + ref + .read(settingsProvider.notifier) + .setMusixmatchLanguage(''); + Navigator.pop(context); + }, + child: Text(context.l10n.downloadMusixmatchAuto), + ), + AppDialogAction( + filled: true, + onPressed: () { + final normalized = controller.text + .trim() + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9\-_]'), ''); + ref + .read(settingsProvider.notifier) + .setMusixmatchLanguage(normalized); + Navigator.pop(context); + }, + child: Text(context.l10n.dialogSave), + ), + ]; + return context.isMornye + ? Wrap( + alignment: WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: actions, + ) + : Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + actions[0], + const SizedBox(width: 8), + actions[1], + const SizedBox(width: 8), + actions[2], + ], + ); + }, ), ], ), diff --git a/lib/screens/settings/metadata_settings_page.dart b/lib/screens/settings/metadata_settings_page.dart index 04bab912..90cd522f 100644 --- a/lib/screens/settings/metadata_settings_page.dart +++ b/lib/screens/settings/metadata_settings_page.dart @@ -6,6 +6,7 @@ import 'package:spotiflac_android/utils/artist_utils.dart'; import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; class MetadataSettingsPage extends ConsumerWidget { const MetadataSettingsPage({super.key}); @@ -173,7 +174,7 @@ class MetadataSettingsPage extends ConsumerWidget { ) { const options = [0, 500, 1000, 1500, 2000]; final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, @@ -203,7 +204,7 @@ class MetadataSettingsPage extends ConsumerWidget { ), ), for (final maxDimension in options) - ListTile( + AppSheetOption( leading: Icon( maxDimension == 0 ? Icons.image_outlined @@ -236,7 +237,7 @@ class MetadataSettingsPage extends ConsumerWidget { String currentMode, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showAppModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -263,7 +264,7 @@ class MetadataSettingsPage extends ConsumerWidget { ), ), ), - ListTile( + AppSheetOption( leading: const Icon(Icons.segment_outlined), title: Text(context.l10n.optionsArtistTagModeJoined), subtitle: Text(context.l10n.optionsArtistTagModeJoinedSubtitle), @@ -277,7 +278,7 @@ class MetadataSettingsPage extends ConsumerWidget { Navigator.pop(context); }, ), - ListTile( + AppSheetOption( leading: const Icon(Icons.library_music_outlined), title: Text(context.l10n.optionsArtistTagModeSplitVorbis), subtitle: Text( diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index c5299359..0efa3863 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -17,6 +17,7 @@ import 'package:spotiflac_android/screens/settings/lyrics_settings_page.dart'; import 'package:spotiflac_android/screens/settings/metadata_settings_page.dart'; import 'package:spotiflac_android/screens/settings/settings_search_catalog.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; @@ -266,7 +267,9 @@ class _SettingsTabState extends ConsumerState { final destination = targetLabel == null ? page : SettingsSearchHighlightScope(targetLabel: targetLabel, child: page); - await Navigator.of(context).push(slidePageRoute(page: destination)); + await Navigator.of( + context, + ).push(slidePageRoute(page: MornyeSettingsTheme(child: destination))); if (!mounted) return; // A route's focus scope remembers its previously focused child. Keep the @@ -284,7 +287,7 @@ class _SettingsTabState extends ConsumerState { return SettingsItem( icon: destination.icon, title: destination.title, - subtitle: destination.subtitle, + subtitle: context.isMornye ? null : destination.subtitle, showDivider: showDivider, onTap: () => _navigateTo(context, destination.pageBuilder()), ); @@ -315,16 +318,15 @@ class _SettingsTabState extends ConsumerState { @override Widget build(BuildContext context) { final tokens = context.tokens; - final bottomInset = context.navBarBottomInset; final wideInset = wideListInset(context); final query = SettingsSearchQuery(_query); final groups = _groups(context); final margin = EdgeInsets.fromLTRB( tokens.gapLg + wideInset, - tokens.gapSm, + context.isMornye ? 12 : tokens.gapSm, tokens.gapLg + wideInset, - tokens.gapSm, + context.isMornye ? 12 : tokens.gapSm, ); final List body; @@ -384,31 +386,33 @@ class _SettingsTabState extends ConsumerState { ]; } - return CustomScrollView( - slivers: [ - AppSliverHeader.tabRoot(title: context.l10n.settingsTitle), - SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.fromLTRB( - tokens.gapLg + wideInset, - tokens.gapSm, - tokens.gapLg + wideInset, - tokens.gapSm, - ), - child: AppSearchField( - controller: _searchController, - focusNode: _searchFocusNode, - hintText: context.l10n.settingsSearchHint, - clearTooltip: context.l10n.dialogClear, - onChanged: (value) => setState(() => _query = value), - onClear: () => setState(() => _query = ''), + return MornyeSettingsTheme( + child: CustomScrollView( + slivers: [ + AppSliverHeader.tabRoot(title: context.l10n.settingsTitle), + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.fromLTRB( + tokens.gapLg + wideInset, + tokens.gapSm, + tokens.gapLg + wideInset, + tokens.gapSm, + ), + child: AppSearchField( + controller: _searchController, + focusNode: _searchFocusNode, + hintText: context.l10n.settingsSearchHint, + clearTooltip: context.l10n.dialogClear, + onChanged: (value) => setState(() => _query = value), + onClear: () => setState(() => _query = ''), + ), ), ), - ), - ...body, - SliverToBoxAdapter(child: SizedBox(height: bottomInset)), - const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), - ], + ...body, + const NavBarSliverSpacer(), + const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), + ], + ), ); } } diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 584d396e..f002f896 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:path_provider/path_provider.dart'; @@ -170,20 +171,21 @@ class _SetupScreenState extends ConsumerState { } Future _showAndroid11StorageDialog() { - return showDialog( + return showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.setupStorageAccessRequired), content: Text( '${context.l10n.setupStorageAccessMessageAndroid11}\n\n' '${context.l10n.setupAllowAccessToManageFiles}', ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.setupOpenSettings), ), @@ -219,19 +221,20 @@ class _SetupScreenState extends ConsumerState { } Future _showPermissionDeniedDialog(String permissionType) async { - await showDialog( + await showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.setupPermissionRequired(permissionType)), content: Text( context.l10n.setupPermissionRequiredMessage(permissionType), ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(context), child: Text(context.l10n.dialogCancel), ), - TextButton( + AppDialogAction( + filled: true, onPressed: () { Navigator.pop(context); openAppSettings(); @@ -281,19 +284,20 @@ class _SetupScreenState extends ConsumerState { if (_selectedTreeUri == null || _selectedTreeUri!.isEmpty) { final defaultDir = await _getDefaultDirectory(); if (mounted) { - final useDefault = await showDialog( + final useDefault = await showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.setupUseDefaultFolder), content: Text( '${context.l10n.setupNoFolderSelected}\n\n$defaultDir', ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel), ), - TextButton( + AppDialogAction( + filled: true, onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.setupUseDefault), ), diff --git a/lib/screens/track_metadata_actions.dart b/lib/screens/track_metadata_actions.dart index 5e85a03e..f54e5993 100644 --- a/lib/screens/track_metadata_actions.dart +++ b/lib/screens/track_metadata_actions.dart @@ -58,7 +58,9 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState { context: context, useRootNavigator: true, isScrollControlled: true, - backgroundColor: colorScheme.surface, + backgroundColor: context.isMornye + ? colorScheme.surfaceContainerLow + : colorScheme.surface, builder: (sheetContext) => _EditMetadataSheet( colorScheme: colorScheme, initialValues: initialValues, @@ -118,18 +120,18 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState { WidgetRef ref, ColorScheme colorScheme, ) { - showDialog( + showAppDialog( context: screenContext, - useRootNavigator: true, - builder: (dialogContext) => AlertDialog( + builder: (dialogContext) => AppAlertDialog( title: Text(dialogContext.l10n.trackDeleteConfirmTitle), content: Text(dialogContext.l10n.trackDeleteConfirmMessage), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(dialogContext), child: Text(dialogContext.l10n.dialogCancel), ), - TextButton( + AppDialogAction( + isDestructive: true, onPressed: () async { var fileDeleted = true; if (_isLocalItem) { @@ -180,10 +182,8 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState { } }); }, - child: Text( - dialogContext.l10n.dialogDelete, - style: TextStyle(color: colorScheme.error), - ), + style: TextButton.styleFrom(foregroundColor: colorScheme.error), + child: Text(dialogContext.l10n.dialogDelete), ), ], ), diff --git a/lib/screens/track_metadata_cards.dart b/lib/screens/track_metadata_cards.dart index 24fb10a6..fd888085 100644 --- a/lib/screens/track_metadata_cards.dart +++ b/lib/screens/track_metadata_cards.dart @@ -308,10 +308,9 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { ColorScheme colorScheme, int? fileSize, ) { - return Card( - elevation: 0, - color: settingsGroupColor(context), - shape: _sectionCardShape(colorScheme), + return _metadataSectionSurface( + context, + colorScheme, child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -319,13 +318,19 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { children: [ Row( children: [ - Icon(Icons.info_outline, size: 20, color: colorScheme.primary), + Icon( + context.isMornye ? CupertinoIcons.info : Icons.info_outline, + size: 20, + color: colorScheme.primary, + ), const SizedBox(width: 8), - Text( - context.l10n.trackMetadata, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, + Expanded( + child: Text( + context.l10n.trackMetadata, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), ), ), ], @@ -365,6 +370,13 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { } else { buttonLabel = context.l10n.trackOpenInSpotify; } + if (context.isMornye) { + return HeaderFilledButton( + icon: CupertinoIcons.arrow_up_right_square, + label: buttonLabel, + onPressed: () => _openServiceUrl(context), + ); + } return OutlinedButton.icon( onPressed: () => _openServiceUrl(context), icon: const Icon(Icons.open_in_new, size: 18), @@ -578,6 +590,28 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { metadata.label == 'Amazon ASIN' || metadata.label == 'Tidal ID' || metadata.label == 'Qobuz ID'; + if (context.isMornye) { + return MornyeMetadataRow( + label: metadata.label, + value: metadata.value, + showDivider: metadata != items.last, + trailing: isCopyable + ? Icon( + CupertinoIcons.doc_on_doc, + size: 16, + color: colorScheme.onSurfaceVariant, + ) + : null, + onTap: isCopyable + ? () => _copyToClipboard(context, metadata.rawValue) + : null, + onLongPress: () => _showMetadataCopySheet( + context, + metadata: metadata, + allMetadata: items, + ), + ); + } return InkWell( onTap: isCopyable ? () => _copyToClipboard(context, metadata.rawValue) @@ -719,10 +753,9 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { final resolvedQuality = _displayAudioQuality; final lossyBitrateLabel = _extractLossyBitrateLabel(resolvedQuality); - return Card( - elevation: 0, - color: settingsGroupColor(context), - shape: _sectionCardShape(colorScheme), + return _metadataSectionSurface( + context, + colorScheme, child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -731,16 +764,20 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { Row( children: [ Icon( - Icons.folder_outlined, + context.isMornye + ? CupertinoIcons.folder + : Icons.folder_outlined, size: 20, color: colorScheme.primary, ), const SizedBox(width: 8), - Text( - context.l10n.trackFileInfo, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, + Expanded( + child: Text( + context.l10n.trackFileInfo, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), ), ), ], @@ -933,7 +970,7 @@ extension _TrackMetadataCards on _TrackMetadataScreenState { ), const SizedBox(width: 8), Icon( - Icons.copy, + context.isMornye ? CupertinoIcons.doc_on_doc : Icons.copy, size: 18, color: colorScheme.onSurfaceVariant, ), diff --git a/lib/screens/track_metadata_convert.dart b/lib/screens/track_metadata_convert.dart index 15a60380..534d96ff 100644 --- a/lib/screens/track_metadata_convert.dart +++ b/lib/screens/track_metadata_convert.dart @@ -248,6 +248,8 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { context: context, useRootNavigator: true, isScrollControlled: true, + backgroundColor: context.isMornye ? Colors.transparent : null, + elevation: context.isMornye ? 0 : null, builder: (sheetContext) => BatchConvertSheet( formats: formats, title: context.l10n.trackConvertTitle, @@ -492,20 +494,21 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { required String date, required List tracks, }) { - showDialog( + showAppDialog( context: context, builder: (dialogContext) { - return AlertDialog( + return AppAlertDialog( title: Text(dialogContext.l10n.cueSplitConfirmTitle), content: Text( dialogContext.l10n.cueSplitConfirmMessage(album, tracks.length), ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(dialogContext), child: Text(dialogContext.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, onPressed: () { Navigator.pop(dialogContext); _performCueSplit( @@ -745,10 +748,10 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { bool keepOriginal = false, }) { final isLossless = isLosslessConversionTarget(targetFormat); - showDialog( + showAppDialog( context: context, builder: (dialogContext) { - return AlertDialog( + return AppAlertDialog( title: Text(dialogContext.l10n.trackConvertConfirmTitle), content: Text( keepOriginal @@ -782,11 +785,12 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { ), ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(dialogContext), child: Text(dialogContext.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, onPressed: () { Navigator.pop(dialogContext); _performConversion( diff --git a/lib/screens/track_metadata_edit_sheet.dart b/lib/screens/track_metadata_edit_sheet.dart index 0e5c3f17..f97a5c5a 100644 --- a/lib/screens/track_metadata_edit_sheet.dart +++ b/lib/screens/track_metadata_edit_sheet.dart @@ -284,7 +284,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { Flexible( child: SingleChildScrollView( padding: const EdgeInsets.only(bottom: 16), - child: SettingsGroup( + child: _editorOptions( children: [ for (var index = 0; index < options.length; index++) _metadataProviderOption( @@ -356,7 +356,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { Flexible( child: SingleChildScrollView( padding: const EdgeInsets.only(bottom: 16), - child: SettingsGroup( + child: _editorOptions( children: [ for (var index = 0; index < options.length; index++) Column( @@ -368,7 +368,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { vertical: 4, ), leading: Icon( - options[index].value == 0 + context.isMornye + ? CupertinoIcons.photo + : options[index].value == 0 ? Icons.photo_size_select_actual_outlined : Icons.photo_size_select_large_outlined, color: options[index].value == currentValue @@ -379,7 +381,11 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { selected: options[index].value == currentValue, selectedColor: cs.primary, trailing: options[index].value == currentValue - ? const Icon(Icons.check_rounded) + ? Icon( + context.isMornye + ? CupertinoIcons.checkmark + : Icons.check_rounded, + ) : null, onTap: () => Navigator.pop( sheetContext, @@ -432,13 +438,19 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { vertical: 4, ), leading: Icon( - icon, + context.isMornye ? mornyeIconFor(icon) : icon, color: selected ? cs.primary : cs.onSurfaceVariant, ), title: Text(name, maxLines: 2, overflow: TextOverflow.ellipsis), selected: selected, selectedColor: cs.primary, - trailing: selected ? const Icon(Icons.check_rounded) : null, + trailing: selected + ? Icon( + context.isMornye + ? CupertinoIcons.checkmark + : Icons.check_rounded, + ) + : null, onTap: () => Navigator.pop(context, id), ), if (showDivider) @@ -1274,7 +1286,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { builder: (sheetContext) => ListView( padding: const EdgeInsets.only(bottom: 16), children: [ - SettingsGroup( + _editorOptions( children: [ for (var index = 0; index < candidates.length; index++) ...[ Builder( @@ -2137,16 +2149,20 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ), if (_saving) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), child: SizedBox( width: 22, height: 22, - child: CircularProgressIndicator(strokeWidth: 2), + child: context.isMornye + ? const CupertinoActivityIndicator() + : const CircularProgressIndicator( + strokeWidth: 2, + ), ), ) else - FilledButton.icon( + AppActionButton( onPressed: _save, icon: const Icon(Icons.check, size: 18), label: Text(context.l10n.dialogSave), @@ -2378,6 +2394,25 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { runSpacing: 4, children: _fieldDefs.keys.map((key) { final selected = _autoFillFields.contains(key); + if (context.isMornye) { + return MornyeFilterChip( + label: _fieldLabel(key), + selected: selected, + glass: false, + tonal: false, + icon: selected ? CupertinoIcons.checkmark : null, + onTap: _fetching + ? null + : () => setState(() { + _invalidateAutoFillPreview(); + if (selected) { + _autoFillFields.remove(key); + } else { + _autoFillFields.add(key); + } + }), + ); + } return FilterChip( label: Text(_fieldLabel(key)), selected: selected, @@ -2406,7 +2441,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { const SizedBox(height: 14), SizedBox( width: double.infinity, - child: FilledButton.icon( + child: AppActionButton( + outlined: context.isMornye, + glass: false, onPressed: (_fetching || _saving || _autoFillFields.isEmpty) ? null : _fetchAutoFillPreview, @@ -2440,7 +2477,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { const SizedBox(height: 8), SizedBox( width: double.infinity, - child: OutlinedButton.icon( + child: AppActionButton( + outlined: true, + glass: false, onPressed: (_fetching || _fetchingMusicBrainz || _saving) ? null : _fetchFromMusicBrainz, @@ -2476,12 +2515,16 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { return Container( width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: cs.surfaceContainerHighest.withValues(alpha: 0.45), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: cs.outlineVariant), - ), + padding: context.isMornye + ? const EdgeInsets.symmetric(vertical: 12) + : const EdgeInsets.all(12), + decoration: context.isMornye + ? null + : BoxDecoration( + color: cs.surfaceContainerHighest.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: cs.outlineVariant), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -2570,7 +2613,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { const SizedBox(height: 6), SizedBox( width: double.infinity, - child: FilledButton.icon( + child: AppActionButton( + outlined: context.isMornye, + glass: false, onPressed: (_fetching || _saving) ? null : _applyAutoFillPreview, icon: const Icon(Icons.check), label: Text(context.l10n.editMetadataAutoFillApply), @@ -2619,11 +2664,36 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } } + Widget _editorOptions({required List children}) { + if (!context.isMornye) return SettingsGroup(children: children); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Material( + color: MornyeTheme.controlFill(context), + borderRadius: BorderRadius.circular(28), + clipBehavior: Clip.antiAlias, + child: Column(mainAxisSize: MainAxisSize.min, children: children), + ), + ); + } + Widget _quickSelectButton({ required String label, required VoidCallback onTap, required ColorScheme cs, }) { + if (context.isMornye) { + return CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + onPressed: _fetching ? null : onTap, + child: Text( + label, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.primary), + ), + ); + } return InkWell( onTap: _fetching ? null : onTap, borderRadius: BorderRadius.circular(16), @@ -2668,7 +2738,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { Row( children: [ Expanded( - child: OutlinedButton.icon( + child: AppActionButton( + outlined: true, + glass: false, onPressed: _saving ? null : _pickCoverImage, icon: const Icon(Icons.image_outlined), label: Text( @@ -2846,6 +2918,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { /// Fill for input fields, one step apart from the card so each field reads as /// a distinct surface in light/dark/AMOLED. Color _fieldFill(ColorScheme cs) { + if (context.isMornye) return Colors.transparent; final isDark = Theme.of(context).brightness == Brightness.dark; return isDark ? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), cs.surface) @@ -2878,36 +2951,50 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ), ), - TextField( - controller: controller, - keyboardType: keyboard, - inputFormatters: inputFormatters, - maxLines: maxLines, - cursorColor: cs.primary, - style: Theme.of(context).textTheme.bodyLarge, - decoration: InputDecoration( - hintText: hint, - filled: true, - fillColor: _fieldFill(cs), - isDense: true, - border: OutlineInputBorder( - borderRadius: radius, - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: radius, - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: radius, - borderSide: BorderSide(color: cs.primary, width: 1.5), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 15, + if (context.isMornye) ...[ + CupertinoTextField( + controller: controller, + keyboardType: keyboard, + inputFormatters: inputFormatters, + maxLines: maxLines, + placeholder: hint, + cursorColor: cs.primary, + style: Theme.of(context).textTheme.bodyLarge, + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 14), + decoration: null, + ), + const Divider(height: 0.5), + ] else + TextField( + controller: controller, + keyboardType: keyboard, + inputFormatters: inputFormatters, + maxLines: maxLines, + cursorColor: cs.primary, + style: Theme.of(context).textTheme.bodyLarge, + decoration: InputDecoration( + hintText: hint, + filled: true, + fillColor: _fieldFill(cs), + isDense: true, + border: OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide(color: cs.primary, width: 1.5), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 15, + ), ), ), - ), ], ), ); @@ -2922,22 +3009,30 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { final cs = widget.colorScheme; return Padding( padding: const EdgeInsets.only(bottom: 14), - child: Material( - color: _fieldFill(cs), - borderRadius: BorderRadius.circular(14), - clipBehavior: Clip.antiAlias, - child: SwitchListTile.adaptive( - value: value, - onChanged: onChanged, - contentPadding: const EdgeInsets.symmetric(horizontal: 16), - title: Text(label, style: Theme.of(context).textTheme.bodyLarge), - subtitle: Text( - subtitle, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), + child: Column( + children: [ + Material( + color: _fieldFill(cs), + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, + child: AppSwitchListTile( + adaptive: true, + value: value, + onChanged: onChanged, + contentPadding: EdgeInsets.symmetric( + horizontal: context.isMornye ? 4 : 16, + ), + title: Text(label, style: Theme.of(context).textTheme.bodyLarge), + subtitle: Text( + subtitle, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ), ), - ), + if (context.isMornye) const Divider(height: 0.5), + ], ), ); } @@ -2949,6 +3044,53 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { bool enabled = true, }) { final cs = widget.colorScheme; + if (context.isMornye) { + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 6), + child: Text( + label, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ), + CupertinoButton( + color: _fieldFill(cs), + disabledColor: Colors.transparent, + borderRadius: BorderRadius.circular(16), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 14), + onPressed: enabled ? onTap : null, + child: Row( + children: [ + Expanded( + child: Text( + value, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: enabled ? cs.onSurface : cs.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 12), + Icon( + CupertinoIcons.chevron_up_chevron_down, + size: 16, + color: cs.primary, + ), + ], + ), + ), + const Divider(height: 0.5), + ], + ), + ); + } final radius = BorderRadius.circular(14); return Padding( padding: const EdgeInsets.only(bottom: 14), @@ -3019,10 +3161,15 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { }) { final cs = widget.colorScheme; final collapsible = onHeaderTap != null; + const inset = 16.0; final headerRow = Row( children: [ - Icon(icon, size: 20, color: cs.primary), + Icon( + context.isMornye ? mornyeIconFor(icon) : icon, + size: 20, + color: cs.primary, + ), const SizedBox(width: 10), Expanded( child: Text( @@ -3039,7 +3186,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { duration: const Duration(milliseconds: 200), curve: Curves.easeOutCubic, child: Icon( - Icons.expand_more, + context.isMornye + ? CupertinoIcons.chevron_down + : Icons.expand_more, size: 22, color: cs.onSurfaceVariant, ), @@ -3051,43 +3200,56 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ? InkWell( onTap: onHeaderTap, child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + padding: EdgeInsets.fromLTRB(inset, 16, inset, 16), child: headerRow, ), ) : Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + padding: EdgeInsets.fromLTRB(inset, 16, inset, 8), child: headerRow, ); + final content = AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + header, + if (children.isNotEmpty) + Padding( + padding: EdgeInsets.fromLTRB(inset, 0, inset, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ), + ], + ), + ); return Padding( padding: const EdgeInsets.only(bottom: 12), - child: Card( - elevation: 0, - margin: EdgeInsets.zero, - color: settingsGroupColor(context), - shape: _sectionCardShape(cs), - clipBehavior: Clip.antiAlias, - child: AnimatedSize( - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - alignment: Alignment.topCenter, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - header, - if (children.isNotEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: children, - ), - ), - ], - ), - ), - ), + child: context.isMornye + ? Material( + color: MornyeTheme.controlFill(context), + borderRadius: BorderRadius.circular(28), + clipBehavior: Clip.antiAlias, + child: DividerTheme( + data: DividerTheme.of( + context, + ).copyWith(color: MornyeTheme.metadataDividerColor(context)), + child: content, + ), + ) + : Card( + elevation: 0, + margin: EdgeInsets.zero, + color: settingsGroupColor(context), + shape: _sectionCardShape(cs), + clipBehavior: Clip.antiAlias, + child: content, + ), ); } } diff --git a/lib/screens/track_metadata_lyrics.dart b/lib/screens/track_metadata_lyrics.dart index a6a79b9c..ef7a09d0 100644 --- a/lib/screens/track_metadata_lyrics.dart +++ b/lib/screens/track_metadata_lyrics.dart @@ -6,10 +6,9 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { final hasDisplaySource = (hasDisplayLyrics || _isInstrumental) && _lyricsSource?.trim().isNotEmpty == true; - return Card( - elevation: 0, - color: settingsGroupColor(context), - shape: _sectionCardShape(colorScheme), + return _metadataSectionSurface( + context, + colorScheme, child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -18,22 +17,28 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { Row( children: [ Icon( - Icons.lyrics_outlined, + context.isMornye + ? CupertinoIcons.quote_bubble + : Icons.lyrics_outlined, size: 20, color: colorScheme.primary, ), const SizedBox(width: 8), - Text( - context.l10n.trackLyrics, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, + Expanded( + child: Text( + context.l10n.trackLyrics, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), ), ), - const Spacer(), if (hasDisplayLyrics) IconButton( - icon: const Icon(Icons.copy, size: 20), + icon: Icon( + context.isMornye ? CupertinoIcons.doc_on_doc : Icons.copy, + size: 20, + ), onPressed: () => _copyToClipboard(context, _lyrics!), tooltip: context.l10n.trackCopyLyrics, ), diff --git a/lib/screens/track_metadata_mornye.dart b/lib/screens/track_metadata_mornye.dart new file mode 100644 index 00000000..1b4137ce --- /dev/null +++ b/lib/screens/track_metadata_mornye.dart @@ -0,0 +1,174 @@ +part of 'track_metadata_screen.dart'; + +extension _TrackMetadataMornye on _TrackMetadataScreenState { + Widget _buildMornyePage(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final artwork = resolveMetadataArtworkSource( + embeddedCoverPath: + _embeddedCoverPreviewPath ?? + DownloadedEmbeddedCoverResolver.resolve(_filePath), + localCoverPath: _localCoverPath, + remoteCoverUrl: _coverUrl, + ); + return GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragEnd: _handleHorizontalDragEnd, + child: Scaffold( + body: CustomScrollView( + controller: scrollController, + slivers: [ + SliverAppBar( + pinned: true, + centerTitle: true, + title: Text(context.l10n.trackMetadata), + leadingWidth: 64, + leading: Padding( + padding: const EdgeInsets.only(left: 16), + child: HeaderCircleButton( + icon: CupertinoIcons.chevron_back, + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: _popWithMetadataResult, + ), + ), + actionsPadding: const EdgeInsets.only(right: 16), + actions: [ + Builder( + builder: (buttonContext) => HeaderCircleButton( + icon: CupertinoIcons.ellipsis, + tooltip: MaterialLocalizations.of(context).showMenuTooltip, + onPressed: () => _showOptionsMenu( + context, + ref, + scheme, + anchor: mornyeMenuAnchor(buttonContext), + ), + ), + ), + ], + ), + SliverToBoxAdapter( + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: adaptiveContentMaxWidth( + MediaQuery.sizeOf(context).width, + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Hero( + tag: _coverHeroTag, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox.square( + dimension: 70, + child: PlayerArtwork( + artUri: artwork, + colorScheme: scheme, + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ExplicitTrackTitle( + title: trackName, + explicit: isExplicit, + style: theme.textTheme.titleLarge, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 5), + Text( + artistName, + style: theme.textTheme.bodyLarge?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + if (albumName.trim().isNotEmpty) + Text( + albumName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium + ?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + if (_hasCheckedFile && !_fileExists) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Text( + context.l10n.trackFileNotFound, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.error, + ), + ), + ), + ], + ), + ), + ), + ), + ), + SliverToBoxAdapter( + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: adaptiveContentMaxWidth( + MediaQuery.sizeOf(context).width, + ), + ), + child: _buildAnimatedTrackContent(context, ref, scheme), + ), + ), + ), + SliverToBoxAdapter( + child: SizedBox(height: context.navBarBottomInset), + ), + ], + ), + ), + ); + } + + Widget _metadataSectionSurface( + BuildContext context, + ColorScheme scheme, { + required Widget child, + }) { + if (context.isMornye) { + return Material( + color: MornyeTheme.controlFill(context), + borderRadius: BorderRadius.circular(28), + clipBehavior: Clip.antiAlias, + child: DividerTheme( + data: DividerTheme.of( + context, + ).copyWith(color: MornyeTheme.metadataDividerColor(context)), + child: child, + ), + ); + } + return Card( + elevation: 0, + color: settingsGroupColor(context), + shape: _sectionCardShape(scheme), + child: child, + ); + } +} diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 8a7fc2e9..de319a92 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -2,6 +2,15 @@ import 'dart:async'; import 'dart:io'; import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:flutter/cupertino.dart' + show + CupertinoButton, + CupertinoIcons, + CupertinoTextField, + CupertinoActivityIndicator; +import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -43,8 +52,14 @@ import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/re_enrich_release_policy.dart'; import 'package:spotiflac_android/utils/saf_display_path.dart'; import 'package:spotiflac_android/theme/cover_palette.dart' show HeaderPalette; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart' - show HeaderMetaRow, HeaderMetaItem; + show HeaderMetaRow, HeaderMetaItem, HeaderCircleButton, HeaderFilledButton; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; +import 'package:spotiflac_android/widgets/mornye_metadata_row.dart'; +import 'package:spotiflac_android/widgets/player_artwork.dart'; import 'package:spotiflac_android/widgets/audio_analysis_widget.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/batch_convert_sheet.dart'; @@ -61,6 +76,7 @@ import 'package:spotiflac_android/utils/clickable_metadata.dart'; part 'track_metadata_screen_cover.dart'; part 'track_metadata_screen_display.dart'; part 'track_metadata_screen_menu.dart'; +part 'track_metadata_mornye.dart'; part 'track_metadata_edit_sheet.dart'; part 'track_metadata_cards.dart'; @@ -657,6 +673,7 @@ class _TrackMetadataScreenState extends ConsumerState @override Widget build(BuildContext context) { + if (context.isMornye) return _buildMornyePage(context); final colorScheme = Theme.of(context).colorScheme; final expandedHeight = calculateExpandedHeight(context); final bottomInset = context.navBarBottomInset; @@ -796,6 +813,29 @@ class _TrackMetadataScreenState extends ConsumerState ColorScheme colorScheme, bool fileExists, ) { + if (context.isMornye) { + return Row( + children: [ + Expanded( + child: HeaderFilledButton( + icon: CupertinoIcons.play_fill, + label: context.l10n.trackMetadataPlay, + onPressed: fileExists + ? () => _openFile(context, rawFilePath) + : null, + ), + ), + const SizedBox(width: 12), + Expanded( + child: HeaderFilledButton( + icon: CupertinoIcons.trash, + label: context.l10n.trackMetadataDelete, + onPressed: () => _confirmDelete(context, ref, colorScheme), + ), + ), + ], + ); + } return Row( children: [ Expanded( @@ -872,6 +912,27 @@ class _MetadataOptionTile extends StatelessWidget { : colorScheme.onSurfaceVariant; final titleColor = option.destructive ? colorScheme.error : null; + if (context.isMornye) { + return CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + onPressed: onTap, + child: Row( + children: [ + Expanded( + child: Text( + option.label, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: titleColor ?? colorScheme.onSurface, + ), + ), + ), + const SizedBox(width: 16), + Icon(mornyeIconFor(option.icon), color: iconColor, size: 22), + ], + ), + ); + } + return InkWell( onTap: onTap, splashColor: colorScheme.primary.withValues(alpha: 0.12), diff --git a/lib/screens/track_metadata_screen_menu.dart b/lib/screens/track_metadata_screen_menu.dart index 2c2af811..14abb66e 100644 --- a/lib/screens/track_metadata_screen_menu.dart +++ b/lib/screens/track_metadata_screen_menu.dart @@ -1,198 +1,263 @@ // ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member part of 'track_metadata_screen.dart'; -// The overflow options sheet and its header cover. +// The overflow menu and the Material sheet's header cover. extension _TrackMetadataMenu on _TrackMetadataScreenState { void _showOptionsMenu( BuildContext screenContext, WidgetRef ref, - ColorScheme colorScheme, - ) { + ColorScheme colorScheme, { + Rect? anchor, + }) { + Widget buildMenu(BuildContext sheetContext) { + final l10n = sheetContext.l10n; + + final options = <_MetadataOption>[ + if (_fileExists) + _MetadataOption( + icon: Icons.playlist_play, + label: l10n.trackPlayNext, + onTap: () => _enqueueThis(ref, playNext: true), + ), + if (_fileExists) + _MetadataOption( + icon: Icons.queue_music, + label: l10n.trackAddToQueue, + onTap: () => _enqueueThis(ref, playNext: false), + ), + if (albumName.trim().isNotEmpty) + _MetadataOption( + icon: Icons.album_outlined, + label: l10n.homeGoToAlbum, + onTap: () => _goToStoredAlbum(screenContext), + ), + _MetadataOption( + icon: Icons.copy_outlined, + label: l10n.trackCopyFilePath, + dividerAbove: _fileExists, + onTap: () => _copyToClipboard(screenContext, cleanFilePath), + ), + if (_fileExists) + _MetadataOption( + icon: Icons.edit_outlined, + label: l10n.trackEditMetadata, + onTap: () => + _showEditMetadataSheet(screenContext, ref, colorScheme), + ), + if (!_isLocalItem && (_coverUrl != null || _fileExists)) + _MetadataOption( + icon: Icons.image_outlined, + label: l10n.trackSaveCoverArt, + onTap: _saveCoverArt, + ), + if (!_isLocalItem) + _MetadataOption( + icon: Icons.lyrics_outlined, + label: l10n.trackSaveLyrics, + onTap: _saveLyrics, + ), + if (_fileExists) + _MetadataOption( + icon: Icons.travel_explore, + label: l10n.trackReEnrich, + onTap: _reEnrichMetadata, + ), + if (_fileExists && _isConvertibleFormat) + _MetadataOption( + icon: Icons.swap_horiz, + label: l10n.trackConvertFormat, + onTap: () => _showConvertSheet(screenContext), + ), + if (_fileExists && !_isCueFile) + _MetadataOption( + icon: Icons.graphic_eq, + label: l10n.trackReplayGain, + onTap: () => _rescanReplayGain(), + ), + if (_fileExists && _isCueFile) + _MetadataOption( + icon: Icons.call_split, + label: l10n.cueSplitTitle, + onTap: () => _showCueSplitSheet(screenContext), + ), + if (_spotifyId != null || (isrc?.isNotEmpty ?? false)) + _MetadataOption( + icon: Icons.open_in_new, + label: l10n.trackOpenOn, + dividerAbove: true, + onTap: () => OpenOnPlatformSheet.show( + screenContext, + spotifyId: _spotifyId ?? '', + isrc: isrc ?? '', + ), + ), + _MetadataOption( + icon: Icons.share_outlined, + label: l10n.trackMetadataShare, + dividerAbove: _spotifyId == null && !(isrc?.isNotEmpty ?? false), + onTap: () => _shareFile(screenContext), + ), + _MetadataOption( + icon: Icons.delete_outline, + label: l10n.trackRemoveFromDevice, + destructive: true, + onTap: () => _confirmDelete(screenContext, ref, colorScheme), + ), + ]; + + if (sheetContext.isMornye) { + final groups = >[]; + for (final option in options) { + if (option.icon == Icons.share_outlined || + option.icon == Icons.edit_outlined) { + continue; + } + if (groups.isEmpty || option.dividerAbove || option.destructive) { + groups.add([]); + } + groups.last.add( + MornyeMenuAction( + icon: mornyeIconFor(option.icon), + label: option.label, + destructive: option.destructive, + onPressed: () => + _closeOptionsMenuAndRun(sheetContext, option.onTap), + ), + ); + } + return MornyeContextMenu( + quickActions: [ + if (_fileExists) ...[ + MornyeMenuAction( + icon: CupertinoIcons.play_fill, + label: l10n.trackMetadataPlay, + onPressed: () => _closeOptionsMenuAndRun( + sheetContext, + () => _openFile(screenContext, rawFilePath), + ), + ), + MornyeMenuAction( + icon: CupertinoIcons.pencil, + label: l10n.trackEditMetadata, + onPressed: () => _closeOptionsMenuAndRun( + sheetContext, + () => _showEditMetadataSheet(screenContext, ref, colorScheme), + ), + ), + ], + MornyeMenuAction( + icon: CupertinoIcons.share_solid, + label: l10n.trackMetadataShare, + onPressed: () => _closeOptionsMenuAndRun( + sheetContext, + () => _shareFile(screenContext), + ), + ), + ], + groups: groups, + ); + } + + Widget buildContent({ScrollController? controller}) => SafeArea( + top: false, + child: SingleChildScrollView( + controller: controller, + primary: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const AppSheetHandle(), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: _buildOptionsHeaderCover(colorScheme), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(sheetContext).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 2), + Text( + artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(sheetContext).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ], + ), + ), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + const SizedBox(height: 8), + Builder( + builder: (context) { + final items = [ + for (int i = 0; i < options.length; i++) ...[ + if (options[i].dividerAbove && i != 0) + Divider( + height: 1, + thickness: 1, + color: colorScheme.outlineVariant.withValues( + alpha: 0.3, + ), + ), + _MetadataOptionTile( + option: options[i], + colorScheme: colorScheme, + onTap: () => _closeOptionsMenuAndRun( + sheetContext, + options[i].onTap, + ), + ), + ], + ]; + return SettingsGroup(children: items); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.85, + ), + child: buildContent(), + ); + } + + if (screenContext.isMornye) { + showMornyeContextMenu( + context: screenContext, + anchor: anchor, + builder: buildMenu, + ); + return; + } showModalBottomSheet( context: screenContext, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, isScrollControlled: true, - builder: (sheetContext) { - final l10n = sheetContext.l10n; - - final options = <_MetadataOption>[ - if (_fileExists) - _MetadataOption( - icon: Icons.playlist_play, - label: l10n.trackPlayNext, - onTap: () => _enqueueThis(ref, playNext: true), - ), - if (_fileExists) - _MetadataOption( - icon: Icons.queue_music, - label: l10n.trackAddToQueue, - onTap: () => _enqueueThis(ref, playNext: false), - ), - if (albumName.trim().isNotEmpty) - _MetadataOption( - icon: Icons.album_outlined, - label: l10n.homeGoToAlbum, - onTap: () => _goToStoredAlbum(screenContext), - ), - _MetadataOption( - icon: Icons.copy_outlined, - label: l10n.trackCopyFilePath, - dividerAbove: _fileExists, - onTap: () => _copyToClipboard(screenContext, cleanFilePath), - ), - if (_fileExists) - _MetadataOption( - icon: Icons.edit_outlined, - label: l10n.trackEditMetadata, - onTap: () => - _showEditMetadataSheet(screenContext, ref, colorScheme), - ), - if (!_isLocalItem && (_coverUrl != null || _fileExists)) - _MetadataOption( - icon: Icons.image_outlined, - label: l10n.trackSaveCoverArt, - onTap: _saveCoverArt, - ), - if (!_isLocalItem) - _MetadataOption( - icon: Icons.lyrics_outlined, - label: l10n.trackSaveLyrics, - onTap: _saveLyrics, - ), - if (_fileExists) - _MetadataOption( - icon: Icons.travel_explore, - label: l10n.trackReEnrich, - onTap: _reEnrichMetadata, - ), - if (_fileExists && _isConvertibleFormat) - _MetadataOption( - icon: Icons.swap_horiz, - label: l10n.trackConvertFormat, - onTap: () => _showConvertSheet(screenContext), - ), - if (_fileExists && !_isCueFile) - _MetadataOption( - icon: Icons.graphic_eq, - label: l10n.trackReplayGain, - onTap: () => _rescanReplayGain(), - ), - if (_fileExists && _isCueFile) - _MetadataOption( - icon: Icons.call_split, - label: l10n.cueSplitTitle, - onTap: () => _showCueSplitSheet(screenContext), - ), - if (_spotifyId != null || (isrc?.isNotEmpty ?? false)) - _MetadataOption( - icon: Icons.open_in_new, - label: l10n.trackOpenOn, - dividerAbove: true, - onTap: () => OpenOnPlatformSheet.show( - screenContext, - spotifyId: _spotifyId ?? '', - isrc: isrc ?? '', - ), - ), - _MetadataOption( - icon: Icons.share_outlined, - label: l10n.trackMetadataShare, - dividerAbove: _spotifyId == null && !(isrc?.isNotEmpty ?? false), - onTap: () => _shareFile(screenContext), - ), - _MetadataOption( - icon: Icons.delete_outline, - label: l10n.trackRemoveFromDevice, - destructive: true, - onTap: () => _confirmDelete(screenContext, ref, colorScheme), - ), - ]; - - return SafeArea( - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.85, - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const AppSheetHandle(), - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: _buildOptionsHeaderCover(colorScheme), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - trackName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(sheetContext) - .textTheme - .titleMedium - ?.copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 2), - Text( - artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(sheetContext) - .textTheme - .bodyMedium - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - ), - Divider( - height: 1, - color: colorScheme.outlineVariant.withValues(alpha: 0.5), - ), - const SizedBox(height: 8), - SettingsGroup( - children: [ - for (int i = 0; i < options.length; i++) ...[ - if (options[i].dividerAbove && i != 0) - Divider( - height: 1, - thickness: 1, - color: colorScheme.outlineVariant.withValues( - alpha: 0.3, - ), - ), - _MetadataOptionTile( - option: options[i], - colorScheme: colorScheme, - onTap: () => _closeOptionsMenuAndRun( - sheetContext, - options[i].onTap, - ), - ), - ], - ], - ), - const SizedBox(height: 16), - ], - ), - ), - ), - ); - }, + builder: buildMenu, ); } diff --git a/lib/screens/tutorial_screen.dart b/lib/screens/tutorial_screen.dart index 0b604b1a..c58b0e97 100644 --- a/lib/screens/tutorial_screen.dart +++ b/lib/screens/tutorial_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/widgets/scroll_edge_fade.dart'; +import 'package:spotiflac_android/widgets/app_search_field.dart'; class TutorialScreen extends ConsumerStatefulWidget { const TutorialScreen({super.key}); @@ -409,29 +410,16 @@ class _InteractiveSearchExampleState extends State<_InteractiveSearchExample> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - TextField( + AppSearchField( controller: _controller, + hintText: context.l10n.tutorialSearchHint, + clearTooltip: context.l10n.dialogClear, + onClear: () => setState(() => _showResult = false), onChanged: (value) { setState(() { _showResult = value.isNotEmpty; }); }, - style: TextStyle(color: colorScheme.onSurface, fontSize: 16), - decoration: InputDecoration( - hintText: context.l10n.tutorialSearchHint, - hintStyle: TextStyle(color: colorScheme.onSurfaceVariant), - prefixIcon: Icon(Icons.search, color: colorScheme.primary), - filled: true, - fillColor: colorScheme.surface, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 16, - ), - ), ), AnimatedSize( diff --git a/lib/services/batch_track_actions.dart b/lib/services/batch_track_actions.dart index 06752452..8b626d26 100644 --- a/lib/services/batch_track_actions.dart +++ b/lib/services/batch_track_actions.dart @@ -13,12 +13,14 @@ import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/replaygain_service.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/widgets/batch_convert_sheet.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; import 'package:spotiflac_android/utils/saf_display_path.dart'; @@ -97,6 +99,8 @@ Future showBatchConvertSheet( context: modalContext, useRootNavigator: true, isScrollControlled: true, + backgroundColor: modalContext.isMornye ? Colors.transparent : null, + elevation: modalContext.isMornye ? 0 : null, builder: (sheetContext) => BatchConvertSheet( formats: formats, title: sheetTitle, @@ -165,9 +169,9 @@ Future _performBatchConversion( final isLossless = isLosslessConversionTarget(targetFormat); final losslessLabels = context.l10n.losslessConversionLabels; - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(context.l10n.selectionBatchConvertConfirmTitle), content: Text( keepOriginal @@ -197,11 +201,13 @@ Future _performBatchConversion( ), ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(ctx, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDefault: true, onPressed: () => Navigator.pop(ctx, true), child: Text(context.l10n.trackConvertFormat), ), @@ -546,19 +552,21 @@ Future runBatchReplayGain( onConfirmOpen?.call(); - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(ctx.l10n.replayGainBatchConfirmTitle), content: Text( ctx.l10n.replayGainBatchConfirmMessage(selectedItems.length), ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(ctx, false), child: Text(ctx.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDefault: true, onPressed: () => Navigator.pop(ctx, true), child: Text(ctx.l10n.replayGainBatchConfirmTitle), ), diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 200c0959..8c87cd21 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -77,16 +77,21 @@ class LibraryDatabase { columns: ['id', 'path'], where: localSources, ); + final localSourceIds = sources.map((source) => source['id']).toSet(); final rows = await txn.query( 'library', - columns: ['id', 'file_path', 'cover_path'], - where: - 'source_id IN (SELECT id FROM library_sources WHERE $localSources)', + columns: ['id', 'source_id', 'file_path', 'cover_path'], ); final batch = txn.batch(); for (final row in rows) { final updates = {}; for (final column in ['file_path', 'cover_path']) { + // Bookmarks own external audio paths, but extracted covers always + // live in our sandbox, including covers from bookmarked folders. + if (column == 'file_path' && + !localSourceIds.contains(row['source_id'])) { + continue; + } final previous = row[column] as String?; if (previous == null) continue; final current = rebaseIosSandboxPath(previous, documents.path); diff --git a/lib/services/local_track_batch_actions.dart b/lib/services/local_track_batch_actions.dart index e05f5dba..46ffd71b 100644 --- a/lib/services/local_track_batch_actions.dart +++ b/lib/services/local_track_batch_actions.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/settings.dart'; @@ -28,17 +29,18 @@ Future queueLocalTracksAsFlac( return; } - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(context.l10n.queueFlacAction), content: Text(context.l10n.queueFlacConfirmMessage(selected.length)), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(ctx, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, onPressed: () => Navigator.pop(ctx, true), child: Text(context.l10n.queueFlacAction), ), diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index d97f73c2..474e7043 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -7,10 +7,14 @@ import 'package:spotiflac_android/services/discord_presence_service.dart'; import 'package:audio_session/audio_session.dart' show AudioSession, AudioSessionConfiguration, AudioInterruptionType; import 'package:audioplayers/audioplayers.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/app_state_database.dart'; +import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/playback_normalization.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; +import 'package:spotiflac_android/utils/ios_container_paths.dart'; +import 'package:spotiflac_android/utils/playback_artwork.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; @@ -117,20 +121,36 @@ class PlayableMedia { if (explicit) 'explicit': true, }; - static PlayableMedia? fromJson(Map json) { + static PlayableMedia? fromJson( + Map json, { + String? iosDocumentsPath, + }) { final id = json['id'] as String?; final source = json['source'] as String?; if (id == null || id.isEmpty || source == null || source.isEmpty) { return null; } final durationMs = (json['durationMs'] as num?)?.toInt(); + var artwork = json['artUri'] as String?; + if (artwork != null && iosDocumentsPath != null) { + final uri = Uri.tryParse(artwork); + if (uri != null && uri.scheme == 'file' && uri.host.isEmpty) { + try { + final path = uri.toFilePath(); + final rebased = rebaseIosSandboxPath(path, iosDocumentsPath); + if (rebased != path) artwork = Uri.file(rebased).toString(); + } on UnsupportedError { + // Leave malformed or non-local artwork URIs to the image fallback. + } + } + } return PlayableMedia( id: id, source: source, title: json['title'] as String? ?? '', artist: json['artist'] as String? ?? '', album: json['album'] as String? ?? '', - artUri: json['artUri'] as String?, + artUri: artwork, duration: (durationMs != null && durationMs > 0) ? Duration(milliseconds: durationMs) : null, @@ -1571,15 +1591,45 @@ Future restorePersistedPlaybackSession() async { final items = []; final keptOriginalIndices = []; + final iosDocumentsPath = Platform.isIOS + ? (await getApplicationDocumentsDirectory()).path + : null; + var artworkRelocated = false; for (var i = 0; i < rawMedia.length; i++) { final entry = rawMedia[i]; if (entry is! Map) continue; - final media = PlayableMedia.fromJson(Map.from(entry)); + final media = PlayableMedia.fromJson( + Map.from(entry), + iosDocumentsPath: iosDocumentsPath, + ); if (media == null) continue; if (!media.isContentUri && !await File(media.source).exists()) { continue; } - items.add(media); + final artwork = await resolveRestoredArtworkUri( + media.artUri, + loadLibraryCoverPath: () async { + final database = LibraryDatabase.instance; + final row = await database.getById(media.id); + if (row != null) return row['coverPath'] as String?; + final matches = await database.findByTrackAndArtist( + media.title, + media.artist, + ); + for (final row in matches) { + if (row['filePath'] == media.source) { + return row['coverPath'] as String?; + } + } + return null; + }, + ); + items.add( + artwork == media.artUri + ? media + : PlayableMedia.fromJson({...media.toJson(), 'artUri': artwork})!, + ); + artworkRelocated |= artwork != entry['artUri']; keptOriginalIndices.add(i); } if (items.isEmpty) { @@ -1607,7 +1657,7 @@ Future restorePersistedPlaybackSession() async { index: index, position: position, shuffle: session['shuffle'] == true, - queueNeedsRewrite: items.length != rawMedia.length, + queueNeedsRewrite: items.length != rawMedia.length || artworkRelocated, repeatMode: AudioServiceRepeatMode.values.firstWhere( (mode) => mode.name == session['repeat'], orElse: () => AudioServiceRepeatMode.none, diff --git a/lib/services/shell_navigation_service.dart b/lib/services/shell_navigation_service.dart index d229c3c6..e3edd65a 100644 --- a/lib/services/shell_navigation_service.dart +++ b/lib/services/shell_navigation_service.dart @@ -3,6 +3,76 @@ import 'package:flutter/widgets.dart'; enum ShellTab { home, library, repository, settings } class ShellNavigationService { + static final homeSearchRequests = ValueNotifier(0); + static final chromeBrightness = ValueNotifier(null); + static final chromeSurface = ValueNotifier(null); + static final _visiblePages = , Route?>{}; + static final _chromeScopes = + < + Object, + ({ModalRoute route, Brightness brightness, Color? surface}) + >{}; + static bool _chromeUpdateScheduled = false; + static int _homeSearchGeneration = 0; + + static void setChromeBrightness({ + required Object owner, + required ModalRoute route, + required Brightness brightness, + Color? surface, + }) { + final scope = (route: route, brightness: brightness, surface: surface); + if (_chromeScopes[owner] == scope) return; + _chromeScopes[owner] = scope; + _scheduleChromeUpdate(); + } + + static void clearChromeBrightness(Object owner) { + _chromeScopes.remove(owner); + _scheduleChromeUpdate(); + } + + static void _scheduleChromeUpdate() { + if (_chromeUpdateScheduled) return; + _chromeUpdateScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _chromeUpdateScheduled = false; + final activePage = _visiblePages[_activeTabNavigatorKey()]; + Brightness? brightness; + Color? surface; + for (final scope in _chromeScopes.values) { + if (identical(scope.route, activePage)) { + brightness = scope.brightness; + surface = scope.surface; + } + } + chromeBrightness.value = brightness; + chromeSurface.value = surface; + }); + WidgetsBinding.instance.ensureVisualUpdate(); + } + + static void requestHomeSearch() { + if (!requestTab(ShellTab.home)) return; + final generation = ++_homeSearchGeneration; + final owner = _tabSelectionOwner; + // PageView creates Home lazily. Deliver the request after its navigator + // and focus listener mount, rather than losing the first tap on another tab. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (generation != _homeSearchGeneration || + !identical(owner, _tabSelectionOwner) || + _currentTabIndex != 0) { + return; + } + homeTabNavigatorKey.currentState?.popUntil((route) => route.isFirst); + homeSearchRequests.value++; + // Home scrolls to its search field and requests focus after layout. + // Scheduling that frame avoids waiting for another tap to wake it up. + WidgetsBinding.instance.ensureVisualUpdate(); + }); + WidgetsBinding.instance.ensureVisualUpdate(); + } + static final GlobalKey homeTabNavigatorKey = GlobalKey(); static final GlobalKey libraryTabNavigatorKey = @@ -40,16 +110,81 @@ class ShellNavigationService { required int currentTabIndex, required bool showRepoTab, }) { + if (_currentTabIndex == currentTabIndex && _showRepoTab == showRepoTab) { + return; + } _currentTabIndex = currentTabIndex; _showRepoTab = showRepoTab; + _scheduleChromeUpdate(); } - static NavigatorState? activeTabNavigator() { - if (_currentTabIndex == 0) return homeTabNavigatorKey.currentState; - if (_currentTabIndex == 1) return libraryTabNavigatorKey.currentState; + static GlobalKey? _activeTabNavigatorKey() { + if (_currentTabIndex == 0) return homeTabNavigatorKey; + if (_currentTabIndex == 1) return libraryTabNavigatorKey; if (_showRepoTab && _currentTabIndex == 2) { - return repoTabNavigatorKey.currentState; + return repoTabNavigatorKey; } return null; } + + static NavigatorState? activeTabNavigator() => + _activeTabNavigatorKey()?.currentState; +} + +/// Track the top page in each tab. Dialogs keep the underlying page's contrast; +/// a pushed album, another tab, or a back gesture restores its own appearance. +class ShellChromeObserver extends NavigatorObserver { + ShellChromeObserver(this.navigatorKey); + + final GlobalKey navigatorKey; + final _routes = >[]; + + void _update() { + Route? page; + for (final route in _routes.reversed) { + if (route is PageRoute) { + page = route; + break; + } + } + ShellNavigationService._visiblePages[navigatorKey] = page; + ShellNavigationService._scheduleChromeUpdate(); + } + + @override + void didPush(Route route, Route? previousRoute) { + _routes.add(route); + _update(); + } + + @override + void didPop(Route route, Route? previousRoute) { + _routes.remove(route); + _update(); + } + + @override + void didRemove(Route route, Route? previousRoute) { + _routes.remove(route); + _update(); + } + + @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; + } + } + _update(); + } + + void detach() { + _routes.clear(); + ShellNavigationService._visiblePages.remove(navigatorKey); + ShellNavigationService._scheduleChromeUpdate(); + } } diff --git a/lib/theme/dynamic_color_wrapper.dart b/lib/theme/dynamic_color_wrapper.dart index 8caccd08..6528c59a 100644 --- a/lib/theme/dynamic_color_wrapper.dart +++ b/lib/theme/dynamic_color_wrapper.dart @@ -4,6 +4,8 @@ import 'package:dynamic_color/dynamic_color.dart'; import 'package:material_ui/material_ui.dart' as material_ui; import 'package:spotiflac_android/providers/theme_provider.dart'; import 'package:spotiflac_android/theme/app_theme.dart'; +import 'package:spotiflac_android/models/theme_settings.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; class DynamicColorWrapper extends ConsumerWidget { final Widget Function(ThemeData light, ThemeData dark, ThemeMode mode) @@ -15,6 +17,14 @@ class DynamicColorWrapper extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final themeSettings = ref.watch(themeProvider); + if (themeSettings.style == AppThemeStyle.mornye) { + return builder( + MornyeTheme.build(Brightness.light), + MornyeTheme.build(Brightness.dark), + themeSettings.themeMode, + ); + } + return DynamicColorBuilder( builder: (lightDynamic, darkDynamic) { ColorScheme lightScheme; diff --git a/lib/theme/mornye_icons.dart b/lib/theme/mornye_icons.dart new file mode 100644 index 00000000..cc4aff50 --- /dev/null +++ b/lib/theme/mornye_icons.dart @@ -0,0 +1,161 @@ +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:flutter/material.dart'; + +/// Cupertino counterparts for the shared Settings and extension UI symbols. +IconData mornyeIconFor(IconData icon) { + if (icon.fontFamily == CupertinoIcons.iconFont) return icon; + return switch (icon) { + Icons.extension || + Icons.extension_outlined || + Icons.apps || + Icons.grid_view || + Icons.grid_view_outlined || + Icons.grid_view_rounded => CupertinoIcons.square_grid_2x2, + Icons.palette_outlined || + Icons.color_lens_outlined => CupertinoIcons.paintbrush, + Icons.library_music || + Icons.library_music_outlined || + Icons.playlist_play_outlined || + Icons.queue_music_outlined => CupertinoIcons.music_note_list, + Icons.sell_outlined || + Icons.label_outline || + Icons.tag => CupertinoIcons.tag, + Icons.lyrics || + Icons.lyrics_outlined || + Icons.subtitles_outlined || + Icons.forum_outlined => CupertinoIcons.quote_bubble, + Icons.download || + Icons.download_outlined || + Icons.file_download_outlined || + Icons.downloading_outlined => CupertinoIcons.arrow_down_circle, + Icons.folder_outlined || + Icons.folder_special_outlined => CupertinoIcons.folder, + Icons.create_new_folder_outlined => CupertinoIcons.folder_badge_plus, + Icons.storage_outlined || + Icons.sd_storage_outlined || + Icons.memory_outlined || + Icons.usb_rounded => CupertinoIcons.tray, + Icons.settings_backup_restore || + Icons.history || + Icons.history_toggle_off_outlined => CupertinoIcons.clock, + Icons.article_outlined || Icons.source_outlined => CupertinoIcons.doc_text, + Icons.favorite_outline || Icons.favorite_border => CupertinoIcons.heart, + Icons.favorite => CupertinoIcons.heart_fill, + Icons.arrow_back || Icons.chevron_left => CupertinoIcons.chevron_back, + Icons.close => CupertinoIcons.xmark, + Icons.play_arrow || Icons.play_arrow_rounded => CupertinoIcons.play_fill, + Icons.shuffle => CupertinoIcons.shuffle, + Icons.playlist_add || Icons.queue_music => CupertinoIcons.text_badge_plus, + Icons.edit_outlined => CupertinoIcons.pencil, + Icons.edit_note => CupertinoIcons.pencil_ellipsis_rectangle, + Icons.fingerprint => CupertinoIcons.number, + Icons.playlist_add_check || + Icons.playlist_add_check_circle => CupertinoIcons.text_badge_checkmark, + Icons.add_circle_outline => CupertinoIcons.add_circled, + Icons.format_list_numbered => CupertinoIcons.list_number, + Icons.checklist => CupertinoIcons.list_bullet, + Icons.calendar_today_outlined => CupertinoIcons.calendar, + Icons.preview_outlined => CupertinoIcons.doc_text_search, + Icons.check || Icons.check_rounded => CupertinoIcons.checkmark, + Icons.download_for_offline_outlined => CupertinoIcons.arrow_down_circle, + Icons.call_split => CupertinoIcons.arrow_branch, + Icons.download_rounded => CupertinoIcons.arrow_down, + Icons.info_outline => CupertinoIcons.info, + Icons.settings_outlined => CupertinoIcons.gear_alt, + Icons.tune || + Icons.tune_outlined || + Icons.table_chart_outlined => CupertinoIcons.slider_horizontal_3, + Icons.build_outlined => CupertinoIcons.wrench, + Icons.people_alt_outlined || + Icons.people_outline => CupertinoIcons.person_2, + Icons.person || + Icons.person_outline || + Icons.person_outline_rounded || + Icons.person_search_outlined => CupertinoIcons.person, + Icons.group_remove_outlined => + CupertinoIcons.person_crop_circle_badge_xmark, + Icons.photo_size_select_large_outlined || + Icons.image_outlined || + Icons.wallpaper => CupertinoIcons.photo, + Icons.graphic_eq || + Icons.graphic_eq_outlined || + Icons.graphic_eq_rounded => CupertinoIcons.waveform, + Icons.filter_alt_outlined || + Icons.filter_list || + Icons.filter_list_outlined => CupertinoIcons.line_horizontal_3_decrease, + Icons.travel_explore || + Icons.language || + Icons.language_outlined || + Icons.public || + Icons.translate || + Icons.translate_outlined => CupertinoIcons.globe, + Icons.text_fields || + Icons.text_fields_outlined => CupertinoIcons.textformat, + Icons.record_voice_over_outlined => CupertinoIcons.mic, + Icons.music_note || + Icons.audio_file_outlined || + Icons.audiotrack || + Icons.music_note_outlined => CupertinoIcons.music_note, + Icons.speed || Icons.speed_outlined => CupertinoIcons.gauge, + Icons.auto_fix_high || + Icons.auto_fix_high_outlined || + Icons.auto_awesome_outlined => CupertinoIcons.wand_stars, + Icons.wifi => CupertinoIcons.wifi, + Icons.dynamic_feed_outlined || + Icons.tab_outlined => CupertinoIcons.square_on_square, + Icons.security_outlined => CupertinoIcons.lock_shield, + Icons.lan_outlined || Icons.link => CupertinoIcons.link, + Icons.sync || Icons.autorenew_rounded => CupertinoIcons.arrow_2_circlepath, + Icons.refresh || + Icons.update || + Icons.system_update => CupertinoIcons.arrow_clockwise, + Icons.content_copy_outlined || + Icons.content_copy_rounded || + Icons.copy_all_rounded || + Icons.copy_outlined || + Icons.copy => CupertinoIcons.doc_on_doc, + Icons.swap_horiz || + Icons.compare_arrows || + Icons.difference_outlined || + Icons.alt_route => CupertinoIcons.arrow_right_arrow_left, + Icons.cleaning_services_outlined => CupertinoIcons.sparkles, + Icons.delete_outline || + Icons.delete_forever || + Icons.delete_sweep_outlined => CupertinoIcons.trash, + Icons.open_in_new || + Icons.open_in_new_rounded || + Icons.open_in_browser => CupertinoIcons.arrow_up_right_square, + Icons.play_circle_outline => CupertinoIcons.play_circle, + Icons.share || + Icons.share_outlined || + Icons.ios_share => CupertinoIcons.share, + Icons.brightness_2 || Icons.dark_mode => CupertinoIcons.moon, + Icons.light_mode => CupertinoIcons.sun_max, + Icons.contrast || + Icons.brightness_auto => CupertinoIcons.circle_lefthalf_fill, + Icons.animation => CupertinoIcons.play_rectangle, + Icons.blur_on => CupertinoIcons.drop, + Icons.new_releases => CupertinoIcons.star_circle, + Icons.bug_report || Icons.bug_report_outlined => CupertinoIcons.ant, + Icons.timer_outlined => CupertinoIcons.timer, + Icons.timer_off_outlined => CupertinoIcons.timer, + Icons.bedtime_outlined => CupertinoIcons.moon_zzz, + Icons.explore_outlined => CupertinoIcons.compass, + Icons.vpn_key_outlined => CupertinoIcons.lock, + Icons.bookmark_outline => CupertinoIcons.bookmark, + Icons.low_priority => CupertinoIcons.sort_down, + Icons.search || Icons.manage_search => CupertinoIcons.search, + Icons.add => CupertinoIcons.add, + Icons.code => CupertinoIcons.chevron_left_slash_chevron_right, + Icons.lightbulb_outline => CupertinoIcons.lightbulb, + Icons.telegram => CupertinoIcons.paperplane, + Icons.check => CupertinoIcons.check_mark, + Icons.album || Icons.album_outlined => CupertinoIcons.square_stack, + Icons.playlist_play => CupertinoIcons.music_note_list, + Icons.video_library => CupertinoIcons.play_rectangle, + Icons.podcasts => CupertinoIcons.mic, + Icons.monitor_heart_outlined => CupertinoIcons.waveform_path_ecg, + Icons.warning_amber_rounded => CupertinoIcons.exclamationmark_triangle, + _ => CupertinoIcons.gear_alt, + }; +} diff --git a/lib/theme/mornye_theme.dart b/lib/theme/mornye_theme.dart new file mode 100644 index 00000000..4ea4b9e3 --- /dev/null +++ b/lib/theme/mornye_theme.dart @@ -0,0 +1,320 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/app_tokens.dart'; + +/// Flutter counterpart of Mornye's iPhone visual system. Reference values come +/// from AccentColor.colorset, Components.swift, PlayerViews.swift and Motion.md +/// in the Mornye project; keep this palette independent of wallpaper colors. +@immutable +class MornyeTheme extends ThemeExtension { + const MornyeTheme({this.chromeSurface}); + + final Color? chromeSurface; + + /// A single translucent fill for controls inside an existing glass surface. + static Color controlFill(BuildContext context, {bool enabled = true}) { + final theme = Theme.of(context); + return theme.colorScheme.onSurface.withValues( + alpha: !enabled + ? 0.04 + : theme.brightness == Brightness.dark + ? 0.10 + : 0.07, + ); + } + + /// Separators blend with their group, keeping definition on pages and sheets. + static Color metadataDividerColor(BuildContext context) { + final theme = Theme.of(context); + return theme.colorScheme.onSurface.withValues( + alpha: theme.brightness == Brightness.dark ? 0.14 : 0.17, + ); + } + + static double chromeOpacity(BuildContext context) => + Theme.of(context).extension()?.chromeSurface != null + ? 0.42 + : 0.70; + + static const lightAccent = Color.fromRGBO(204, 46, 51, 1); + static const darkAccent = Color.fromRGBO(224, 61, 60, 1); + + static final tokens = AppTokens.standard.copyWith( + radiusBadge: 5, + radiusThumb: 6, + radiusCover: 10, + radiusControl: 12, + radiusCard: 12, + radiusSheet: 32, + coverMini: 38, + headerExpandedTitleSize: 34, + headerCollapsedTitleSize: 17, + motionFast: const Duration(milliseconds: 180), + motionMedium: const Duration(milliseconds: 220), + motionSlow: const Duration(milliseconds: 380), + ); + + // Theme construction includes seeded color generation and typography. Reuse + // identical themes across routes; bound artwork-derived variants so browsing + // many artists cannot grow the cache indefinitely. Platform is part of the + // key because it controls fonts and route transitions. + static final _themeCache = + <(TargetPlatform, Brightness, Color?), ThemeData>{}; + + static ThemeData build(Brightness brightness, {Color? chromeSurface}) { + final key = (defaultTargetPlatform, brightness, chromeSurface); + final cached = _themeCache.remove(key); + if (cached != null) { + _themeCache[key] = cached; + return cached; + } + final theme = _build(brightness, chromeSurface: chromeSurface); + if (_themeCache.length >= 16) { + _themeCache.remove(_themeCache.keys.first); + } + _themeCache[key] = theme; + return theme; + } + + static ThemeData _build(Brightness brightness, {Color? chromeSurface}) { + final dark = brightness == Brightness.dark; + final accent = dark ? darkAccent : lightAccent; + final foreground = dark ? Colors.white : Colors.black; + final surface = dark ? Colors.black : Colors.white; + final grouped = dark ? const Color(0xff1c1c1e) : const Color(0xfff2f2f7); + final secondary = dark ? const Color(0xff98989f) : const Color(0xff6c6c70); + final scheme = + ColorScheme.fromSeed( + seedColor: accent, + brightness: brightness, + ).copyWith( + primary: accent, + onPrimary: Colors.white, + primaryContainer: grouped, + onPrimaryContainer: accent, + secondary: accent, + onSecondary: Colors.white, + secondaryContainer: grouped, + onSecondaryContainer: foreground, + surface: surface, + onSurface: foreground, + surfaceContainerLowest: surface, + surfaceContainerLow: grouped, + surfaceContainer: grouped, + surfaceContainerHigh: chromeSurface == null + ? (dark ? const Color(0xff2c2c2e) : grouped) + : Color.lerp(chromeSurface, Colors.black, 0.16), + surfaceContainerHighest: dark + ? const Color(0xff3a3a3c) + : const Color(0xffe5e5ea), + onSurfaceVariant: secondary, + outline: secondary, + outlineVariant: dark + ? const Color(0xff38383a) + : const Color(0xffc6c6c8), + surfaceTint: Colors.transparent, + ); + // Apple platforms use the installed SF system faces. Inter is bundled for + // other platforms; Cupertino's family names alone cannot provide SF there. + final apple = + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS; + final type = CupertinoThemeData(brightness: brightness).textTheme; + final text = type.textStyle.copyWith( + inherit: true, + color: foreground, + fontFamily: apple ? type.textStyle.fontFamily : 'Inter', + ); + final display = type.navLargeTitleTextStyle.copyWith( + inherit: true, + color: foreground, + fontFamily: apple ? type.navLargeTitleTextStyle.fontFamily : 'Inter', + ); + // Supply complete styles: replacing a Material role with a bare TextStyle + // loses its system family and retains Material tracking in other roles. + final typography = TextTheme( + displayLarge: display.copyWith(fontSize: 57), + displayMedium: display.copyWith(fontSize: 45), + displaySmall: display.copyWith(fontSize: 36), + headlineLarge: display, + headlineMedium: display.copyWith(fontSize: 28, letterSpacing: 0.36), + headlineSmall: display.copyWith(fontSize: 22, letterSpacing: 0.35), + titleLarge: display.copyWith( + fontSize: 20, + fontWeight: FontWeight.w600, + letterSpacing: 0.38, + ), + titleMedium: text.copyWith(fontWeight: FontWeight.w500), + titleSmall: text.copyWith( + fontSize: 15, + fontWeight: FontWeight.w600, + letterSpacing: -0.23, + ), + bodyLarge: text, + bodyMedium: text.copyWith(fontSize: 15, letterSpacing: -0.23), + bodySmall: text.copyWith( + fontSize: 13, + letterSpacing: -0.08, + color: secondary, + ), + labelLarge: text.copyWith(fontWeight: FontWeight.w600), + labelMedium: text.copyWith( + fontSize: 13, + fontWeight: FontWeight.w500, + letterSpacing: -0.08, + ), + labelSmall: text.copyWith( + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: -0.24, + color: secondary, + ), + ); + final base = ThemeData( + useMaterial3: true, + brightness: brightness, + colorScheme: scheme, + scaffoldBackgroundColor: surface, + fontFamily: text.fontFamily, + splashFactory: NoSplash.splashFactory, + extensions: >[ + MornyeTheme(chromeSurface: chromeSurface), + tokens, + ], + ); + final controlShape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ); + return base.copyWith( + textTheme: typography, + primaryTextTheme: typography.apply( + bodyColor: scheme.onPrimary, + displayColor: scheme.onPrimary, + ), + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: FadeForwardsPageTransitionsBuilder(), + TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), + TargetPlatform.macOS: CupertinoPageTransitionsBuilder(), + }, + ), + appBarTheme: AppBarTheme( + backgroundColor: surface, + foregroundColor: accent, + elevation: 0, + scrolledUnderElevation: 0, + surfaceTintColor: Colors.transparent, + centerTitle: true, + titleTextStyle: text.copyWith(fontWeight: FontWeight.w600), + ), + cardTheme: CardThemeData( + color: grouped, + elevation: 0, + shape: controlShape, + ), + dividerTheme: DividerThemeData( + color: scheme.outlineVariant, + thickness: 0.5, + space: 0.5, + ), + listTileTheme: ListTileThemeData( + iconColor: accent, + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), + shape: controlShape, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + shape: controlShape, + padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 12), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom(shape: controlShape), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom(shape: controlShape), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: grouped, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: grouped, + surfaceTintColor: Colors.transparent, + constraints: const BoxConstraints(maxWidth: 640), + shape: tokens.sheetShape, + ), + dialogTheme: DialogThemeData( + backgroundColor: grouped, + shape: controlShape, + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + backgroundColor: scheme.surfaceContainerHigh, + contentTextStyle: text.copyWith(fontSize: 15, color: foreground), + actionTextColor: accent, + disabledActionTextColor: secondary, + closeIconColor: foreground, + elevation: 0, + insetPadding: const EdgeInsets.fromLTRB(20, 8, 20, 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.5)), + ), + ), + ); + } + + @override + MornyeTheme copyWith({Color? chromeSurface}) => + MornyeTheme(chromeSurface: chromeSurface ?? this.chromeSurface); + + @override + MornyeTheme lerp(covariant MornyeTheme? other, double t) => MornyeTheme( + chromeSurface: Color.lerp(chromeSurface, other?.chromeSurface, t), + ); +} + +extension MornyeThemeContext on BuildContext { + bool get isMornye => Theme.of(this).extension() != null; +} + +/// Settings uses iOS's inset grouped surfaces while the music library keeps +/// its artwork-led, plain background. +class MornyeSettingsTheme extends StatelessWidget { + const MornyeSettingsTheme({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + if (!context.isMornye) return child; + final theme = Theme.of(context); + final dark = theme.brightness == Brightness.dark; + final background = dark ? Colors.black : const Color(0xfff2f2f7); + final group = dark ? const Color(0xff1c1c1e) : Colors.white; + return Theme( + data: theme.copyWith( + scaffoldBackgroundColor: background, + extensions: [ + for (final extension in theme.extensions.values) + if (extension is AppTokens) + extension.copyWith(radiusCard: 24) + else + extension, + ], + colorScheme: theme.colorScheme.copyWith( + surface: background, + surfaceContainer: group, + ), + appBarTheme: theme.appBarTheme.copyWith(backgroundColor: background), + ), + child: ColoredBox(color: background, child: child), + ); + } +} diff --git a/lib/utils/confirm_and_delete_tracks.dart b/lib/utils/confirm_and_delete_tracks.dart index ca233d6d..cc18f86b 100644 --- a/lib/utils/confirm_and_delete_tracks.dart +++ b/lib/utils/confirm_and_delete_tracks.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; /// Shows a delete-confirmation dialog, deletes the given [ids] one by one via /// [deleteItem], then shows a "deleted N tracks" snackbar. @@ -17,17 +18,20 @@ Future confirmAndDeleteTracks({ Future Function(List ids)? persistDeletedItems, required VoidCallback onExitSelectionMode, }) async { - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(context.l10n.downloadedAlbumDeleteSelected), content: Text(context.l10n.downloadedAlbumDeleteMessage(ids.length)), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(ctx, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.pop(ctx, true), style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, diff --git a/lib/utils/nav_bar_inset.dart b/lib/utils/nav_bar_inset.dart index 0ca43da5..1397cfb8 100644 --- a/lib/utils/nav_bar_inset.dart +++ b/lib/utils/nav_bar_inset.dart @@ -10,3 +10,17 @@ import 'package:flutter/widgets.dart'; extension NavBarInset on BuildContext { double get navBarBottomInset => MediaQuery.paddingOf(this).bottom; } + +/// Keeps animated navigation-bar insets local to the trailing spacer instead +/// of rebuilding the screen and its lists on every chrome animation frame. +class NavBarSliverSpacer extends StatelessWidget { + const NavBarSliverSpacer({super.key, this.bottomInset}); + + /// An explicit inset for surfaces that do not inherit the shell's padding. + final double? bottomInset; + + @override + Widget build(BuildContext context) => SliverToBoxAdapter( + child: SizedBox(height: bottomInset ?? context.navBarBottomInset), + ); +} diff --git a/lib/utils/playback_artwork.dart b/lib/utils/playback_artwork.dart new file mode 100644 index 00000000..026d9a4b --- /dev/null +++ b/lib/utils/playback_artwork.dart @@ -0,0 +1,26 @@ +import 'dart:io'; + +/// A saved queue may outlive the scan directory that supplied its artwork. +/// Consult the Library only when the saved local image is missing. +Future resolveRestoredArtworkUri( + String? artwork, { + required Future Function() loadLibraryCoverPath, +}) async { + try { + if (artwork != null && artwork.isNotEmpty) { + final uri = Uri.parse(artwork); + if (uri.hasScheme && uri.scheme != 'file') return artwork; + final path = uri.scheme == 'file' ? uri.toFilePath() : artwork; + if (await File(path).exists()) return artwork; + } + final cover = await loadLibraryCoverPath(); + if (cover == null || cover.isEmpty) return artwork; + final path = cover.startsWith('file://') + ? Uri.parse(cover).toFilePath() + : cover; + if (await File(path).exists()) return Uri.file(path).toString(); + } catch (_) { + // Missing artwork or an unavailable Library must not discard playback. + } + return artwork; +} diff --git a/lib/widgets/album_detail_header.dart b/lib/widgets/album_detail_header.dart index 7fbc1804..601aa17a 100644 --- a/lib/widgets/album_detail_header.dart +++ b/lib/widgets/album_detail_header.dart @@ -1,8 +1,14 @@ import 'dart:ui'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/theme/cover_palette.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; /// Collapsing album-detail header shared by the album, local-album, and @@ -32,6 +38,8 @@ class AlbumDetailHeader extends StatelessWidget { this.appBarTitle, this.leading, this.backgroundColor, + this.immersive = false, + this.squareArtwork = true, }); final String title; @@ -73,6 +81,10 @@ class AlbumDetailHeader extends StatelessWidget { final Widget? leading; final Color? backgroundColor; + final bool immersive; + + /// Ordinary cover art stays 1:1; provider-supplied banners may fill a header. + final bool squareArtwork; /// Shrinks long titles so up to three lines fit the header. double _titleFontSize() { @@ -84,14 +96,164 @@ class AlbumDetailHeader extends StatelessWidget { @override Widget build(BuildContext context) { + if (context.isMornye && (coverBuilder != null || immersive)) { + final scheme = Theme.of(context).colorScheme; + return HeaderPalette( + scheme: scheme, + child: Builder( + builder: (context) => + SliverMainAxisGroup(slivers: buildSlivers(context)), + ), + ); + } return CoverPaletteBuilder( imageSource: paletteSource, builder: (context, headerScheme) => _buildAppBar(context, headerScheme), ); } + /// Keep the toolbar at the collection scroll-view level so it stays pinned + /// after the dynamically sized artwork and metadata have scrolled away. + List buildSlivers(BuildContext context) { + if (!context.isMornye || (coverBuilder == null && !immersive)) { + return [this]; + } + final scheme = Theme.of(context).colorScheme; + final edgeInset = detailHeaderEdgeInset(context); + final artworkSize = MediaQuery.sizeOf(context).width.clamp(0.0, 440.0); + // Keep the artwork and text in normal sliver flow: Dynamic Type and long + // album names must grow the header instead of overflowing a fixed height. + return [ + // Only the toolbar needs safe-area padding. Reading it on the caller's + // context rebuilds all header slivers as the navigation bar folds. + Builder( + builder: (context) => SliverAppBar( + pinned: true, + expandedHeight: immersive + ? squareArtwork + ? artworkSize - MediaQuery.paddingOf(context).top + : MediaQuery.sizeOf(context).width.clamp(280.0, 440.0) + : null, + backgroundColor: scheme.surface, + surfaceTintColor: Colors.transparent, + flexibleSpace: immersive + ? FlexibleSpaceBar( + collapseMode: CollapseMode.pin, + background: Stack( + fit: StackFit.expand, + children: [ + if (squareArtwork) + Align( + alignment: Alignment.topCenter, + child: SizedBox.square( + dimension: artworkSize, + child: background, + ), + ) + else + background, + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0, 0.52, 1], + colors: [ + Colors.black.withValues(alpha: 0.12), + scheme.surface.withValues(alpha: 0), + scheme.surface, + ], + ), + ), + ), + ], + ), + ) + : null, + title: AnimatedOpacity( + opacity: showTitleInAppBar || appBarTitle != null ? 1 : 0, + duration: const Duration(milliseconds: 180), + child: Text( + appBarTitle ?? title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), + ), + leadingWidth: kToolbarHeight + edgeInset, + leading: Padding( + padding: EdgeInsets.only(left: edgeInset), + child: + leading ?? + HeaderCircleButton( + icon: CupertinoIcons.chevron_back, + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: () => Navigator.pop(context), + ), + ), + actionsPadding: EdgeInsets.only(right: edgeInset), + actions: appBarActions, + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.fromLTRB(20, immersive ? 0 : 20, 20, 28), + child: Column( + children: [ + if (!immersive) + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: AspectRatio( + aspectRatio: 1, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.16), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: LayoutBuilder( + builder: (context, constraints) => + coverBuilder!(context, constraints.maxWidth), + ), + ), + ), + ), + ), + if (!immersive) const SizedBox(height: 22), + Text( + title, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontSize: 22, + fontWeight: FontWeight.w700, + ), + ), + if (subtitle != null) ...[const SizedBox(height: 5), subtitle!], + if (meta != null) ...[const SizedBox(height: 5), meta!], + if (actions != null) ...[ + const SizedBox(height: 24), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: actions!, + ), + ], + ], + ), + ), + ), + ]; + } + Widget _buildAppBar(BuildContext context, ColorScheme headerScheme) { final tokens = context.tokens; + final plainArtworkHeader = context.isMornye && coverBuilder != null; // iOS does not add horizontal safe-area padding in portrait. Give toolbar // controls and header content an explicit inset so circular actions do not // sit against the glass edge on either iPhone or iPad. Android retains its @@ -135,33 +297,36 @@ class AlbumDetailHeader extends StatelessWidget { background: Stack( fit: StackFit.expand, children: [ - if (blurAndScrimBackground) + if (plainArtworkHeader) + ColoredBox(color: headerScheme.surface) + else if (blurAndScrimBackground) ImageFiltered( imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32), child: background, ) else background, - if (blurAndScrimBackground) + if (!plainArtworkHeader && blurAndScrimBackground) ColoredBox(color: scrimColor.withValues(alpha: 0.4)), - Positioned( - left: 0, - right: 0, - bottom: 0, - height: expandedHeight * 0.65, - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - scrimColor.withValues(alpha: 0), - scrimColor.withValues(alpha: 0.92), - ], + if (!plainArtworkHeader) + Positioned( + left: 0, + right: 0, + bottom: 0, + height: expandedHeight * 0.65, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + scrimColor.withValues(alpha: 0), + scrimColor.withValues(alpha: 0.92), + ], + ), ), ), ), - ), Positioned( left: 20 + iosEdgeInset, right: 20 + iosEdgeInset, @@ -184,7 +349,9 @@ class AlbumDetailHeader extends StatelessWidget { height: coverSize, decoration: BoxDecoration( borderRadius: BorderRadius.circular( - tokens.radiusControl, + plainArtworkHeader + ? tokens.radiusCover + : tokens.radiusControl, ), boxShadow: [ BoxShadow( @@ -248,7 +415,9 @@ class AlbumDetailHeader extends StatelessWidget { leading ?? IconButton.filledTonal( tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: const Icon(Icons.arrow_back), + icon: Icon( + context.isMornye ? Icons.chevron_left : Icons.arrow_back, + ), style: IconButton.styleFrom( minimumSize: Size.square(tokens.minTouchTarget), backgroundColor: headerScheme.surfaceContainerHigh.withValues( @@ -285,6 +454,28 @@ class AlbumPlayActions extends StatelessWidget { Widget build(BuildContext context) { final tokens = context.tokens; final scheme = HeaderPalette.of(context); + if (context.isMornye) { + return Row( + children: [ + Expanded( + child: HeaderFilledButton( + icon: CupertinoIcons.play_fill, + label: playLabel, + onPressed: onPlay, + tonal: true, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _MornyeHeaderButton( + icon: CupertinoIcons.shuffle, + label: shuffleTooltip, + onPressed: onShuffle, + ), + ), + ], + ); + } return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -329,16 +520,26 @@ class HeaderFilledButton extends StatelessWidget { required this.icon, required this.label, required this.onPressed, + this.tonal = false, }); final IconData icon; final String label; final VoidCallback? onPressed; + final bool tonal; @override Widget build(BuildContext context) { final tokens = context.tokens; final scheme = HeaderPalette.of(context); + if (context.isMornye) { + return _MornyeHeaderButton( + icon: mornyeIconFor(icon), + label: label, + onPressed: onPressed, + prominent: !tonal, + ); + } return FilledButton.icon( onPressed: onPressed, icon: Icon(icon, size: 18), @@ -357,13 +558,14 @@ class HeaderFilledButton extends StatelessWidget { /// Circular header icon button (add-to-playlist, love-all, ...). Sized to the /// minimum touch target and tinted from the [HeaderPalette]. -class HeaderCircleButton extends StatelessWidget { +class HeaderCircleButton extends ConsumerWidget { const HeaderCircleButton({ super.key, required this.icon, required this.tooltip, required this.onPressed, this.iconColor, + this.tonal = false, }); final IconData icon; @@ -373,10 +575,62 @@ class HeaderCircleButton extends StatelessWidget { /// Overrides the palette foreground, e.g. to mark an active "loved" state. final Color? iconColor; + /// Uses the same translucent fill as the local album's Play/Shuffle pills. + final bool tonal; + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final tokens = context.tokens; final scheme = HeaderPalette.of(context); + if (context.isMornye) { + if (tonal) { + return Tooltip( + message: tooltip, + child: SizedBox.square( + dimension: 44, + child: CupertinoButton( + padding: EdgeInsets.zero, + borderRadius: BorderRadius.circular(24), + color: scheme.onSurface.withValues( + alpha: scheme.brightness == Brightness.dark ? 0.10 : 0.06, + ), + onPressed: onPressed, + child: Icon( + mornyeIconFor(icon), + size: 22, + color: onPressed == null + ? scheme.onSurfaceVariant + : iconColor ?? scheme.primary, + ), + ), + ), + ); + } + final blurEnabled = + !ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider); + final button = IconButton( + onPressed: onPressed, + tooltip: tooltip, + icon: Icon(mornyeIconFor(icon), size: 22), + style: IconButton.styleFrom( + minimumSize: const Size.square(44), + foregroundColor: iconColor ?? scheme.onSurface, + ), + ); + return Center( + widthFactor: 1, + child: MornyeGlass.navigation( + radius: 24, + tintColor: scheme.brightness == Brightness.dark + ? Colors.black + : Colors.white, + tintOpacity: scheme.brightness == Brightness.dark ? 0.45 : 0.55, + blurEnabled: blurEnabled, + child: button, + ), + ); + } return IconButton.filledTonal( onPressed: onPressed, icon: Icon(icon, size: 22), @@ -390,6 +644,63 @@ class HeaderCircleButton extends StatelessWidget { } } +class _MornyeHeaderButton extends StatelessWidget { + const _MornyeHeaderButton({ + required this.icon, + required this.label, + required this.onPressed, + this.prominent = false, + }); + + final IconData icon; + final String label; + final VoidCallback? onPressed; + final bool prominent; + + @override + Widget build(BuildContext context) { + final scheme = HeaderPalette.of(context); + final foreground = onPressed == null + ? scheme.onSurfaceVariant + : prominent + ? scheme.onPrimary + : scheme.primary; + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 50), + child: CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + borderRadius: BorderRadius.circular(28), + color: prominent + ? scheme.primary + : scheme.onSurface.withValues( + alpha: scheme.brightness == Brightness.dark ? 0.10 : 0.06, + ), + onPressed: onPressed, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 20, color: foreground), + const SizedBox(width: 8), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: foreground, + ), + ), + ), + ], + ), + ), + ); + } +} + /// "•"-joined row of [HeaderMetaItem]s shown under the header subtitle /// (track count, duration, quality, ...). class HeaderMetaRow extends StatelessWidget { @@ -436,7 +747,7 @@ class HeaderMetaItem extends StatelessWidget { Widget build(BuildContext context) { final scheme = HeaderPalette.of(context); final textStyle = TextStyle( - color: scheme.onSurface, + color: context.isMornye ? scheme.onSurfaceVariant : scheme.onSurface, fontSize: 13, fontWeight: FontWeight.w500, ); diff --git a/lib/widgets/album_track_tile.dart b/lib/widgets/album_track_tile.dart index 346a5885..beadc574 100644 --- a/lib/widgets/album_track_tile.dart +++ b/lib/widgets/album_track_tile.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/widgets/track_card.dart'; @@ -58,10 +60,15 @@ class AlbumTrackTile extends StatelessWidget { trailing: IconButton( tooltip: context.l10n.tooltipPlay, onPressed: onPlay, - icon: Icon(Icons.play_arrow, color: colorScheme.primary), + icon: Icon( + context.isMornye ? CupertinoIcons.play_fill : Icons.play_arrow, + color: colorScheme.primary, + ), style: IconButton.styleFrom( minimumSize: Size.square(context.tokens.minTouchTarget), - backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3), + backgroundColor: context.isMornye + ? Colors.transparent + : colorScheme.primaryContainer.withValues(alpha: 0.3), ), ), ); diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart index 6e5f5d95..9ae544b3 100644 --- a/lib/widgets/animation_utils.dart +++ b/lib/widgets/animation_utils.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; /// Wraps a child in a staggered fade-in + slide-up animation. /// @@ -194,6 +195,24 @@ class TrackListSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { + if (context.isMornye) { + return ShimmerLoading( + child: SingleChildScrollView( + child: Column( + children: [ + if (showCoverHeader) + const _CollectionHeaderSkeleton(showSubtitle: false), + for (var index = 0; index < itemCount; index++) + _MornyeTrackSkeleton( + numbered: false, + index: index, + showPreview: true, + ), + ], + ), + ), + ); + } return ShimmerLoading( child: SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), @@ -265,6 +284,23 @@ class AlbumTrackListSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { + if (context.isMornye) { + return ShimmerLoading( + child: SingleChildScrollView( + child: Column( + children: [ + if (showCoverHeader) const _CollectionHeaderSkeleton(), + for (var index = 0; index < itemCount; index++) + _MornyeTrackSkeleton( + numbered: true, + index: index, + showPreview: true, + ), + ], + ), + ), + ); + } return ShimmerLoading( child: SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), @@ -331,14 +367,87 @@ class AlbumTrackListSkeleton extends StatelessWidget { } } -/// Header skeleton matching the redesigned album/playlist header: a blurred -/// backdrop block with a centered square cover, title/subtitle bars, a meta -/// line (year + quality badges) and the action button row. +/// Mirrors each theme's collection header, including Mornye's full-width +/// square artwork and compact primary action between two circular controls. class _CollectionHeaderSkeleton extends StatelessWidget { - const _CollectionHeaderSkeleton(); + const _CollectionHeaderSkeleton({this.showSubtitle = true}); + + final bool showSubtitle; @override Widget build(BuildContext context) { + if (context.isMornye) { + return LayoutBuilder( + builder: (context, constraints) { + final coverSize = constraints.maxWidth.clamp(0.0, 440.0); + return Column( + children: [ + ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) => const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0, 0.52, 1], + colors: [Colors.white, Colors.white, Colors.transparent], + ).createShader(bounds), + child: SkeletonBox( + width: coverSize, + height: coverSize, + borderRadius: 0, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 28), + child: Column( + children: [ + const FractionallySizedBox( + widthFactor: 0.75, + child: SkeletonBox( + width: double.infinity, + height: 28, + borderRadius: 6, + ), + ), + if (showSubtitle) ...[ + const SizedBox(height: 5), + const FractionallySizedBox( + widthFactor: 0.5, + child: SkeletonBox( + width: double.infinity, + height: 24, + borderRadius: 5, + ), + ), + ], + const SizedBox(height: 5), + const SkeletonBox(width: 140, height: 16, borderRadius: 4), + const SizedBox(height: 24), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 16, + children: [ + SkeletonBox(width: 44, height: 44, borderRadius: 22), + Flexible( + child: SkeletonBox( + width: 172, + height: 50, + borderRadius: 25, + ), + ), + SkeletonBox(width: 44, height: 44, borderRadius: 22), + ], + ), + ), + ], + ), + ), + ], + ); + }, + ); + } final screenWidth = MediaQuery.sizeOf(context).width; final coverSize = (screenWidth * 0.5).clamp(150.0, 210.0).toDouble(); @@ -403,6 +512,104 @@ class ArtistScreenSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { + if (context.isMornye) { + return ShimmerLoading( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showCoverHeader) ...[ + const SkeletonBox( + width: double.infinity, + height: 342, + borderRadius: 0, + ), + const Center(child: SkeletonBox(width: 220, height: 36)), + const SizedBox(height: 16), + const Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 24, + children: [ + SkeletonBox(width: 44, height: 44, borderRadius: 22), + SkeletonBox(width: 60, height: 60, borderRadius: 30), + SkeletonBox(width: 44, height: 44, borderRadius: 22), + ], + ), + ], + Padding( + padding: const EdgeInsets.fromLTRB(20, 28, 20, 0), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(14), + ), + child: const Row( + spacing: 14, + children: [ + SkeletonBox(width: 76, height: 76, borderRadius: 5), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 6, + children: [ + SkeletonBox(width: 38, height: 12, borderRadius: 4), + FractionallySizedBox( + widthFactor: 0.9, + child: SkeletonBox( + width: double.infinity, + height: 18, + borderRadius: 4, + ), + ), + SkeletonBox(width: 62, height: 12, borderRadius: 4), + ], + ), + ), + SkeletonBox(width: 10, height: 14, borderRadius: 4), + ], + ), + ), + ), + if (showPopularSection) ...[ + const Padding( + padding: EdgeInsets.fromLTRB(20, 24, 20, 12), + child: SkeletonBox(width: 110, height: 24, borderRadius: 4), + ), + for (var index = 0; index < popularCount; index++) + _MornyeTrackSkeleton(numbered: false, index: index), + ], + const Padding( + padding: EdgeInsets.fromLTRB(20, 24, 20, 12), + child: SkeletonBox(width: 120, height: 24, borderRadius: 4), + ), + SizedBox( + height: 208, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: albumCount, + separatorBuilder: (_, _) => const SizedBox(width: 12), + itemBuilder: (_, index) => const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonBox(width: 156, height: 156, borderRadius: 8), + SizedBox(height: 8), + SkeletonBox(width: 120, height: 16, borderRadius: 4), + SizedBox(height: 4), + SkeletonBox(width: 45, height: 12, borderRadius: 4), + ], + ), + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ); + } final screenWidth = MediaQuery.sizeOf(context).width; return ShimmerLoading( child: SingleChildScrollView( @@ -513,6 +720,77 @@ class ArtistScreenSkeleton extends StatelessWidget { } } +class _MornyeTrackSkeleton extends StatelessWidget { + const _MornyeTrackSkeleton({ + required this.numbered, + required this.index, + this.showPreview = false, + }); + + final bool numbered; + final int index; + final bool showPreview; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + children: [ + if (numbered) + const SizedBox( + width: 32, + child: Center( + child: SkeletonBox(width: 14, height: 16, borderRadius: 4), + ), + ) + else + const SkeletonBox(width: 48, height: 48, borderRadius: 4), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FractionallySizedBox( + widthFactor: 0.7 + (index % 3) * 0.1, + child: const SkeletonBox( + width: double.infinity, + height: 18, + borderRadius: 4, + ), + ), + const SizedBox(height: 6), + const FractionallySizedBox( + widthFactor: 0.5, + child: SkeletonBox( + width: double.infinity, + height: 14, + borderRadius: 4, + ), + ), + ], + ), + ), + const SizedBox(width: 20), + if (showPreview) ...[ + const SkeletonBox(width: 20, height: 20, borderRadius: 10), + const SizedBox(width: 24), + ], + const SkeletonBox(width: 20, height: 6, borderRadius: 3), + ], + ), + ), + Padding( + padding: EdgeInsets.only(left: numbered ? 64 : 80, right: 20), + child: const Divider(height: 1, thickness: 0.5), + ), + ], + ); + } +} + /// Home search skeleton – mimics filter chips + sectioned results /// (Artists section with rounded card items, Albums section, etc.) class HomeSearchSkeleton extends StatelessWidget { @@ -558,6 +836,50 @@ class HomeSearchSkeleton extends StatelessWidget { double headerWidth, int itemCount, ) { + final mornye = context.isMornye; + final rows = List.generate(itemCount, (index) { + return Padding( + padding: EdgeInsets.symmetric( + horizontal: mornye ? 12 : 16, + vertical: mornye ? 12 : 10, + ), + child: Row( + children: [ + SkeletonBox( + width: mornye ? 56 : 48, + height: mornye ? 56 : 48, + borderRadius: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonBox( + width: 100 + (index % 3) * 40, + height: 14, + borderRadius: 4, + ), + const SizedBox(height: 6), + SkeletonBox( + width: 60 + (index % 2) * 25, + height: 12, + borderRadius: 4, + ), + ], + ), + ), + const SkeletonBox(width: 20, height: 20, borderRadius: 10), + ], + ), + ); + }); + final decoration = BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(mornye ? 24 : 20), + ); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -571,50 +893,24 @@ class HomeSearchSkeleton extends StatelessWidget { ], ), ), - Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(20), + if (mornye) + for (var index = 0; index < rows.length; index++) + Container( + margin: EdgeInsets.fromLTRB( + 16, + 0, + 16, + index == rows.length - 1 ? 0 : 8, + ), + decoration: decoration, + child: rows[index], + ) + else + Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: decoration, + child: Column(children: rows), ), - child: Column( - children: List.generate(itemCount, (index) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - child: Row( - children: [ - const SkeletonBox(width: 48, height: 48, borderRadius: 24), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SkeletonBox( - width: 100 + (index % 3) * 40, - height: 14, - borderRadius: 4, - ), - const SizedBox(height: 6), - SkeletonBox( - width: 60 + (index % 2) * 25, - height: 12, - borderRadius: 4, - ), - ], - ), - ), - const SkeletonBox(width: 20, height: 20, borderRadius: 10), - ], - ), - ); - }), - ), - ), ], ); } diff --git a/lib/widgets/app_action_button.dart b/lib/widgets/app_action_button.dart new file mode 100644 index 00000000..0ac64493 --- /dev/null +++ b/lib/widgets/app_action_button.dart @@ -0,0 +1,98 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; + +/// Shared sheet actions follow the selected design on every platform. +class AppActionButton extends StatelessWidget { + const AppActionButton({ + super.key, + required this.onPressed, + required this.icon, + required this.label, + this.style, + this.outlined = false, + this.glass = true, + this.tonal = false, + this.isDestructive = false, + this.fontSize = 17, + }); + + final VoidCallback? onPressed; + final Widget icon; + final Widget label; + final ButtonStyle? style; + final bool outlined; + final bool glass; + final bool tonal; + final bool isDestructive; + final double fontSize; + + @override + Widget build(BuildContext context) { + if (!context.isMornye) { + return outlined + ? OutlinedButton.icon( + onPressed: onPressed, + icon: icon, + label: label, + style: style, + ) + : FilledButton.icon( + onPressed: onPressed, + icon: icon, + label: label, + style: style, + ); + } + final scheme = Theme.of(context).colorScheme; + final prominent = isDestructive || !outlined; + final foreground = onPressed == null + ? scheme.onSurfaceVariant.withValues(alpha: 0.5) + : prominent + ? scheme.onPrimary + : scheme.primary; + final action = CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + borderRadius: BorderRadius.circular(28), + color: prominent + ? scheme.primary + : tonal + ? MornyeTheme.controlFill(context) + : Colors.transparent, + disabledColor: !prominent && !tonal && !glass + ? Colors.transparent + : tonal && !prominent + ? MornyeTheme.controlFill(context, enabled: false) + : scheme.surfaceContainerHighest, + onPressed: onPressed, + child: DefaultTextStyle( + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: foreground, + fontSize: fontSize, + fontWeight: FontWeight.w600, + ), + child: IconTheme( + data: IconThemeData(color: foreground, size: 20), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon case Icon(:final icon?)) + Icon(mornyeIconFor(icon), size: 20) + else + icon, + const SizedBox(width: 8), + Flexible(child: label), + ], + ), + ), + ), + ); + // These buttons sit inside glass sheets/groups; avoid another blur pass. + return prominent || tonal || !glass + ? action + : MornyeGlass.navigation(radius: 28, blurEnabled: false, child: action); + } +} diff --git a/lib/widgets/app_alert_dialog.dart b/lib/widgets/app_alert_dialog.dart new file mode 100644 index 00000000..c2497212 --- /dev/null +++ b/lib/widgets/app_alert_dialog.dart @@ -0,0 +1,291 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; + +/// Dialog presentation follows the selected design, including on Android. +Future showAppDialog({ + required BuildContext context, + required WidgetBuilder builder, + bool barrierDismissible = true, + bool useRootNavigator = true, +}) { + if (context.isMornye) { + final navigator = Navigator.of(context, rootNavigator: useRootNavigator); + final themes = InheritedTheme.capture(from: context, to: navigator.context); + return navigator.push( + _MornyeDialogRoute( + context: context, + builder: (context) => themes.wrap(Builder(builder: builder)), + barrierDismissible: barrierDismissible, + ), + ); + } + return showDialog( + context: context, + builder: builder, + barrierDismissible: barrierDismissible, + useRootNavigator: useRootNavigator, + ); +} + +/// The confirmation glass also hosts larger dialogs such as release notes. +class AppDialogSurface extends StatelessWidget { + const AppDialogSurface({ + super.key, + required this.child, + this.maxWidth = 480, + this.backgroundColor, + this.shape, + }); + + final Widget child; + final double maxWidth; + final Color? backgroundColor; + final ShapeBorder? shape; + + @override + Widget build(BuildContext context) { + final mornye = context.isMornye; + return Dialog( + backgroundColor: mornye ? Colors.transparent : backgroundColor, + surfaceTintColor: mornye ? Colors.transparent : null, + elevation: mornye ? 0 : null, + shape: shape, + insetPadding: EdgeInsets.symmetric( + horizontal: mornye ? 24 : 40, + vertical: 24, + ), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: mornye + ? MornyeGlassPanel.overlay( + child: SingleChildScrollView(child: child), + ) + : child, + ), + ); + } +} + +class _MornyeDialogRoute extends RawDialogRoute { + _MornyeDialogRoute({ + required BuildContext context, + required WidgetBuilder builder, + required super.barrierDismissible, + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => + builder(context), + barrierColor: CupertinoDynamicColor.resolve( + kCupertinoModalBarrierColor, + context, + ), + barrierLabel: CupertinoLocalizations.of( + context, + ).modalBarrierDismissLabel, + transitionDuration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 220), + ); + + // Use a timed exit instead of CupertinoDialogRoute's settling spring. + @override + Duration get reverseTransitionDuration => transitionDuration == Duration.zero + ? Duration.zero + : const Duration(milliseconds: 160); + + CurvedAnimation? _scaleAnimation; + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + if (MediaQuery.disableAnimationsOf(context)) return child; + // Keep the backdrop in the page's compositing context. A fading dialog + // creates an opacity layer that hides the page from its BackdropFilter. + final scaleAnimation = _scaleAnimation ??= CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + return ScaleTransition( + scale: scaleAnimation.drive(Tween(begin: 0.96, end: 1)), + child: child, + ); + } + + @override + void dispose() { + _scaleAnimation?.dispose(); + super.dispose(); + } +} + +class AppAlertDialog extends StatelessWidget { + const AppAlertDialog({ + super.key, + this.icon, + this.title, + this.content, + this.actions = const [], + this.backgroundColor, + this.shape, + }); + + final Widget? icon; + final Widget? title; + final Widget? content; + final List actions; + final Color? backgroundColor; + final ShapeBorder? shape; + + @override + Widget build(BuildContext context) { + if (!context.isMornye) { + return AlertDialog( + backgroundColor: backgroundColor, + shape: shape, + icon: icon, + title: title, + content: content, + actions: actions, + ); + } + final theme = Theme.of(context); + final primaryActions = actions.where( + (action) => + action is AppDialogAction && (action.isDestructive || action.filled), + ); + final secondaryActions = actions.where( + (action) => + action is! AppDialogAction || + (!action.isDestructive && !action.filled), + ); + final orderedActions = [...primaryActions, ...secondaryActions]; + return Dialog( + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + elevation: 0, + insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: MornyeGlassPanel.overlay( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (icon != null || title != null || content != null) + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (icon != null) ...[ + Align( + alignment: AlignmentDirectional.centerStart, + child: icon!, + ), + const SizedBox(height: 12), + ], + if (title != null) + DefaultTextStyle( + style: theme.textTheme.titleLarge!.copyWith( + fontSize: 20, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + child: title!, + ), + if (title != null && content != null) + const SizedBox(height: 10), + if (content != null) + DefaultTextStyle( + style: theme.textTheme.bodyLarge!.copyWith( + fontSize: 17, + height: 1.35, + color: theme.colorScheme.onSurface, + ), + child: content!, + ), + ], + ), + ), + ), + for (var i = 0; i < orderedActions.length; i++) ...[ + if (i > 0) const SizedBox(height: 8), + orderedActions[i], + ], + ], + ), + ), + ), + ), + ); + } +} + +class AppDialogAction extends StatelessWidget { + const AppDialogAction({ + super.key, + required this.onPressed, + required this.child, + this.isDestructive = false, + this.isDefault = false, + this.filled = false, + this.outlined = false, + this.style, + }); + + final VoidCallback? onPressed; + final Widget child; + final bool isDestructive; + final bool isDefault; + final bool filled; + final bool outlined; + final ButtonStyle? style; + + @override + Widget build(BuildContext context) { + if (context.isMornye) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final color = onPressed == null + ? scheme.onSurfaceVariant + : isDestructive + ? scheme.primary + : scheme.onSurface; + return CupertinoButton( + onPressed: onPressed, + color: MornyeTheme.controlFill(context), + disabledColor: MornyeTheme.controlFill(context, enabled: false), + borderRadius: BorderRadius.circular(28), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: DefaultTextStyle( + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge!.copyWith( + color: color, + fontSize: 17, + fontWeight: isDestructive || isDefault || filled + ? FontWeight.w600 + : FontWeight.w500, + ), + child: IconTheme( + data: IconThemeData(color: color, size: 20), + child: child, + ), + ), + ); + } + return filled + ? FilledButton(onPressed: onPressed, style: style, child: child) + : outlined + ? OutlinedButton(onPressed: onPressed, style: style, child: child) + : TextButton(onPressed: onPressed, style: style, child: child); + } +} diff --git a/lib/widgets/app_announcement_dialog.dart b/lib/widgets/app_announcement_dialog.dart index 6fb9756c..d3332bee 100644 --- a/lib/widgets/app_announcement_dialog.dart +++ b/lib/widgets/app_announcement_dialog.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/app_remote_config_service.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; class AppAnnouncementDialog extends StatelessWidget { final RemoteAnnouncement announcement; @@ -68,7 +69,8 @@ class AppAnnouncementDialog extends StatelessWidget { final actions = [ if (announcement.hasCta) - FilledButton( + AppDialogAction( + filled: true, onPressed: () => _openCta(context), child: Text(announcement.ctaLabel!), ), @@ -76,7 +78,7 @@ class AppAnnouncementDialog extends StatelessWidget { return PopScope( canPop: false, - child: AlertDialog( + child: AppAlertDialog( title: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -111,7 +113,7 @@ class AppAnnouncementDialog extends StatelessWidget { ), ), ), - actions: actions.isEmpty ? null : actions, + actions: actions, ), ); } @@ -122,7 +124,7 @@ Future showAppAnnouncementDialog( required RemoteAnnouncement announcement, required VoidCallback onDismiss, }) { - return showDialog( + return showAppDialog( context: context, barrierDismissible: false, builder: (context) => diff --git a/lib/widgets/app_bottom_sheet.dart b/lib/widgets/app_bottom_sheet.dart index c104d483..110e93e7 100644 --- a/lib/widgets/app_bottom_sheet.dart +++ b/lib/widgets/app_bottom_sheet.dart @@ -1,5 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart' + show CupertinoButton, CupertinoDynamicColor, kCupertinoModalBarrierColor; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; /// The drag handle shown at the top of every modal sheet. /// @@ -131,7 +136,7 @@ class AppBottomSheet extends StatelessWidget { ), child: Text( subtitle!, - maxLines: 1, + maxLines: context.isMornye ? 3 : 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, @@ -179,7 +184,7 @@ Future showAppBottomSheet({ bool showHandle = true, Color? backgroundColor, }) { - return showModalBottomSheet( + return showAppModalBottomSheet( context: context, isScrollControlled: isScrollControlled, isDismissible: isDismissible, @@ -195,3 +200,128 @@ Future showAppBottomSheet({ ), ); } + +/// Adaptive surface for sheets which already supply their own header/layout. +Future showAppModalBottomSheet({ + required BuildContext context, + required WidgetBuilder builder, + bool isScrollControlled = false, + bool isDismissible = true, + bool enableDrag = true, + bool useRootNavigator = false, + bool useSafeArea = false, + BoxConstraints? constraints, + Color? backgroundColor, +}) { + final mornye = context.isMornye; + return showModalBottomSheet( + context: context, + isScrollControlled: isScrollControlled, + isDismissible: isDismissible, + enableDrag: enableDrag, + useRootNavigator: useRootNavigator, + useSafeArea: useSafeArea, + constraints: constraints, + backgroundColor: mornye ? Colors.transparent : backgroundColor, + barrierColor: mornye + ? CupertinoDynamicColor.resolve(kCupertinoModalBarrierColor, context) + : null, + elevation: mornye ? 0 : null, + builder: (context) => mornye + ? MornyeGlassPanel.overlay(child: Builder(builder: builder)) + : builder(context), + ); +} + +/// Flat rows sit directly on a sheet's glass; Material keeps its ListTile. +class AppSheetOption extends StatelessWidget { + const AppSheetOption({ + super.key, + required this.title, + this.subtitle, + this.leading, + this.trailing, + this.onTap, + this.contentPadding, + this.enabled = true, + }); + + final Widget title; + final Widget? subtitle; + final Widget? leading; + final Widget? trailing; + final VoidCallback? onTap; + final EdgeInsetsGeometry? contentPadding; + final bool enabled; + + @override + Widget build(BuildContext context) { + if (!context.isMornye) { + return ListTile( + title: title, + subtitle: subtitle, + leading: leading, + trailing: trailing, + onTap: onTap, + contentPadding: contentPadding, + enabled: enabled, + ); + } + final theme = Theme.of(context); + final scheme = theme.colorScheme; + Widget adaptedIcon(Widget widget, double size) { + if (widget case Icon(:final icon?)) { + return Icon( + mornyeIconFor(icon), + size: size, + color: enabled ? widget.color ?? scheme.primary : theme.disabledColor, + ); + } + return widget; + } + + return CupertinoButton( + onPressed: enabled ? onTap : null, + padding: + contentPadding ?? + const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + child: Row( + children: [ + if (leading != null) ...[ + adaptedIcon(leading!, 24), + const SizedBox(width: 16), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DefaultTextStyle( + style: theme.textTheme.bodyLarge!.copyWith( + color: enabled ? scheme.onSurface : theme.disabledColor, + fontWeight: FontWeight.w500, + ), + child: title, + ), + if (subtitle != null) ...[ + const SizedBox(height: 4), + DefaultTextStyle( + style: theme.textTheme.bodySmall!.copyWith( + color: enabled + ? scheme.onSurfaceVariant + : theme.disabledColor, + ), + child: subtitle!, + ), + ], + ], + ), + ), + if (trailing != null) ...[ + const SizedBox(width: 12), + adaptedIcon(trailing!, 20), + ], + ], + ), + ); + } +} diff --git a/lib/widgets/app_choice_chip.dart b/lib/widgets/app_choice_chip.dart new file mode 100644 index 00000000..01168a07 --- /dev/null +++ b/lib/widgets/app_choice_chip.dart @@ -0,0 +1,75 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +/// A single tonal selection inside a sheet, without a second glass layer. +class AppChoiceChip extends StatelessWidget { + const AppChoiceChip({ + super.key, + required this.label, + required this.selected, + required this.onSelected, + this.visualDensity, + this.singleChoice = false, + }); + + final Widget label; + final bool selected; + final ValueChanged? onSelected; + final VisualDensity? visualDensity; + final bool singleChoice; + + @override + Widget build(BuildContext context) { + if (!context.isMornye) { + if (singleChoice) { + return ChoiceChip( + label: label, + selected: selected, + onSelected: onSelected, + visualDensity: visualDensity, + ); + } + return FilterChip( + label: label, + selected: selected, + onSelected: onSelected, + visualDensity: visualDensity, + ); + } + final scheme = Theme.of(context).colorScheme; + final foreground = onSelected == null + ? scheme.onSurfaceVariant + : selected + ? scheme.primary + : scheme.onSurface; + return Semantics( + selected: selected, + child: CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + borderRadius: BorderRadius.circular(24), + color: selected + ? scheme.primary.withValues(alpha: 0.12) + : MornyeTheme.controlFill(context), + disabledColor: MornyeTheme.controlFill(context, enabled: false), + onPressed: onSelected == null ? null : () => onSelected!(!selected), + child: DefaultTextStyle( + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: foreground, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (selected) ...[ + Icon(CupertinoIcons.check_mark, size: 18, color: foreground), + const SizedBox(width: 8), + ], + Flexible(child: label), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/app_content_card.dart b/lib/widgets/app_content_card.dart new file mode 100644 index 00000000..217c723d --- /dev/null +++ b/lib/widgets/app_content_card.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +/// One inline surface, matching the metadata groups and dialog controls. +class AppContentCard extends StatelessWidget { + const AppContentCard({ + super.key, + required this.child, + this.color, + this.shape, + this.elevation, + this.clipBehavior = Clip.none, + this.preserveColor = false, + }); + + final Widget child; + final Color? color; + final ShapeBorder? shape; + final double? elevation; + final Clip clipBehavior; + final bool preserveColor; + + @override + Widget build(BuildContext context) { + if (!context.isMornye) { + return Card( + color: color, + shape: shape, + elevation: elevation, + clipBehavior: clipBehavior, + child: child, + ); + } + return Material( + color: preserveColor ? color : MornyeTheme.controlFill(context), + borderRadius: BorderRadius.circular(28), + clipBehavior: Clip.antiAlias, + child: DividerTheme( + data: DividerTheme.of( + context, + ).copyWith(color: MornyeTheme.metadataDividerColor(context)), + child: child, + ), + ); + } +} diff --git a/lib/widgets/app_search_field.dart b/lib/widgets/app_search_field.dart index 9d7a2a4d..6a23ff14 100644 --- a/lib/widgets/app_search_field.dart +++ b/lib/widgets/app_search_field.dart @@ -1,5 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; /// The standard search field used by top-level app surfaces. @@ -12,8 +17,11 @@ class AppSearchField extends StatelessWidget { required this.controller, required this.hintText, required this.clearTooltip, - required this.onChanged, + this.onChanged, required this.onClear, + this.onSubmitted, + this.prefixIcon, + this.suffixIcon, this.focusNode, this.autofocus = false, }); @@ -21,17 +29,23 @@ class AppSearchField extends StatelessWidget { final TextEditingController controller; final String hintText; final String clearTooltip; - final ValueChanged onChanged; + final ValueChanged? onChanged; final VoidCallback onClear; + final ValueChanged? onSubmitted; + final Widget? prefixIcon; + final Widget? suffixIcon; final FocusNode? focusNode; final bool autofocus; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final radius = BorderRadius.circular(context.tokens.radiusSheet); + final mornye = context.isMornye; + final radius = BorderRadius.circular( + mornye ? 28 : context.tokens.radiusSheet, + ); - return ValueListenableBuilder( + final field = ValueListenableBuilder( valueListenable: controller, builder: (context, value, _) => TextField( controller: controller, @@ -40,39 +54,64 @@ class AppSearchField extends StatelessWidget { textInputAction: TextInputAction.search, decoration: InputDecoration( hintText: hintText, - prefixIcon: const Icon(Icons.search), - suffixIcon: value.text.isEmpty - ? null - : IconButton( - tooltip: clearTooltip, - icon: const Icon(Icons.clear), - onPressed: () { - controller.clear(); - onClear(); - }, - ), + prefixIcon: + prefixIcon ?? Icon(mornye ? CupertinoIcons.search : Icons.search), + suffixIcon: + suffixIcon ?? + (value.text.isEmpty + ? null + : IconButton( + tooltip: clearTooltip, + icon: Icon( + mornye + ? CupertinoIcons.clear_circled_solid + : Icons.clear, + ), + onPressed: () { + controller.clear(); + onClear(); + }, + )), border: OutlineInputBorder( borderRadius: radius, - borderSide: BorderSide(color: colorScheme.outlineVariant), + borderSide: mornye + ? BorderSide.none + : BorderSide(color: colorScheme.outlineVariant), ), enabledBorder: OutlineInputBorder( borderRadius: radius, - borderSide: BorderSide(color: colorScheme.outlineVariant), + borderSide: mornye + ? BorderSide.none + : BorderSide(color: colorScheme.outlineVariant), ), focusedBorder: OutlineInputBorder( borderRadius: radius, - borderSide: BorderSide(color: colorScheme.primary, width: 2), + borderSide: mornye + ? BorderSide.none + : BorderSide(color: colorScheme.primary, width: 2), ), filled: true, - fillColor: settingsGroupColor(context), + fillColor: mornye ? Colors.transparent : settingsGroupColor(context), contentPadding: const EdgeInsets.symmetric( horizontal: 20, vertical: 16, ), ), onChanged: onChanged, + onSubmitted: onSubmitted, onTapOutside: (_) => FocusScope.of(context).unfocus(), ), ); + if (!mornye) return field; + return Consumer( + builder: (context, ref, child) => MornyeGlass.navigation( + radius: 28, + blurEnabled: + !ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider), + child: child!, + ), + child: field, + ); } } diff --git a/lib/widgets/app_sliver_header.dart b/lib/widgets/app_sliver_header.dart index cc411f10..351f1147 100644 --- a/lib/widgets/app_sliver_header.dart +++ b/lib/widgets/app_sliver_header.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; @@ -64,7 +65,9 @@ class AppSliverHeader extends StatelessWidget { tooltip: MaterialLocalizations.of( context, ).backButtonTooltip, - icon: const Icon(Icons.arrow_back), + icon: Icon( + context.isMornye ? Icons.chevron_left : Icons.arrow_back, + ), onPressed: () => Navigator.pop(context), ), ) @@ -116,6 +119,7 @@ class AppSliverHeader extends StatelessWidget { style: TextStyle( fontSize: fontSize, fontWeight: FontWeight.bold, + letterSpacing: context.isMornye ? -0.7 * expandRatio : null, color: colorScheme.onSurface, ), ), diff --git a/lib/widgets/app_snack_bar.dart b/lib/widgets/app_snack_bar.dart new file mode 100644 index 00000000..dfdb1f25 --- /dev/null +++ b/lib/widgets/app_snack_bar.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; + +/// Transient messages use the same glass as confirmation dialogs in Mornye. +ScaffoldFeatureController showAppSnackBar( + BuildContext context, { + required Widget content, + Duration duration = const Duration(seconds: 4), + SnackBarBehavior? behavior, +}) { + final messenger = ScaffoldMessenger.of(context); + if (!context.isMornye) { + return messenger.showSnackBar( + SnackBar(content: content, duration: duration, behavior: behavior), + ); + } + final theme = Theme.of(context); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final snackBar = _MornyeSnackBar( + duration: duration, + reduceMotion: reduceMotion, + content: Theme( + data: theme, + child: MornyeGlassPanel.overlay( + radius: 24, + // Messages have no modal scrim; let more of the page show through. + tintOpacity: 0.60, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: DefaultTextStyle( + style: theme.textTheme.bodyMedium!.copyWith( + color: theme.colorScheme.onSurface, + ), + child: content, + ), + ), + ), + ), + ); + return messenger.showSnackBar(snackBar); +} + +class _MornyeSnackBar extends SnackBar { + const _MornyeSnackBar({ + required super.content, + required super.duration, + required this.reduceMotion, + }) : super( + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.transparent, + elevation: 0, + clipBehavior: Clip.none, + padding: EdgeInsets.zero, + margin: const EdgeInsets.fromLTRB(20, 8, 20, 12), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(24)), + ), + ); + + final bool reduceMotion; + + @override + SnackBar withAnimation(Animation newAnimation, {Key? fallbackKey}) { + // Keep the messenger's controller/timer for queuing and dismissal, but + // replace Material's opacity layer with movement so blur samples the page. + return SnackBar( + key: key ?? fallbackKey, + duration: duration, + behavior: behavior, + backgroundColor: backgroundColor, + elevation: elevation, + clipBehavior: clipBehavior, + padding: padding, + margin: margin, + shape: shape, + animation: const AlwaysStoppedAnimation(1), + content: AnimatedBuilder( + animation: newAnimation, + child: content, + builder: (context, child) => Transform.translate( + offset: Offset( + 0, + reduceMotion + ? 0 + : 8 * (1 - Curves.easeOutCubic.transform(newAnimation.value)), + ), + child: child, + ), + ), + ); + } +} diff --git a/lib/widgets/app_switch.dart b/lib/widgets/app_switch.dart new file mode 100644 index 00000000..14bb95d6 --- /dev/null +++ b/lib/widgets/app_switch.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:liquid_glass_easy/liquid_glass_easy.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +/// One switch style for settings, extension controls and editing sheets. +/// Mornye keeps its wide oval thumb even when glass or motion is disabled. +class AppSwitch extends StatelessWidget { + const AppSwitch({ + super.key, + required this.value, + required this.onChanged, + this.adaptive = false, + this.semanticLabel, + }); + + final bool value; + final ValueChanged? onChanged; + final bool adaptive; + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + if (!context.isMornye) { + return adaptive + ? Switch.adaptive(value: value, onChanged: onChanged) + : Switch(value: value, onChanged: onChanged); + } + + return Consumer( + builder: (context, ref, _) { + final scheme = Theme.of(context).colorScheme; + final enabled = onChanged != null; + final useGlass = + enabled && + !MediaQuery.disableAnimationsOf(context) && + !MediaQuery.highContrastOf(context) && + (!ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider)); + final inactive = scheme.brightness == Brightness.dark + ? const Color(0xff39393d) + : const Color(0xff8e8e93); + + final Widget control; + if (useGlass) { + control = Transform.flip( + flipX: Directionality.of(context) == TextDirection.rtl, + child: LiquidGlassSwitch( + value: value, + onChanged: onChanged!, + activeColor: scheme.primary, + inactiveColor: inactive, + ), + ); + } else { + control = Container( + width: 63, + height: 28, + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: value ? scheme.primary : inactive, + borderRadius: BorderRadius.circular(14), + ), + child: Align( + alignment: value + ? AlignmentDirectional.centerEnd + : AlignmentDirectional.centerStart, + child: Container( + width: 37, + height: 24, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ); + } + + return Semantics( + label: semanticLabel, + toggled: value, + enabled: enabled, + child: Opacity( + opacity: enabled ? 1 : 0.5, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: enabled ? () => onChanged!(!value) : null, + borderRadius: BorderRadius.circular(22), + // InkWell supplies keyboard activation and an accessible tap + // action; the glass control owns the inner drag gesture. + child: ExcludeSemantics( + child: SizedBox( + width: 71, + height: 44, + child: Center(child: control), + ), + ), + ), + ), + ), + ); + }, + ); + } +} + +class AppSwitchListTile extends StatelessWidget { + const AppSwitchListTile({ + super.key, + required this.value, + required this.onChanged, + required this.title, + this.subtitle, + this.contentPadding, + this.adaptive = false, + }); + + final bool value; + final ValueChanged? onChanged; + final Widget title; + final Widget? subtitle; + final EdgeInsetsGeometry? contentPadding; + final bool adaptive; + + @override + Widget build(BuildContext context) { + if (!context.isMornye) { + return adaptive + ? SwitchListTile.adaptive( + value: value, + onChanged: onChanged, + title: title, + subtitle: subtitle, + contentPadding: contentPadding, + ) + : SwitchListTile( + value: value, + onChanged: onChanged, + title: title, + subtitle: subtitle, + contentPadding: contentPadding, + ); + } + return MergeSemantics( + child: ListTile( + title: title, + subtitle: subtitle, + contentPadding: contentPadding, + enabled: onChanged != null, + onTap: onChanged == null ? null : () => onChanged!(!value), + trailing: AppSwitch(value: value, onChanged: onChanged), + ), + ); + } +} diff --git a/lib/widgets/audio_analysis_info_card.dart b/lib/widgets/audio_analysis_info_card.dart index 1c87bb83..85f7853a 100644 --- a/lib/widgets/audio_analysis_info_card.dart +++ b/lib/widgets/audio_analysis_info_card.dart @@ -13,7 +13,7 @@ class _AudioInfoCard extends StatelessWidget { final cs = Theme.of(context).colorScheme; final nyquist = data.sampleRate / 2; - return Card( + return AppContentCard( elevation: 0, color: settingsGroupColor(context), shape: RoundedRectangleBorder( diff --git a/lib/widgets/audio_analysis_spectrogram.dart b/lib/widgets/audio_analysis_spectrogram.dart index f251b695..636e5468 100644 --- a/lib/widgets/audio_analysis_spectrogram.dart +++ b/lib/widgets/audio_analysis_spectrogram.dart @@ -28,7 +28,8 @@ class _SpectrogramView extends StatelessWidget { final cs = Theme.of(context).colorScheme; const labelColor = Color(0xFFB5B5B5); - return Card( + final card = AppContentCard( + preserveColor: true, color: Colors.black, clipBehavior: Clip.antiAlias, child: Column( @@ -41,7 +42,8 @@ class _SpectrogramView extends StatelessWidget { scrollDirection: Axis.horizontal, child: Row( children: [ - ChoiceChip( + AppChoiceChip( + singleChoice: true, label: Text(context.l10n.audioAnalysisChannels), selected: selectedChannel < 0, onSelected: channelLoading @@ -51,7 +53,8 @@ class _SpectrogramView extends StatelessWidget { ), for (var channel = 0; channel < channels; channel++) ...[ const SizedBox(width: 6), - ChoiceChip( + AppChoiceChip( + singleChoice: true, label: Text('Ch ${channel + 1}'), selected: selectedChannel == channel, onSelected: channelLoading @@ -141,6 +144,11 @@ class _SpectrogramView extends StatelessWidget { ], ), ); + // The plot stays black in both appearances; its controls need matching + // contrast even when the surrounding Mornye page uses the light theme. + return context.isMornye + ? Theme(data: MornyeTheme.build(Brightness.dark), child: card) + : card; } static List _legendColors() { diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index 4d86ce84..b516f6ee 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -10,8 +10,11 @@ import 'package:ffmpeg_kit_flutter_new_full/ffprobe_kit.dart'; import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/services/audio_analysis_jobs.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/widgets/app_content_card.dart'; +import 'package:spotiflac_android/widgets/app_choice_chip.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -1540,7 +1543,7 @@ class _AudioAnalysisCardState extends State { if (_checkingCache) return const SizedBox.shrink(); if (_unsupportedCodec != null) { - return Card( + return AppContentCard( elevation: 0, color: settingsGroupColor(context), shape: RoundedRectangleBorder( @@ -1586,7 +1589,7 @@ class _AudioAnalysisCardState extends State { if (_analyzing) { final isRescan = _data != null || _spectrogramImage != null; - return Card( + return AppContentCard( elevation: 0, color: settingsGroupColor(context), shape: RoundedRectangleBorder( @@ -1619,7 +1622,8 @@ class _AudioAnalysisCardState extends State { } if (_error != null) { - return Card( + return AppContentCard( + preserveColor: true, color: cs.errorContainer, child: Padding( padding: const EdgeInsets.all(16), @@ -1649,7 +1653,7 @@ class _AudioAnalysisCardState extends State { } if (_data == null) { - return Card( + return AppContentCard( elevation: 0, color: settingsGroupColor(context), shape: RoundedRectangleBorder( diff --git a/lib/widgets/batch_convert_sheet.dart b/lib/widgets/batch_convert_sheet.dart index 055f6800..db1e337f 100644 --- a/lib/widgets/batch_convert_sheet.dart +++ b/lib/widgets/batch_convert_sheet.dart @@ -1,11 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; -/// Modern, card-based batch convert sheet shared by the queue and album -/// screens, matching the single-track convert sheet styling. +/// Conversion options shared by the queue, album and single-track sheets. class BatchConvertSheet extends StatefulWidget { final List formats; final String title; @@ -93,7 +97,7 @@ class _BatchConvertSheetState extends State { minChildSize: 0.5, maxChildSize: 0.95, expand: false, - builder: (context, scrollController) => SafeArea( + builder: (context, scrollController) => _sheetSurface( child: SingleChildScrollView( controller: scrollController, padding: const EdgeInsets.fromLTRB(20, 12, 20, 20), @@ -327,13 +331,21 @@ class _BatchConvertSheetState extends State { horizontal: 14, vertical: 12, ), - decoration: BoxDecoration( - color: cs.primaryContainer.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(14), - ), + decoration: context.isMornye + ? null + : BoxDecoration( + color: cs.primaryContainer.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(14), + ), child: Row( children: [ - Icon(Icons.verified, size: 18, color: cs.primary), + Icon( + context.isMornye + ? CupertinoIcons.checkmark_seal + : Icons.verified, + size: 18, + color: cs.primary, + ), const SizedBox(width: 8), Expanded( child: Text( @@ -364,7 +376,7 @@ class _BatchConvertSheetState extends State { _card( cs, - child: SwitchListTile( + child: AppSwitchListTile( contentPadding: EdgeInsets.zero, title: Text(context.l10n.trackConvertKeepOriginal), subtitle: Text( @@ -378,7 +390,7 @@ class _BatchConvertSheetState extends State { const SizedBox(height: 8), SizedBox( width: double.infinity, - child: FilledButton.icon( + child: AppActionButton( onPressed: () => widget.onConvert( _selectedFormat, _selectedBitrate, @@ -421,7 +433,27 @@ class _BatchConvertSheetState extends State { ); } + Widget _sheetSurface({required Widget child}) { + final content = SafeArea(child: child); + return context.isMornye + ? MornyeGlassPanel.overlay(child: content) + : content; + } + Widget _card(ColorScheme cs, {required Widget child}) { + if (context.isMornye) { + return Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + child, + const SizedBox(height: 20), + const Divider(height: 1), + ], + ), + ); + } return Container( width: double.infinity, margin: const EdgeInsets.only(bottom: 12), @@ -458,6 +490,15 @@ class _BatchConvertSheetState extends State { required bool selected, required VoidCallback onTap, }) { + if (context.isMornye) { + return MornyeFilterChip( + label: label, + selected: selected, + onTap: onTap, + icon: selected ? CupertinoIcons.checkmark : null, + glass: false, + ); + } return Material( color: selected ? cs.primaryContainer : cs.surface, borderRadius: BorderRadius.circular(14), diff --git a/lib/widgets/batch_progress_dialog.dart b/lib/widgets/batch_progress_dialog.dart index 673b17aa..16a550cc 100644 --- a/lib/widgets/batch_progress_dialog.dart +++ b/lib/widgets/batch_progress_dialog.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; class _BatchProgress { final int current; @@ -65,7 +66,7 @@ class BatchProgressDialog extends StatefulWidget { _activeNotifier = ValueNotifier(const _BatchProgress()); final notifier = _activeNotifier!; - showDialog( + showAppDialog( context: context, barrierDismissible: false, builder: (_) => BatchProgressDialog._( @@ -117,7 +118,7 @@ class _BatchProgressDialogState extends State { final detail = widget._progressNotifier.value.detail; final progress = widget.total > 0 ? current / widget.total : 0.0; - return AlertDialog( + return AppAlertDialog( backgroundColor: colorScheme.surfaceContainerHigh, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), content: Column( @@ -176,7 +177,7 @@ class _BatchProgressDialogState extends State { ], ), actions: [ - TextButton( + AppDialogAction( onPressed: widget.onCancel, child: Text(context.l10n.dialogCancel), ), diff --git a/lib/widgets/collection_scaffold.dart b/lib/widgets/collection_scaffold.dart index 4b42dc1e..06532e35 100644 --- a/lib/widgets/collection_scaffold.dart +++ b/lib/widgets/collection_scaffold.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/utils/nav_bar_inset.dart'; +import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; /// Shared shell for every track-collection screen: album, playlist, local @@ -17,7 +19,7 @@ class CollectionScaffold extends StatefulWidget { required this.onExitSelectionMode, required this.appBar, required this.slivers, - required this.bottomInset, + this.bottomInset, this.selectionBar, this.backgroundColor, }); @@ -32,8 +34,8 @@ class CollectionScaffold extends StatefulWidget { /// Content slivers below the header (track list, error card, footer, ...). final List slivers; - /// Bottom inset needed to clear the shell navigation bar. - final double bottomInset; + /// Overrides the shell inset; otherwise only the trailing spacer watches it. + final double? bottomInset; /// Rebuilt by the caller each frame; shown only while [isSelectionMode] is /// true. Null means the screen has no multi-select. @@ -101,7 +103,10 @@ class _CollectionScaffoldState extends State { body: CustomScrollView( controller: widget.scrollController, slivers: [ - widget.appBar, + if (widget.appBar case final AlbumDetailHeader header) + ...header.buildSlivers(context) + else + widget.appBar, ...widget.slivers, SliverToBoxAdapter( child: SizedBox( @@ -109,7 +114,7 @@ class _CollectionScaffoldState extends State { height: widget.isSelectionMode ? _selectionBarHeight : 32, ), ), - SliverToBoxAdapter(child: SizedBox(height: widget.bottomInset)), + NavBarSliverSpacer(bottomInset: widget.bottomInset), ], ), ), diff --git a/lib/widgets/cross_extension_share_sheet.dart b/lib/widgets/cross_extension_share_sheet.dart index c4139f7c..cb1b57f6 100644 --- a/lib/widgets/cross_extension_share_sheet.dart +++ b/lib/widgets/cross_extension_share_sheet.dart @@ -148,7 +148,7 @@ class _CrossExtensionShareTile extends StatelessWidget { final url = result.url; final hasUrl = result.found && url != null && url.isNotEmpty; - final tile = ListTile( + final tile = AppSheetOption( contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), leading: Container( width: 44, diff --git a/lib/widgets/destructive_selection_button.dart b/lib/widgets/destructive_selection_button.dart index 3ee2cd03..f712a61c 100644 --- a/lib/widgets/destructive_selection_button.dart +++ b/lib/widgets/destructive_selection_button.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; /// Full-width delete action for selection-mode bottom bars: destructive /// (error) styling once at least one item is selected, disabled/neutral @@ -20,7 +21,8 @@ class DestructiveSelectionButton extends StatelessWidget { Widget build(BuildContext context) { return SizedBox( width: double.infinity, - child: FilledButton.icon( + child: AppActionButton( + isDestructive: true, onPressed: count > 0 ? onPressed : null, icon: const Icon(Icons.delete_outline), label: Text( diff --git a/lib/widgets/discard_changes_dialog.dart b/lib/widgets/discard_changes_dialog.dart index bb92cefb..226ad41d 100644 --- a/lib/widgets/discard_changes_dialog.dart +++ b/lib/widgets/discard_changes_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; /// Shared discard-unsaved-changes confirmation dialog used by priority / @@ -7,17 +8,20 @@ Future showDiscardChangesDialog( BuildContext context, { String? content, }) async { - final result = await showDialog( + final result = await showAppDialog( context: context, - builder: (context) => AlertDialog( + builder: (context) => AppAlertDialog( title: Text(context.l10n.dialogDiscardChanges), content: Text(content ?? context.l10n.dialogUnsavedChanges), actions: [ - TextButton( + AppDialogAction( + isDefault: true, onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.dialogDiscard), ), diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index d1551ef2..889ead6e 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -1,10 +1,15 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; class DownloadServicePicker extends ConsumerStatefulWidget { final String? trackName; @@ -39,19 +44,41 @@ class DownloadServicePicker extends ConsumerStatefulWidget { showModalBottomSheet( context: context, useRootNavigator: true, - backgroundColor: colorScheme.surfaceContainerHigh, + backgroundColor: context.isMornye + ? Colors.transparent + : colorScheme.surfaceContainerHigh, + elevation: context.isMornye ? 0 : null, + barrierColor: context.isMornye + ? Colors.black.withValues(alpha: 0.26) + : null, isScrollControlled: true, - showDragHandle: true, + showDragHandle: !context.isMornye, constraints: BoxConstraints( maxHeight: MediaQuery.sizeOf(context).height * 0.88, ), - builder: (context) => DownloadServicePicker( - trackName: trackName, - artistName: artistName, - coverUrl: coverUrl, - onSelect: onSelect, - recommendedService: recommendedService, - ), + builder: (context) { + final picker = DownloadServicePicker( + trackName: trackName, + artistName: artistName, + coverUrl: coverUrl, + onSelect: onSelect, + recommendedService: recommendedService, + ); + return context.isMornye + ? MornyeGlassPanel.overlay( + tintOpacity: colorScheme.brightness == Brightness.dark + ? 0.68 + : 0.75, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const AppSheetHandle(), + Flexible(child: picker), + ], + ), + ) + : picker; + }, ); } } @@ -120,6 +147,18 @@ class _DownloadServicePickerState extends ConsumerState { final downloadExtensions = _downloadExtensions(); final hasProviders = downloadExtensions.isNotEmpty; final qualityOptions = _getQualityOptions(downloadExtensions); + final qualityRows = [ + for (final quality in qualityOptions) + _QualityOption( + title: _localizedQualityLabel(context, quality), + subtitle: _localizedQualityDescription(context, quality), + icon: _getQualityIcon(quality.id), + onTap: () { + Navigator.pop(context); + widget.onSelect(quality.id, _selectedService); + }, + ), + ]; return SafeArea( top: false, @@ -160,9 +199,8 @@ class _DownloadServicePickerState extends ConsumerState { children: [ for (final ext in downloadExtensions) _ServiceChip( - label: widget.recommendedService == ext.id - ? '${ext.displayName} (Recommended)' - : ext.displayName, + label: ext.displayName, + isRecommended: widget.recommendedService == ext.id, healthStatus: ext.hasServiceHealth ? extensionState.healthStatuses[ext.id]?.status : null, @@ -179,7 +217,12 @@ class _DownloadServicePickerState extends ConsumerState { ), if (hasProviders) ...[ Padding( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), + padding: EdgeInsets.fromLTRB( + 24, + context.isMornye ? 24 : 16, + 24, + context.isMornye ? 12 : 8, + ), child: Text( context.l10n.downloadSelectQuality, style: Theme.of(context).textTheme.titleMedium?.copyWith( @@ -187,16 +230,32 @@ class _DownloadServicePickerState extends ConsumerState { ), ), ), - for (final quality in qualityOptions) - _QualityOption( - title: _localizedQualityLabel(context, quality), - subtitle: _localizedQualityDescription(context, quality), - icon: _getQualityIcon(quality.id), - onTap: () { - Navigator.pop(context); - widget.onSelect(quality.id, _selectedService); - }, - ), + if (context.isMornye) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Material( + color: MornyeTheme.controlFill(context), + borderRadius: BorderRadius.circular(24), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < qualityRows.length; i++) ...[ + if (i > 0) + Divider( + height: 0.5, + indent: 52, + endIndent: 16, + color: MornyeTheme.metadataDividerColor(context), + ), + qualityRows[i], + ], + ], + ), + ), + ) + else + ...qualityRows, ], const SizedBox(height: 16), @@ -273,6 +332,53 @@ class _QualityOption extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + if (context.isMornye) { + return CupertinoButton( + padding: const EdgeInsets.all(16), + onPressed: onTap, + child: Row( + children: [ + SizedBox( + width: 24, + child: Icon( + mornyeIconFor(icon), + color: colorScheme.primary, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (subtitle.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + subtitle, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + Icon( + CupertinoIcons.chevron_right, + size: 14, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ); + } return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), leading: Container( @@ -298,6 +404,7 @@ class _QualityOption extends StatelessWidget { class _ServiceChip extends StatelessWidget { final String label; final bool isSelected; + final bool isRecommended; final VoidCallback? onTap; final String? iconPath; final String? healthStatus; @@ -306,6 +413,7 @@ class _ServiceChip extends StatelessWidget { required this.label, required this.isSelected, required this.onTap, + this.isRecommended = false, this.iconPath, this.healthStatus, }); @@ -313,6 +421,76 @@ class _ServiceChip extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final accessibleLabel = isRecommended ? '$label (Recommended)' : label; + if (context.isMornye) { + final foreground = isSelected + ? colorScheme.primary + : colorScheme.onSurface; + return Semantics( + button: true, + selected: isSelected, + hint: isRecommended ? 'Recommended' : null, + child: CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + borderRadius: BorderRadius.circular(24), + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.14) + : MornyeTheme.controlFill(context), + onPressed: onTap, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isSelected) ...[ + Icon(CupertinoIcons.checkmark, color: foreground, size: 18), + const SizedBox(width: 8), + ], + if (iconPath != null) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.file( + File(iconPath!), + width: 18, + height: 18, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Icon( + CupertinoIcons.square_grid_2x2, + size: 18, + color: foreground, + ), + ), + ), + const SizedBox(width: 8), + ], + Flexible( + child: Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: foreground, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + ), + ), + ), + if (isRecommended) ...[ + const SizedBox(width: 6), + Tooltip( + message: accessibleLabel, + excludeFromSemantics: true, + child: Icon( + CupertinoIcons.star_fill, + size: 12, + color: foreground, + ), + ), + ], + if (healthStatus != null) ...[ + const SizedBox(width: 8), + _ServiceHealthDot(status: healthStatus!), + ], + ], + ), + ), + ); + } return GestureDetector( onTap: onTap, child: AnimatedContainer( @@ -356,7 +534,7 @@ class _ServiceChip extends StatelessWidget { const SizedBox(width: 6), ], Text( - label, + accessibleLabel, style: TextStyle( fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, color: isSelected diff --git a/lib/widgets/duplicate_review_sheet.dart b/lib/widgets/duplicate_review_sheet.dart index 61b95971..05aa69a7 100644 --- a/lib/widgets/duplicate_review_sheet.dart +++ b/lib/widgets/duplicate_review_sheet.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; @@ -92,18 +93,19 @@ class _DuplicateReviewSheetState extends ConsumerState { } Future _confirmDelete(String message) async { - final confirmed = await showDialog( + final confirmed = await showAppDialog( context: context, - useRootNavigator: true, - builder: (ctx) => AlertDialog( + builder: (ctx) => AppAlertDialog( title: Text(ctx.l10n.dialogDeleteSelectedTitle), content: Text(message), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(ctx, false), child: Text(ctx.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDestructive: true, onPressed: () => Navigator.pop(ctx, true), style: FilledButton.styleFrom( backgroundColor: Theme.of(ctx).colorScheme.error, diff --git a/lib/widgets/extension_repo_card.dart b/lib/widgets/extension_repo_card.dart new file mode 100644 index 00000000..6fbab2c3 --- /dev/null +++ b/lib/widgets/extension_repo_card.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; + +/// Repo cards share the Mornye glass material, including their detail pages. +class ExtensionRepoCard extends ConsumerWidget { + const ExtensionRepoCard({ + super.key, + required this.child, + this.color, + this.radius = 20, + this.margin = const EdgeInsets.all(4), + }); + + final Widget child; + final Color? color; + final double radius; + final EdgeInsetsGeometry margin; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (!context.isMornye) { + return Card( + elevation: 0, + color: color, + margin: margin, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radius), + ), + child: child, + ); + } + return Padding( + padding: margin, + child: MornyeGlass.navigation( + radius: 24, + blurEnabled: + !ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider), + child: Material(color: Colors.transparent, child: child), + ), + ); + } +} diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart index e206bee6..046511dd 100644 --- a/lib/widgets/mini_player.dart +++ b/lib/widgets/mini_player.dart @@ -1,15 +1,22 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/screens/now_playing_screen.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; class MiniPlayer extends ConsumerStatefulWidget { - const MiniPlayer({super.key}); + const MiniPlayer({super.key, this.compact = false, this.bottomPadding = 8}); + + final bool compact; + final double bottomPadding; @override ConsumerState createState() => _MiniPlayerState(); @@ -34,8 +41,10 @@ class _MiniPlayerState extends ConsumerState { final controller = ref.read(musicPlayerControllerProvider); final colorScheme = Theme.of(context).colorScheme; + final mornye = context.isMornye; + final compact = mornye && widget.compact; - return Dismissible( + final player = Dismissible( key: ValueKey('mini-player-${mediaItem.id}'), direction: DismissDirection.horizontal, onDismissed: (_) { @@ -45,14 +54,18 @@ class _MiniPlayerState extends ConsumerState { child: DecoratedBox( position: DecorationPosition.foreground, decoration: BoxDecoration( - border: Border( - top: BorderSide( - color: colorScheme.outlineVariant.withValues(alpha: 0.5), - ), - ), + border: mornye + ? null + : Border( + top: BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ), ), child: Material( - color: settingsGroupColor(context).withValues(alpha: 0.72), + color: mornye + ? Colors.transparent + : settingsGroupColor(context).withValues(alpha: 0.72), child: InkWell( onTap: () { Navigator.of( @@ -63,14 +76,15 @@ class _MiniPlayerState extends ConsumerState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - _MiniPlayerProgress( - duration: mediaItem.duration ?? Duration.zero, - backgroundColor: colorScheme.surfaceContainerHighest, - ), + if (!mornye) + _MiniPlayerProgress( + duration: mediaItem.duration ?? Duration.zero, + backgroundColor: colorScheme.surfaceContainerHighest, + ), Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, + padding: EdgeInsets.symmetric( + horizontal: compact ? 8 : 12, + vertical: mornye ? 0 : 8, ), child: Row( children: [ @@ -79,8 +93,8 @@ class _MiniPlayerState extends ConsumerState { child: ClipRRect( borderRadius: BorderRadius.circular(6), child: SizedBox( - width: 44, - height: 44, + width: mornye ? 38 : 44, + height: mornye ? 38 : 44, child: PlayerArtwork( artUri: mediaItem.artUri?.toString(), colorScheme: colorScheme, @@ -90,7 +104,7 @@ class _MiniPlayerState extends ConsumerState { ), ), ), - const SizedBox(width: 12), + SizedBox(width: compact ? 8 : 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -112,7 +126,16 @@ class _MiniPlayerState extends ConsumerState { mediaItem.artist ?? '', style: Theme.of(context).textTheme.bodySmall ?.copyWith( - color: colorScheme.onSurfaceVariant, + color: mornye + ? Color.lerp( + colorScheme.onSurfaceVariant, + colorScheme.onSurface, + colorScheme.brightness == + Brightness.dark + ? 0.7 + : 0.6, + ) + : colorScheme.onSurfaceVariant, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -121,6 +144,7 @@ class _MiniPlayerState extends ConsumerState { ), ), IconButton( + color: mornye ? colorScheme.onSurface : null, tooltip: isPlaying ? context.l10n.actionPause : context.l10n.tooltipPlay, @@ -131,16 +155,30 @@ class _MiniPlayerState extends ConsumerState { strokeWidth: 2.5, ), ) - : Icon(isPlaying ? Icons.pause : Icons.play_arrow), + : Icon( + mornye + ? (isPlaying + ? CupertinoIcons.pause_fill + : CupertinoIcons.play_fill) + : (isPlaying + ? Icons.pause + : Icons.play_arrow), + ), onPressed: isLoading ? null : () => controller.togglePlayPause(isPlaying), ), - IconButton( - tooltip: context.l10n.nowPlayingNextTrack, - icon: const Icon(Icons.skip_next), - onPressed: controller.next, - ), + if (!compact) + IconButton( + color: mornye ? colorScheme.onSurface : null, + tooltip: context.l10n.nowPlayingNextTrack, + icon: Icon( + mornye + ? CupertinoIcons.forward_fill + : Icons.skip_next, + ), + onPressed: controller.next, + ), ], ), ), @@ -150,6 +188,19 @@ class _MiniPlayerState extends ConsumerState { ), ), ); + return mornye + ? Padding( + padding: EdgeInsets.only(bottom: widget.bottomPadding), + child: MornyeGlass.navigation( + strongTint: true, + tintOpacity: MornyeTheme.chromeOpacity(context), + blurEnabled: + !ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider), + child: player, + ), + ) + : player; } } diff --git a/lib/widgets/mornye_artist_header.dart b/lib/widgets/mornye_artist_header.dart new file mode 100644 index 00000000..32746785 --- /dev/null +++ b/lib/widgets/mornye_artist_header.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/cover_palette.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/services/shell_navigation_service.dart'; +import 'package:spotiflac_android/widgets/album_detail_header.dart'; + +/// Share the same artwork-derived dark surface while loading and after the +/// artist metadata arrives, including when the app itself uses light mode. +class MornyeArtistSurface extends StatefulWidget { + const MornyeArtistSurface({ + super.key, + required this.imageSource, + required this.child, + this.neutralActions = false, + }); + + final String? imageSource; + final Widget child; + + /// Album and playlist actions use white controls over the artwork tint. + final bool neutralActions; + + @override + State createState() => _MornyeArtistSurfaceState(); +} + +class _MornyeArtistSurfaceState extends State { + @override + void dispose() { + ShellNavigationService.clearChromeBrightness(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final darkTheme = MornyeTheme.build(Brightness.dark); + return Theme( + data: darkTheme.copyWith( + colorScheme: darkTheme.colorScheme.copyWith(primary: Colors.grey), + ), + child: CoverPaletteBuilder( + imageSource: widget.imageSource, + builder: (context, palette) { + // Retain the artwork's hue with a muted, slightly lifted surface. + // Large backgrounds need less saturation than the cover's accent. + final dominant = HSLColor.fromColor(palette.primary); + final surface = dominant + .withSaturation((dominant.saturation * 0.45).clamp(0.0, 0.22)) + .withLightness(0.34) + .toColor(); + final route = ModalRoute.of(context); + if (route != null) { + ShellNavigationService.setChromeBrightness( + owner: this, + route: route, + brightness: Brightness.dark, + surface: surface, + ); + } + return Theme( + data: darkTheme.copyWith( + scaffoldBackgroundColor: surface, + colorScheme: darkTheme.colorScheme.copyWith( + primary: widget.neutralActions ? Colors.white : null, + onPrimary: widget.neutralActions ? surface : null, + onSurfaceVariant: widget.neutralActions ? Colors.white70 : null, + surface: surface, + surfaceContainer: Color.alphaBlend( + Colors.white.withValues(alpha: 0.08), + surface, + ), + surfaceContainerHigh: Color.alphaBlend( + Colors.white.withValues(alpha: 0.12), + surface, + ), + ), + ), + child: widget.child, + ); + }, + ), + ); + } +} + +/// Portrait and centered identity from Mornye's MobileArtistView. Actions stay +/// supplied by the screen so remote artists retain download/favorite behavior. +class MornyeArtistHeader extends StatelessWidget { + const MornyeArtistHeader({ + super.key, + required this.name, + required this.artwork, + required this.actions, + required this.showTitle, + this.listeners, + }); + + final String name; + final Widget artwork; + final List actions; + final bool showTitle; + final String? listeners; + + @override + Widget build(BuildContext context) => + SliverMainAxisGroup(slivers: buildSlivers(context)); + + List buildSlivers(BuildContext context) { + final surface = Theme.of(context).colorScheme.surface; + return [ + // Padding changes while the shell folds. Keep that dependency on the + // toolbar so callers do not rebuild the artist's entire discography. + Builder( + builder: (context) => SliverAppBar( + pinned: true, + expandedHeight: 342 - MediaQuery.paddingOf(context).top, + backgroundColor: surface, + surfaceTintColor: Colors.transparent, + leadingWidth: 64, + leading: Padding( + padding: const EdgeInsets.only(left: 12), + child: HeaderCircleButton( + icon: Icons.arrow_back, + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: () => Navigator.pop(context), + ), + ), + title: AnimatedOpacity( + opacity: showTitle ? 1 : 0, + duration: const Duration(milliseconds: 180), + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), + ), + flexibleSpace: FlexibleSpaceBar( + collapseMode: CollapseMode.pin, + background: Stack( + fit: StackFit.expand, + children: [ + artwork, + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0, 0.52, 1], + colors: [ + Colors.black.withValues(alpha: 0.15), + surface.withValues(alpha: 0), + surface, + ], + ), + ), + ), + ], + ), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 4), + child: Column( + children: [ + Text( + name, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 30, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + if (listeners != null) ...[ + const SizedBox(height: 7), + Text( + listeners!, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: Colors.white.withValues(alpha: 0.7), + ), + ), + ], + if (actions.isNotEmpty) ...[ + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 24, + children: actions, + ), + ], + ], + ), + ), + ), + ]; + } +} diff --git a/lib/widgets/mornye_bottom_bar.dart b/lib/widgets/mornye_bottom_bar.dart new file mode 100644 index 00000000..752e7c07 --- /dev/null +++ b/lib/widgets/mornye_bottom_bar.dart @@ -0,0 +1,207 @@ +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mini_player.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; + +/// Only deliberate vertical drags change the chrome. Horizontal artwork rows, +/// programmatic scrolling and edge bounce must not minimize the navigation. +class MornyeChromeController extends ValueNotifier { + MornyeChromeController() : super(false); + + double _distance = 0; + + void expand() { + _distance = 0; + value = false; + } + + bool handleScroll(ScrollNotification notification) { + if (notification.depth != 0 || notification.metrics.axis != Axis.vertical) { + return false; + } + if (notification is ScrollStartNotification || + notification is ScrollEndNotification) { + _distance = 0; + } + if (notification is! ScrollUpdateNotification || + notification.dragDetails == null) { + return false; + } + final metrics = notification.metrics; + if (metrics.pixels <= metrics.minScrollExtent + 12) { + expand(); + return false; + } + if (metrics.outOfRange) return false; + final delta = notification.scrollDelta ?? 0; + if (delta == 0) return false; + if (_distance.sign != delta.sign) _distance = 0; + _distance += delta; + if (_distance >= 28) { + value = true; + _distance = 0; + } else if (_distance <= -18) { + expand(); + } + return false; + } +} + +/// A single mini-player survives the transition, preserving its artwork Hero, +/// playback controls and swipe-to-dismiss state as the tabs fold away. +class MornyeBottomBar extends ConsumerWidget { + const MornyeBottomBar({ + super.key, + required this.collapsed, + required this.destinations, + required this.selectedIndex, + required this.onSelected, + required this.onExpand, + required this.onSearch, + required this.blurEnabled, + }); + + final bool collapsed; + final List destinations; + final int selectedIndex; + final ValueChanged onSelected; + final VoidCallback onExpand; + final VoidCallback onSearch; + final bool blurEnabled; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final hasPlayer = ref.watch( + currentMediaItemProvider.select((item) => item.value != null), + ); + // Animated glass tabs already reserve 8px above their visible capsule. + final tabGap = + blurEnabled && + !MediaQuery.disableAnimationsOf(context) && + !MediaQuery.highContrastOf(context) + ? 0.0 + : 8.0; + // These contents do not depend on animation progress. Retain their widget + // instances so folding only updates size/opacity wrappers each frame. + Widget sideContent({ + required Widget icon, + required String tooltip, + required VoidCallback onPressed, + }) => RepaintBoundary( + child: MornyeGlass.navigation( + blurEnabled: blurEnabled, + strongTint: true, + tintOpacity: MornyeTheme.chromeOpacity(context), + radius: 26, + child: SizedBox.square( + dimension: 52, + child: Material( + color: Colors.transparent, + child: IconButton( + tooltip: tooltip, + color: Theme.of(context).colorScheme.primary, + icon: icon, + onPressed: onPressed, + ), + ), + ), + ), + ); + final expandButton = sideContent( + icon: destinations[selectedIndex].icon, + tooltip: context.l10n.mornyeShowTabs, + onPressed: onExpand, + ); + final searchButton = sideContent( + icon: const Icon(CupertinoIcons.search), + tooltip: context.l10n.mornyeSearch, + onPressed: onSearch, + ); + final player = MiniPlayer(compact: collapsed, bottomPadding: 0); + final tabs = TickerMode( + enabled: !collapsed, + child: RepaintBoundary( + child: MornyeTabBar( + destinations: destinations, + selectedIndex: selectedIndex, + onSelected: onSelected, + blurEnabled: blurEnabled, + ), + ), + ); + return TweenAnimationBuilder( + tween: Tween(end: collapsed ? 1 : 0), + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 380), + curve: Curves.easeInOutCubic, + builder: (context, amount, _) { + Widget sideButton({ + required Widget child, + required Alignment alignment, + }) => ClipRect( + child: Align( + alignment: alignment, + widthFactor: amount, + child: SizedBox( + width: 60, + height: 48 + 4 * amount, + child: Align( + alignment: alignment, + child: IgnorePointer( + ignoring: !collapsed, + child: ExcludeSemantics( + excluding: !collapsed, + child: Opacity(opacity: amount, child: child), + ), + ), + ), + ), + ), + ); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (hasPlayer || amount > 0) + Padding( + padding: EdgeInsets.only( + bottom: tabGap + (8 - tabGap) * amount, + ), + child: Row( + children: [ + sideButton( + child: expandButton, + alignment: Alignment.centerLeft, + ), + Expanded(child: player), + sideButton( + child: searchButton, + alignment: Alignment.centerRight, + ), + ], + ), + ), + ClipRect( + child: Align( + alignment: Alignment.bottomCenter, + heightFactor: 1 - amount, + child: IgnorePointer( + ignoring: collapsed, + child: ExcludeSemantics( + excluding: collapsed, + child: Opacity(opacity: 1 - amount, child: tabs), + ), + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/widgets/mornye_chrome.dart b/lib/widgets/mornye_chrome.dart new file mode 100644 index 00000000..a1f49845 --- /dev/null +++ b/lib/widgets/mornye_chrome.dart @@ -0,0 +1,613 @@ +import 'dart:ui' show ImageFilter; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:liquid_glass_easy/liquid_glass_easy.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +class MornyeSegmentedControl extends ConsumerWidget { + const MornyeSegmentedControl({ + super.key, + required this.labels, + required this.selectedIndex, + required this.onChanged, + }); + + final List labels; + final int selectedIndex; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final blur = + !MediaQuery.highContrastOf(context) && + (!ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider)); + final animate = !MediaQuery.disableAnimationsOf(context); + final height = MediaQuery.textScalerOf(context).scale(15) + 36; + final rtl = Directionality.of(context) == TextDirection.rtl; + final visualLabels = rtl ? labels.reversed.toList() : labels; + int logicalIndex(int index) => rtl ? labels.length - 1 - index : index; + return LayoutBuilder( + builder: (context, constraints) => SizedBox( + height: height + 16, + child: MediaQuery.removePadding( + context: context, + removeBottom: true, + child: Directionality( + textDirection: TextDirection.ltr, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 0, + right: 0, + top: 8, + height: height, + child: _MornyeGlassSurface( + blurEnabled: blur, + radius: height / 2, + child: const SizedBox.expand(), + ), + ), + LiquidGlassTabBar.withImpeller( + width: constraints.maxWidth, + height: height, + margin: const EdgeInsets.only(bottom: 8), + itemPadding: 4, + selectedIndex: logicalIndex(selectedIndex), + onChanged: (index) => onChanged(logicalIndex(index)), + style: const LiquidGlassStyle( + shape: LiquidGlassShape.continuousRoundedRectangle( + cornerRadius: 32, + borderWidth: 0, + lightIntensity: 0, + ), + appearance: LiquidGlassAppearance(), + refraction: LiquidGlassRefraction( + distortion: 0, + chromaticAberration: 0, + ), + ), + pillStyle: LiquidGlassTabPillStyle( + mode: blur && animate + ? LiquidGlassPillMode.impellerOnly + : LiquidGlassPillMode.none, + animated: animate, + ), + itemStyle: LiquidGlassTabItemStyle( + selectedColor: scheme.onSurface, + unselectedColor: scheme.onSurface, + iconSize: 0, + iconLabelGap: 0, + labelFontSize: 15, + ), + items: [ + for (final label in visualLabels) + LiquidGlassTabBarItem( + label: label, + iconBuilder: (_, _) => const SizedBox.shrink(), + labelBuilder: (context, state) => Text( + label, + textDirection: rtl + ? TextDirection.rtl + : TextDirection.ltr, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurface, + fontWeight: state.selected + ? FontWeight.w600 + : FontWeight.w400, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +/// The bounded, translucent chrome used by Mornye's tab accessory. Blur is +/// limited to the control itself and follows the app's device performance gate. +class MornyeGlassPanel extends ConsumerWidget { + const MornyeGlassPanel({ + super.key, + required this.child, + this.radius = 32, + this.strongTint = false, + this.tintOpacity, + }); + + /// Shared glass material for confirmation dialogs and floating sheets. + const MornyeGlassPanel.overlay({ + super.key, + required this.child, + this.radius = 32, + this.tintOpacity = 0.78, + }) : strongTint = false; + + final Widget child; + final double radius; + final bool strongTint; + final double? tintOpacity; + + @override + Widget build(BuildContext context, WidgetRef ref) => MornyeGlass.navigation( + radius: radius, + strongTint: strongTint, + tintOpacity: tintOpacity, + blurEnabled: + !MediaQuery.highContrastOf(context) && + (!ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider)), + child: Material( + color: strongTint + ? Theme.of(context).colorScheme.surface.withValues(alpha: 0.60) + : Colors.transparent, + child: child, + ), + ); +} + +class MornyeGlass extends StatelessWidget { + const MornyeGlass({ + super.key, + required this.child, + required this.blurEnabled, + this.radius = 32, + this.strongTint = false, + this.tintOpacity, + this.tintColor, + }) : _useLens = true, + firstInGroup = true, + lastInGroup = true; + + /// Shares the navigation bar's tint, blur and visible outer rim. + const MornyeGlass.navigation({ + super.key, + required this.child, + required this.blurEnabled, + this.radius = 32, + this.firstInGroup = true, + this.lastInGroup = true, + this.strongTint = false, + this.tintOpacity, + this.tintColor, + }) : _useLens = false; + + final Widget child; + final bool blurEnabled; + final double radius; + final bool _useLens; + final bool firstInGroup; + final bool lastInGroup; + + /// Keeps floating controls readable over arbitrary album artwork. + final bool strongTint; + + /// Uses a single tint instead of layered highlights for translucent surfaces. + final double? tintOpacity; + final Color? tintColor; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final dark = scheme.brightness == Brightness.dark; + final useGlass = + _useLens && blurEnabled && !MediaQuery.highContrastOf(context); + return _MornyeGlassSurface( + blurEnabled: blurEnabled, + radius: radius, + firstInGroup: firstInGroup, + lastInGroup: lastInGroup, + strongTint: strongTint, + tintOpacity: tintOpacity, + tintColor: tintColor, + child: useGlass + ? LiquidGlassLens( + style: LiquidGlassStyle( + shape: LiquidGlassShape.continuousRoundedRectangle( + cornerRadius: radius, + borderWidth: dark && tintOpacity == null ? 0 : 0.6, + lightIntensity: dark && tintOpacity == null ? 0 : 0.18, + ), + appearance: LiquidGlassAppearance( + color: tintOpacity != null + ? Colors.transparent + : dark + ? scheme.surfaceContainerHigh.withValues(alpha: 0.28) + : Colors.white.withValues( + alpha: strongTint ? 0.20 : 0.55, + ), + // The surface already blurs the backdrop. Refracting that + // frosted result needs no second Gaussian blur pass. + blur: const LiquidGlassBlur(), + ), + refraction: const LiquidGlassRefraction( + distortion: 0.02, + distortionWidth: 8, + chromaticAberration: 0, + ), + ), + child: child, + ) + : child, + ); + } +} + +/// Keep the capsule legible independently of the shader renderer. The liquid +/// lens adds refraction above this frosted base, never above bare page text. +class _MornyeGlassSurface extends StatelessWidget { + static final _backdropBlur = ImageFilter.blur(sigmaX: 18, sigmaY: 18); + + const _MornyeGlassSurface({ + required this.child, + required this.blurEnabled, + this.radius = 32, + this.firstInGroup = true, + this.lastInGroup = true, + this.strongTint = false, + this.tintOpacity, + this.tintColor, + }); + + final Widget child; + final bool blurEnabled; + final double radius; + final bool firstInGroup; + final bool lastInGroup; + final bool strongTint; + final double? tintOpacity; + final Color? tintColor; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final useBlur = blurEnabled && !MediaQuery.highContrastOf(context); + final dark = scheme.brightness == Brightness.dark; + final shape = BorderRadius.vertical( + top: firstInGroup ? Radius.circular(radius) : Radius.zero, + bottom: lastInGroup ? Radius.circular(radius) : Radius.zero, + ); + final rim = BorderSide( + color: dark + ? Colors.white.withValues(alpha: 0.16) + : Colors.black.withValues(alpha: 0.17), + width: 0.75, + ); + final border = Border( + top: firstInGroup ? rim : BorderSide.none, + bottom: lastInGroup ? rim : BorderSide.none, + left: rim, + right: rim, + ); + final surface = DecoratedBox( + decoration: BoxDecoration( + color: (tintColor ?? scheme.surfaceContainerHigh).withValues( + alpha: useBlur ? (tintOpacity ?? (strongTint ? 0.80 : 0.60)) : 1, + ), + gradient: useBlur && !dark && tintOpacity == null + ? LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0, 0.35, 0.75, 1], + colors: [ + Colors.white.withValues(alpha: 0.90), + Colors.white.withValues(alpha: strongTint ? 0.80 : 0.78), + scheme.surfaceContainerHigh.withValues( + alpha: strongTint ? 0.76 : 0.72, + ), + Colors.white.withValues(alpha: 0.85), + ], + ) + : null, + borderRadius: shape, + border: dark ? border : null, + ), + child: child, + ); + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: shape, + boxShadow: firstInGroup && lastInGroup + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: dark ? 0.2 : 0.06), + blurRadius: dark ? 18 : 10, + offset: Offset(0, dark ? 4 : 2), + ), + ] + : null, + ), + child: DecoratedBox( + // Paint the light outline above the lens so its pale tint cannot wash + // the edge out on an all-white page. Dark chrome keeps its quiet rim. + position: DecorationPosition.foreground, + decoration: BoxDecoration( + borderRadius: shape, + border: dark ? null : border, + ), + child: ClipRRect( + borderRadius: shape, + child: useBlur + ? BackdropFilter(filter: _backdropBlur, child: surface) + : surface, + ), + ), + ); + } +} + +/// A searchable category uses the same material as the navigation capsule. +class MornyeFilterChip extends ConsumerWidget { + const MornyeFilterChip({ + super.key, + required this.label, + required this.selected, + required this.onTap, + this.icon, + this.blurEnabled = true, + this.glass = true, + this.tonal = true, + }); + + final String label; + final bool selected; + final VoidCallback? onTap; + final IconData? icon; + final bool blurEnabled; + final bool glass; + final bool tonal; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scheme = Theme.of(context).colorScheme; + final content = Semantics( + button: true, + selected: selected, + enabled: onTap != null, + child: Material( + color: !glass && !tonal + ? Colors.transparent + : selected + ? scheme.primary.withValues(alpha: 0.12) + : glass + ? Colors.transparent + : MornyeTheme.controlFill(context, enabled: onTap != null), + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 18, color: scheme.primary), + const SizedBox(width: 8), + ], + Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: selected ? scheme.primary : scheme.onSurface, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + ), + ), + ], + ), + ), + ), + ), + ); + if (!glass) { + return ClipRRect(borderRadius: BorderRadius.circular(24), child: content); + } + return MornyeGlass.navigation( + radius: 24, + blurEnabled: + blurEnabled && + (!ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider)), + child: content, + ); + } +} + +class MornyeTabBar extends StatelessWidget { + const MornyeTabBar({ + super.key, + required this.destinations, + required this.selectedIndex, + required this.onSelected, + required this.blurEnabled, + }); + + final List destinations; + final int selectedIndex; + final ValueChanged onSelected; + final bool blurEnabled; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final inactiveIconColor = Color.lerp( + scheme.onSurfaceVariant, + scheme.onSurface, + scheme.brightness == Brightness.dark ? 0.5 : 0.4, + ); + if (blurEnabled && + !MediaQuery.disableAnimationsOf(context) && + !MediaQuery.highContrastOf(context)) { + return LayoutBuilder( + builder: (context, constraints) => SizedBox( + // Leave room above and below for the travelling pill to lift and + // stretch; its shader must not be clipped to the resting capsule. + height: 80, + child: MediaQuery.removePadding( + context: context, + removeBottom: true, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 0, + right: 0, + bottom: 8, + height: 64, + child: _MornyeGlassSurface( + blurEnabled: blurEnabled, + strongTint: true, + tintOpacity: MornyeTheme.chromeOpacity(context), + child: const SizedBox.expand(), + ), + ), + LiquidGlassTabBar.withImpeller( + width: constraints.maxWidth, + height: 64, + margin: const EdgeInsets.only(bottom: 8), + selectedIndex: selectedIndex, + onChanged: onSelected, + style: LiquidGlassTabBar.defaultStyle.copyWith( + // The frosted base owns the subtle outline. Disable the + // package's default specular rim around the whole capsule. + shape: const LiquidGlassShape.continuousRoundedRectangle( + cornerRadius: 32, + borderWidth: 0, + lightIntensity: 0, + ), + // The sibling surface already supplies tint and blur. Keep + // the lens clear so the moving pill can refract the icons. + appearance: const LiquidGlassAppearance(), + refraction: const LiquidGlassRefraction( + distortion: 0, + chromaticAberration: 0, + ), + ), + itemStyle: LiquidGlassTabItemStyle( + selectedColor: scheme.primary, + unselectedColor: scheme.onSurface, + iconSize: 25, + labelFontSize: 11, + ), + pillStyle: const LiquidGlassTabPillStyle( + mode: LiquidGlassPillMode.impellerOnly, + animated: true, + // Keep the moving refractive pill, without stacking the + // package's second magnifier lens beneath it. + magnifierPill: LiquidGlassTabMagnifierPillStyle( + enabled: false, + ), + ), + items: [ + for (final destination in destinations) + LiquidGlassTabBarItem( + label: destination.label, + iconBuilder: (context, icon) => IconTheme( + data: IconThemeData( + size: icon.size, + color: icon.selected + ? scheme.primary + : inactiveIconColor, + ), + child: destination.icon, + ), + labelBuilder: (context, label) => Text( + destination.label, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + fontSize: label.textStyle.fontSize, + fontWeight: FontWeight.w600, + color: scheme.onSurface, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + return MornyeGlass.navigation( + blurEnabled: blurEnabled, + strongTint: true, + tintOpacity: MornyeTheme.chromeOpacity(context), + child: Padding( + padding: const EdgeInsets.all(5), + child: Row( + children: [ + for (var index = 0; index < destinations.length; index++) + Expanded( + child: Semantics( + selected: index == selectedIndex, + button: true, + label: destinations[index].label, + excludeSemantics: true, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(28), + onTap: () => onSelected(index), + child: AnimatedContainer( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 220), + constraints: const BoxConstraints(minHeight: 54), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 6, + ), + decoration: BoxDecoration( + color: index == selectedIndex + ? scheme.onSurface.withValues(alpha: 0.09) + : Colors.transparent, + borderRadius: BorderRadius.circular(28), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconTheme( + data: IconThemeData( + size: 25, + color: index == selectedIndex + ? scheme.primary + : inactiveIconColor, + ), + // Keep tab selection quiet, as in Mornye. Badges + // stay live without the Material bounce/spin. + child: destinations[index].icon, + ), + const SizedBox(height: 2), + Text( + destinations[index].label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + fontWeight: FontWeight.w600, + color: scheme.onSurface, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/mornye_context_menu.dart b/lib/widgets/mornye_context_menu.dart new file mode 100644 index 00000000..22866d74 --- /dev/null +++ b/lib/widgets/mornye_context_menu.dart @@ -0,0 +1,265 @@ +import 'dart:math' as math; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; + +/// Bounds of the tapped control or row, in the root overlay's coordinates. +Rect? mornyeMenuAnchor(BuildContext context) { + final box = context.findRenderObject(); + if (box is! RenderBox || !box.hasSize) return null; + if (box.size.height > MediaQuery.sizeOf(context).height * 0.65) return null; + return box.localToGlobal(Offset.zero) & box.size; +} + +Future showMornyeContextMenu({ + required BuildContext context, + required WidgetBuilder builder, + Rect? anchor, +}) { + final navigator = Navigator.of(context, rootNavigator: true); + final themes = InheritedTheme.capture(from: context, to: navigator.context); + final target = anchor ?? mornyeMenuAnchor(context); + return navigator.push( + RawDialogRoute( + barrierColor: Colors.black.withValues(alpha: 0.12), + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + transitionDuration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 160), + // Opacity layers isolate a BackdropFilter from the underlying artwork. + transitionBuilder: (_, _, _, child) => child, + pageBuilder: (context, animation, _) { + final media = MediaQuery.of(context); + return Semantics( + scopesRoute: true, + namesRoute: true, + label: MaterialLocalizations.of(context).popupMenuLabel, + explicitChildNodes: true, + child: CustomSingleChildLayout( + delegate: _MenuLayout( + anchor: target, + padding: media.padding.copyWith( + bottom: math.max(media.padding.bottom, media.viewInsets.bottom), + ), + ), + child: ScaleTransition( + scale: animation.drive(Tween(begin: 0.96, end: 1)), + child: themes.wrap(Builder(builder: builder)), + ), + ), + ); + }, + ), + ); +} + +class _MenuLayout extends SingleChildLayoutDelegate { + const _MenuLayout({required this.anchor, required this.padding}); + + final Rect? anchor; + final EdgeInsets padding; + static const double _margin = 16; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + final width = math.min( + 320.0, + math.max(0.0, constraints.maxWidth - padding.horizontal - _margin * 2), + ); + return BoxConstraints( + minWidth: width, + maxWidth: width, + maxHeight: math.max( + 0, + constraints.maxHeight - padding.vertical - _margin * 2, + ), + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + final left = padding.left + _margin; + final top = padding.top + _margin; + final right = math.max( + left, + size.width - padding.right - _margin - childSize.width, + ); + final bottom = math.max( + top, + size.height - padding.bottom - _margin - childSize.height, + ); + final x = anchor == null + ? (size.width - childSize.width) / 2 + : anchor!.right - childSize.width; + var y = anchor == null ? size.height * 0.18 : anchor!.bottom + 8; + if (anchor != null && y > bottom) { + y = anchor!.top - childSize.height - 8; + if (y < top) y = bottom; + } + return Offset(x.clamp(left, right), y.clamp(top, bottom)); + } + + @override + bool shouldRelayout(_MenuLayout oldDelegate) => + anchor != oldDelegate.anchor || padding != oldDelegate.padding; +} + +class MornyeMenuAction { + const MornyeMenuAction({ + required this.icon, + required this.label, + required this.onPressed, + this.subtitle, + this.selected = false, + this.destructive = false, + }); + + final IconData icon; + final String label; + final String? subtitle; + final VoidCallback? onPressed; + final bool selected; + final bool destructive; +} + +/// One frosted popover, with compact shortcuts and separated action groups. +class MornyeContextMenu extends StatelessWidget { + const MornyeContextMenu({ + super.key, + this.quickActions = const [], + required this.groups, + this.inheritSurface = false, + }); + + final List quickActions; + final List> groups; + final bool inheritSurface; + + @override + Widget build(BuildContext context) { + final theme = inheritSurface + ? Theme.of(context) + : MornyeTheme.build(Theme.of(context).brightness); + final visibleGroups = groups.where((group) => group.isNotEmpty).toList(); + final shortcuts = quickActions.isEmpty + ? null + : Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final action in quickActions) + Expanded(child: _action(theme, action, compact: true)), + ], + ), + ); + final actions = Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < visibleGroups.length; i++) ...[ + if (i > 0 || quickActions.isNotEmpty) + Padding( + padding: EdgeInsets.symmetric(vertical: i == 0 ? 0 : 8), + child: const Divider( + height: 0.5, + thickness: 0.5, + indent: 20, + endIndent: 20, + ), + ), + for (final action in visibleGroups[i]) _action(theme, action), + ], + ], + ), + ); + return Theme( + data: theme, + child: MornyeGlassPanel.overlay( + radius: 28, + child: LayoutBuilder( + builder: (context, constraints) { + // Let the entire menu scroll when pinning the shortcuts would + // leave too little room for a row in landscape or large text. + if (constraints.maxHeight < 360 || + MediaQuery.textScalerOf(context).scale(17) > 28) { + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [?shortcuts, actions], + ), + ); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ?shortcuts, + Flexible(child: SingleChildScrollView(child: actions)), + ], + ); + }, + ), + ), + ); + } + + Widget _action( + ThemeData theme, + MornyeMenuAction action, { + bool compact = false, + }) { + final color = action.onPressed == null + ? theme.colorScheme.onSurfaceVariant + : action.destructive || action.selected + ? theme.colorScheme.primary + : theme.colorScheme.onSurface; + final icon = Icon(action.icon, size: compact ? 23 : 21, color: color); + final label = Text( + action.label, + textAlign: compact ? TextAlign.center : TextAlign.start, + style: theme.textTheme.bodyLarge?.copyWith( + color: color, + fontSize: compact ? 13 : 17, + fontWeight: compact ? FontWeight.w600 : FontWeight.w400, + ), + ); + return Semantics( + selected: action.selected, + child: CupertinoButton( + padding: compact + ? const EdgeInsets.symmetric(horizontal: 4, vertical: 10) + : const EdgeInsets.symmetric(horizontal: 20, vertical: 13), + onPressed: action.onPressed, + child: compact + ? Column( + mainAxisSize: MainAxisSize.min, + children: [icon, const SizedBox(height: 7), label], + ) + : Row( + children: [ + icon, + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + label, + if (action.subtitle != null) ...[ + const SizedBox(height: 3), + Text( + action.subtitle!, + style: theme.textTheme.bodySmall, + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/mornye_landscape_player.dart b/lib/widgets/mornye_landscape_player.dart new file mode 100644 index 00000000..214b653b --- /dev/null +++ b/lib/widgets/mornye_landscape_player.dart @@ -0,0 +1,178 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; + +/// Landscape keeps the cover in place while the right pane switches between +/// lyrics, the queue, and controls revealed by a tap. +class MornyeLandscapePlayer extends StatefulWidget { + const MornyeLandscapePlayer({ + super.key, + required this.artwork, + required this.header, + required this.lyrics, + required this.queue, + required this.controls, + required this.volume, + }); + + final Widget artwork; + final Widget header; + final Widget lyrics; + final Widget queue; + final Widget controls; + final Widget volume; + + @override + State createState() => _MornyeLandscapePlayerState(); +} + +class _MornyeLandscapePlayerState extends State { + Timer? _hideTimer; + bool _controlsVisible = false; + bool _queueVisible = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (MediaQuery.accessibleNavigationOf(context)) { + _controlsVisible = true; + _hideTimer?.cancel(); + } + } + + void _scheduleHide() { + _hideTimer?.cancel(); + if (!_controlsVisible || MediaQuery.accessibleNavigationOf(context)) return; + _hideTimer = Timer(const Duration(seconds: 4), () { + if (!mounted) return; + if (ModalRoute.of(context)?.isCurrent == false) { + _scheduleHide(); + return; + } + setState(() => _controlsVisible = false); + }); + } + + void _reveal() { + if (!_controlsVisible) setState(() => _controlsVisible = true); + _scheduleHide(); + } + + void _showPanel({required bool queue}) { + _hideTimer?.cancel(); + setState(() { + _queueVisible = queue; + _controlsVisible = false; + }); + } + + @override + void dispose() { + _hideTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Listener( + onPointerDown: (_) => _hideTimer?.cancel(), + onPointerUp: (_) => _scheduleHide(), + onPointerCancel: (_) => _scheduleHide(), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _reveal, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded(child: widget.artwork), + Expanded( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: widget.header, + ), + const SizedBox(height: 8), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) => AnimatedSwitcher( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 180), + layoutBuilder: (current, previous) => Stack( + fit: StackFit.expand, + children: [ + for (final child in previous) + IgnorePointer( + child: ExcludeSemantics(child: child), + ), + ?current, + ], + ), + child: _controlsVisible + ? SingleChildScrollView( + key: const ValueKey('landscape-controls'), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: Column( + mainAxisAlignment: + MainAxisAlignment.spaceEvenly, + children: [ + widget.controls, + widget.volume, + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + IconButton( + tooltip: context + .l10n + .nowPlayingTabLyrics, + icon: const Icon( + CupertinoIcons.quote_bubble, + ), + onPressed: () => + _showPanel(queue: false), + ), + IconButton( + tooltip: + context.l10n.nowPlayingUpNext, + icon: const Icon( + CupertinoIcons.list_bullet, + ), + onPressed: () => + _showPanel(queue: true), + ), + ], + ), + ), + ], + ), + ), + ) + : KeyedSubtree( + key: ValueKey(_queueVisible), + child: _queueVisible + ? widget.queue + : widget.lyrics, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); +} diff --git a/lib/widgets/mornye_metadata_row.dart b/lib/widgets/mornye_metadata_row.dart new file mode 100644 index 00000000..05546f30 --- /dev/null +++ b/lib/widgets/mornye_metadata_row.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +/// Mornye TrackInfoView's vertically stacked label and value. +class MornyeMetadataRow extends StatelessWidget { + const MornyeMetadataRow({ + super.key, + required this.label, + required this.value, + this.showDivider = true, + this.trailing, + this.onTap, + this.onLongPress, + }); + + final String label; + final String value; + final bool showDivider; + final Widget? trailing; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final content = Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ?trailing, + ], + ), + const SizedBox(height: 5), + Text(value, style: theme.textTheme.bodyLarge), + if (showDivider) ...[ + const SizedBox(height: 7), + const Divider(height: 0.5), + ], + ], + ), + ); + if (onTap == null && onLongPress == null) return content; + return InkWell( + onTap: onTap, + onLongPress: onLongPress, + borderRadius: BorderRadius.circular(8), + child: content, + ); + } +} diff --git a/lib/widgets/mornye_playback_button.dart b/lib/widgets/mornye_playback_button.dart new file mode 100644 index 00000000..33b86b63 --- /dev/null +++ b/lib/widgets/mornye_playback_button.dart @@ -0,0 +1,143 @@ +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:flutter/material.dart'; + +/// The press response and advancing skip glyphs used by Mornye's transport. +class MornyePlaybackButton extends StatefulWidget { + const MornyePlaybackButton({ + super.key, + required this.icon, + required this.tooltip, + required this.color, + required this.onPressed, + this.iconSize = 40, + this.padding = const EdgeInsets.all(8), + this.loading = false, + }); + + final IconData icon; + final String tooltip; + final Color color; + final VoidCallback? onPressed; + final double iconSize; + final EdgeInsetsGeometry padding; + final bool loading; + + @override + State createState() => _MornyePlaybackButtonState(); +} + +class _MornyePlaybackButtonState extends State + with SingleTickerProviderStateMixin { + final _states = WidgetStatesController(); + late final _skip = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + + bool get _isSkip => + widget.icon == CupertinoIcons.forward_fill || + widget.icon == CupertinoIcons.backward_fill; + + @override + void dispose() { + _states.dispose(); + _skip.dispose(); + super.dispose(); + } + + Widget _skipGlyph(bool reduceMotion) => AnimatedBuilder( + animation: _skip, + builder: (context, _) { + if (!_skip.isAnimating || reduceMotion) return Icon(widget.icon); + final progress = Curves.easeInOut.transform(_skip.value); + final size = widget.iconSize; + Widget triangle(double position, double scale) => Positioned( + left: size * (position - 0.26), + top: 0, + width: size * 0.52, + height: size, + child: Transform.scale( + scale: scale, + child: const FittedBox( + fit: BoxFit.fill, + child: Icon(CupertinoIcons.play_fill), + ), + ), + ); + return Transform.flip( + flipX: widget.icon == CupertinoIcons.backward_fill, + child: Stack( + clipBehavior: Clip.none, + children: [ + triangle(0.25, progress), + triangle(0.25 + progress * 0.5, 1), + triangle(0.75, 1 - progress), + ], + ), + ); + }, + ); + + @override + Widget build(BuildContext context) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final motion = Duration(milliseconds: reduceMotion ? 120 : 180); + final enabled = widget.onPressed != null && !widget.loading; + return IconButton( + statesController: _states, + tooltip: widget.tooltip, + color: widget.color, + iconSize: widget.iconSize, + padding: widget.padding, + style: ButtonStyle( + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateProperty.resolveWith((states) { + return states.contains(WidgetState.focused) || + states.contains(WidgetState.hovered) + ? widget.color.withValues(alpha: 0.08) + : Colors.transparent; + }), + ), + onPressed: enabled + ? () { + if (_isSkip && !reduceMotion) _skip.forward(from: 0); + widget.onPressed!(); + } + : null, + icon: ValueListenableBuilder>( + valueListenable: _states, + builder: (context, states, _) { + final pressed = states.contains(WidgetState.pressed); + return AnimatedScale( + scale: pressed && !reduceMotion ? 0.95 : 1, + duration: motion, + curve: Curves.easeOutCubic, + child: AnimatedOpacity( + opacity: pressed ? 0.72 : 1, + duration: motion, + child: SizedBox.square( + dimension: widget.iconSize, + child: AnimatedSwitcher( + duration: Duration(milliseconds: reduceMotion ? 120 : 200), + child: widget.loading + ? const Center( + child: SizedBox.square( + dimension: 32, + child: CircularProgressIndicator(strokeWidth: 3), + ), + ) + : KeyedSubtree( + key: ValueKey(widget.icon), + child: _isSkip + ? _skipGlyph(reduceMotion) + : Icon(widget.icon), + ), + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/mornye_playback_time.dart b/lib/widgets/mornye_playback_time.dart new file mode 100644 index 00000000..8c4e10b9 --- /dev/null +++ b/lib/widgets/mornye_playback_time.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; + +/// Rolls only the changed digits, keeping the timeline labels steady in width. +class MornyePlaybackTime extends StatefulWidget { + const MornyePlaybackTime({ + super.key, + required this.seconds, + required this.style, + this.remaining = false, + }); + + final int seconds; + final TextStyle? style; + final bool remaining; + + @override + State createState() => _MornyePlaybackTimeState(); +} + +class _MornyePlaybackTimeState extends State { + bool _increasing = true; + + @override + void didUpdateWidget(MornyePlaybackTime oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.seconds != oldWidget.seconds) { + _increasing = widget.seconds > oldWidget.seconds; + } + } + + @override + Widget build(BuildContext context) { + final clock = formatClock(widget.seconds); + final label = '${widget.remaining ? '-' : ''}$clock'; + final style = (widget.style ?? const TextStyle()).copyWith( + fontFeatures: [ + ...?widget.style?.fontFeatures, + const FontFeature.tabularFigures(), + ], + ); + if (MediaQuery.disableAnimationsOf(context)) { + return Text(label, style: style); + } + + return Semantics( + label: label, + excludeSemantics: true, + child: Row( + mainAxisSize: MainAxisSize.min, + textDirection: TextDirection.ltr, + children: [ + if (widget.remaining) Text('-', style: style), + for (var index = 0; index < clock.length; index++) + ClipRect( + // Anchor digit slots from the seconds end when minutes grow. + key: ValueKey(clock.length - index), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 280), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) { + final entering = child.key == ValueKey(clock[index]); + final direction = _increasing ? 0.7 : -0.7; + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: Offset(0, entering ? direction : -direction), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ); + }, + child: Text( + clock[index], + key: ValueKey(clock[index]), + style: style, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/mornye_player_actions_sheet.dart b/lib/widgets/mornye_player_actions_sheet.dart new file mode 100644 index 00000000..6cb42c95 --- /dev/null +++ b/lib/widgets/mornye_player_actions_sheet.dart @@ -0,0 +1,126 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/theme/cover_palette.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; + +/// Navigation for the title/artist, separate from playback and file actions. +class MornyePlayerNavigationMenu extends StatelessWidget { + const MornyePlayerNavigationMenu({super.key, required this.mediaItem}); + + final MediaItem mediaItem; + + @override + Widget build(BuildContext context) { + final theme = MornyeTheme.build(Brightness.dark); + final art = mediaItem.artUri; + return Theme( + data: theme.copyWith( + colorScheme: theme.colorScheme.copyWith(primary: Colors.grey), + ), + child: CoverPaletteBuilder( + imageSource: art?.scheme == 'file' + ? art!.toFilePath() + : art?.toString(), + builder: (context, palette) { + final dominant = HSLColor.fromColor(palette.primary); + final surface = dominant + .withSaturation(dominant.saturation.clamp(0.0, 0.28)) + .withLightness(0.28) + .toColor(); + return Theme( + data: theme.copyWith( + colorScheme: theme.colorScheme.copyWith( + surfaceContainerHigh: surface, + ), + ), + child: MornyeContextMenu( + inheritSurface: true, + groups: [ + [ + if ((mediaItem.artist ?? '').trim().isNotEmpty) + MornyeMenuAction( + icon: CupertinoIcons.mic, + label: context.l10n.mornyeGoToArtist, + subtitle: mediaItem.artist, + onPressed: () => Navigator.of(context).pop('artist'), + ), + if ((mediaItem.album ?? '').trim().isNotEmpty) + MornyeMenuAction( + icon: CupertinoIcons.square_stack, + label: context.l10n.homeGoToAlbum, + subtitle: mediaItem.album, + onPressed: () => Navigator.of(context).pop('album'), + ), + ], + ], + ), + ); + }, + ), + ); + } +} + +/// A floating menu that inherits the player's dark appearance. +class MornyePlayerActionsSheet extends StatelessWidget { + const MornyePlayerActionsSheet({ + super.key, + required this.mediaItem, + this.sleepTimerSubtitle, + }); + + final MediaItem mediaItem; + final String? sleepTimerSubtitle; + + @override + Widget build(BuildContext context) => MornyeContextMenu( + groups: [ + [ + if ((mediaItem.album ?? '').trim().isNotEmpty) + _action( + context, + 'album', + context.l10n.homeGoToAlbum, + CupertinoIcons.square_stack, + ), + _action( + context, + 'details', + context.l10n.nowPlayingDetails, + CupertinoIcons.info, + ), + ], + [ + _action( + context, + 'sleepTimer', + context.l10n.nowPlayingSleepTimer, + CupertinoIcons.moon_zzz, + subtitle: sleepTimerSubtitle, + ), + _action( + context, + 'external', + context.l10n.nowPlayingOpenInExternalPlayer, + CupertinoIcons.arrow_up_right_square, + ), + ], + ], + ); + + MornyeMenuAction _action( + BuildContext context, + String value, + String label, + IconData icon, { + String? subtitle, + }) => MornyeMenuAction( + icon: icon, + label: label, + subtitle: subtitle, + onPressed: () => Navigator.of(context).pop(value), + ); +} diff --git a/lib/widgets/mornye_player_artwork.dart b/lib/widgets/mornye_player_artwork.dart new file mode 100644 index 00000000..8504c0cf --- /dev/null +++ b/lib/widgets/mornye_player_artwork.dart @@ -0,0 +1,35 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/motion_header_banner.dart'; +import 'package:spotiflac_android/widgets/player_artwork.dart'; + +class MornyePlayerArtwork extends StatelessWidget { + const MornyePlayerArtwork({ + super.key, + required this.mediaItem, + this.videoUrl, + }); + + final MediaItem mediaItem; + final String? videoUrl; + + @override + Widget build(BuildContext context) { + final fallback = PlayerArtwork( + artUri: mediaItem.artUri?.toString(), + colorScheme: Theme.of(context).colorScheme, + cacheWidth: + (MediaQuery.sizeOf(context).width * + MediaQuery.devicePixelRatioOf(context)) + .round(), + ); + if (MediaQuery.disableAnimationsOf(context)) return fallback; + return videoUrl == null + ? fallback + : MotionHeaderBanner( + key: ValueKey(videoUrl), + videoUrl: videoUrl!, + fallback: fallback, + ); + } +} diff --git a/lib/widgets/mornye_player_background.dart b/lib/widgets/mornye_player_background.dart new file mode 100644 index 00000000..7bfe23c6 --- /dev/null +++ b/lib/widgets/mornye_player_background.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/cover_palette.dart'; + +/// Opaque cover-derived surface, with full-bleed artwork on the player page +/// and a muted two-tone gradient behind lyrics and the queue. +class MornyePlayerBackground extends StatelessWidget { + const MornyePlayerBackground({ + super.key, + required this.artUri, + this.artwork, + this.squareArtwork = true, + }); + + final Uri? artUri; + final Widget? artwork; + final bool squareArtwork; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final source = artUri?.scheme == 'file' + ? artUri!.toFilePath() + : artUri?.toString(); + + return Theme( + // Missing or unreadable covers use neutral gray, not the app's accent. + data: theme.copyWith( + colorScheme: theme.colorScheme.copyWith( + brightness: Brightness.dark, + primary: const Color(0xff808080), + ), + ), + child: CoverPaletteBuilder( + imageSource: source, + builder: (context, scheme) { + final dominant = HSLColor.fromColor(scheme.primary); + final muted = dominant.withSaturation( + dominant.saturation.clamp(0.0, 0.28), + ); + final motion = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 380); + return AnimatedContainer( + duration: motion, + curve: Curves.easeInOutCubic, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + muted.withLightness(artwork == null ? 0.28 : 0.10).toColor(), + muted.withLightness(artwork == null ? 0.15 : 0.10).toColor(), + ], + ), + ), + child: Stack( + fit: StackFit.expand, + children: [ + Positioned( + left: 0, + right: 0, + top: 0, + height: squareArtwork + ? MediaQuery.sizeOf(context).width + : MediaQuery.sizeOf(context).height * 0.66, + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) => const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0, 0.58, 1], + colors: [Colors.white, Colors.white, Colors.transparent], + ).createShader(bounds), + child: HeroMode( + // The compact header owns the route hero while the + // full cover is fading out behind the lyrics. + enabled: artwork != null, + child: AnimatedSwitcher( + duration: motion, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + layoutBuilder: (current, previous) => Stack( + fit: StackFit.expand, + children: [ + for (final child in previous) + HeroMode(enabled: false, child: child), + ?current, + ], + ), + child: artwork == null + ? null + : KeyedSubtree( + key: const ValueKey('full-player-artwork'), + child: artwork!, + ), + ), + ), + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/mornye_player_details_sheet.dart b/lib/widgets/mornye_player_details_sheet.dart new file mode 100644 index 00000000..b68da365 --- /dev/null +++ b/lib/widgets/mornye_player_details_sheet.dart @@ -0,0 +1,152 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/mornye_metadata_row.dart'; +import 'package:spotiflac_android/widgets/player_artwork.dart'; + +/// Uses Mornye's TrackInfoView layout: artwork header, then selectable values +/// below small labels, separated by thin dividers on one glass surface. +class MornyePlayerDetailsSheet extends ConsumerWidget { + const MornyePlayerDetailsSheet({ + super.key, + required this.rows, + required this.scrollController, + this.mediaItem, + }); + + final List<(String, String)> rows; + final ScrollController scrollController; + final MediaItem? mediaItem; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final item = mediaItem; + final blur = + !MediaQuery.highContrastOf(context) && + (!ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider)); + return MornyeGlass.navigation( + blurEnabled: blur, + child: SafeArea( + top: false, + child: Column( + children: [ + const AppSheetHandle(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: [ + Expanded( + child: Text( + context.l10n.nowPlayingDetails, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + CupertinoButton( + padding: const EdgeInsets.symmetric(vertical: 12), + onPressed: () => Navigator.of(context).pop(), + child: Text( + context.l10n.dialogDone, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + Expanded( + child: SelectionArea( + child: CustomScrollView( + controller: scrollController, + slivers: [ + if (item != null) + SliverPadding( + padding: const EdgeInsets.fromLTRB(24, 18, 24, 18), + sliver: SliverToBoxAdapter( + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox.square( + dimension: 70, + child: PlayerArtwork( + artUri: item.artUri?.toString(), + colorScheme: theme.colorScheme, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 5), + Text( + item.artist ?? '', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyLarge + ?.copyWith( + color: theme + .colorScheme + .onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ), + if (rows.isEmpty) + SliverPadding( + padding: const EdgeInsets.all(24), + sliver: SliverToBoxAdapter( + child: Text( + context.l10n.nowPlayingNoMetadata, + style: theme.textTheme.bodyMedium, + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + sliver: SliverList.builder( + itemCount: rows.length, + itemBuilder: (context, index) { + final (label, value) = rows[index]; + return MornyeMetadataRow( + label: label, + value: value, + showDivider: index < rows.length - 1, + ); + }, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/mornye_player_favorite_button.dart b/lib/widgets/mornye_player_favorite_button.dart new file mode 100644 index 00000000..09205358 --- /dev/null +++ b/lib/widgets/mornye_player_favorite_button.dart @@ -0,0 +1,75 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/widgets/mornye_playback_button.dart'; + +class MornyePlayerFavoriteButton extends ConsumerStatefulWidget { + const MornyePlayerFavoriteButton({ + super.key, + required this.mediaItem, + this.compact = false, + }); + + final MediaItem mediaItem; + final bool compact; + + @override + ConsumerState createState() => + _MornyePlayerFavoriteButtonState(); +} + +class _MornyePlayerFavoriteButtonState + extends ConsumerState { + bool _saving = false; + + Future _toggle() async { + if (_saving) return; + setState(() => _saving = true); + try { + final provider = playerCollectionTrackProvider(widget.mediaItem); + if (ref.read(provider).hasError) ref.invalidate(provider); + final track = await ref.read(provider.future); + if (!mounted) return; + await ref.read(libraryCollectionsProvider.notifier).toggleLoved(track); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarError(context.friendlyError(error)), + ), + ), + ); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final track = ref + .watch(playerCollectionTrackProvider(widget.mediaItem)) + .value; + final loved = ref.watch( + libraryCollectionsProvider.select( + (state) => track != null && state.isLoved(track), + ), + ); + return Semantics( + toggled: loved, + child: MornyePlaybackButton( + icon: loved ? CupertinoIcons.star_fill : CupertinoIcons.star, + tooltip: loved + ? context.l10n.trackOptionRemoveFromLoved + : context.l10n.trackOptionAddToLoved, + color: Theme.of(context).colorScheme.onSurface, + iconSize: widget.compact ? 20 : 24, + onPressed: _saving ? null : _toggle, + ), + ); + } +} diff --git a/lib/widgets/mornye_player_queue.dart b/lib/widgets/mornye_player_queue.dart new file mode 100644 index 00000000..a5cc7cfd --- /dev/null +++ b/lib/widgets/mornye_player_queue.dart @@ -0,0 +1,232 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/widgets/player_artwork.dart'; +import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; + +/// Upcoming tracks share the player's artwork backdrop and transport controls. +class MornyePlayerQueue extends ConsumerWidget { + const MornyePlayerQueue({ + super.key, + required this.colorScheme, + required this.onShuffleLibrary, + }); + + final ColorScheme colorScheme; + final VoidCallback onShuffleLibrary; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final queue = ref.watch(playQueueProvider).value ?? const []; + final currentId = ref.watch( + currentMediaItemProvider.select((item) => item.value?.id), + ); + final playback = ref.watch( + playbackStateProvider.select( + (state) => ( + index: state.value?.queueIndex, + shuffle: state.value?.shuffleMode == AudioServiceShuffleMode.all, + repeat: state.value?.repeatMode ?? AudioServiceRepeatMode.none, + ), + ), + ); + final reportedIndex = playback.index; + final currentIndex = + reportedIndex != null && + reportedIndex >= 0 && + reportedIndex < queue.length && + queue[reportedIndex].id == currentId + ? reportedIndex + : queue.indexWhere((item) => item.id == currentId); + final start = currentIndex + 1; + final count = queue.length - start; + final controller = ref.read(musicPlayerControllerProvider); + final type = Theme.of(context).textTheme; + final repeatLabel = switch (playback.repeat) { + AudioServiceRepeatMode.one => context.l10n.nowPlayingRepeatOne, + AudioServiceRepeatMode.none => context.l10n.nowPlayingRepeatOff, + _ => context.l10n.nowPlayingRepeatAll, + }; + + Widget modeButton({ + required IconData icon, + required String label, + required bool selected, + required VoidCallback onPressed, + }) => Expanded( + child: Semantics( + selected: selected, + child: Tooltip( + message: label, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Colors.white.withValues( + alpha: selected ? 0.3 : 0.12, + ), + foregroundColor: Colors.white, + minimumSize: const Size(0, 44), + shape: const StadiumBorder(), + ), + onPressed: onPressed, + child: Icon(icon, semanticLabel: label, size: 24), + ), + ), + ), + ); + + return CustomScrollView( + key: const PageStorageKey('mornye-player-queue'), + slivers: [ + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 28), + sliver: SliverToBoxAdapter( + child: Column( + children: [ + Row( + children: [ + modeButton( + icon: CupertinoIcons.shuffle, + label: playback.shuffle + ? context.l10n.nowPlayingShuffleOn + : context.l10n.nowPlayingPlayInOrder, + selected: playback.shuffle, + onPressed: () => controller.setShuffle(!playback.shuffle), + ), + const SizedBox(width: 12), + modeButton( + icon: playback.repeat == AudioServiceRepeatMode.one + ? CupertinoIcons.repeat_1 + : CupertinoIcons.repeat, + label: repeatLabel, + selected: playback.repeat != AudioServiceRepeatMode.none, + onPressed: () => + controller.setRepeatMode(switch (playback.repeat) { + AudioServiceRepeatMode.none => + AudioServiceRepeatMode.all, + AudioServiceRepeatMode.all => + AudioServiceRepeatMode.one, + _ => AudioServiceRepeatMode.none, + }), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Text( + context.l10n.mornyeContinuePlaying, + style: type.titleLarge?.copyWith(color: Colors.white), + ), + ), + Builder( + builder: (buttonContext) => IconButton( + tooltip: MaterialLocalizations.of( + context, + ).moreButtonTooltip, + icon: const Icon( + CupertinoIcons.ellipsis, + color: Colors.white, + ), + onPressed: () => showMornyeContextMenu( + context: buttonContext, + builder: (menuContext) => MornyeContextMenu( + groups: [ + [ + MornyeMenuAction( + icon: CupertinoIcons.shuffle, + label: context.l10n.nowPlayingShuffleLibrary, + onPressed: () { + Navigator.pop(menuContext); + onShuffleLibrary(); + }, + ), + ], + ], + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + if (count == 0) + SliverPadding( + padding: const EdgeInsets.all(28), + sliver: SliverToBoxAdapter( + child: Text( + context.l10n.nowPlayingQueueEmpty, + style: type.bodyMedium?.copyWith(color: Colors.white70), + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + sliver: SliverReorderableList( + itemCount: count, + onReorderItem: (oldIndex, newIndex) => + controller.moveQueueItem(start + oldIndex, start + newIndex), + proxyDecorator: (child, index, animation) => Material( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + child: child, + ), + itemBuilder: (context, index) { + final item = queue[start + index]; + return ListTile( + key: ValueKey('${item.id}_${start + index}'), + contentPadding: const EdgeInsets.only(left: 12), + minVerticalPadding: 6, + leading: ClipRRect( + borderRadius: BorderRadius.circular(5), + child: SizedBox.square( + dimension: 40, + child: PlayerArtwork( + artUri: item.artUri?.toString(), + colorScheme: colorScheme, + cacheWidth: 120, + iconSize: 22, + ), + ), + ), + title: Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: type.bodyLarge?.copyWith(color: Colors.white), + ), + subtitle: Text( + item.artist ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: type.bodySmall?.copyWith(color: Colors.white70), + ), + trailing: ReorderableDragStartListener( + index: index, + child: const ColoredBox( + color: Colors.transparent, + child: SizedBox.square( + dimension: 44, + child: Icon( + CupertinoIcons.line_horizontal_3, + color: Colors.white38, + ), + ), + ), + ), + onTap: () => controller.jumpTo(start + index), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/mornye_player_slider.dart b/lib/widgets/mornye_player_slider.dart new file mode 100644 index 00000000..6098b052 --- /dev/null +++ b/lib/widgets/mornye_player_slider.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +/// A continuous player track with no thumb in either gesture state. +class MornyePlayerSlider extends StatelessWidget { + const MornyePlayerSlider({ + super.key, + required this.value, + required this.onChanged, + this.max = 1, + this.activeColor = Colors.white, + this.inactiveColor = const Color(0x2EFFFFFF), + this.onChangeStart, + this.onChangeEnd, + }); + + final double value; + final double max; + final Color activeColor; + final Color inactiveColor; + final ValueChanged onChanged; + final ValueChanged? onChangeStart; + final ValueChanged? onChangeEnd; + + @override + Widget build(BuildContext context) => SizedBox( + height: 44, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 6, + trackGap: 0, + trackShape: const RoundedRectSliderTrackShape(), + thumbShape: SliderComponentShape.noThumb, + overlayShape: SliderComponentShape.noOverlay, + showValueIndicator: ShowValueIndicator.never, + ), + child: Slider( + padding: const EdgeInsets.symmetric(horizontal: 8), + value: value, + max: max, + activeColor: activeColor, + inactiveColor: inactiveColor, + onChanged: onChanged, + onChangeStart: onChangeStart, + onChangeEnd: onChangeEnd, + ), + ), + ); +} diff --git a/lib/widgets/mornye_volume_control.dart b/lib/widgets/mornye_volume_control.dart new file mode 100644 index 00000000..fa2b0c5f --- /dev/null +++ b/lib/widgets/mornye_volume_control.dart @@ -0,0 +1,144 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/widgets/mornye_player_slider.dart'; +import 'package:spotiflac_android/utils/logger.dart'; +import 'package:volume_controller/volume_controller.dart'; + +/// A single shared subscription for the visible player, including hardware +/// volume-button changes. This controls system volume, not ReplayGain gain. +final systemVolumeProvider = StreamProvider.autoDispose((ref) { + final events = StreamController(); + final volume = VolumeController.instance; + final subscription = volume.addListener(events.add); + subscription.onError((Object error, StackTrace stack) { + events.addError(error, stack); + }); + ref.onDispose(() { + unawaited(subscription.cancel()); + volume.removeListener(); + unawaited(events.close()); + }); + return events.stream; +}); + +final systemVolumeWriterProvider = Provider Function(double)>(( + ref, +) { + return (value) { + final volume = VolumeController.instance; + volume.showSystemUI = false; + return volume.setVolume(value.clamp(0, 1)); + }; +}); + +class MornyeVolumeControl extends ConsumerStatefulWidget { + const MornyeVolumeControl({super.key}); + + @override + ConsumerState createState() => + _MornyeVolumeControlState(); +} + +class _MornyeVolumeControlState extends ConsumerState { + double? _preview; + double? _pendingVolume; + bool _dragging = false; + bool _writing = false; + + void _setVolume(double value) { + setState(() => _preview = value); + _pendingVolume = value; + if (!_writing) unawaited(_flushVolume()); + } + + Future _flushVolume() async { + _writing = true; + final write = ref.read(systemVolumeWriterProvider); + try { + // Send changes during the gesture. If the platform is still processing + // a write, keep only the newest value instead of queuing stale positions. + while (mounted && _pendingVolume != null) { + final value = _pendingVolume!; + _pendingVolume = null; + try { + await write(value); + } catch (error) { + AppLogger('PlayerVolume').w('Could not set system volume: $error'); + } + } + } finally { + _writing = false; + if (mounted && !_dragging) setState(() => _preview = null); + } + } + + @override + Widget build(BuildContext context) { + final volume = ref.watch(systemVolumeProvider).value; + final value = (_preview ?? volume ?? 0).clamp(0.0, 1.0); + String percentage(double volume) => '${(volume * 100).round()}%'; + return Semantics( + label: context.l10n.nowPlayingVolume, + slider: true, + enabled: volume != null, + excludeSemantics: true, + value: volume == null ? null : percentage(value), + increasedValue: volume == null + ? null + : percentage((value + 0.05).clamp(0, 1)), + decreasedValue: volume == null + ? null + : percentage((value - 0.05).clamp(0, 1)), + onIncrease: volume == null + ? null + : () => _setVolume((value + 0.05).clamp(0, 1)), + onDecrease: volume == null + ? null + : () => _setVolume((value - 0.05).clamp(0, 1)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Row( + children: [ + const Icon( + CupertinoIcons.speaker_fill, + size: 16, + color: Colors.white54, + ), + const SizedBox(width: 10), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) => IgnorePointer( + ignoring: volume == null, + child: Opacity( + opacity: volume == null ? 0.4 : 1, + child: MornyePlayerSlider( + value: value, + activeColor: Colors.white70, + inactiveColor: Colors.white12, + onChangeStart: (_) => _dragging = true, + onChanged: _setVolume, + onChangeEnd: (value) { + _dragging = false; + _setVolume(value); + }, + ), + ), + ), + ), + ), + const SizedBox(width: 10), + const Icon( + CupertinoIcons.speaker_3_fill, + size: 16, + color: Colors.white54, + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/motion_header_banner.dart b/lib/widgets/motion_header_banner.dart index 3d4b60f4..38290d97 100644 --- a/lib/widgets/motion_header_banner.dart +++ b/lib/widgets/motion_header_banner.dart @@ -97,7 +97,9 @@ class _MotionHeaderBannerState extends State final controller = VideoPlayerController.networkUrl( Uri.parse(url), - formatHint: VideoFormat.hls, + formatHint: Uri.parse(url).path.toLowerCase().endsWith('.m3u8') + ? VideoFormat.hls + : null, videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true), ); _controller = controller; diff --git a/lib/widgets/open_on_platform_sheet.dart b/lib/widgets/open_on_platform_sheet.dart index 5af332db..2c6c7606 100644 --- a/lib/widgets/open_on_platform_sheet.dart +++ b/lib/widgets/open_on_platform_sheet.dart @@ -140,7 +140,7 @@ class _OpenOnPlatformSheetState extends State { SettingsGroup( children: [ for (final entry in entries) - ListTile( + AppSheetOption( title: Text(_displayName(entry.key)), trailing: Icon( Icons.open_in_new, diff --git a/lib/widgets/playback_seek_slider.dart b/lib/widgets/playback_seek_slider.dart index b871bf14..ebbe7da3 100644 --- a/lib/widgets/playback_seek_slider.dart +++ b/lib/widgets/playback_seek_slider.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_player_slider.dart'; /// Previews scrubbing locally and seeks once when the gesture ends. class PlaybackSeekSlider extends StatefulWidget { @@ -37,6 +39,43 @@ class _PlaybackSeekSliderState extends State { final durationMs = widget.duration.inMilliseconds; final enabled = durationMs > 0; final maxMs = enabled ? durationMs.toDouble() : 1.0; + if (context.isMornye) { + final currentMs = (_previewMs ?? widget.position.inMilliseconds) + .clamp(0, maxMs) + .toDouble(); + String percentage(double milliseconds) => + '${(milliseconds / maxMs * 100).clamp(0, 100).round()}%'; + return LayoutBuilder( + builder: (context, constraints) => Semantics( + slider: true, + enabled: enabled, + excludeSemantics: true, + value: percentage(currentMs), + increasedValue: enabled + ? percentage((currentMs + 5000).clamp(0, maxMs).toDouble()) + : null, + decreasedValue: enabled + ? percentage((currentMs - 5000).clamp(0, maxMs).toDouble()) + : null, + onIncrease: enabled + ? () => _commit((currentMs + 5000).clamp(0, maxMs).toDouble()) + : null, + onDecrease: enabled + ? () => _commit((currentMs - 5000).clamp(0, maxMs).toDouble()) + : null, + child: IgnorePointer( + ignoring: !enabled, + child: MornyePlayerSlider( + value: enabled ? currentMs : 0, + max: maxMs, + onChangeStart: (_) => _gestureGeneration++, + onChanged: (value) => setState(() => _previewMs = value), + onChangeEnd: _commit, + ), + ), + ), + ); + } return Slider( value: enabled ? (_previewMs ?? widget.position.inMilliseconds.toDouble()).clamp( diff --git a/lib/widgets/player_artwork.dart b/lib/widgets/player_artwork.dart index 78c29ceb..1ac64141 100644 --- a/lib/widgets/player_artwork.dart +++ b/lib/widgets/player_artwork.dart @@ -2,9 +2,11 @@ import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; import 'package:spotiflac_android/services/cover_cache_manager.dart'; class PlayerArtwork extends StatelessWidget { + /// An HTTP(S) URL, file URI, or absolute local artwork path. final String? artUri; final ColorScheme colorScheme; final int? cacheWidth; @@ -44,8 +46,10 @@ class PlayerArtwork extends StatelessWidget { errorWidget: (_, _, _) => placeholder, ); } - if (uri.startsWith('file://')) { - final path = Uri.parse(uri).toFilePath(); + if (uri.startsWith('file://') || p.isAbsolute(uri)) { + final path = uri.startsWith('file://') + ? Uri.parse(uri).toFilePath() + : uri; return Image.file( File(path), fit: BoxFit.cover, diff --git a/lib/widgets/playlist_picker_sheet.dart b/lib/widgets/playlist_picker_sheet.dart index ef7a44a0..1a237cee 100644 --- a/lib/widgets/playlist_picker_sheet.dart +++ b/lib/widgets/playlist_picker_sheet.dart @@ -1,11 +1,17 @@ import 'dart:io'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; Future showAddTrackToPlaylistSheet( BuildContext context, @@ -28,13 +34,17 @@ Future showAddTracksToPlaylistSheet( await showModalBottomSheet( context: context, useRootNavigator: true, - showDragHandle: true, + showDragHandle: !context.isMornye, + backgroundColor: context.isMornye ? Colors.transparent : null, isScrollControlled: true, builder: (sheetContext) { - return _PlaylistPickerSheetContent( + final content = _PlaylistPickerSheetContent( tracks: tracks, playlistNamePrefill: playlistNamePrefill, ); + return sheetContext.isMornye + ? MornyeGlassPanel(tintOpacity: 0.78, child: content) + : content; }, ); } @@ -111,8 +121,7 @@ class _PlaylistPickerSheetContentState final track = widget.tracks.first; subtitle = '${track.name} • ${track.artistName}'; } else { - subtitle = - '${widget.tracks.length} ${widget.tracks.length == 1 ? 'track' : 'tracks'}'; + subtitle = context.l10n.tracksCount(widget.tracks.length); } final resolvedPlaylists = playlistSummariesValue.asData?.value ?? const []; @@ -124,18 +133,59 @@ class _PlaylistPickerSheetContentState final idsToAdd = _selectedPlaylistIds.difference(effectiveDisabledIds); final hasNewSelections = idsToAdd.isNotEmpty; + void onDone() { + if (hasNewSelections) { + _handleDone(resolvedPlaylists); + } else { + Navigator.of(context).pop(); + } + } + return SafeArea( + top: false, child: Column( mainAxisSize: MainAxisSize.min, children: [ - ListTile( - leading: const Icon(Icons.playlist_add), - title: Text(context.l10n.collectionAddToPlaylist), - subtitle: Text(subtitle), - ), + if (context.isMornye) ...[ + const AppSheetHandle(), + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), + child: SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.collectionAddToPlaylist, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 6), + Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ] else + ListTile( + leading: const Icon(Icons.playlist_add), + title: Text(context.l10n.collectionAddToPlaylist), + subtitle: Text(subtitle), + ), const Divider(height: 1), - ListTile( - leading: const Icon(Icons.add_circle_outline), + _PlaylistPickerRow( + leading: Icon( + context.isMornye + ? CupertinoIcons.add_circled + : Icons.add_circle_outline, + color: Theme.of(context).colorScheme.primary, + ), title: Text(context.l10n.collectionCreatePlaylist), onTap: () async { final name = await _promptPlaylistName( @@ -147,6 +197,7 @@ class _PlaylistPickerSheetContentState } final playlistId = await notifier.createPlaylist(name.trim()); await notifier.addTracksToPlaylist(playlistId, widget.tracks); + if (!mounted) return; setState(() { _committedPlaylistIds.add(playlistId); _selectedPlaylistIds.remove(playlistId); @@ -188,10 +239,10 @@ class _PlaylistPickerSheetContentState _selectedPlaylistIds.contains(playlist.id) || isAlreadyIn; - return ListTile( + return _PlaylistPickerRow( leading: _PlaylistPickerThumbnail( playlist: playlist, - isSelected: isSelected, + isSelected: !context.isMornye && isSelected, ), title: Text(playlist.name), subtitle: Text( @@ -200,6 +251,7 @@ class _PlaylistPickerSheetContentState ), ), enabled: !isAlreadyIn, + selected: isSelected, onTap: !isAlreadyIn ? () { setState(() { @@ -241,16 +293,16 @@ class _PlaylistPickerSheetContentState padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: SizedBox( width: double.infinity, - child: FilledButton( - onPressed: () { - if (hasNewSelections) { - _handleDone(resolvedPlaylists); - } else { - Navigator.of(context).pop(); - } - }, - child: Text(context.l10n.dialogDone), - ), + child: context.isMornye + ? AppActionButton( + onPressed: onDone, + icon: const Icon(CupertinoIcons.checkmark), + label: Text(context.l10n.dialogDone), + ) + : FilledButton( + onPressed: onDone, + child: Text(context.l10n.dialogDone), + ), ), ), ], @@ -266,10 +318,10 @@ Future _promptPlaylistName( final controller = TextEditingController(text: playlistNamePrefill); final formKey = GlobalKey(); - final result = await showDialog( + final result = await showAppDialog( context: context, builder: (dialogContext) { - return AlertDialog( + return AppAlertDialog( title: Text(dialogContext.l10n.collectionCreatePlaylist), content: Form( key: formKey, @@ -294,11 +346,13 @@ Future _promptPlaylistName( ), ), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.of(dialogContext).pop(), child: Text(dialogContext.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, + isDefault: true, onPressed: () { if (formKey.currentState?.validate() != true) return; Navigator.of(dialogContext).pop(controller.text.trim()); @@ -313,6 +367,92 @@ Future _promptPlaylistName( return result; } +class _PlaylistPickerRow extends StatelessWidget { + const _PlaylistPickerRow({ + required this.leading, + required this.title, + this.subtitle, + this.enabled = true, + this.selected, + this.onTap, + }); + + final Widget leading; + final Widget title; + final Widget? subtitle; + final bool enabled; + final bool? selected; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + if (!context.isMornye) { + return ListTile( + leading: leading, + title: title, + subtitle: subtitle, + enabled: enabled, + onTap: onTap, + ); + } + final theme = Theme.of(context); + return Semantics( + selected: selected, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + onPressed: enabled ? onTap : null, + child: Opacity( + opacity: enabled ? 1 : 0.5, + child: Row( + children: [ + leading, + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DefaultTextStyle( + style: theme.textTheme.bodyLarge!, + child: title, + ), + if (subtitle != null) ...[ + const SizedBox(height: 3), + DefaultTextStyle( + style: theme.textTheme.bodyMedium!.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + child: subtitle!, + ), + ], + ], + ), + ), + if (selected != null) ...[ + const SizedBox(width: 14), + Icon( + selected! + ? CupertinoIcons.checkmark_circle_fill + : CupertinoIcons.circle, + size: 24, + color: selected! + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + ), + ], + ], + ), + ), + ), + const Divider(height: 0.5, thickness: 0.5, indent: 20, endIndent: 20), + ], + ), + ); + } +} + class _PlaylistPickerThumbnail extends StatelessWidget { final PlaylistPickerSummary playlist; final bool isSelected; @@ -383,7 +523,7 @@ class _PlaylistPickerThumbnail extends StatelessWidget { cacheWidth: (size * MediaQuery.devicePixelRatioOf(context)).round(), cacheHeight: (size * MediaQuery.devicePixelRatioOf(context)).round(), filterQuality: FilterQuality.low, - errorBuilder: (_, _, _) => _iconFallback(colorScheme, size), + errorBuilder: (_, _, _) => _iconFallback(context, colorScheme, size), ); } @@ -394,14 +534,18 @@ class _PlaylistPickerThumbnail extends StatelessWidget { width: size, height: size, networkCacheWidth: (size * 2).toInt(), - placeholder: (_) => _iconFallback(colorScheme, size), + placeholder: (_) => _iconFallback(context, colorScheme, size), ); } - return _iconFallback(colorScheme, size); + return _iconFallback(context, colorScheme, size); } - Widget _iconFallback(ColorScheme colorScheme, double size) { + Widget _iconFallback( + BuildContext context, + ColorScheme colorScheme, + double size, + ) { return Container( width: size, height: size, @@ -409,7 +553,10 @@ class _PlaylistPickerThumbnail extends StatelessWidget { color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), - child: Icon(Icons.queue_music, color: colorScheme.onSurfaceVariant), + child: Icon( + context.isMornye ? CupertinoIcons.music_note_list : Icons.queue_music, + color: colorScheme.onSurfaceVariant, + ), ); } } diff --git a/lib/widgets/re_enrich_field_dialog.dart b/lib/widgets/re_enrich_field_dialog.dart index b908e9ea..37fc80e7 100644 --- a/lib/widgets/re_enrich_field_dialog.dart +++ b/lib/widgets/re_enrich_field_dialog.dart @@ -1,4 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; import '../l10n/app_localizations.dart'; import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; @@ -156,50 +160,34 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> { ), ), ), - SettingsGroup( + _options( children: [ - ListTile( - leading: const Icon(Icons.fingerprint), - title: Text(l10n.trackReEnrichModeIsrc), - subtitle: Text(l10n.trackReEnrichModeIsrcSubtitle), - trailing: _mode == ReEnrichBatchMode.isrcOnly - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () => - setState(() => _mode = ReEnrichBatchMode.isrcOnly), + _modeOption( + icon: Icons.fingerprint, + title: l10n.trackReEnrichModeIsrc, + subtitle: l10n.trackReEnrichModeIsrcSubtitle, + mode: ReEnrichBatchMode.isrcOnly, ), const Divider(height: 1, indent: 56), - ListTile( - leading: const Icon(Icons.playlist_add_check), - title: Text(l10n.trackReEnrichModeMissing), - subtitle: Text(l10n.trackReEnrichModeMissingSubtitle), - trailing: _mode == ReEnrichBatchMode.missingOnly - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () => - setState(() => _mode = ReEnrichBatchMode.missingOnly), + _modeOption( + icon: Icons.playlist_add_check, + title: l10n.trackReEnrichModeMissing, + subtitle: l10n.trackReEnrichModeMissingSubtitle, + mode: ReEnrichBatchMode.missingOnly, ), const Divider(height: 1, indent: 56), - ListTile( - leading: const Icon(Icons.tune), - title: Text(l10n.trackReEnrichModeReplace), - subtitle: Text(l10n.trackReEnrichModeReplaceSubtitle), - trailing: _mode == ReEnrichBatchMode.selectedFields - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () => - setState(() => _mode = ReEnrichBatchMode.selectedFields), + _modeOption( + icon: Icons.tune, + title: l10n.trackReEnrichModeReplace, + subtitle: l10n.trackReEnrichModeReplaceSubtitle, + mode: ReEnrichBatchMode.selectedFields, ), const Divider(height: 1, indent: 56), - ListTile( - leading: const Icon(Icons.edit_note), - title: Text(l10n.trackReEnrichModeManual), - subtitle: Text(l10n.trackReEnrichModeManualSubtitle), - trailing: _mode == ReEnrichBatchMode.manualValues - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () => - setState(() => _mode = ReEnrichBatchMode.manualValues), + _modeOption( + icon: Icons.edit_note, + title: l10n.trackReEnrichModeManual, + subtitle: l10n.trackReEnrichModeManualSubtitle, + mode: ReEnrichBatchMode.manualValues, ), ], ), @@ -215,26 +203,20 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> { ), ), ), - SettingsGroup( + _options( children: [ - CheckboxListTile( - title: Text( - l10n.trackReEnrichSelectAll, - style: const TextStyle(fontWeight: FontWeight.w600), - ), + _fieldToggle( + title: l10n.trackReEnrichSelectAll, value: _allSelected, - tristate: true, onChanged: _toggleAll, - controlAffinity: ListTileControlAffinity.leading, ), for (final field in ReEnrichFields.all) ...[ const Divider(height: 1, indent: 56), - CheckboxListTile( - secondary: Icon(_iconFor(field), size: 20), - title: Text(_labelFor(field, l10n)), + _fieldToggle( + icon: _iconFor(field), + title: _labelFor(field, l10n), value: _selected.contains(field), onChanged: (value) => _toggle(field, value), - controlAffinity: ListTileControlAffinity.leading, ), ], ], @@ -264,7 +246,8 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> { ], ), ), - SettingsGroup( + _options( + grouped: false, children: [ for ( var index = 0; @@ -274,19 +257,50 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> { if (index > 0) const Divider(height: 1), Padding( padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), - child: TextField( - controller: - _manualControllers[manualBatchMetadataFields[index]], - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - labelText: _manualLabelFor( - manualBatchMetadataFields[index], - l10n, - ), - border: const OutlineInputBorder(), - isDense: true, - ), - ), + child: context.isMornye + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _manualLabelFor( + manualBatchMetadataFields[index], + l10n, + ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 6), + CupertinoTextField( + controller: + _manualControllers[manualBatchMetadataFields[index]], + onChanged: (_) => setState(() {}), + style: Theme.of(context).textTheme.bodyLarge, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + decoration: BoxDecoration( + color: MornyeTheme.controlFill(context), + borderRadius: BorderRadius.circular(16), + ), + ), + ], + ) + : TextField( + controller: + _manualControllers[manualBatchMetadataFields[index]], + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + labelText: _manualLabelFor( + manualBatchMetadataFields[index], + l10n, + ), + border: const OutlineInputBorder(), + isDense: true, + ), + ), ), ], ], @@ -297,7 +311,7 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> { padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: SizedBox( width: double.infinity, - child: FilledButton.icon( + child: AppActionButton( onPressed: (_mode == ReEnrichBatchMode.selectedFields && _selected.isEmpty) || @@ -322,4 +336,126 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> { ), ); } + + Widget _options({required List children, bool grouped = true}) { + if (!context.isMornye) return SettingsGroup(children: children); + final content = Column(mainAxisSize: MainAxisSize.min, children: children); + return Padding( + padding: EdgeInsets.symmetric(horizontal: grouped ? 16 : 4, vertical: 4), + child: grouped + ? Material( + color: MornyeTheme.controlFill(context), + borderRadius: BorderRadius.circular(28), + clipBehavior: Clip.antiAlias, + child: content, + ) + : content, + ); + } + + Widget _modeOption({ + required IconData icon, + required String title, + required String subtitle, + required ReEnrichBatchMode mode, + }) { + void select() => setState(() => _mode = mode); + final scheme = Theme.of(context).colorScheme; + if (!context.isMornye) { + return ListTile( + leading: Icon(icon), + title: Text(title), + subtitle: Text(subtitle), + trailing: _mode == mode + ? Icon(Icons.check, color: scheme.primary) + : null, + onTap: select, + ); + } + return Semantics( + selected: _mode == mode, + child: CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + onPressed: select, + child: Row( + children: [ + Icon(mornyeIconFor(icon), color: scheme.primary, size: 24), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.bodyLarge), + const SizedBox(height: 4), + Text( + subtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 20, + child: _mode == mode + ? Icon( + CupertinoIcons.checkmark, + color: scheme.primary, + size: 20, + ) + : null, + ), + ], + ), + ), + ); + } + + Widget _fieldToggle({ + required String title, + required bool value, + required ValueChanged onChanged, + IconData? icon, + }) { + if (!context.isMornye) { + return CheckboxListTile( + title: Text(title), + value: value, + onChanged: onChanged, + secondary: icon == null ? null : Icon(icon, size: 20), + controlAffinity: ListTileControlAffinity.leading, + ); + } + final scheme = Theme.of(context).colorScheme; + return Semantics( + checked: value, + child: CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + onPressed: () => onChanged(!value), + child: Row( + children: [ + Icon( + value + ? CupertinoIcons.check_mark_circled_solid + : CupertinoIcons.circle, + color: value ? scheme.primary : scheme.onSurfaceVariant, + size: 24, + ), + const SizedBox(width: 14), + Expanded( + child: Text(title, style: Theme.of(context).textTheme.bodyLarge), + ), + if (icon != null) + Icon( + mornyeIconFor(icon), + color: scheme.onSurfaceVariant, + size: 20, + ), + ], + ), + ), + ); + } } diff --git a/lib/widgets/re_enrich_review_sheet.dart b/lib/widgets/re_enrich_review_sheet.dart index f9e72c42..5f031f21 100644 --- a/lib/widgets/re_enrich_review_sheet.dart +++ b/lib/widgets/re_enrich_review_sheet.dart @@ -3,6 +3,9 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/widgets/app_content_card.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; Future showReEnrichReviewSheet( BuildContext context, { @@ -108,70 +111,79 @@ class _ReEnrichReviewContent extends StatelessWidget { itemCount: visible.length, itemBuilder: (context, index) { final preview = visible[index]; - return SettingsGroup( - children: [ - ListTile( - leading: const Icon(Icons.audio_file_outlined), - title: Text( - preview.item.trackName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - preview.item.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + final children = [ + ListTile( + leading: const Icon(Icons.audio_file_outlined), + title: Text( + preview.item.trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - for (final change in preview.changes) ...[ - const Divider(height: 1, indent: 56), - Semantics( - label: - '${_fieldLabel(context, change.field)}: ${_oldValue(context, change)}, ${_newValue(context, change)}', - child: Padding( - padding: const EdgeInsets.fromLTRB(56, 10, 16, 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _fieldLabel(context, change.field), - style: Theme.of(context).textTheme.labelMedium - ?.copyWith( + subtitle: Text( + preview.item.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + for (final change in preview.changes) ...[ + const Divider(height: 1, indent: 56), + Semantics( + label: + '${_fieldLabel(context, change.field)}: ${_oldValue(context, change)}, ${_newValue(context, change)}', + child: Padding( + padding: const EdgeInsets.fromLTRB(56, 10, 16, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _fieldLabel(context, change.field), + style: Theme.of(context).textTheme.labelMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 3), + Text.rich( + TextSpan( + children: [ + TextSpan( + text: _oldValue(context, change), + style: TextStyle( color: colorScheme.onSurfaceVariant, ), + ), + TextSpan( + text: ' → ', + style: TextStyle( + color: colorScheme.primary, + ), + ), + TextSpan( + text: _newValue(context, change), + style: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + ], ), - const SizedBox(height: 3), - Text.rich( - TextSpan( - children: [ - TextSpan( - text: _oldValue(context, change), - style: TextStyle( - color: colorScheme.onSurfaceVariant, - ), - ), - TextSpan( - text: ' → ', - style: TextStyle( - color: colorScheme.primary, - ), - ), - TextSpan( - text: _newValue(context, change), - style: const TextStyle( - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - ), + ), + ], ), ), - ], + ), ], - ); + ]; + return context.isMornye + ? Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: AppContentCard( + child: Column(children: children), + ), + ) + : SettingsGroup(children: children); }, ), ), @@ -179,7 +191,7 @@ class _ReEnrichReviewContent extends StatelessWidget { padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: SizedBox( width: double.infinity, - child: FilledButton.icon( + child: AppActionButton( onPressed: () => Navigator.pop(context, true), icon: const Icon(Icons.save_outlined), label: Text(context.l10n.trackReEnrichApplyChanges), diff --git a/lib/widgets/selection_action_button.dart b/lib/widgets/selection_action_button.dart index c6a97d46..644fb2ad 100644 --- a/lib/widgets/selection_action_button.dart +++ b/lib/widgets/selection_action_button.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; /// Icon+label pill used in selection-mode bottom bars. Disabled state dims /// both the fill and the content to 50% alpha. @@ -19,6 +21,16 @@ class SelectionActionButton extends StatelessWidget { @override Widget build(BuildContext context) { + if (context.isMornye) { + return AppActionButton( + outlined: true, + tonal: true, + onPressed: onPressed, + icon: Icon(icon), + label: Text(label, maxLines: 2, overflow: TextOverflow.ellipsis), + fontSize: 15, + ); + } final isDisabled = onPressed == null; return Semantics( button: true, diff --git a/lib/widgets/selection_bottom_bar.dart b/lib/widgets/selection_bottom_bar.dart index 49ce6bb1..c7db3c00 100644 --- a/lib/widgets/selection_bottom_bar.dart +++ b/lib/widgets/selection_bottom_bar.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; @@ -225,31 +228,35 @@ class SelectionBottomBar extends StatelessWidget { final tokens = context.tokens; final colorScheme = Theme.of(context).colorScheme; - return Container( - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.vertical( - top: Radius.circular(tokens.radiusSheet), - ), - boxShadow: [ - BoxShadow( - color: colorScheme.shadow.withValues(alpha: 0.15), - blurRadius: 12, - offset: const Offset(0, -4), - ), - ], - ), - child: SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const AppSheetHandle(), + final content = SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const AppSheetHandle(), - Row( - children: [ + Row( + children: [ + if (context.isMornye) + Tooltip( + message: MaterialLocalizations.of( + context, + ).closeButtonTooltip, + child: CupertinoButton( + color: MornyeTheme.controlFill(context), + borderRadius: BorderRadius.circular(28), + padding: const EdgeInsets.all(12), + onPressed: onClose, + child: Icon( + CupertinoIcons.xmark, + color: colorScheme.onSurface, + size: 24, + ), + ), + ) + else IconButton.filledTonal( onPressed: onClose, tooltip: MaterialLocalizations.of( @@ -260,30 +267,45 @@ class SelectionBottomBar extends StatelessWidget { backgroundColor: colorScheme.surfaceContainerHighest, ), ), - const SizedBox(width: 12), + const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.selectionSelected(selectedCount), - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.bold), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.selectionSelected(selectedCount), + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + Text( + allSelected + ? allSelectedLabel ?? + context.l10n.selectionAllSelected + : tapToSelectLabel ?? + context.l10n.downloadedAlbumTapToSelect, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, ), - Text( - allSelected - ? allSelectedLabel ?? - context.l10n.selectionAllSelected - : tapToSelectLabel ?? - context.l10n.downloadedAlbumTapToSelect, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ], - ), + ), + ], ), + ), + if (context.isMornye) + CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 8), + onPressed: onToggleSelectAll, + child: Text( + allSelected + ? context.l10n.actionDeselect + : context.l10n.actionSelectAll, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.primary, + ), + ), + ) + else TextButton.icon( onPressed: onToggleSelectAll, icon: Icon( @@ -299,16 +321,34 @@ class SelectionBottomBar extends StatelessWidget { foregroundColor: colorScheme.primary, ), ), - ], - ), + ], + ), - const SizedBox(height: 12), + const SizedBox(height: 12), - ...children, - ], - ), + ...children, + ], ), ), ); + if (context.isMornye) { + return MornyeGlassPanel.overlay(child: content); + } + return Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.vertical( + top: Radius.circular(tokens.radiusSheet), + ), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.15), + blurRadius: 12, + offset: const Offset(0, -4), + ), + ], + ), + child: content, + ); } } diff --git a/lib/widgets/settings_group.dart b/lib/widgets/settings_group.dart index a55b81d8..7af25a94 100644 --- a/lib/widgets/settings_group.dart +++ b/lib/widgets/settings_group.dart @@ -1,7 +1,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:spotiflac_android/widgets/app_switch.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; /// Background fill for grouped cards, matching the Settings group look. Blends a @@ -9,6 +14,7 @@ import 'package:spotiflac_android/utils/adaptive_layout.dart'; /// black) dark themes as well as normal light/dark themes. Color settingsGroupColor(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + if (context.isMornye) return colorScheme.surfaceContainer; final isDark = Theme.of(context).brightness == Brightness.dark; return isDark ? Color.alphaBlend( @@ -182,8 +188,14 @@ class _SettingsSearchTargetState extends State { class SettingsGroup extends StatelessWidget { final List children; final EdgeInsetsGeometry? margin; + final bool glass; - const SettingsGroup({super.key, required this.children, this.margin}); + const SettingsGroup({ + super.key, + required this.children, + this.margin, + this.glass = false, + }); @override Widget build(BuildContext context) { @@ -193,15 +205,35 @@ class SettingsGroup extends StatelessWidget { final decoration = BoxDecoration( color: cardColor, borderRadius: BorderRadius.circular(context.tokens.radiusCard), - border: Border.all( - color: colorScheme.outlineVariant.withValues(alpha: 0.5), - ), + border: context.isMornye + ? null + : Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), ); final child = Material( color: Colors.transparent, child: Column(mainAxisSize: MainAxisSize.min, children: children), ); + if (glass && context.isMornye) { + return LayoutBuilder( + builder: (context, constraints) => Padding( + padding: + margin ?? + EdgeInsets.symmetric( + horizontal: + 16 + + (constraints.hasBoundedWidth + ? wideInsetForWidth(constraints.maxWidth) + : 0), + vertical: 4, + ), + child: MornyeGlassPanel(child: child), + ), + ); + } + // Explicit caller margin wins as-is. Otherwise center on wide surfaces // using the incoming constraint (not screen width) so groups nested in an // already clamped box, e.g. a bottom sheet, are not over-inset. @@ -263,64 +295,90 @@ class SettingsItem extends StatelessWidget { splashColor: colorScheme.primary.withValues(alpha: 0.12), highlightColor: Colors.transparent, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - child: Row( - children: [ - if (icon != null) ...[ - Icon(icon, color: colorScheme.onSurfaceVariant, size: 24), - const SizedBox(width: 16), - ], - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - title, - style: Theme.of(context).textTheme.bodyLarge, + padding: EdgeInsets.symmetric( + horizontal: context.isMornye ? 16 : 20, + vertical: context.isMornye ? 10 : 16, + ), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: context.isMornye ? 28 : 0), + child: Row( + children: [ + if (icon != null) ...[ + Icon( + context.isMornye ? mornyeIconFor(icon!) : icon, + color: context.isMornye + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + size: 24, + ), + SizedBox(width: context.isMornye ? 12 : 16), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + title, + style: Theme.of(context).textTheme.bodyLarge, + ), ), - ), - if (titleTrailing != null) ...[ - const SizedBox(width: 8), - titleTrailing!, + if (titleTrailing != null) ...[ + const SizedBox(width: 8), + titleTrailing!, + ], ], - ], - ), - if (subtitle != null) ...[ - const SizedBox(height: 2), - Text( - subtitle!, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: colorScheme.onSurfaceVariant), ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + style: + (context.isMornye + ? Theme.of(context).textTheme.bodySmall + : Theme.of( + context, + ).textTheme.bodyMedium) + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], ], - ], - ), - ), - if (trailing != null) ...[ - const SizedBox(width: 8), - trailing!, - ] else if (onTap != null) ...[ - const SizedBox(width: 8), - Icon( - Icons.chevron_right, - color: colorScheme.onSurfaceVariant, + ), ), + if (trailing != null) ...[ + const SizedBox(width: 8), + trailing!, + ] else if (onTap != null) ...[ + const SizedBox(width: 8), + Icon( + context.isMornye + ? CupertinoIcons.chevron_forward + : Icons.chevron_right, + size: context.isMornye ? 18 : 24, + color: colorScheme.onSurfaceVariant, + ), + ], ], - ], + ), ), ), ), if (showDivider) Divider( height: 1, - thickness: 1, - indent: icon != null ? 56 : 20, - endIndent: 20, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), + thickness: context.isMornye ? 0.5 : 1, + indent: context.isMornye + ? (icon != null ? 52 : 16) + : (icon != null ? 56 : 20), + endIndent: context.isMornye ? 16 : 20, + color: context.isMornye + ? colorScheme.outlineVariant + : colorScheme.outlineVariant.withValues(alpha: 0.3), ), ], ); @@ -365,18 +423,23 @@ class SettingsSwitchItem extends StatelessWidget { splashColor: colorScheme.primary.withValues(alpha: 0.12), highlightColor: Colors.transparent, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + padding: EdgeInsets.symmetric( + horizontal: context.isMornye ? 16 : 20, + vertical: context.isMornye ? 8 : 12, + ), child: Row( children: [ if (icon != null) ...[ Icon( - icon, + context.isMornye ? mornyeIconFor(icon!) : icon, color: isDisabled ? colorScheme.outline + : context.isMornye + ? colorScheme.primary : colorScheme.onSurfaceVariant, size: 24, ), - const SizedBox(width: 16), + SizedBox(width: context.isMornye ? 12 : 16), ], Expanded( child: Column( @@ -418,8 +481,9 @@ class SettingsSwitchItem extends StatelessWidget { ), ), const SizedBox(width: 8), - Switch( + AppSwitch( value: value, + semanticLabel: title, onChanged: isDisabled ? null : onChanged, ), ], @@ -430,10 +494,14 @@ class SettingsSwitchItem extends StatelessWidget { if (showDivider) Divider( height: 1, - thickness: 1, - indent: icon != null ? 56 : 20, - endIndent: 20, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), + thickness: context.isMornye ? 0.5 : 1, + indent: context.isMornye + ? (icon != null ? 52 : 16) + : (icon != null ? 56 : 20), + endIndent: context.isMornye ? 16 : 20, + color: context.isMornye + ? colorScheme.outlineVariant + : colorScheme.outlineVariant.withValues(alpha: 0.3), ), ], ); @@ -451,11 +519,19 @@ class SettingsSectionHeader extends StatelessWidget { final content = Padding( padding: const EdgeInsets.fromLTRB(32, 24, 32, 8), child: Text( - title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w600, - ), + context.isMornye ? title.toUpperCase() : title, + style: + (context.isMornye + ? Theme.of(context).textTheme.bodySmall + : Theme.of(context).textTheme.titleSmall) + ?.copyWith( + color: context.isMornye + ? Theme.of(context).colorScheme.onSurfaceVariant + : Theme.of(context).colorScheme.primary, + fontWeight: context.isMornye + ? FontWeight.normal + : FontWeight.w600, + ), ), ); return SettingsSearchTarget(label: title, child: content); @@ -500,13 +576,17 @@ class SettingsChoiceChip extends StatelessWidget { final isDark = theme.brightness == Brightness.dark; final borderRadius = BorderRadius.circular(tokens.radiusCover); - final unselectedColor = isDark + final unselectedColor = context.isMornye + ? Colors.transparent + : isDark ? Color.alphaBlend( Colors.white.withValues(alpha: 0.05), colorScheme.surface, ) : colorScheme.surfaceContainerHigh; - final foreground = isSelected + final foreground = context.isMornye && isSelected + ? colorScheme.onSurface + : isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant; @@ -528,7 +608,10 @@ class SettingsChoiceChip extends StatelessWidget { content = Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, color: foreground), + Icon( + context.isMornye ? mornyeIconFor(icon!) : icon, + color: foreground, + ), SizedBox(height: tokens.gapXs + 2), labelText, ], @@ -537,7 +620,11 @@ class SettingsChoiceChip extends StatelessWidget { content = Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, size: 18, color: foreground), + Icon( + context.isMornye ? mornyeIconFor(icon!) : icon, + size: 18, + color: foreground, + ), SizedBox(width: tokens.gapSm), Flexible(child: labelText), ], @@ -545,7 +632,11 @@ class SettingsChoiceChip extends StatelessWidget { } final chip = Material( - color: isSelected ? colorScheme.primaryContainer : unselectedColor, + color: isSelected + ? (context.isMornye + ? colorScheme.surfaceContainerHighest + : colorScheme.primaryContainer) + : unselectedColor, borderRadius: borderRadius, child: InkWell( onTap: onTap, @@ -608,6 +699,7 @@ class SettingsInfoCard extends StatelessWidget { this.tone = SettingsInfoTone.neutral, this.action, this.margin, + this.glass = false, }); final String message; @@ -620,6 +712,9 @@ class SettingsInfoCard extends StatelessWidget { final EdgeInsetsGeometry? margin; + /// Uses the shared glass surface in Mornye without changing Material cards. + final bool glass; + @override Widget build(BuildContext context) { final tokens = context.tokens; @@ -641,23 +736,19 @@ class SettingsInfoCard extends StatelessWidget { ), }; - return Container( - margin: - margin ?? - EdgeInsets.symmetric( - horizontal: tokens.gapLg, - vertical: tokens.gapXs, - ), + final useGlass = glass && context.isMornye; + final textColor = useGlass ? colorScheme.onSurface : foreground; + final content = Padding( padding: EdgeInsets.all(tokens.gapLg), - decoration: BoxDecoration( - color: background, - borderRadius: tokens.borderRadiusCard, - ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (icon != null) ...[ - Icon(icon, size: 20, color: foreground), + Icon( + useGlass ? mornyeIconFor(icon!) : icon, + size: 20, + color: useGlass ? colorScheme.primary : foreground, + ), SizedBox(width: tokens.gapMd), ], Expanded( @@ -669,7 +760,7 @@ class SettingsInfoCard extends StatelessWidget { Text( title!, style: theme.textTheme.bodyMedium?.copyWith( - color: foreground, + color: textColor, fontWeight: FontWeight.w600, ), ), @@ -677,7 +768,7 @@ class SettingsInfoCard extends StatelessWidget { ], Text( message, - style: theme.textTheme.bodySmall?.copyWith(color: foreground), + style: theme.textTheme.bodySmall?.copyWith(color: textColor), ), if (action != null) ...[ SizedBox(height: tokens.gapSm), @@ -689,5 +780,22 @@ class SettingsInfoCard extends StatelessWidget { ], ), ); + return Padding( + padding: + margin ?? + EdgeInsets.symmetric( + horizontal: tokens.gapLg, + vertical: tokens.gapXs, + ), + child: useGlass + ? MornyeGlassPanel.overlay(child: content) + : DecoratedBox( + decoration: BoxDecoration( + color: background, + borderRadius: tokens.borderRadiusCard, + ), + child: content, + ), + ); } } diff --git a/lib/widgets/track_card.dart b/lib/widgets/track_card.dart index 9f474681..bbd6ffcf 100644 --- a/lib/widgets/track_card.dart +++ b/lib/widgets/track_card.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; /// How a [TrackCard] paints its container. @@ -80,13 +81,13 @@ class TrackCard extends StatelessWidget { final Color? cardColor; if (isSelected) { cardColor = colorScheme.primaryContainer.withValues(alpha: 0.3); - } else if (style == TrackCardStyle.flat) { + } else if (style == TrackCardStyle.flat || context.isMornye) { cardColor = Colors.transparent; } else { cardColor = null; // Inherits cardTheme.color. } - return Card( + final card = Card( elevation: 0, // The two styles carry their own outer margin so no call site has to // repeat it: flat rows sit tight inside a collection list, filled cards @@ -163,6 +164,20 @@ class TrackCard extends StatelessWidget { ), ), ); + if (!context.isMornye) return card; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + card, + Padding( + padding: EdgeInsetsDirectional.only( + start: tokens.coverList + 40, + end: 20, + ), + child: const Divider(), + ), + ], + ); } } diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart index a104e698..ae9e2d9c 100644 --- a/lib/widgets/track_collection_quick_actions.dart +++ b/lib/widgets/track_collection_quick_actions.dart @@ -1,5 +1,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:flutter/services.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -10,6 +12,8 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/utils/local_playback.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; +import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:spotiflac_android/widgets/track_collection_action_policy.dart'; import 'package:spotiflac_android/widgets/track_detail_actions.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; @@ -31,12 +35,22 @@ class TrackCollectionQuickActions extends ConsumerWidget { bool hasLocalPlaybackCandidate = false, }) { final colorScheme = Theme.of(context).colorScheme; + if (context.isMornye) { + showMornyeContextMenu( + context: context, + builder: (_) => _TrackOptionsSheet( + track: track, + hasLocalPlaybackCandidate: hasLocalPlaybackCandidate, + ), + ); + return; + } showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, backgroundColor: colorScheme.surfaceContainerHigh, - builder: (sheetContext) => _TrackOptionsSheet( + builder: (_) => _TrackOptionsSheet( track: track, hasLocalPlaybackCandidate: hasLocalPlaybackCandidate, ), @@ -50,7 +64,7 @@ class TrackCollectionQuickActions extends ConsumerWidget { return IconButton( tooltip: MaterialLocalizations.of(context).showMenuTooltip, icon: Icon( - Icons.more_vert, + context.isMornye ? CupertinoIcons.ellipsis : Icons.more_vert, color: colorScheme.onSurfaceVariant, size: 20, ), @@ -93,7 +107,111 @@ class _TrackOptionsSheet extends ConsumerWidget { hasLocalPlaybackCandidate: hasLocalPlaybackCandidate, ); + if (context.isMornye) { + final l10n = context.l10n; + return MornyeContextMenu( + quickActions: [ + MornyeMenuAction( + icon: CupertinoIcons.arrow_down_circle_fill, + label: l10n.dialogDownload, + onPressed: () { + final root = Navigator.of(context, rootNavigator: true).context; + Navigator.pop(context); + downloadSingleTrack(root, ref, track); + }, + ), + MornyeMenuAction( + icon: CupertinoIcons.star_fill, + label: isLoved ? l10n.mornyeFavorited : l10n.mornyeFavorite, + selected: isLoved, + onPressed: () => _toggleCollection(context, ref, wishlist: false), + ), + MornyeMenuAction( + icon: CupertinoIcons.share_solid, + label: l10n.trackMetadataShare, + onPressed: () { + final origin = + mornyeMenuAnchor(context) ?? + Rect.fromCenter( + center: MediaQuery.sizeOf(context).center(Offset.zero), + width: 1, + height: 1, + ); + Navigator.pop(context); + SharePlus.instance.share( + ShareParams( + text: '${track.name} — ${track.artistName}', + sharePositionOrigin: origin, + ), + ); + }, + ), + ], + groups: [ + [ + if (qualityVariantAction == QualityVariantMenuAction.playLocal) + MornyeMenuAction( + icon: CupertinoIcons.play, + label: l10n.trackMetadataPlay, + onPressed: () => _playLocal(context, ref), + ), + if (qualityVariantAction == + QualityVariantMenuAction.downloadAnotherQuality) + MornyeMenuAction( + icon: CupertinoIcons.arrow_down_circle, + label: l10n.trackOptionDownloadQualityVariant, + onPressed: () => _downloadQualityVariant(context, ref), + ), + MornyeMenuAction( + icon: CupertinoIcons.square_stack, + label: l10n.homeGoToAlbum, + onPressed: () => _goToAlbum(context), + ), + ], + [ + MornyeMenuAction( + icon: CupertinoIcons.text_badge_plus, + label: l10n.collectionAddToPlaylist, + onPressed: () { + final root = Navigator.of(context, rootNavigator: true).context; + Navigator.pop(context); + showAddTrackToPlaylistSheet(root, ref, track); + }, + ), + MornyeMenuAction( + icon: isInWishlist + ? CupertinoIcons.checkmark_circle + : CupertinoIcons.add_circled, + label: isInWishlist + ? l10n.trackOptionRemoveFromWishlist + : l10n.trackOptionAddToWishlist, + onPressed: () => _toggleCollection(context, ref, wishlist: true), + ), + ], + [ + MornyeMenuAction( + icon: CupertinoIcons.doc_on_doc, + label: l10n.trackOptionCopyTrackName, + onPressed: () => _copyText(context, track.name), + ), + MornyeMenuAction( + icon: CupertinoIcons.person, + label: l10n.trackOptionCopyArtist, + onPressed: () => _copyText(context, track.artistName), + ), + MornyeMenuAction( + icon: CupertinoIcons.doc_on_doc, + label: l10n.trackOptionCopyTrackAndArtist, + onPressed: () => + _copyText(context, '${track.name} - ${track.artistName}'), + ), + ], + ], + ); + } + return SafeArea( + top: false, child: ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.sizeOf(context).height * 0.82, @@ -274,8 +392,12 @@ class _TrackOptionsSheet extends ConsumerWidget { icon: Icons.playlist_add, title: context.l10n.collectionAddToPlaylist, onTap: () { + final rootContext = Navigator.of( + context, + rootNavigator: true, + ).context; Navigator.pop(context); - showAddTrackToPlaylistSheet(context, ref, track); + showAddTrackToPlaylistSheet(rootContext, ref, track); }, ), @@ -293,6 +415,29 @@ class _TrackOptionsSheet extends ConsumerWidget { downloadSingleTrack(rootContext, ref, track, forceQualityPicker: true); } + Future _toggleCollection( + BuildContext context, + WidgetRef ref, { + required bool wishlist, + }) async { + final root = Navigator.of(context, rootNavigator: true).context; + final notifier = ref.read(libraryCollectionsProvider.notifier); + Navigator.pop(context); + final added = wishlist + ? await notifier.toggleWishlist(track) + : await notifier.toggleLoved(track); + if (!root.mounted) return; + final l10n = root.l10n; + final message = wishlist + ? (added + ? l10n.collectionAddedToWishlist(track.name) + : l10n.collectionRemovedFromWishlist(track.name)) + : (added + ? l10n.collectionAddedToLoved(track.name) + : l10n.collectionRemovedFromLoved(track.name)); + ScaffoldMessenger.of(root).showSnackBar(SnackBar(content: Text(message))); + } + void _copyText(BuildContext context, String text) { final rootContext = Navigator.of(context, rootNavigator: true).context; Navigator.pop(context); diff --git a/lib/widgets/track_detail_actions.dart b/lib/widgets/track_detail_actions.dart index 206ff69d..c6ad357e 100644 --- a/lib/widgets/track_detail_actions.dart +++ b/lib/widgets/track_detail_actions.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; @@ -30,6 +31,8 @@ void downloadSingleTrack( } if (settings.askQualityBeforeDownload || forceQualityPicker) { + // A context menu may be disposed before the quality picker is submitted. + final container = ProviderScope.containerOf(context, listen: false); DownloadServicePicker.show( context, trackName: track.name, @@ -37,7 +40,7 @@ void downloadSingleTrack( coverUrl: track.coverUrl, recommendedService: recommendedService, onSelect: (quality, service) { - ref + container .read(downloadQueueProvider.notifier) .addToQueue( track, @@ -82,20 +85,19 @@ void confirmDownloadAllDialog( VoidCallback onConfirm, ) { if (trackCount == 0) return; - showDialog( + showAppDialog( context: context, builder: (dialogContext) { - final colorScheme = Theme.of(dialogContext).colorScheme; - return AlertDialog( - backgroundColor: colorScheme.surfaceContainerHigh, + return AppAlertDialog( title: Text(context.l10n.dialogDownloadAllTitle), content: Text(context.l10n.dialogDownloadAllMessage(trackCount)), actions: [ - TextButton( + AppDialogAction( onPressed: () => Navigator.pop(dialogContext), child: Text(context.l10n.dialogCancel), ), - FilledButton( + AppDialogAction( + filled: true, onPressed: () { Navigator.pop(dialogContext); onConfirm(); diff --git a/lib/widgets/track_list_tile.dart b/lib/widgets/track_list_tile.dart index e8b0d977..3205c5c2 100644 --- a/lib/widgets/track_list_tile.dart +++ b/lib/widgets/track_list_tile.dart @@ -23,6 +23,7 @@ class TrackListTile extends ConsumerWidget { final void Function({bool forceQualityPicker}) onDownload; final Widget leading; final bool clickableArtist; + final bool showQualityBadges; final bool isSelectionMode; final bool isSelected; final VoidCallback? onToggleSelection; @@ -35,6 +36,7 @@ class TrackListTile extends ConsumerWidget { required this.onDownload, required this.leading, this.clickableArtist = false, + this.showQualityBadges = true, this.isSelectionMode = false, this.isSelected = false, this.onToggleSelection, @@ -95,8 +97,8 @@ class TrackListTile extends ConsumerWidget { ), badges: [ ...buildQualityBadges( - audioQuality: track.audioQuality, - audioModes: track.audioModes, + audioQuality: showQualityBadges ? track.audioQuality : null, + audioModes: showQualityBadges ? track.audioModes : null, colorScheme: colorScheme, explicit: track.isExplicit, ), diff --git a/lib/widgets/update_dialog.dart b/lib/widgets/update_dialog.dart index 49af193e..aad843f9 100644 --- a/lib/widgets/update_dialog.dart +++ b/lib/widgets/update_dialog.dart @@ -6,6 +6,9 @@ import 'package:spotiflac_android/services/apk_downloader.dart'; import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; class UpdateDialog extends StatefulWidget { final UpdateInfo updateInfo; @@ -133,7 +136,7 @@ class _UpdateDialogState extends State { return PopScope( canPop: !widget.forced, - child: Dialog( + child: AppDialogSurface( backgroundColor: colorScheme.surfaceContainerHigh, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), child: ConstrainedBox( @@ -198,7 +201,9 @@ class _UpdateDialogState extends State { vertical: 12, ), decoration: BoxDecoration( - color: isDark + color: context.isMornye + ? MornyeTheme.controlFill(context) + : isDark ? Color.alphaBlend( Colors.white.withValues(alpha: 0.08), colorScheme.surface, @@ -208,17 +213,23 @@ class _UpdateDialogState extends State { colorScheme.surface, ), borderRadius: BorderRadius.circular(16), - border: Border.all( - color: colorScheme.outlineVariant.withValues(alpha: 0.5), - ), + border: context.isMornye + ? null + : Border.all( + color: colorScheme.outlineVariant.withValues( + alpha: 0.5, + ), + ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - _VersionChip( - version: AppInfo.displayVersion, - label: context.l10n.updateCurrent, - colorScheme: colorScheme, + Flexible( + child: _VersionChip( + version: AppInfo.displayVersion, + label: context.l10n.updateCurrent, + colorScheme: colorScheme, + ), ), const SizedBox(width: 12), Icon( @@ -227,11 +238,13 @@ class _UpdateDialogState extends State { color: colorScheme.primary, ), const SizedBox(width: 12), - _VersionChip( - version: widget.updateInfo.version, - label: context.l10n.updateNew, - colorScheme: colorScheme, - isNew: true, + Flexible( + child: _VersionChip( + version: widget.updateInfo.version, + label: context.l10n.updateNew, + colorScheme: colorScheme, + isNew: true, + ), ), ], ), @@ -242,7 +255,9 @@ class _UpdateDialogState extends State { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: isDark + color: context.isMornye + ? MornyeTheme.controlFill(context) + : isDark ? Color.alphaBlend( Colors.white.withValues(alpha: 0.05), colorScheme.surface, @@ -319,7 +334,9 @@ class _UpdateDialogState extends State { Container( constraints: const BoxConstraints(maxHeight: 180), decoration: BoxDecoration( - color: isDark + color: context.isMornye + ? MornyeTheme.controlFill(context) + : isDark ? Color.alphaBlend( Colors.white.withValues(alpha: 0.05), colorScheme.surface, @@ -351,7 +368,8 @@ class _UpdateDialogState extends State { width: double.infinity, child: widget.forced ? const SizedBox.shrink() - : OutlinedButton( + : AppDialogAction( + outlined: true, onPressed: () => Navigator.pop(context), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), @@ -367,7 +385,7 @@ class _UpdateDialogState extends State { children: [ SizedBox( width: double.infinity, - child: FilledButton.icon( + child: AppActionButton( onPressed: _downloadAndInstall, icon: const Icon(Icons.download_rounded, size: 20), label: Text(context.l10n.updateDownloadInstall), @@ -384,7 +402,7 @@ class _UpdateDialogState extends State { Row( children: [ Expanded( - child: TextButton( + child: AppDialogAction( onPressed: () { widget.onDisableUpdates(); Navigator.pop(context); @@ -407,7 +425,8 @@ class _UpdateDialogState extends State { ), const SizedBox(width: 8), Expanded( - child: OutlinedButton( + child: AppDialogAction( + outlined: true, onPressed: () { widget.onDismiss(); Navigator.pop(context); @@ -529,15 +548,20 @@ class _VersionChip extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( - color: isNew + color: context.isMornye + ? Colors.transparent + : isNew ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20), ), child: Text( 'v$version', + textAlign: TextAlign.center, style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: isNew + color: context.isMornye && isNew + ? colorScheme.primary + : isNew ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, fontWeight: isNew ? FontWeight.bold : FontWeight.w500, @@ -555,7 +579,7 @@ Future showUpdateDialog( required VoidCallback onDisableUpdates, bool forced = false, }) async { - return showDialog( + return showAppDialog( context: context, barrierDismissible: !forced, builder: (context) => UpdateDialog( diff --git a/pubspec.lock b/pubspec.lock index d32cdad1..44e18bdf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -337,6 +337,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" cupertino_ui: dependency: transitive description: @@ -797,6 +805,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + liquid_glass_easy: + dependency: "direct main" + description: + name: liquid_glass_easy + sha256: "5b3f01c8bea8f4f2b88387be9426eef61657cb29a26297ae431a02fed2ac6b95" + url: "https://pub.dev" + source: hosted + version: "4.3.1" listen: dependency: transitive description: @@ -1474,6 +1490,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.3.0" + volume_controller: + dependency: "direct main" + description: + name: volume_controller + sha256: cbd59f996c0b577ae2df5fc6cf2c5d9c98db259395ab5ae749d313d20ddca30f + url: "https://pub.dev" + source: hosted + version: "3.7.0" watcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 10f185c1..27e7d70a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,9 @@ dependencies: # UI Components cached_network_image: ^4.0.0 flutter_cache_manager: ^3.4.2 + liquid_glass_easy: ^4.3.1 + cupertino_icons: ^1.0.9 + volume_controller: ^3.6.1 # Material Expressive 3 / Dynamic Color dynamic_color: ^2.1.0 @@ -104,8 +107,12 @@ flutter: assets: - assets/images/ + - assets/fonts/Inter-LICENSE.txt fonts: + - family: Inter + fonts: + - asset: assets/fonts/InterVariable.ttf - family: Google Sans Flex fonts: - asset: assets/fonts/GoogleSansFlex.ttf diff --git a/test/adaptive_overlay_test.dart b/test/adaptive_overlay_test.dart new file mode 100644 index 00000000..a50b23e8 --- /dev/null +++ b/test/adaptive_overlay_test.dart @@ -0,0 +1,249 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/services/app_remote_config_service.dart'; +import 'package:spotiflac_android/services/update_checker.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_announcement_dialog.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/app_choice_chip.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/update_dialog.dart'; + +Widget _app(ThemeData theme, Widget home) => ProviderScope( + child: MaterialApp( + theme: theme, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: home, + ), +); + +void main() { + for (final mornye in [false, true]) { + for (final brightness in Brightness.values) { + final theme = mornye + ? MornyeTheme.build(brightness) + : ThemeData(brightness: brightness); + final variant = 'Mornye: $mornye, $brightness'; + + testWidgets('sheet selects, disables and returns values ($variant)', ( + tester, + ) async { + tester.view.physicalSize = const Size(360, 740); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var selected = false; + var disabledTaps = 0; + bool? result; + await tester.pumpWidget( + _app( + theme, + Builder( + builder: (context) => Scaffold( + body: TextButton( + child: const Text('Open'), + onPressed: () async { + result = await showAppBottomSheet( + context: context, + title: 'Library filter', + builder: (context) => StatefulBuilder( + builder: (context, setSheetState) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + children: [ + AppChoiceChip( + label: const Text('Downloaded tracks'), + selected: selected, + onSelected: (value) => setSheetState(() { + selected = value; + }), + ), + ], + ), + AppSheetOption( + enabled: false, + title: const Text('Unavailable'), + onTap: () => disabledTaps++, + ), + AppSheetOption( + title: const Text('Apply'), + onTap: () => Navigator.pop(context, selected), + ), + ], + ), + ), + ); + }, + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect( + find.byType(MornyeGlassPanel), + mornye ? findsOneWidget : findsNothing, + ); + expect(find.byType(FilterChip), mornye ? findsNothing : findsOneWidget); + await tester.tap(find.text('Unavailable')); + expect(disabledTaps, 0); + await tester.tap(find.text('Downloaded tracks')); + await tester.pumpAndSettle(); + expect(selected, isTrue); + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + expect(result, isTrue); + expect(find.text('Library filter'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('nested dialog retains theme and navigation ($variant)', ( + tester, + ) async { + final nestedNavigator = GlobalKey(); + bool? result; + await tester.pumpWidget( + _app( + ThemeData(), + Navigator( + key: nestedNavigator, + onGenerateRoute: (_) => MaterialPageRoute( + builder: (_) => Theme( + data: theme, + child: Builder( + builder: (context) => Scaffold( + body: TextButton( + child: const Text('Open import'), + onPressed: () async { + result = await showAppDialog( + context: context, + useRootNavigator: false, + builder: (context) => AppAlertDialog( + title: const Text('Import playlist'), + actions: [ + AppDialogAction( + onPressed: () => Navigator.pop(context, true), + child: const Text('Confirm'), + ), + ], + ), + ); + }, + ), + ), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open import')); + await tester.pumpAndSettle(); + expect(nestedNavigator.currentState!.canPop(), isTrue); + expect( + find.byType(MornyeGlassPanel), + mornye ? findsOneWidget : findsNothing, + ); + expect( + Theme.of(tester.element(find.text('Import playlist'))).brightness, + brightness, + ); + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + expect(result, isTrue); + expect(nestedNavigator.currentState!.canPop(), isFalse); + expect(find.text('Open import'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('required update resists back and barrier ($variant)', ( + tester, + ) async { + tester.view.physicalSize = const Size(393, 740); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + _app( + theme, + Builder( + builder: (context) => Scaffold( + body: TextButton( + child: const Text('Update'), + onPressed: () => showUpdateDialog( + context, + updateInfo: UpdateInfo( + version: '9.0.0', + changelog: 'Playback and library improvements.\n' * 8, + downloadUrl: 'https://example.com/release', + publishedAt: DateTime(2026), + releasesBehind: 3, + ), + onDisableUpdates: () => + fail('Cannot disable required update'), + forced: true, + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Update')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.tapAt(const Offset(5, 5)); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.byType(UpdateDialog), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('announcement keeps explicit dismissal (Mornye: $mornye)', ( + tester, + ) async { + var dismissals = 0; + await tester.pumpWidget( + _app( + mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(), + Builder( + builder: (context) => Scaffold( + body: TextButton( + child: const Text('Open notice'), + onPressed: () => showAppAnnouncementDialog( + context, + announcement: const RemoteAnnouncement( + id: 'notice', + enabled: true, + title: 'Service notice', + message: 'An announcement without an external action.', + dismissible: false, + ), + onDismiss: () => dismissals++, + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open notice')); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(5, 5)); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('Service notice'), findsOneWidget); + expect(dismissals, 0); + await tester.tap(find.byTooltip('Close')); + await tester.pumpAndSettle(); + expect(find.text('Service notice'), findsNothing); + expect(dismissals, 1); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/test/album_track_metadata_test.dart b/test/album_track_metadata_test.dart index ee5c177a..993a6d2d 100644 --- a/test/album_track_metadata_test.dart +++ b/test/album_track_metadata_test.dart @@ -8,6 +8,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/track_list_tile.dart'; void main() { @@ -31,6 +33,9 @@ void main() { 'copyright': 'Example Copyright', 'comment': 'Example Comment', 'upc': '0123456789012', + 'audio_quality': '16-bit', + 'audio_modes': 'DOLBY_ATMOS', + 'explicit': true, }; var albumRequested = false; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -48,6 +53,7 @@ void main() { 'name': 'Example Album', 'total_tracks': 1, 'album_type': 'album', + 'audio_traits': ['lossless', 'dolby_atmos'], }, }); } @@ -66,6 +72,7 @@ void main() { await tester.pumpWidget( ProviderScope( child: MaterialApp( + theme: MornyeTheme.build(Brightness.light), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: const AlbumScreen( @@ -91,6 +98,12 @@ void main() { expect(albumTrack.albumId, 'metadata-album'); expect(albumTrack.albumType, 'album'); expect(albumTrack.totalTracks, 1); + expect(albumTrack.audioQuality, '16-bit'); + expect(find.text('Lossless'), findsOneWidget); + expect(find.text('Dolby Atmos'), findsOneWidget); + expect(find.byType(AudioQualityBadge), findsNothing); + expect(find.byType(DolbyAtmosBadge), findsNothing); + expect(find.byType(ExplicitBadge), findsOneWidget); await tester.pumpWidget(const SizedBox()); await tester.pumpAndSettle(); }); diff --git a/test/app_alert_dialog_test.dart b/test/app_alert_dialog_test.dart new file mode 100644 index 00000000..e2491619 --- /dev/null +++ b/test/app_alert_dialog_test.dart @@ -0,0 +1,221 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; + +void main() { + for (final brightness in Brightness.values) { + for (final reduceMotion in [false, true]) { + testWidgets( + 'confirmation keeps blur during entry and cancels promptly ($brightness, reduce motion: $reduceMotion)', + (tester) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(brightness), + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: reduceMotion), + child: child!, + ), + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + child: const Text('Open'), + onPressed: () => showAppDialog( + context: context, + builder: (context) => AppAlertDialog( + title: const Text('Delete Selected'), + content: const Text( + 'This will also delete the files.', + ), + actions: [ + AppDialogAction( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pump(); + for (final elapsed in [16, 64, 180]) { + await tester.pump(Duration(milliseconds: elapsed)); + final blur = find.descendant( + of: find.byType(AppAlertDialog), + matching: find.byType(BackdropFilter), + ); + expect(blur, findsOneWidget); + // An opacity layer isolates the filter from the underlying page + // until the route finishes entering. + final fades = tester.widgetList( + find.ancestor(of: blur, matching: find.byType(FadeTransition)), + ); + expect(fades.every((fade) => fade.opacity.value == 1), isTrue); + final bodyContext = tester.element( + find.text('This will also delete the files.'), + ); + expect( + DefaultTextStyle.of(bodyContext).style.color, + Theme.of(bodyContext).colorScheme.onSurface, + ); + } + final panel = find.byType(MornyeGlassPanel); + final openWidth = tester.getRect(panel).width; + await tester.tap(find.text('Cancel')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + if (!reduceMotion) { + // Closing must move on the first frame, rather than holding the + // fully open scale while a reversed entrance curve settles. + expect(tester.getRect(panel).width, lessThan(openWidth - 1)); + await tester.pump(const Duration(milliseconds: 164)); + await tester.pump(); + } + expect(find.byType(AppAlertDialog), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + } + } + + for (final mornye in [true, false]) { + testWidgets( + 'confirmation preserves cancel and confirm results (Mornye: $mornye)', + (tester) async { + bool? result; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(), + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + child: const Text('Open'), + onPressed: () async { + result = await showAppDialog( + context: context, + builder: (context) => AppAlertDialog( + title: const Text('Remove extension'), + content: const Text('Remove this example extension?'), + actions: [ + AppDialogAction( + isDefault: true, + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + AppDialogAction( + filled: true, + isDestructive: true, + onPressed: () => Navigator.pop(context, true), + child: const Text('Remove'), + ), + ], + ), + ); + }, + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect( + find.byType(MornyeGlassPanel), + mornye ? findsOneWidget : findsNothing, + ); + expect( + find.byType(AlertDialog), + mornye ? findsNothing : findsOneWidget, + ); + if (mornye) { + final remove = find.widgetWithText(CupertinoButton, 'Remove'); + final cancel = find.widgetWithText(CupertinoButton, 'Cancel'); + expect( + tester.getRect(remove).bottom, + lessThan(tester.getRect(cancel).top), + ); + expect(tester.getRect(remove).width, tester.getRect(cancel).width); + expect( + DefaultTextStyle.of( + tester.element(find.text('Remove')), + ).style.color, + MornyeTheme.build(Brightness.dark).colorScheme.primary, + ); + expect(find.byType(CupertinoAlertDialog), findsNothing); + expect(find.byType(FilledButton), findsNothing); + } + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(result, isFalse); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + expect(result, isTrue); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + expect(result, isNull); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets('glass confirmation keeps actions reachable with long text', ( + tester, + ) async { + tester.view.physicalSize = const Size(393, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(Brightness.light), + home: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(1.5)), + child: AppAlertDialog( + title: const Text('Delete these tracks from your library?'), + content: Text( + List.filled(12, 'This removes the selected files.').join(' '), + ), + actions: [ + AppDialogAction(onPressed: () {}, child: const Text('Cancel')), + AppDialogAction( + isDestructive: true, + onPressed: () {}, + child: const Text('Delete from Library'), + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Cancel').hitTestable(), findsOneWidget); + expect(find.text('Delete from Library').hitTestable(), findsOneWidget); + final scroll = find.byType(SingleChildScrollView).first; + await tester.drag(scroll, const Offset(0, -150)); + await tester.pumpAndSettle(); + expect(find.text('Cancel').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/app_snack_bar_test.dart b/test/app_snack_bar_test.dart new file mode 100644 index 00000000..c39598bb --- /dev/null +++ b/test/app_snack_bar_test.dart @@ -0,0 +1,145 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_snack_bar.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; + +void main() { + for (final brightness in Brightness.values) { + testWidgets('snackbar retains backdrop color during entry in $brightness', ( + tester, + ) async { + tester.view.physicalSize = const Size(393, 780); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final capture = GlobalKey(); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(brightness), + builder: (_, child) => RepaintBoundary(key: capture, child: child), + home: Builder( + builder: (context) => Scaffold( + body: Stack( + fit: StackFit.expand, + children: [ + const Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: ColoredBox(color: Colors.blue)), + Expanded(child: ColoredBox(color: Colors.red)), + ], + ), + Center( + child: TextButton( + onPressed: () => showAppSnackBar( + context, + content: const Text('Extension installed'), + ), + child: const Text('Show'), + ), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Show')); + await tester.pump(); + for (final elapsed in [16, 80, 250]) { + await tester.pump(Duration(milliseconds: elapsed)); + final rect = tester.getRect(find.byType(MornyeGlassPanel)); + final colors = await tester.runAsync(() async { + final boundary = tester.renderObject( + find.byKey(capture), + ); + final image = await boundary.toImage(); + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + final colors = []; + for (final fraction in [0.25, 0.75]) { + final x = (rect.left + rect.width * fraction).floor(); + final y = (rect.bottom - 8).floor(); + final offset = (y * image.width + x) * 4; + colors.add( + Color.fromARGB( + bytes.getUint8(offset + 3), + bytes.getUint8(offset), + bytes.getUint8(offset + 1), + bytes.getUint8(offset + 2), + ), + ); + } + image.dispose(); + return colors; + }); + // Retain a visible backdrop hue even beneath the glass shadow. + expect(colors![0].b - colors[0].r, greaterThan(0.20)); + expect(colors[1].r - colors[1].b, greaterThan(0.20)); + final foreground = MornyeTheme.build(brightness).colorScheme.onSurface; + for (final background in colors) { + final luminances = [ + foreground.computeLuminance(), + background.computeLuminance(), + ]..sort(); + expect( + (luminances.last + 0.05) / (luminances.first + 0.05), + greaterThanOrEqualTo(4.5), + ); + } + } + expect(tester.takeException(), isNull); + }); + } + + for (final mornye in [true, false]) { + testWidgets( + 'messages still queue, time out and dismiss (Mornye: $mornye)', + (tester) async { + late BuildContext messageContext; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(), + home: Builder( + builder: (context) { + messageContext = context; + return const Scaffold(body: SizedBox.expand()); + }, + ), + ), + ), + ); + final first = showAppSnackBar( + messageContext, + content: const Text('First'), + duration: const Duration(seconds: 1), + ); + final second = showAppSnackBar( + messageContext, + content: const Text('Second'), + ); + await tester.pumpAndSettle(); + expect(find.text('First'), findsOneWidget); + expect(find.text('Second'), findsNothing); + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + expect(await first.closed, SnackBarClosedReason.timeout); + expect(find.text('Second'), findsOneWidget); + await tester.drag(find.text('Second'), const Offset(0, 300)); + await tester.pumpAndSettle(); + expect(await second.closed, SnackBarClosedReason.swipe); + expect(find.text('Second'), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + } +} diff --git a/test/app_switch_test.dart b/test/app_switch_test.dart new file mode 100644 index 00000000..a32b3074 --- /dev/null +++ b/test/app_switch_test.dart @@ -0,0 +1,128 @@ +import 'dart:ui' show SemanticsAction; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:liquid_glass_easy/liquid_glass_easy.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +void main() { + Future pumpSwitch( + WidgetTester tester, { + required Widget Function(StateSetter setState) builder, + bool reduceMotion = false, + }) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(Brightness.light), + home: MediaQuery( + data: MediaQueryData(disableAnimations: reduceMotion), + child: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (context, setState) => builder(setState), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + } + + testWidgets('glass switch supports tap, keyboard and accessible activation', ( + tester, + ) async { + var value = false; + final changes = []; + await pumpSwitch( + tester, + builder: (setState) => AppSwitch( + value: value, + semanticLabel: 'Example setting', + onChanged: (next) { + changes.add(next); + setState(() => value = next); + }, + ), + ); + + await tester.tap(find.byType(LiquidGlassSwitch)); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + expect(changes, [true]); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + expect(changes, [true, false]); + + final node = tester.getSemantics(find.bySemanticsLabel('Example setting')); + node.owner!.performAction(node.id, SemanticsAction.tap); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + expect(changes, [true, false, true]); + expect(tester.takeException(), isNull); + }); + + testWidgets('disabled glass switch has no toggle action', (tester) async { + await pumpSwitch( + tester, + builder: (_) => const AppSwitch( + value: true, + onChanged: null, + semanticLabel: 'Disabled setting', + ), + ); + final node = tester.getSemantics(find.bySemanticsLabel('Disabled setting')); + expect(node.getSemanticsData().hasAction(SemanticsAction.tap), isFalse); + expect(find.byType(LiquidGlassSwitch), findsNothing); + expect(tester.takeException(), isNull); + }); + + for (final grouped in [false, true]) { + testWidgets('row and thumb each toggle once (grouped=$grouped)', ( + tester, + ) async { + var value = false; + final changes = []; + await pumpSwitch( + tester, + reduceMotion: true, + builder: (setState) { + void update(bool next) { + changes.add(next); + setState(() => value = next); + } + + return grouped + ? SettingsGroup( + children: [ + SettingsSwitchItem( + title: 'Example setting', + value: value, + onChanged: update, + ), + ], + ) + : AppSwitchListTile( + title: const Text('Example setting'), + value: value, + onChanged: update, + ); + }, + ); + expect(find.byType(LiquidGlassSwitch), findsNothing); + await tester.tap(find.text('Example setting')); + await tester.pumpAndSettle(); + expect(changes, [true]); + await tester.tap(find.byType(AppSwitch)); + await tester.pumpAndSettle(); + expect(changes, [true, false]); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/test/design_system_test.dart b/test/design_system_test.dart index 7adb75a4..ae0a8e3c 100644 --- a/test/design_system_test.dart +++ b/test/design_system_test.dart @@ -6,14 +6,17 @@ import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/visitor.dart'; import 'package:analyzer/source/line_info.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart'; import 'package:spotiflac_android/theme/app_theme.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/theme/cover_palette.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/widgets/app_search_field.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; import 'package:spotiflac_android/widgets/collection_scaffold.dart'; import 'package:spotiflac_android/widgets/selection_action_button.dart'; @@ -77,8 +80,43 @@ void main() { AppTheme.dark(isAmoled: true).extension(), AppTokens.standard, ); + expect( + MornyeTheme.build(Brightness.light).extension(), + MornyeTheme.tokens, + ); + expect( + MornyeTheme.build(Brightness.dark).colorScheme.primary, + MornyeTheme.darkAccent, + ); }); + testWidgets( + 'Mornye keeps a font family and Cupertino tracking in text roles', + (tester) async { + final theme = MornyeTheme.build(Brightness.light); + final apple = theme.platform == TargetPlatform.iOS; + for (final role in [ + theme.textTheme.bodyLarge, + theme.textTheme.bodyMedium, + theme.textTheme.titleMedium, + theme.textTheme.titleSmall, + theme.textTheme.labelLarge, + theme.textTheme.labelSmall, + ]) { + expect(role?.fontFamily, apple ? 'CupertinoSystemText' : 'Inter'); + expect(role?.letterSpacing, lessThan(0)); + } + expect( + theme.textTheme.headlineLarge?.fontFamily, + apple ? 'CupertinoSystemDisplay' : 'Inter', + ); + }, + variant: const TargetPlatformVariant({ + TargetPlatform.iOS, + TargetPlatform.android, + }), + ); + testWidgets('context.tokens falls back to the standard scale', ( tester, ) async { @@ -185,6 +223,87 @@ void main() { }); group('AppSearchField', () { + for (final direction in TextDirection.values) { + testWidgets( + 'glass theme selector preserves logical selection in $direction', + (tester) async { + var selected = 0; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(Brightness.light), + home: Scaffold( + body: Directionality( + textDirection: direction, + child: StatefulBuilder( + builder: (context, setState) => MornyeSegmentedControl( + labels: const ['System', 'Light', 'Dark'], + selectedIndex: selected, + onChanged: (index) => setState(() => selected = index), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + final systemX = tester.getCenter(find.text('System').first).dx; + final darkX = tester.getCenter(find.text('Dark').first).dx; + expect(systemX < darkX, direction == TextDirection.ltr); + // The package puts a transparent tap layer above the drawn labels. + await tester.tapAt(tester.getCenter(find.text('Dark').first)); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + expect(selected, 2); + await tester.tapAt(tester.getCenter(find.text('System').first)); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + expect(selected, 0); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets('glass search keeps text editing, submit and clear usable', ( + tester, + ) async { + final controller = TextEditingController(); + final changes = []; + String? submitted; + var cleared = false; + addTearDown(controller.dispose); + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + home: Scaffold( + body: AppSearchField( + controller: controller, + hintText: 'Search library', + clearTooltip: 'Clear search', + onChanged: changes.add, + onSubmitted: (value) => submitted = value, + onClear: () => cleared = true, + ), + ), + ), + ), + ); + + expect(find.byType(MornyeGlass), findsOneWidget); + await tester.tap(find.byType(TextField)); + await tester.enterText(find.byType(TextField), 'Album'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(); + expect(changes, ['Album']); + expect(submitted, 'Album'); + await tester.tap(find.byTooltip('Clear search')); + await tester.pump(); + expect(controller.text, isEmpty); + expect(cleared, isTrue); + expect(tester.takeException(), isNull); + }); + testWidgets('uses the shared filled search style and clears input', ( tester, ) async { diff --git a/test/download_service_picker_drag_test.dart b/test/download_service_picker_drag_test.dart index 465d0ef9..56a6b3a4 100644 --- a/test/download_service_picker_drag_test.dart +++ b/test/download_service_picker_drag_test.dart @@ -5,12 +5,13 @@ import 'package:spotiflac_android/l10n/app_localizations.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; class _Extensions extends ExtensionNotifier { - _Extensions(this.qualityCount); + _Extensions(this._qualityCount); - final int qualityCount; + final int _qualityCount; @override ExtensionState build() => ExtensionState( @@ -25,7 +26,7 @@ class _Extensions extends ExtensionNotifier { status: 'loaded', hasDownloadProvider: true, qualityOptions: List.generate( - qualityCount, + _qualityCount, (index) => QualityOption(id: '$index', label: 'Quality $index'), ), ), @@ -42,64 +43,87 @@ class _Settings extends SettingsNotifier { } void main() { - for (final qualityCount in [3, 16]) { - testWidgets('iOS picker fits $qualityCount options and handle dismisses', ( - tester, - ) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - extensionProvider.overrideWith(() => _Extensions(qualityCount)), - settingsProvider.overrideWith(_Settings.new), - ], - child: MaterialApp( - theme: ThemeData(platform: TargetPlatform.iOS), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: Builder( - builder: (context) => TextButton( - onPressed: () => DownloadServicePicker.show( - context, - trackName: 'Example track', - onSelect: (_, _) {}, + for (final (qualityCount, mornye) in [ + (3, false), + (16, false), + (3, true), + (16, true), + ]) { + testWidgets( + 'picker fits $qualityCount options and dismisses (Mornye=$mornye)', + (tester) async { + tester.view.physicalSize = const Size(320, 780); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + (String, String)? selection; + await tester.pumpWidget( + ProviderScope( + overrides: [ + extensionProvider.overrideWith(() => _Extensions(qualityCount)), + settingsProvider.overrideWith(_Settings.new), + ], + child: MaterialApp( + theme: mornye + ? MornyeTheme.build(Brightness.dark) + : ThemeData(platform: TargetPlatform.iOS), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => DownloadServicePicker.show( + context, + trackName: 'Example track', + onSelect: (quality, service) => + selection = (quality, service), + ), + child: const Text('Open'), ), - child: const Text('Open'), ), ), ), ), - ), - ); - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - if (qualityCount == 3) { - final sheetBottom = tester.getBottomLeft(find.byType(BottomSheet)).dy; - final lastOptionBottom = tester - .getBottomLeft(find.text('Quality 2')) - .dy; - expect(sheetBottom - lastOptionBottom, lessThan(100)); - } - if (qualityCount == 16) { - final scroll = find.descendant( - of: find.byType(DownloadServicePicker), - matching: find.byType(SingleChildScrollView), ); - await tester.drag(scroll, const Offset(0, -300)); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); - expect(find.byType(DownloadServicePicker), findsOneWidget); - await tester.drag(scroll, const Offset(0, 1500)); + if (qualityCount == 3) { + final sheetBottom = tester.getBottomLeft(find.byType(BottomSheet)).dy; + final lastOptionBottom = tester + .getBottomLeft(find.text('Quality 2')) + .dy; + expect(sheetBottom - lastOptionBottom, lessThan(100)); + } + if (qualityCount == 16) { + final scroll = find.descendant( + of: find.byType(DownloadServicePicker), + matching: find.byType(SingleChildScrollView), + ); + await tester.drag(scroll, const Offset(0, -300)); + await tester.pumpAndSettle(); + expect(find.byType(DownloadServicePicker), findsOneWidget); + await tester.drag(scroll, const Offset(0, 1500)); + await tester.pumpAndSettle(); + } + final sheetTop = tester.getTopLeft(find.byType(BottomSheet)); + final sheetWidth = tester.getSize(find.byType(BottomSheet)).width; + await tester.dragFrom( + sheetTop + Offset(sheetWidth / 2, 16), + const Offset(0, 600), + ); await tester.pumpAndSettle(); - } - final sheetTop = tester.getTopLeft(find.byType(BottomSheet)); - final sheetWidth = tester.getSize(find.byType(BottomSheet)).width; - await tester.dragFrom( - sheetTop + Offset(sheetWidth / 2, 16), - const Offset(0, 600), - ); - await tester.pumpAndSettle(); - expect(find.byType(DownloadServicePicker), findsNothing); - expect(tester.takeException(), isNull); - }); + expect(find.byType(DownloadServicePicker), findsNothing); + expect(selection, isNull); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + final lastQuality = find.text('Quality ${qualityCount - 1}'); + await tester.ensureVisible(lastQuality); + await tester.pumpAndSettle(); + await tester.tap(lastQuality); + await tester.pumpAndSettle(); + expect(selection, ('${qualityCount - 1}', 'example')); + expect(find.byType(DownloadServicePicker), findsNothing); + expect(tester.takeException(), isNull); + }, + ); } } diff --git a/test/extension_initialization_test.dart b/test/extension_initialization_test.dart index e20145e4..ccd6e6f4 100644 --- a/test/extension_initialization_test.dart +++ b/test/extension_initialization_test.dart @@ -314,6 +314,58 @@ void main() { expect(container.read(repoProvider).error, isNull); }); + for (final hasWorkingExtension in [false, true]) { + test('native package load errors stay visible and recover on retry ' + '(partial=$hasWorkingExtension)', () async { + SharedPreferences.setMockInitialValues({}); + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(extensionProvider.notifier); + var fail = true; + const loadError = + 'example-unreadable: cipher: message authentication failed'; + messenger.setMockMethodCallHandler(backend, (call) async { + switch (call.method) { + case 'loadExtensionsFromDir': + return { + 'loaded': hasWorkingExtension ? ['example-working'] : null, + 'errors': fail ? [loadError] : [], + }; + case 'getInstalledExtensions': + return >[ + if (hasWorkingExtension) + { + 'id': 'example-working', + 'name': 'example-working', + 'display_name': 'Example Working', + 'version': '1.0.0', + 'enabled': false, + }, + ]; + case 'getProviderPriority': + case 'getMetadataProviderPriority': + return []; + default: + return null; + } + }); + + await container.read(settingsProvider.notifier).ensureLoaded(); + final loaded = await notifier.loadExtensions('${root.path}/sources'); + expect(notifier.state.error, contains(loadError)); + expect(loaded, hasWorkingExtension); + expect(notifier.state.isLoading, isFalse); + expect( + notifier.state.extensions.map((extension) => extension.id), + hasWorkingExtension ? ['example-working'] : isEmpty, + ); + + fail = false; + expect(await notifier.loadExtensions('${root.path}/sources'), isTrue); + expect(notifier.state.error, isNull); + }); + } + for (final failureMethod in const [ 'loadExtensionsFromDir', 'getInstalledExtensions', diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index e9cf9690..70f4eae2 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -1107,6 +1107,7 @@ void main() { useDynamicColor: false, seedColorValue: 0xff123456, useAmoled: true, + style: AppThemeStyle.mornye, ); final decoded = ThemeSettings.fromJson(settings.toJson()); @@ -1117,10 +1118,15 @@ void main() { expect(decoded.seedColor, const Color(0xff123456)); expect(copied.themeMode, ThemeMode.light); expect(copied.useAmoled, isTrue); + expect(decoded.style, AppThemeStyle.mornye); expect( ThemeSettings.fromJson({'theme_mode': 'invalid'}).themeMode, ThemeMode.system, ); + expect( + ThemeSettings.fromJson({'theme_style': 'invalid'}).style, + AppThemeStyle.material, + ); }); }); diff --git a/test/mornye_bottom_bar_test.dart b/test/mornye_bottom_bar_test.dart new file mode 100644 index 00000000..e582d1f2 --- /dev/null +++ b/test/mornye_bottom_bar_test.dart @@ -0,0 +1,404 @@ +import 'dart:ui' as ui; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:liquid_glass_easy/liquid_glass_easy.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; +import 'package:spotiflac_android/screens/settings/settings_tab.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/utils/nav_bar_inset.dart'; +import 'package:spotiflac_android/widgets/album_detail_header.dart'; +import 'package:spotiflac_android/widgets/collection_scaffold.dart'; +import 'package:spotiflac_android/widgets/mini_player.dart'; +import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; +import 'package:spotiflac_android/widgets/mornye_bottom_bar.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; + +void main() { + late MornyeChromeController chrome; + late ScrollController scroll; + late int searches; + final capture = GlobalKey(); + + setUp(() { + chrome = MornyeChromeController(); + scroll = ScrollController(); + searches = 0; + }); + tearDown(() { + chrome.dispose(); + scroll.dispose(); + }); + + Future pumpShell( + WidgetTester tester, { + bool withPlayer = true, + bool blur = false, + Brightness brightness = Brightness.light, + Color? backdrop, + Color? chromeSurface, + Widget? body, + }) async { + tester.view.physicalSize = const Size(393, 760); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentMediaItemProvider.overrideWith( + (ref) => Stream.value( + withPlayer + ? const MediaItem( + id: 'track', + title: 'A song', + artist: 'Artist', + ) + : null, + ), + ), + playbackStateProvider.overrideWith((ref) => const Stream.empty()), + lowEndDeviceProvider.overrideWithValue(!blur), + backdropBlurEnabledProvider.overrideWithValue(blur), + ], + child: MaterialApp( + theme: MornyeTheme.build(brightness, chromeSurface: chromeSurface), + builder: (_, child) => RepaintBoundary(key: capture, child: child), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + backgroundColor: backdrop, + extendBody: true, + body: NotificationListener( + onNotification: chrome.handleScroll, + child: + body ?? + ListView.builder( + controller: scroll, + itemExtent: 60, + itemCount: 50, + itemBuilder: (_, index) => Text('Row $index'), + ), + ), + bottomNavigationBar: ValueListenableBuilder( + valueListenable: chrome, + builder: (_, collapsed, _) => MornyeBottomBar( + collapsed: collapsed, + destinations: const [ + NavigationDestination(icon: Icon(Icons.home), label: 'Home'), + NavigationDestination( + icon: Icon(Icons.music_note), + label: 'Library', + ), + NavigationDestination( + icon: Icon(Icons.settings), + label: 'Settings', + ), + ], + selectedIndex: 0, + onSelected: (_) {}, + onExpand: chrome.expand, + onSearch: () => searches++, + blurEnabled: blur, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + const albumBlue = Color(0xff464566); + for (final brightness in Brightness.values) { + for (final backdrop in [ + Colors.white, + Colors.black, + if (brightness == Brightness.dark) albumBlue, + ]) { + testWidgets( + 'floating glass stays legible in $brightness over $backdrop', + (tester) async { + await pumpShell( + tester, + blur: true, + brightness: brightness, + backdrop: backdrop, + chromeSurface: backdrop == albumBlue ? albumBlue : null, + ); + final artist = find.text('Artist'); + // The glass renderer also builds a copy for the refractive pill. + final libraryIcon = find + .descendant( + of: find.byType(MornyeTabBar), + matching: find.byIcon(Icons.music_note), + ) + .first; + final artistRect = tester.getRect(artist); + final iconRect = tester.getRect(libraryIcon); + final foregrounds = [ + tester.widget(artist).style!.color!, + IconTheme.of(tester.element(libraryIcon)).color!, + ]; + final samples = [ + Offset(artistRect.left + 8, artistRect.bottom + 2), + Offset(iconRect.right + 8, iconRect.center.dy), + ]; + final backgrounds = await tester.runAsync(() async { + final boundary = tester.renderObject( + find.byKey(capture), + ); + final image = await boundary.toImage(); + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + final colors = []; + for (final point in samples) { + final offset = + (point.dy.floor() * image.width + point.dx.floor()) * 4; + colors.add( + Color.fromARGB( + bytes.getUint8(offset + 3), + bytes.getUint8(offset), + bytes.getUint8(offset + 1), + bytes.getUint8(offset + 2), + ), + ); + } + image.dispose(); + return colors; + }); + for (var index = 0; index < foregrounds.length; index++) { + if (backdrop == albumBlue) { + final glass = HSLColor.fromColor(backgrounds![index]); + final page = HSLColor.fromColor(albumBlue); + expect(glass.hue, closeTo(page.hue, 8)); + expect(glass.saturation, greaterThan(page.saturation * 0.7)); + } + final luminances = [ + foregrounds[index].computeLuminance(), + backgrounds![index].computeLuminance(), + ]..sort(); + final contrast = + (luminances.last + 0.05) / (luminances.first + 0.05); + expect( + contrast, + greaterThanOrEqualTo(index == 0 ? 4.5 : 3), + reason: index == 0 ? 'Mini-player artist' : 'Inactive tab icon', + ); + } + expect(tester.takeException(), isNull); + }, + ); + } + } + + testWidgets('downward scroll folds the tabs without replacing the player', ( + tester, + ) async { + await pumpShell(tester); + final playerState = tester.state(find.byType(MiniPlayer)); + final expandedHeight = tester.getSize(find.byType(MornyeBottomBar)).height; + await tester.drag(find.byType(ListView), const Offset(0, -240)); + await tester.pumpAndSettle(); + expect(chrome.value, isTrue); + expect(tester.state(find.byType(MiniPlayer)), same(playerState)); + expect(tester.widget(find.byType(MiniPlayer)).compact, isTrue); + expect( + tester.getSize(find.byType(MornyeBottomBar)).height, + lessThan(expandedHeight - 60), + ); + expect(find.byTooltip('Next track'), findsNothing); + await tester.tap(find.byTooltip('Show tabs')); + await tester.pumpAndSettle(); + expect(chrome.value, isFalse); + expect(tester.getSize(find.byType(MornyeBottomBar)).height, expandedHeight); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'collapse keeps the player and tab contents out of frame rebuilds', + (tester) async { + await pumpShell(tester); + var tabBuilds = 0; + var playerBuilds = 0; + final previous = debugOnRebuildDirtyWidget; + debugOnRebuildDirtyWidget = (element, builtOnce) { + previous?.call(element, builtOnce); + if (element.widget is MornyeTabBar) tabBuilds++; + if (element.widget is MiniPlayer) playerBuilds++; + }; + addTearDown(() => debugOnRebuildDirtyWidget = previous); + chrome.value = true; + await tester.pump(); + for (var frame = 0; frame < 25; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + } + expect( + tabBuilds, + lessThanOrEqualTo(2), + reason: 'Only the wrapper geometry animates each frame', + ); + expect( + playerBuilds, + lessThanOrEqualTo(2), + reason: + 'The player rebuilds for compact mode, not for every animation tick', + ); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('folding chrome updates the spacer without rebuilding Settings', ( + tester, + ) async { + await pumpShell(tester, body: const SettingsTab(), blur: true); + var settingsBuilds = 0; + var spacerBuilds = 0; + final previous = debugOnRebuildDirtyWidget; + debugOnRebuildDirtyWidget = (element, builtOnce) { + previous?.call(element, builtOnce); + if (element.widget is SettingsTab) settingsBuilds++; + if (element.widget is NavBarSliverSpacer) spacerBuilds++; + }; + addTearDown(() => debugOnRebuildDirtyWidget = previous); + + for (final collapsed in [true, false]) { + chrome.value = collapsed; + await tester.pump(); + for (var frame = 0; frame < 25; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + } + } + + expect(settingsBuilds, 0); + expect(spacerBuilds, greaterThan(20)); + expect(tester.takeException(), isNull); + }); + + for (final artist in [true, false]) { + testWidgets('folding chrome isolates header insets (artist: $artist)', ( + tester, + ) async { + var pageBuilds = 0; + await pumpShell( + tester, + body: Builder( + builder: (context) { + pageBuilds++; + final tracks = SliverFixedExtentList.builder( + itemExtent: 60, + itemCount: 50, + itemBuilder: (_, index) => Text('Track $index'), + ); + if (artist) { + return CustomScrollView( + controller: scroll, + slivers: [ + ...const MornyeArtistHeader( + name: 'Artist', + showTitle: false, + artwork: ColoredBox(color: Colors.blue), + actions: [], + ).buildSlivers(context), + tracks, + const NavBarSliverSpacer(), + ], + ); + } + return CollectionScaffold( + scrollController: scroll, + isSelectionMode: false, + onExitSelectionMode: () {}, + appBar: const AlbumDetailHeader( + title: 'Album', + expandedHeight: 400, + immersive: true, + showTitleInAppBar: false, + background: ColoredBox(color: Colors.blue), + ), + slivers: [tracks], + ); + }, + ), + ); + pageBuilds = 0; + var collectionBuilds = 0; + final previous = debugOnRebuildDirtyWidget; + debugOnRebuildDirtyWidget = (element, builtOnce) { + previous?.call(element, builtOnce); + if (element.widget is CollectionScaffold) collectionBuilds++; + }; + addTearDown(() => debugOnRebuildDirtyWidget = previous); + for (final collapsed in [true, false]) { + chrome.value = collapsed; + await tester.pump(); + for (var frame = 0; frame < 25; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + } + } + expect(pageBuilds, 0); + expect(collectionBuilds, 0); + expect(tester.takeException(), isNull); + }); + } + + testWidgets( + 'scrolling up expands; programmatic and horizontal movement do not collapse', + (tester) async { + await pumpShell(tester); + scroll.jumpTo(500); + await tester.pumpAndSettle(); + expect(chrome.value, isFalse); + await tester.drag(find.byType(ListView), const Offset(-120, 0)); + await tester.pumpAndSettle(); + expect(chrome.value, isFalse); + await tester.drag(find.byType(ListView), const Offset(0, -160)); + await tester.pumpAndSettle(); + expect(chrome.value, isTrue); + await tester.drag(find.byType(ListView), const Offset(0, 90)); + await tester.pumpAndSettle(); + expect(chrome.value, isFalse); + }, + ); + + testWidgets( + 'glass bar stays mounted and pauses its tickers while collapsed', + (tester) async { + await pumpShell(tester, blur: true); + final glass = tester.element(find.byType(LiquidGlassTabBar)); + final player = tester.state(find.byType(MiniPlayer)); + expect(TickerMode.valuesOf(glass).enabled, isTrue); + chrome.value = true; + await tester.pumpAndSettle(); + expect(tester.element(find.byType(LiquidGlassTabBar)), same(glass)); + expect(TickerMode.valuesOf(glass).enabled, isFalse); + chrome.expand(); + await tester.pumpAndSettle(); + expect(tester.element(find.byType(LiquidGlassTabBar)), same(glass)); + expect(tester.state(find.byType(MiniPlayer)), same(player)); + expect(TickerMode.valuesOf(glass).enabled, isTrue); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('compact navigation works without a playing track', ( + tester, + ) async { + await pumpShell(tester, withPlayer: false); + await tester.drag(find.byType(ListView), const Offset(0, -240)); + await tester.pumpAndSettle(); + expect(chrome.value, isTrue); + await tester.tap(find.byTooltip('Search')); + expect(searches, 1); + await tester.tap(find.byTooltip('Show tabs')); + await tester.pumpAndSettle(); + expect(chrome.value, isFalse); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/mornye_collection_sheets_test.dart b/test/mornye_collection_sheets_test.dart new file mode 100644 index 00000000..7e9e75e9 --- /dev/null +++ b/test/mornye_collection_sheets_test.dart @@ -0,0 +1,194 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; +import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; + +const _track = Track( + id: 'example-track', + name: 'Example track', + artistName: 'Example artist', + albumName: 'Example album', + duration: 180, +); + +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(); +} + +class _Collections extends LibraryCollectionsNotifier { + List _summaries = []; + final additions = []; + final createdNames = []; + + @override + LibraryCollectionsState build() => LibraryCollectionsState(isLoaded: true); + + @override + Future createPlaylist(String name) async { + createdNames.add(name); + _summaries = [..._summaries, _playlist(name)]; + ref.invalidate(libraryPlaylistPickerSummariesProvider); + return name; + } + + @override + Future addTracksToPlaylist( + String playlistId, + Iterable tracks, + ) async { + additions.add(playlistId); + return PlaylistAddBatchResult( + addedCount: tracks.length, + alreadyInPlaylistCount: 0, + ); + } +} + +PlaylistPickerSummary _playlist(String name, {bool contains = false}) => + PlaylistPickerSummary( + id: name, + name: name, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + trackCount: 2, + containsAllRequestedTracks: contains, + ); + +Widget _app( + Widget child, + _Collections collections, { + required Brightness brightness, + bool mornye = true, + double textScale = 1, +}) => ProviderScope( + overrides: [ + settingsProvider.overrideWith(_Settings.new), + libraryCollectionsProvider.overrideWith(() => collections), + libraryPlaylistPickerSummariesProvider.overrideWith( + (ref, request) async => collections._summaries, + ), + ], + child: MaterialApp( + theme: mornye ? MornyeTheme.build(brightness) : ThemeData(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + home: Scaffold(body: child), + ), +); + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + setUpAll(() async { + await (FontLoader( + 'Inter', + )..addFont(rootBundle.load('assets/fonts/InterVariable.ttf'))).load(); + await (FontLoader('packages/cupertino_icons/CupertinoIcons')..addFont( + rootBundle.load('packages/cupertino_icons/assets/CupertinoIcons.ttf'), + )) + .load(); + }); + + for (final brightness in Brightness.values) { + testWidgets('track menu reaches playlist creation in $brightness', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 680); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final collections = _Collections(); + await tester.pumpWidget( + _app( + const TrackCollectionQuickActions(track: _track), + collections, + brightness: brightness, + textScale: 1.5, + ), + ); + await tester.tap(find.byIcon(CupertinoIcons.ellipsis)); + await tester.pumpAndSettle(); + expect(find.byType(MornyeGlassPanel), findsOneWidget); + expect(find.byType(ListTile), findsNothing); + expect(find.byIcon(CupertinoIcons.doc_on_doc), findsNWidgets(2)); + + await tester.ensureVisible(find.text('Add to playlist')); + await tester.tap(find.text('Add to playlist')); + await tester.pumpAndSettle(); + expect(find.text('No playlists yet'), findsOneWidget); + expect(find.byType(MornyeGlassPanel), findsOneWidget); + expect(find.text('Done').hitTestable(), findsOneWidget); + + await tester.tap(find.text('Create playlist')); + await tester.pumpAndSettle(); + expect(find.byType(AppAlertDialog), findsOneWidget); + await tester.tap(find.text('Create')); + await tester.pumpAndSettle(); + expect(collections.createdNames, isEmpty); + await tester.enterText(find.byType(TextFormField), 'Evening music'); + await tester.tap(find.text('Create')); + await tester.pumpAndSettle(); + expect(collections.createdNames, ['Evening music']); + expect(collections.additions, ['Evening music']); + expect(find.byIcon(CupertinoIcons.checkmark_circle_fill), findsOneWidget); + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); + expect(collections.additions, ['Evening music']); + expect(find.byType(MornyeGlassPanel), findsNothing); + expect(tester.takeException(), isNull); + }); + } + + for (final mornye in [true, false]) { + testWidgets('picker adds only new selections (Mornye: $mornye)', ( + tester, + ) async { + final collections = _Collections() + .._summaries = [ + _playlist('Already included', contains: true), + _playlist('Road trip'), + _playlist('Unselected playlist'), + ]; + await tester.pumpWidget( + _app( + Consumer( + builder: (context, ref, _) => TextButton( + onPressed: () => + showAddTracksToPlaylistSheet(context, ref, [_track]), + child: const Text('Open'), + ), + ), + collections, + brightness: Brightness.light, + mornye: mornye, + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Already included')); + await tester.tap(find.text('Road trip')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); + expect(collections.additions, ['Road trip']); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/test/mornye_context_menu_test.dart b/test/mornye_context_menu_test.dart new file mode 100644 index 00000000..8f13ca93 --- /dev/null +++ b/test/mornye_context_menu_test.dart @@ -0,0 +1,158 @@ +import 'package:flutter/cupertino.dart'; +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:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + Future openMenu( + WidgetTester tester, { + required Rect anchor, + required ValueChanged onResult, + Size size = const Size(390, 844), + Brightness brightness = Brightness.light, + double textScale = 1, + bool reduceMotion = false, + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(brightness), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + padding: const EdgeInsets.fromLTRB(0, 24, 0, 24), + textScaler: TextScaler.linear(textScale), + disableAnimations: reduceMotion, + ), + child: child!, + ), + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: TextButton( + onPressed: () async { + final result = await showMornyeContextMenu( + context: context, + anchor: anchor, + builder: (menuContext) { + MornyeMenuAction action(String label, IconData icon) => + MornyeMenuAction( + icon: icon, + label: label, + onPressed: () => + Navigator.of(menuContext).pop(label), + ); + return MornyeContextMenu( + quickActions: [ + action( + 'Download', + CupertinoIcons.arrow_down_circle, + ), + action('Favorite', CupertinoIcons.star), + action('Share', CupertinoIcons.share), + ], + groups: [ + [ + action('Play', CupertinoIcons.play), + action('Shuffle', CupertinoIcons.shuffle), + ], + [ + action( + 'Add to playlist', + CupertinoIcons.text_badge_plus, + ), + action('Play next', CupertinoIcons.text_insert), + ], + [action('Remove', CupertinoIcons.trash)], + ], + ); + }, + ); + onResult(result); + }, + child: const Text('Open'), + ), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + } + + for (final brightness in Brightness.values) { + testWidgets('menu stays on screen near a bottom edge ($brightness)', ( + tester, + ) async { + String? result; + const anchor = Rect.fromLTWH(338, 728, 36, 44); + await openMenu( + tester, + anchor: anchor, + brightness: brightness, + onResult: (value) => result = value, + ); + final rect = tester.getRect(find.byType(MornyeContextMenu)); + expect(rect.left, greaterThanOrEqualTo(16)); + expect(rect.right, lessThanOrEqualTo(374)); + expect(rect.top, greaterThanOrEqualTo(40)); + expect(rect.bottom, lessThan(anchor.top)); + expect(find.text('Share').hitTestable(), findsOneWidget); + await tester.tap(find.text('Share')); + await tester.pumpAndSettle(); + expect(result, 'Share'); + expect(find.byType(MornyeContextMenu), findsNothing); + expect(tester.takeException(), isNull); + }); + } + + testWidgets( + 'large text in landscape keeps shortcuts and last action usable', + (tester) async { + String? result; + await openMenu( + tester, + anchor: const Rect.fromLTWH(600, 200, 24, 44), + size: const Size(640, 320), + textScale: 2.5, + onResult: (value) => result = value, + ); + final rect = tester.getRect(find.byType(MornyeContextMenu)); + expect(rect.top, greaterThanOrEqualTo(40)); + expect(rect.bottom, lessThanOrEqualTo(280)); + expect(find.text('Download').hitTestable(), findsOneWidget); + await tester.ensureVisible(find.text('Remove')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + expect(result, 'Remove'); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('outside tap dismisses without selecting in reduced motion', ( + tester, + ) async { + final results = []; + await openMenu( + tester, + anchor: const Rect.fromLTWH(12, 40, 44, 44), + reduceMotion: true, + onResult: results.add, + ); + await tester.tapAt(const Offset(8, 800)); + await tester.pump(); + expect(results, [null]); + expect(find.byType(MornyeContextMenu), findsNothing); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/mornye_detail_headers_test.dart b/test/mornye_detail_headers_test.dart new file mode 100644 index 00000000..96643d21 --- /dev/null +++ b/test/mornye_detail_headers_test.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/album_detail_header.dart'; +import 'package:spotiflac_android/widgets/collection_scaffold.dart'; +import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; + +void main() { + for (final brightness in Brightness.values) { + for (final scale in [1.0, 2.0]) { + testWidgets( + 'album actions and pinned navigation work in $brightness at $scale', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + tester.view.padding = FakeViewPadding(top: 59, bottom: 34); + addTearDown(tester.view.reset); + final controller = ScrollController(); + addTearDown(controller.dispose); + var plays = 0; + var shuffles = 0; + var backs = 0; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(brightness), + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: CollectionScaffold( + scrollController: controller, + isSelectionMode: false, + onExitSelectionMode: () {}, + bottomInset: 0, + appBar: AlbumDetailHeader( + immersive: true, + title: + 'A long album title that must wrap without hiding any of its controls', + expandedHeight: 400, + showTitleInAppBar: false, + background: const ColoredBox( + key: ValueKey('album-artwork'), + color: Colors.orange, + ), + coverBuilder: (_, _) => + const ColoredBox(color: Colors.orange), + subtitle: const Text('Artist name'), + leading: HeaderCircleButton( + icon: Icons.arrow_back, + tooltip: 'Back', + onPressed: () => backs++, + ), + actions: AlbumPlayActions( + playLabel: 'Play', + shuffleTooltip: 'Shuffle', + onPlay: () => plays++, + onShuffle: () => shuffles++, + ), + ), + slivers: [ + SliverList.builder( + itemCount: 40, + itemBuilder: (_, index) => + SizedBox(height: 64, child: Text('Track $index')), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect( + tester.getSize(find.byKey(const ValueKey('album-artwork'))), + const Size(390, 390), + ); + await tester.ensureVisible(find.text('Play')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Play')); + await tester.tap(find.text('Shuffle')); + expect(plays, 1); + expect(shuffles, 1); + controller.jumpTo(1300); + await tester.pumpAndSettle(); + expect(find.byTooltip('Back').hitTestable(), findsOneWidget); + await tester.tap(find.byTooltip('Back')); + expect(backs, 1); + expect(tester.takeException(), isNull); + }, + ); + } + } + + testWidgets( + 'artist identity grows with text and retains navigation after scroll', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final controller = ScrollController(); + addTearDown(controller.dispose); + var favorites = 0; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + home: Builder( + builder: (context) => Scaffold( + body: CustomScrollView( + controller: controller, + slivers: [ + ...MornyeArtistHeader( + name: 'An artist with a name spanning several lines', + showTitle: true, + artwork: const ColoredBox(color: Colors.orange), + actions: [ + HeaderCircleButton( + icon: Icons.favorite_border, + tooltip: 'Favorite', + onPressed: () => favorites++, + ), + ], + ).buildSlivers(context), + const SliverToBoxAdapter(child: SizedBox(height: 2000)), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Favorite')); + expect(favorites, 1); + controller.jumpTo(1000); + await tester.pumpAndSettle(); + expect(find.byTooltip('Back').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/test/mornye_library_actions_test.dart b/test/mornye_library_actions_test.dart new file mode 100644 index 00000000..0e026ea1 --- /dev/null +++ b/test/mornye_library_actions_test.dart @@ -0,0 +1,344 @@ +import 'dart:convert'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/unified_library_item.dart'; +import 'package:spotiflac_android/providers/download_history_provider.dart'; +import 'package:spotiflac_android/screens/track_metadata_screen.dart'; +import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart'; +import 'package:spotiflac_android/services/batch_track_actions.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; +import 'package:spotiflac_android/widgets/batch_convert_sheet.dart'; +import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; +import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart'; +import 'package:spotiflac_android/widgets/selection_action_button.dart'; +import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; +import 'package:spotiflac_android/widgets/destructive_selection_button.dart'; + +Widget _app( + Widget child, { + Brightness brightness = Brightness.dark, + TargetPlatform? platform, +}) => ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(brightness).copyWith(platform: platform), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), + ), +); + +Future _tapVisible(WidgetTester tester, Finder finder) async { + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); + await tester.pumpAndSettle(); +} + +void _mockMetadataFile(WidgetTester tester) { + const channel = MethodChannel('com.zarz.spotiflac/backend'); + final messenger = tester.binding.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + channel, + (call) async => switch (call.method) { + 'safStat' => jsonEncode({'exists': true, 'size': 100}), + 'readAudioMetadata' || + 'readFileMetadata' => jsonEncode({'title': 'Track', 'artist': 'Artist'}), + 'getLyricsLRCWithSource' => jsonEncode({'lyrics': '', 'source': ''}), + 'getSafFileModTimes' => '{}', + _ => null, + }, + ); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); +} + +void main() { + setUpAll(() async { + await (FontLoader( + 'Inter', + )..addFont(rootBundle.load('assets/fonts/InterVariable.ttf'))).load(); + await (FontLoader('packages/cupertino_icons/CupertinoIcons')..addFont( + rootBundle.load('packages/cupertino_icons/assets/CupertinoIcons.ttf'), + )) + .load(); + }); + setUp(() { + SharedPreferences.setMockInitialValues({}); + FlutterSecureStorage.setMockInitialValues({}); + }); + + testWidgets('Mornye selection actions remain accessible on narrow screens', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(320, 780)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + var selectedAll = false; + var closed = false; + var deleted = false; + var converted = false; + await tester.pumpWidget( + _app( + Builder( + builder: (context) => Align( + alignment: Alignment.bottomCenter, + child: SelectionBottomBar( + selectedCount: 1, + allSelected: false, + onClose: () => closed = true, + onToggleSelectAll: () => selectedAll = true, + bottomPadding: 0, + children: [ + SelectionActionButton( + icon: Icons.swap_horiz, + label: 'Convert 1 track', + onPressed: () => converted = true, + colorScheme: Theme.of(context).colorScheme, + ), + const SizedBox(height: 8), + DestructiveSelectionButton( + count: 1, + colorScheme: Theme.of(context).colorScheme, + onPressed: () => deleted = true, + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await _tapVisible(tester, find.text('Select All')); + await _tapVisible(tester, find.text('Convert 1 track')); + await _tapVisible(tester, find.byIcon(CupertinoIcons.trash)); + await _tapVisible(tester, find.byIcon(CupertinoIcons.xmark)); + expect((selectedAll, converted, deleted, closed), (true, true, true, true)); + expect(tester.takeException(), isNull); + }); + + for (final brightness in Brightness.values) { + testWidgets('Mornye conversion preserves selected options ($brightness)', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(390, 844)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + (String, String, bool)? result; + await tester.pumpWidget( + _app( + BatchConvertSheet( + formats: const [ + 'ALAC', + 'FLAC', + 'WAV', + 'AIFF', + 'AAC', + 'MP3', + 'Opus', + ], + title: 'Batch Convert', + confirmLabel: 'Convert tracks', + sourceBitDepth: 24, + sourceSampleRate: 48000, + onConvert: (format, bitrate, quality, processing, keepOriginal) { + result = (format, bitrate, keepOriginal); + }, + ), + brightness: brightness, + ), + ); + await tester.pumpAndSettle(); + await _tapVisible(tester, find.text('AAC')); + await _tapVisible(tester, find.text('128k')); + await _tapVisible(tester, find.byType(AppSwitch)); + await _tapVisible(tester, find.text('Convert tracks')); + expect(result, ('AAC', '128k', true)); + expect(tester.takeException(), isNull); + }); + } + + testWidgets( + 'Mornye Re-enrich retains field choices and disables empty review', + (tester) async { + await tester.binding.setSurfaceSize(const Size(390, 844)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + ReEnrichFieldSelection? result; + await tester.pumpWidget( + _app( + Builder( + builder: (context) => CupertinoButton( + onPressed: () async { + result = await showReEnrichFieldDialog( + context, + selectedCount: 2, + ); + }, + child: const Text('Open'), + ), + ), + ), + ); + await _tapVisible(tester, find.text('Open')); + await _tapVisible(tester, find.text('Update selected tags')); + await _tapVisible(tester, find.text('Select All')); + final review = find.widgetWithText(AppActionButton, 'Review changes'); + expect(tester.widget(review).onPressed, isNull); + await _tapVisible(tester, find.text('Cover Art')); + await _tapVisible(tester, find.text('Review changes')); + expect(result?.mode, ReEnrichBatchMode.selectedFields); + expect(result?.fields, [ReEnrichFields.cover]); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'Mornye ReplayGain cancellation restores selection without analysis', + (tester) async { + bool? confirmed; + var exitedSelection = false; + await tester.pumpWidget( + _app( + Builder( + builder: (context) => CupertinoButton( + onPressed: () => runBatchReplayGain( + context, + [ + UnifiedLibraryItem( + id: 'track', + trackName: 'Track', + artistName: 'Artist', + albumName: 'Album', + filePath: '/missing/track.flac', + addedAt: DateTime(2026), + source: LibraryItemSource.local, + ), + ], + onExitSelectionMode: () => exitedSelection = true, + onConfirmClosed: (value) async => confirmed = value, + ), + child: const Text('Open'), + ), + ), + ), + ); + await _tapVisible(tester, find.text('Open')); + expect(find.byType(AppAlertDialog), findsOneWidget); + await _tapVisible(tester, find.text('Cancel')); + expect(confirmed, isFalse); + expect(exitedSelection, isFalse); + expect(tester.takeException(), isNull); + }, + ); + + for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) { + testWidgets('Metadata menu scroll reaches the last action ($platform)', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(390, 540)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + _mockMetadataFile(tester); + await tester.pumpWidget( + _app( + TrackMetadataScreen( + item: DownloadHistoryItem( + id: 'menu-track', + trackName: 'Track', + artistName: 'Artist', + albumName: 'Album', + filePath: 'content://library/document/menu-track.flac', + service: 'provider-a', + downloadedAt: DateTime(2026), + ), + ), + platform: platform, + ), + ); + await tester.pumpAndSettle(); + await _tapVisible(tester, find.byIcon(CupertinoIcons.ellipsis)); + final lastAction = find.text('Remove from device'); + expect(lastAction.hitTestable(), findsNothing); + expect(find.byType(MornyeContextMenu), findsOneWidget); + expect(find.byType(BottomSheet), findsNothing); + for (var drag = 0; drag < 4; drag++) { + await tester.dragFrom(const Offset(195, 400), const Offset(0, -320)); + await tester.pumpAndSettle(); + } + expect(find.text('Share').hitTestable(), findsOneWidget); + expect(lastAction.hitTestable(), findsOneWidget); + // Device Hub / Simulator users can also scroll with a trackpad or wheel. + await tester.sendEventToBinding( + const PointerScrollEvent( + position: Offset(195, 400), + scrollDelta: Offset(0, -2000), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Play next').hitTestable(), findsOneWidget); + await tester.sendEventToBinding( + const PointerScrollEvent( + position: Offset(195, 400), + scrollDelta: Offset(0, 2000), + ), + ); + await tester.pumpAndSettle(); + expect(lastAction.hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('Mornye metadata editor keeps edited text and Save available', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(390, 844)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + _mockMetadataFile(tester); + await tester.pumpWidget( + _app( + TrackMetadataScreen( + item: DownloadHistoryItem( + id: 'edit-track', + trackName: 'Track', + artistName: 'Artist', + albumName: 'Album', + filePath: 'content://library/document/edit-track.flac', + service: 'provider-a', + downloadedAt: DateTime(2026), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await _tapVisible(tester, find.byIcon(CupertinoIcons.ellipsis)); + await tester.tap(find.text('Edit Metadata')); + await tester.pump(const Duration(milliseconds: 400)); + // The editor extracts its preview through real temporary-file I/O. + await tester.runAsync(() async { + for (var attempt = 0; attempt < 100; attempt++) { + await Future.delayed(const Duration(milliseconds: 10)); + await tester.pump(); + if (find.byType(LinearProgressIndicator).evaluate().isEmpty) break; + } + }); + expect(find.byType(LinearProgressIndicator), findsNothing); + await tester.pumpAndSettle(); + final title = find.byWidgetPredicate( + (widget) => + widget is CupertinoTextField && widget.controller?.text == 'Track', + ); + await tester.ensureVisible(title); + await tester.enterText(title, 'Edited track'); + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pumpAndSettle(); + expect(find.text('Edited track'), findsOneWidget); + expect(find.widgetWithText(AppActionButton, 'Save'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/mornye_loading_and_chrome_test.dart b/test/mornye_loading_and_chrome_test.dart new file mode 100644 index 00000000..35cef881 --- /dev/null +++ b/test/mornye_loading_and_chrome_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/shell_navigation_service.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; +import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; + +void main() { + for (final brightness in Brightness.values) { + testWidgets('Mornye loading layouts fit a narrow screen in $brightness', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + for (final skeleton in const [ + ArtistScreenSkeleton(showCoverHeader: false), + AlbumTrackListSkeleton(showCoverHeader: true), + TrackListSkeleton(showCoverHeader: true), + ]) { + await tester.pumpWidget( + MaterialApp( + theme: MornyeTheme.build(brightness), + home: Scaffold(body: skeleton), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull); + await tester.drag( + find.byType(SingleChildScrollView).first, + const Offset(0, -350), + ); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull); + } + await tester.pumpWidget(const SizedBox.shrink()); + }); + } + + testWidgets( + 'chrome follows the visible artist and restores for albums and tabs', + (tester) async { + final key = ShellNavigationService.homeTabNavigatorKey; + final observer = ShellChromeObserver(key); + ShellNavigationService.syncState(currentTabIndex: 0, showRepoTab: true); + addTearDown(observer.detach); + await tester.pumpWidget( + MaterialApp( + theme: MornyeTheme.build(Brightness.light), + home: Navigator( + key: key, + observers: [observer], + onGenerateRoute: (_) => MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Home')), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(ShellNavigationService.chromeBrightness.value, isNull); + key.currentState!.push( + MaterialPageRoute( + builder: (_) => const MornyeArtistSurface( + imageSource: null, + child: Scaffold(body: Text('Artist')), + ), + ), + ); + await tester.pumpAndSettle(); + expect(ShellNavigationService.chromeBrightness.value, Brightness.dark); + final artistSurface = Theme.of( + tester.element(find.text('Artist')), + ).colorScheme.surface; + expect(ShellNavigationService.chromeSurface.value, artistSurface); + + // Menus do not turn the bars light over a dark artist. + showDialog( + context: tester.element(find.text('Artist')), + useRootNavigator: false, + builder: (_) => const AlertDialog(title: Text('Options')), + ); + await tester.pumpAndSettle(); + expect(ShellNavigationService.chromeBrightness.value, Brightness.dark); + key.currentState!.pop(); + await tester.pumpAndSettle(); + + // The mounted artist under a pushed album must not keep a stale override. + key.currentState!.push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Album')), + ), + ); + await tester.pumpAndSettle(); + expect(ShellNavigationService.chromeBrightness.value, isNull); + expect(ShellNavigationService.chromeSurface.value, isNull); + key.currentState!.pop(); + await tester.pumpAndSettle(); + expect(ShellNavigationService.chromeBrightness.value, Brightness.dark); + expect(ShellNavigationService.chromeSurface.value, artistSurface); + + ShellNavigationService.syncState(currentTabIndex: 3, showRepoTab: true); + await tester.pumpAndSettle(); + expect(ShellNavigationService.chromeBrightness.value, isNull); + expect(ShellNavigationService.chromeSurface.value, isNull); + ShellNavigationService.syncState(currentTabIndex: 0, showRepoTab: true); + await tester.pumpAndSettle(); + expect(ShellNavigationService.chromeBrightness.value, Brightness.dark); + key.currentState!.pop(); + await tester.pumpAndSettle(); + expect(ShellNavigationService.chromeBrightness.value, isNull); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); +} diff --git a/test/mornye_playback_button_test.dart b/test/mornye_playback_button_test.dart new file mode 100644 index 00000000..c2df633e --- /dev/null +++ b/test/mornye_playback_button_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/widgets/mornye_playback_button.dart'; + +void main() { + Future pumpButton( + WidgetTester tester, { + required VoidCallback onPressed, + IconData icon = CupertinoIcons.play_fill, + bool reduceMotion = false, + }) => tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: reduceMotion), + child: Scaffold( + body: Center( + child: MornyePlaybackButton( + icon: icon, + tooltip: 'Playback action', + color: Colors.white, + onPressed: onPressed, + ), + ), + ), + ), + ), + ); + + testWidgets('press feedback keeps the tap target fixed and cancels safely', ( + tester, + ) async { + var calls = 0; + await pumpButton(tester, onPressed: () => calls++); + final button = find.byType(IconButton); + final icon = find.byIcon(CupertinoIcons.play_fill); + final bounds = tester.getRect(button); + final iconBounds = tester.getRect(icon); + final press = await tester.startGesture(bounds.center); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.getRect(icon).width, lessThan(iconBounds.width)); + expect(tester.getRect(button), bounds); + await press.up(); + await tester.pumpAndSettle(); + expect(calls, 1); + expect(tester.getRect(icon), iconBounds); + + final cancelled = await tester.startGesture(bounds.center); + await tester.pump(const Duration(milliseconds: 100)); + await cancelled.cancel(); + await tester.pumpAndSettle(); + expect(calls, 1); + expect(tester.getRect(icon), iconBounds); + }); + + for (final icon in [ + CupertinoIcons.forward_fill, + CupertinoIcons.backward_fill, + ]) { + testWidgets( + 'skip $icon animates without delaying repeated playback actions', + (tester) async { + var calls = 0; + await pumpButton(tester, icon: icon, onPressed: () => calls++); + final button = find.byType(IconButton); + final bounds = tester.getRect(button); + for (var index = 0; index < 3; index++) { + await tester.tap(button); + await tester.pump(const Duration(milliseconds: 60)); + expect(calls, index + 1); + expect(find.byIcon(CupertinoIcons.play_fill), findsNWidgets(3)); + expect(tester.getRect(button), bounds); + } + await tester.pumpAndSettle(); + expect(find.byIcon(icon), findsOneWidget); + expect(find.byIcon(CupertinoIcons.play_fill), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets('Reduce Motion keeps skip glyphs stationary and actions usable', ( + tester, + ) async { + var calls = 0; + await pumpButton( + tester, + icon: CupertinoIcons.forward_fill, + reduceMotion: true, + onPressed: () => calls++, + ); + final icon = find.byIcon(CupertinoIcons.forward_fill); + final bounds = tester.getRect(icon); + final press = await tester.startGesture(tester.getCenter(icon)); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.getRect(icon), bounds); + await press.up(); + await tester.pump(const Duration(milliseconds: 60)); + expect(calls, 1); + expect(find.byIcon(CupertinoIcons.play_fill), findsNothing); + expect(tester.getRect(icon), bounds); + await tester.pumpAndSettle(); + }); +} diff --git a/test/mornye_playback_time_test.dart b/test/mornye_playback_time_test.dart new file mode 100644 index 00000000..0e5b2782 --- /dev/null +++ b/test/mornye_playback_time_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/widgets/mornye_playback_time.dart'; + +Widget _app(int seconds, {bool remaining = false, bool reduceMotion = false}) => + MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: reduceMotion), + child: Scaffold( + body: Align( + alignment: Alignment.centerRight, + child: MornyePlaybackTime( + seconds: seconds, + remaining: remaining, + style: const TextStyle(fontSize: 14), + ), + ), + ), + ), + ); + +void main() { + for (final increasing in [true, false]) { + testWidgets( + 'Time digits roll ${increasing ? 'up' : 'down'} without drift', + (tester) async { + final oldDigit = increasing ? '4' : '5'; + final newDigit = increasing ? '5' : '4'; + await tester.pumpWidget(_app(increasing ? 34 : 35)); + final oldPosition = tester.getTopLeft(find.text(oldDigit)); + final colonPosition = tester.getTopLeft(find.text(':')); + final initialSize = tester.getSize(find.byType(MornyePlaybackTime)); + + await tester.pumpWidget(_app(increasing ? 35 : 34)); + await tester.pump(const Duration(milliseconds: 80)); + final oldY = tester.getTopLeft(find.text(oldDigit)).dy; + final newY = tester.getTopLeft(find.text(newDigit)).dy; + expect( + oldY, + increasing ? lessThan(oldPosition.dy) : greaterThan(oldPosition.dy), + ); + expect( + newY, + increasing ? greaterThan(oldPosition.dy) : lessThan(oldPosition.dy), + ); + expect(tester.getTopLeft(find.text(':')), colonPosition); + expect(tester.getSize(find.byType(MornyePlaybackTime)), initialSize); + + await tester.pumpAndSettle(); + expect(find.text(oldDigit), findsNothing); + expect(tester.getTopLeft(find.text(newDigit)), oldPosition); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets('Minute rollover and rapid seeks expose only the current time', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await tester.pumpWidget(_app(599, remaining: true)); + final colonPosition = tester.getTopLeft(find.text(':')); + await tester.pumpWidget(_app(600, remaining: true)); + await tester.pump(const Duration(milliseconds: 80)); + expect(find.bySemanticsLabel('-10:00'), findsOneWidget); + expect(find.bySemanticsLabel('-9:59'), findsNothing); + expect(find.text('-'), findsOneWidget); + expect(tester.getTopLeft(find.text(':')), colonPosition); + + await tester.pumpWidget(_app(598, remaining: true)); + await tester.pumpAndSettle(); + expect(find.bySemanticsLabel('-9:58'), findsOneWidget); + expect(find.text('0'), findsNothing); + expect(find.text('-'), findsOneWidget); + expect(tester.takeException(), isNull); + semantics.dispose(); + }); + + testWidgets('Reduce Motion updates time without rolling digits', ( + tester, + ) async { + await tester.pumpWidget(_app(34)); + await tester.pumpWidget(_app(35)); + await tester.pump(const Duration(milliseconds: 80)); + await tester.pumpWidget(_app(36, reduceMotion: true)); + expect(find.text('0:36'), findsOneWidget); + expect(find.byType(AnimatedSwitcher), findsNothing); + expect(tester.hasRunningAnimations, isFalse); + }); +} diff --git a/test/mornye_player_queue_test.dart b/test/mornye_player_queue_test.dart new file mode 100644 index 00000000..3c5505e5 --- /dev/null +++ b/test/mornye_player_queue_test.dart @@ -0,0 +1,97 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_player_queue.dart'; + +class _Player extends MusicPlayerController { + int? jumpedTo; + (int, int)? moved; + bool? shuffled; + AudioServiceRepeatMode? repeat; + + @override + Future jumpTo(int index) async => jumpedTo = index; + @override + void moveQueueItem(int oldIndex, int newIndex) => + moved = (oldIndex, newIndex); + @override + Future setShuffle(bool enabled) async => shuffled = enabled; + @override + Future setRepeatMode(AudioServiceRepeatMode mode) async => + repeat = mode; +} + +void main() { + testWidgets( + 'upcoming queue maps taps and reorders to the complete playback queue', + (tester) async { + final player = _Player(); + var libraryShuffles = 0; + final queue = [ + for (final name in ['Previous', 'Current', 'Next', 'Last']) + MediaItem(id: name, title: name, artist: 'Artist'), + ]; + final theme = MornyeTheme.build(Brightness.dark); + await tester.pumpWidget( + ProviderScope( + overrides: [ + musicPlayerControllerProvider.overrideWithValue(player), + currentMediaItemProvider.overrideWith( + (ref) => Stream.value(queue[1]), + ), + playQueueProvider.overrideWith((ref) => Stream.value(queue)), + playbackStateProvider.overrideWith( + (ref) => Stream.value(PlaybackState(queueIndex: 1)), + ), + ], + child: MaterialApp( + theme: theme, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: MornyePlayerQueue( + colorScheme: theme.colorScheme, + onShuffleLibrary: () => libraryShuffles++, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Previous'), findsNothing); + expect(find.text('Current'), findsNothing); + await tester.tap(find.text('Last')); + expect(player.jumpedTo, 3); + final firstHandle = find.byType(ReorderableDragStartListener).first; + final drag = await tester.startGesture(tester.getCenter(firstHandle)); + await tester.pump(const Duration(milliseconds: 100)); + await drag.moveBy(const Offset(0, 30)); + await tester.pump(); + await drag.moveBy(const Offset(0, 180)); + await tester.pump(const Duration(milliseconds: 300)); + await drag.up(); + await tester.pumpAndSettle(); + expect(player.moved, (2, 3)); + await tester.tap(find.byIcon(CupertinoIcons.shuffle)); + expect(player.shuffled, isTrue); + await tester.tap(find.byIcon(CupertinoIcons.repeat)); + expect(player.repeat, AudioServiceRepeatMode.all); + await tester.tap(find.byIcon(CupertinoIcons.ellipsis)); + await tester.pumpAndSettle(); + final shuffleLibrary = AppLocalizations.of( + tester.element(find.byType(MornyePlayerQueue)), + ).nowPlayingShuffleLibrary; + await tester.tap(find.text(shuffleLibrary)); + await tester.pumpAndSettle(); + expect(libraryShuffles, 1); + expect(find.text(shuffleLibrary), findsNothing); + expect(find.text('Next'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/test/mornye_search_glass_test.dart b/test/mornye_search_glass_test.dart new file mode 100644 index 00000000..431d4cee --- /dev/null +++ b/test/mornye_search_glass_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; + +void main() { + for (final brightness in Brightness.values) { + testWidgets( + 'search glass cards scroll and filters remain usable in $brightness', + (tester) async { + var selected = false; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(brightness), + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => Column( + children: [ + MornyeGlassPanel.overlay( + radius: 24, + child: MornyeFilterChip( + label: 'Albums', + selected: selected, + glass: false, + tonal: selected, + onTap: () => setState(() => selected = !selected), + ), + ), + Expanded( + child: ListView.builder( + itemCount: 12, + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: MornyeGlassPanel.overlay( + radius: 24, + child: SizedBox( + height: 88, + child: Text('Result $index'), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.tap(find.text('Albums')); + await tester.pumpAndSettle(); + expect(selected, isTrue); + await tester.scrollUntilVisible(find.text('Result 11'), 200); + await tester.pumpAndSettle(); + expect(find.text('Result 11').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + } +} diff --git a/test/mornye_volume_control_test.dart b/test/mornye_volume_control_test.dart new file mode 100644 index 00000000..3683239f --- /dev/null +++ b/test/mornye_volume_control_test.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/widgets/mornye_volume_control.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('In-app volume writes suppress the system volume panel', () async { + const channel = MethodChannel('com.kurenai7968.volume_controller.method'); + final calls = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + channel, + (call) async => calls.add(call), + ); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final container = ProviderContainer(); + addTearDown(container.dispose); + await container.read(systemVolumeWriterProvider)(0.7); + expect(calls.single.method, 'setVolume'); + expect(calls.single.arguments, {'volume': 0.7, 'showSystemUI': false}); + }); + + testWidgets('Slow volume writes coalesce and cannot reset an active drag', ( + tester, + ) async { + final writes = []; + final completions = >[]; + final volumes = StreamController(); + addTearDown(volumes.close); + await tester.pumpWidget( + ProviderScope( + overrides: [ + systemVolumeProvider.overrideWith((ref) => volumes.stream), + systemVolumeWriterProvider.overrideWith( + (ref) => (value) { + writes.add(value); + final completion = Completer(); + completions.add(completion); + return completion.future; + }, + ), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: MornyeVolumeControl()), + ), + ), + ); + volumes.add(0.5); + await tester.pumpAndSettle(); + Slider slider() => tester.widget(find.byType(Slider)); + + slider().onChangeStart!(0.5); + slider().onChanged!(0.6); + slider().onChanged!(0.7); + slider().onChanged!(0.8); + await tester.pump(); + expect(writes, [0.6]); + expect(slider().value, 0.8); + + completions.first.complete(); + await tester.pump(); + expect(writes, [0.6, 0.8]); + volumes.add(0.6); + completions.last.complete(); + await tester.pump(); + expect(slider().value, 0.8); + + slider().onChangeEnd!(0.8); + slider().onChangeStart!(0.8); + slider().onChanged!(0.9); + completions.last.complete(); + await tester.pump(); + expect(writes.last, 0.9); + completions.last.complete(); + await tester.pump(); + expect(slider().value, 0.9); + + slider().onChangeEnd!(0.9); + volumes.add(0.9); + completions.last.complete(); + await tester.pump(); + volumes.add(0.3); + await tester.pumpAndSettle(); + expect(slider().value, 0.3); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/motion_header_banner_test.dart b/test/motion_header_banner_test.dart index 509080d4..2e8a6848 100644 --- a/test/motion_header_banner_test.dart +++ b/test/motion_header_banner_test.dart @@ -6,6 +6,8 @@ import 'package:video_player_platform_interface/video_player_platform_interface. class _VideoPlatform extends VideoPlayerPlatform { bool playing = false; int playCalls = 0; + bool looping = false; + double volume = 1; @override Future init() async {} @@ -32,10 +34,12 @@ class _VideoPlatform extends VideoPlayerPlatform { Future pause(int playerId) async => playing = false; @override - Future setLooping(int playerId, bool looping) async {} + Future setLooping(int playerId, bool looping) async => + this.looping = looping; @override - Future setVolume(int playerId, double volume) async {} + Future setVolume(int playerId, double volume) async => + this.volume = volume; @override Future setPlaybackSpeed(int playerId, double speed) async {} @@ -91,6 +95,8 @@ void main() { await tester.pumpWidget(app()); await tester.pump(); expect(platform.playing, isTrue); + expect(platform.looping, isTrue); + expect(platform.volume, 0); final initialPlayCalls = platform.playCalls; scroll.jumpTo(50); diff --git a/test/music_player_media_metadata_test.dart b/test/music_player_media_metadata_test.dart index f139eba3..71bfa905 100644 --- a/test/music_player_media_metadata_test.dart +++ b/test/music_player_media_metadata_test.dart @@ -37,6 +37,65 @@ void main() { expect(restored.explicit, isTrue); }); + test( + 'restored iOS artwork follows the container without changing external audio', + () { + const previous = + '/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111'; + const current = + '/var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222'; + const externalAudio = + '/var/mobile/Containers/Data/Application/33333333-3333-4333-8333-333333333333/Documents/track.flac'; + final saved = { + ...media.toJson(), + 'source': externalAudio, + 'artUri': Uri.file( + '$previous/Library/Caches/covers/曲 #cover.jpg', + ).toString(), + }; + final restored = PlayableMedia.fromJson( + saved, + iosDocumentsPath: '$current/Documents', + )!; + expect(restored.source, externalAudio); + expect(restored.id, media.id); + expect( + restored.artUri, + Uri.file('$current/Library/Caches/covers/曲 #cover.jpg').toString(), + ); + expect( + restored.toMediaItem().artUri?.toFilePath(), + '$current/Library/Caches/covers/曲 #cover.jpg', + ); + expect( + PlayableMedia.fromJson( + restored.toJson(), + iosDocumentsPath: '$current/Documents', + )!.artUri, + restored.artUri, + ); + }, + ); + + test('artwork restore preserves network, Android and external artwork', () { + const documents = + '/var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents'; + for (final artwork in [ + null, + 'https://example.com/cover.jpg', + 'content://media/covers/123', + 'file:///data/user/0/example/cache/cover.jpg', + 'file:///private/var/mobile/Library/Mobile%20Documents/cover.jpg', + ]) { + final saved = {...media.toJson(), 'artUri': artwork}; + expect( + PlayableMedia.fromJson(saved, iosDocumentsPath: documents)!.artUri, + artwork, + ); + expect(PlayableMedia.fromJson(saved)!.artUri, artwork); + } + }); + test('file probe cannot erase valid queue quality with empty values', () { final merged = mergePlaybackFileMetadata( playbackAudioMetadataFromMediaItem(media.toMediaItem()), diff --git a/test/nav_bar_inset_test.dart b/test/nav_bar_inset_test.dart new file mode 100644 index 00000000..bbd66413 --- /dev/null +++ b/test/nav_bar_inset_test.dart @@ -0,0 +1,110 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/utils/nav_bar_inset.dart'; +import 'package:spotiflac_android/widgets/collection_scaffold.dart'; + +void main() { + for (final brightness in Brightness.values) { + testWidgets('localized inset preserves collection pixels in $brightness', ( + tester, + ) async { + final capture = GlobalKey(); + final inset = ValueNotifier(140.0); + final scroll = ScrollController(); + addTearDown(inset.dispose); + addTearDown(scroll.dispose); + + Future pumpPage({required bool explicitInset}) async { + await tester.pumpWidget( + MaterialApp( + theme: MornyeTheme.build(brightness), + home: ValueListenableBuilder( + valueListenable: inset, + builder: (context, value, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(padding: EdgeInsets.only(top: 24, bottom: value)), + child: child!, + ), + child: RepaintBoundary( + key: capture, + child: Builder( + builder: (context) => CollectionScaffold( + scrollController: scroll, + isSelectionMode: false, + onExitSelectionMode: () {}, + bottomInset: explicitInset + ? context.navBarBottomInset + : null, + appBar: const SliverToBoxAdapter( + child: SizedBox(height: 120, child: Text('Collection')), + ), + slivers: [ + SliverFixedExtentList.builder( + itemExtent: 56, + itemCount: 30, + itemBuilder: (_, index) => ListTile( + leading: const Icon(Icons.music_note), + title: Text('Track $index'), + ), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Future pixels() async { + return (await tester.runAsync(() async { + final boundary = tester.renderObject( + find.byKey(capture), + ); + final image = await boundary.toImage(); + try { + final data = await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ); + return Uint8List.fromList(data!.buffer.asUint8List()); + } finally { + image.dispose(); + } + }))!; + } + + // Compare the previous, parent-owned inset with the localized spacer at + // intermediate animation heights, including the final scroll position. + final expected = {}; + final extents = {}; + await pumpPage(explicitInset: true); + for (final height in [140.0, 118.0, 86.0, 64.0, 0.0]) { + inset.value = height; + await tester.pumpAndSettle(); + scroll.jumpTo(scroll.position.maxScrollExtent); + await tester.pumpAndSettle(); + expected[height] = await pixels(); + extents[height] = scroll.position.maxScrollExtent; + } + + await pumpPage(explicitInset: false); + for (final height in expected.keys) { + inset.value = height; + await tester.pumpAndSettle(); + scroll.jumpTo(scroll.position.maxScrollExtent); + await tester.pumpAndSettle(); + expect(scroll.position.maxScrollExtent, extents[height]); + expect(await pixels(), orderedEquals(expected[height]!)); + } + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } +} diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 3418f493..dc2f7bc6 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -1,22 +1,49 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:ui' show ImageFilter; +import 'dart:ui' as ui; import 'package:audio_service/audio_service.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show RenderRepaintBoundary; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart'; import 'package:spotiflac_android/screens/now_playing_screen.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_volume_control.dart'; +import 'package:spotiflac_android/widgets/mornye_player_queue.dart'; +import 'package:spotiflac_android/widgets/mornye_playback_button.dart'; +import 'package:spotiflac_android/widgets/mornye_playback_time.dart'; +import 'package:spotiflac_android/widgets/mornye_player_actions_sheet.dart'; +import 'package:spotiflac_android/widgets/mornye_player_favorite_button.dart'; +import 'package:spotiflac_android/widgets/mornye_player_details_sheet.dart'; +import 'package:spotiflac_android/widgets/mornye_metadata_row.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/mornye_player_background.dart'; +import 'package:spotiflac_android/widgets/mornye_player_artwork.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); const backendChannel = MethodChannel('com.zarz.spotiflac/backend'); late StreamController mediaItems; + late List volumeWrites; + late Map metadataOverrides; + late List metadataReads; setUp(() { + SharedPreferences.setMockInitialValues({}); mediaItems = StreamController.broadcast(); + volumeWrites = []; + metadataOverrides = {}; + metadataReads = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(backendChannel, (call) async { if (call.method != 'readFileMetadata') { @@ -24,14 +51,18 @@ void main() { } final arguments = (call.arguments as Map).cast(); final path = arguments['file_path']?.toString() ?? ''; + metadataReads.add(path); final lyrics = path.endsWith('/timed.flac') ? '''

Short

''' + : path.endsWith('/many.flac') + ? '[00:00.00]First line\n[00:02.00]Second line\n[00:04.00]Third line\n[00:06.00]Fourth line' : path.endsWith('/second.flac') ? '[00:01.00]Second lyric' : '[00:01.00]First lyric'; return jsonEncode({ 'title': path.endsWith('/second.flac') ? 'Second' : 'First', 'lyrics': lyrics, + ...metadataOverrides, }); }); }); @@ -51,8 +82,15 @@ void main() { extras: {'source': 'content://library/$id.flac'}, ); - Future pumpNowPlaying(WidgetTester tester) async { - tester.view.physicalSize = const Size(1080, 1920); + Future pumpNowPlaying( + WidgetTester tester, { + ThemeData? theme, + Size size = const Size(1080, 1920), + PlaybackState? playback, + Stream? playbackEvents, + Widget Function(Widget)? wrapPlayer, + }) async { + tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); @@ -61,18 +99,843 @@ void main() { ProviderScope( overrides: [ currentMediaItemProvider.overrideWith((ref) => mediaItems.stream), - playbackStateProvider.overrideWith((ref) => const Stream.empty()), + playerMotionArtworkProvider.overrideWith((ref, album) async => null), + playerCollectionTrackProvider.overrideWith( + (ref, item) async => Track( + id: item.id, + name: item.title, + artistName: item.artist ?? '', + albumName: item.album ?? '', + duration: item.duration?.inSeconds ?? 0, + source: 'local', + ), + ), + libraryCollectionsProvider.overrideWith(_TestCollections.new), + playbackStateProvider.overrideWith( + (ref) => + playbackEvents ?? + (playback == null + ? const Stream.empty() + : Stream.value(playback)), + ), playQueueProvider.overrideWith((ref) => const Stream.empty()), + systemVolumeProvider.overrideWith((ref) => Stream.value(0.5)), + systemVolumeWriterProvider.overrideWith( + (ref) => + (value) async => volumeWrites.add(value), + ), ], - child: const MaterialApp( + child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: NowPlayingScreen(), + theme: theme, + home: + wrapPlayer?.call(const NowPlayingScreen()) ?? + const NowPlayingScreen(), ), ), ); } + testWidgets('Mornye player background does not reveal the page below', ( + tester, + ) async { + final background = ValueNotifier(Colors.white); + addTearDown(background.dispose); + final capture = GlobalKey(); + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.light), + size: const Size(393, 780), + wrapPlayer: (player) => RepaintBoundary( + key: capture, + child: ValueListenableBuilder( + valueListenable: background, + child: player, + builder: (context, color, child) => Stack( + fit: StackFit.expand, + children: [ + ColoredBox(color: color), + child!, + ], + ), + ), + ), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + + Future?> edgePixel() => tester.runAsync(() async { + final boundary = tester.renderObject( + find.byKey(capture), + ); + final image = await boundary.toImage(); + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + final offset = (image.width + 1) * 4; + final pixel = List.generate(4, (i) => bytes.getUint8(offset + i)); + image.dispose(); + return pixel; + }); + + final onWhite = await edgePixel(); + background.value = Colors.red; + await tester.pumpAndSettle(); + expect(await edgePixel(), onWhite); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'Mornye player menu floats above its button and retains dark colors', + (tester) async { + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.light), + size: const Size(393, 780), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(CupertinoIcons.ellipsis).hitTestable()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + final panel = find.byType(MornyePlayerActionsSheet); + final enteringWidth = tester.getSize(panel).width; + await tester.pumpAndSettle(); + expect(tester.getSize(panel).width, enteringWidth); + expect(tester.getRect(panel).bottom, lessThan(780 - 16)); + expect(tester.getRect(panel).width, 320); + expect(find.byType(BottomSheet), findsNothing); + expect(Theme.of(tester.element(panel)).brightness, Brightness.dark); + expect( + tester.widget(find.text('Go to Album')).style?.color, + Colors.white, + ); + expect(find.byIcon(CupertinoIcons.square_stack), findsOneWidget); + expect(find.byIcon(CupertinoIcons.moon_zzz), findsOneWidget); + expect(find.byIcon(CupertinoIcons.gear_alt), findsNothing); + await tester.tap(find.text('Sleep timer')); + await tester.pumpAndSettle(); + expect( + Theme.of(tester.element(find.text('15 minutes'))).brightness, + Brightness.dark, + ); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'Mornye title opens artist and album destinations with subtitles', + (tester) async { + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 780), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + await tester.tap(find.text('First').hitTestable()); + await tester.pumpAndSettle(); + expect(find.text('Go to Artist'), findsOneWidget); + expect(find.text('Go to Album'), findsOneWidget); + expect( + find.descendant( + of: find.byType(MornyePlayerNavigationMenu), + matching: find.text('Artist'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byType(MornyePlayerNavigationMenu), + matching: find.text('Album'), + ), + findsOneWidget, + ); + await tester.tapAt(const Offset(5, 770)); + await tester.pumpAndSettle(); + expect(find.byType(MornyePlayerNavigationMenu), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('Android Details reads full tags from a restored content URI', ( + tester, + ) async { + metadataOverrides = { + 'album_artist': 'Album Artist', + 'genre': 'Pop/Rock', + 'composer': 'Composer Name', + 'date': '2025-04-16', + 'track_number': 4, + }; + await pumpNowPlaying( + tester, + theme: MornyeTheme.build( + Brightness.dark, + ).copyWith(platform: TargetPlatform.android), + size: const Size(393, 852), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + expect(metadataReads, isEmpty); + + await tester.tap(find.byIcon(CupertinoIcons.ellipsis).hitTestable()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Details')); + await tester.pumpAndSettle(); + + expect(metadataReads, ['content://library/first.flac']); + final details = tester.widget( + find.byType(MornyePlayerDetailsSheet), + ); + expect( + details.rows, + containsAll([ + ('Title', 'First'), + ('Album', 'Album'), + ('Album Artist', 'Album Artist'), + ('Genre', 'Pop/Rock'), + ('Composer', 'Composer Name'), + ('Date', '2025-04-16'), + ('Track #', '4'), + ]), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'Mornye Details keeps long values readable and Done visible while scrolling', + (tester) async { + final rights = List.filled(10, 'A long copyright value').join(' '); + metadataOverrides = { + 'title': 'First', + 'artist': 'Artist', + 'album': 'Album', + 'genre': 'Rock', + 'composer': 'Composer', + 'isrc': 'USAAA2600001', + 'copyright': rights, + 'format': 'flac', + 'sample_rate': 96000, + 'bit_depth': 24, + }; + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.light), + size: const Size(393, 780), + ); + mediaItems.add( + item('first').copyWith(extras: {'source': '/library/first.flac'}), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(CupertinoIcons.ellipsis).hitTestable()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Details')); + await tester.pumpAndSettle(); + final sheet = find.byType(MornyePlayerDetailsSheet); + expect(sheet, findsOneWidget); + expect(Theme.of(tester.element(sheet)).brightness, Brightness.dark); + expect( + find.descendant(of: sheet, matching: find.byType(Card)), + findsNothing, + ); + expect( + find.descendant(of: sheet, matching: find.byType(MornyeGlass)), + findsOneWidget, + ); + final titleRow = find.widgetWithText(MornyeMetadataRow, 'Title'); + expect( + tester + .getTopLeft( + find.descendant(of: titleRow, matching: find.text('First')), + ) + .dy, + greaterThan(tester.getBottomLeft(find.text('Title')).dy), + ); + await tester.scrollUntilVisible( + find.text(rights), + 200, + scrollable: find.descendant( + of: sheet, + matching: find.byType(Scrollable), + ), + ); + expect(tester.getSize(find.text(rights)).height, greaterThan(40)); + expect(find.text('Done').hitTestable(), findsOneWidget); + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); + expect(sheet, findsNothing); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'Mornye star shares Library favorites across tracks and compact headers', + (tester) async { + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.light), + size: const Size(393, 780), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(NowPlayingScreen)), + ); + final star = find.byIcon(CupertinoIcons.star).hitTestable(); + expect(star, findsOneWidget); + await tester.tap(star); + await tester.pumpAndSettle(); + expect( + find.byIcon(CupertinoIcons.star_fill).hitTestable(), + findsOneWidget, + ); + expect( + container.read(libraryCollectionsProvider).loved.single.track.id, + 'first', + ); + await tester.tap(find.byIcon(CupertinoIcons.list_bullet)); + await tester.pumpAndSettle(); + expect( + find.byIcon(CupertinoIcons.star_fill).hitTestable(), + findsOneWidget, + ); + mediaItems.add(item('second')); + await tester.pumpAndSettle(); + expect(find.byIcon(CupertinoIcons.star).hitTestable(), findsOneWidget); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + expect( + find.byIcon(CupertinoIcons.star_fill).hitTestable(), + findsOneWidget, + ); + + // A change from Library must also update the player, without reopening it. + final track = container + .read(libraryCollectionsProvider) + .loved + .single + .track; + await container + .read(libraryCollectionsProvider.notifier) + .toggleLoved(track); + await tester.pumpAndSettle(); + expect(find.byIcon(CupertinoIcons.star).hitTestable(), findsOneWidget); + expect( + find.byType(MornyePlayerFavoriteButton).hitTestable(), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('Mornye lyrics stay blurred until playback starts', ( + tester, + ) async { + final playback = StreamController(); + addTearDown(playback.close); + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 780), + playbackEvents: playback.stream, + ); + mediaItems.add(item('many')); + playback.add( + PlaybackState( + processingState: AudioProcessingState.loading, + playing: true, + updatePosition: const Duration(seconds: 2), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + void expectLine(String text, {required bool active}) { + final line = find.text(text); + final filter = tester.widget( + find.ancestor(of: line, matching: find.byType(ImageFiltered)).first, + ); + final opacity = tester.widget( + find.ancestor(of: line, matching: find.byType(AnimatedOpacity)).first, + ); + expect(opacity.opacity, active ? 1 : 0.48); + expect(filter.enabled, !active, reason: '$text: ${filter.imageFilter}'); + } + + expectLine('First line', active: false); + expectLine('Second line', active: false); + expectLine('Third line', active: false); + + playback.add(PlaybackState(processingState: AudioProcessingState.ready)); + await tester.pumpAndSettle(); + expectLine('First line', active: false); + + playback.add( + PlaybackState( + processingState: AudioProcessingState.ready, + playing: true, + updatePosition: const Duration(seconds: 2), + ), + ); + await tester.pump(); + for (var frame = 0; frame < 3; frame++) { + await tester.pump(const Duration(milliseconds: 160)); + } + expectLine('First line', active: false); + expectLine('Second line', active: true); + expectLine('Third line', active: false); + + playback.add( + PlaybackState( + processingState: AudioProcessingState.ready, + updatePosition: const Duration(seconds: 2), + ), + ); + await tester.pumpAndSettle(); + expectLine('Second line', active: true); + + playback.add( + PlaybackState( + processingState: AudioProcessingState.buffering, + playing: true, + updatePosition: const Duration(seconds: 2), + ), + ); + await tester.pump(); + for (var frame = 0; frame < 3; frame++) { + await tester.pump(const Duration(milliseconds: 160)); + } + expectLine('First line', active: false); + expectLine('Second line', active: false); + expectLine('Third line', active: false); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'Mornye lyrics keep the active line sharp with bold leading-aligned text', + (tester) async { + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.light), + size: const Size(393, 780), + playback: PlaybackState(updatePosition: const Duration(seconds: 2)), + ); + mediaItems.add(item('many')); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + final active = find.text('Second line'); + final inactive = find.text('Third line'); + final activeText = tester.widget(active); + expect(activeText.textAlign, TextAlign.start); + expect(activeText.style?.fontWeight, FontWeight.bold); + expect(activeText.style?.fontSize, 28); + expect(tester.getTopLeft(active).dx, tester.getTopLeft(inactive).dx); + expect( + tester.getTopLeft(active).dx, + tester.getTopLeft(find.byType(ListView)).dx + 24, + ); + final activeFilters = tester.widgetList( + find.ancestor(of: active, matching: find.byType(ImageFiltered)), + ); + expect(activeFilters.any((filter) => !filter.enabled), isTrue); + final inactiveFilters = tester.widgetList( + find.ancestor(of: inactive, matching: find.byType(ImageFiltered)), + ); + expect( + inactiveFilters.any( + (filter) => + filter.enabled && + filter.imageFilter == ImageFilter.blur(sigmaX: 0.6, sigmaY: 0.6), + ), + isTrue, + ); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'Mornye queue replaces artwork while volume and playback stay in place', + (tester) async { + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.light), + size: const Size(393, 780), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + expect(find.text('First lyric').hitTestable(), findsOneWidget); + final volume = tester.getRect(find.byType(MornyeVolumeControl)); + await tester.tap(find.byIcon(CupertinoIcons.list_bullet)); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + expect(find.byType(MornyePlayerQueue), findsOneWidget); + expect(find.byType(BottomSheet), findsNothing); + expect(tester.getRect(find.byType(MornyeVolumeControl)), volume); + expect(tester.takeException(), isNull); + await tester.tap(find.byIcon(CupertinoIcons.list_bullet)); + await tester.pump(); + expect(find.text('First lyric'), findsNothing); + await tester.pump(const Duration(milliseconds: 120)); + expect(find.text('First lyric'), findsNothing); + expect(find.byType(MornyePlayerQueue), findsOneWidget); + expect(find.byType(MornyePlayerQueue).hitTestable(), findsNothing); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + expect(find.byType(MornyePlayerQueue), findsNothing); + expect(tester.getRect(find.byType(MornyeVolumeControl)), volume); + }, + ); + + testWidgets('Mornye player renders Apple-style transport controls', ( + tester, + ) async { + await pumpNowPlaying(tester, theme: MornyeTheme.build(Brightness.light)); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + + expect(find.byIcon(CupertinoIcons.play_fill), findsOneWidget); + expect(find.byIcon(CupertinoIcons.backward_fill), findsOneWidget); + expect(find.byIcon(CupertinoIcons.forward_fill), findsOneWidget); + expect(find.byType(MornyeVolumeControl), findsOneWidget); + expect(tester.takeException(), isNull); + + final nextButton = find.widgetWithIcon( + MornyePlaybackButton, + CupertinoIcons.forward_fill, + ); + final nextState = tester.state(nextButton); + mediaItems.add(item('second')); + await tester.pumpAndSettle(); + expect(tester.state(nextButton), same(nextState)); + }); + + testWidgets('Mornye elapsed and remaining times roll on the same second', ( + tester, + ) async { + final playbackEvents = StreamController(); + addTearDown(playbackEvents.close); + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 780), + playbackEvents: playbackEvents.stream, + ); + mediaItems.add( + item( + 'first', + ).copyWith(duration: const Duration(seconds: 180, milliseconds: 600)), + ); + + Future positionAt(int milliseconds) async { + playbackEvents.add( + PlaybackState( + processingState: AudioProcessingState.ready, + playing: false, + updatePosition: Duration(milliseconds: milliseconds), + ), + ); + await tester.pump(); + await tester.pump(); + } + + final elapsed = find.byKey(const ValueKey('elapsed:first')); + final remaining = find.byKey(const ValueKey('remaining:first')); + void expectTimes(int elapsedSeconds, int remainingSeconds) { + expect( + tester.widget(elapsed).seconds, + elapsedSeconds, + ); + expect( + tester.widget(remaining).seconds, + remainingSeconds, + ); + } + + await positionAt(34200); + await tester.pumpAndSettle(); + expectTimes(34, 146); + await positionAt(34800); + expectTimes(34, 146); + + await positionAt(35000); + await tester.pump(const Duration(milliseconds: 80)); + expectTimes(35, 145); + double incomingOffset(Finder label) => tester + .widget( + find + .ancestor( + of: find.descendant(of: label, matching: find.text('5')), + matching: find.byType(SlideTransition), + ) + .first, + ) + .position + .value + .dy; + expect(incomingOffset(elapsed), greaterThan(0)); + expect(incomingOffset(remaining), -incomingOffset(elapsed)); + + // Another position report within this second must not restart either roll. + await positionAt(35200); + await tester.pump(const Duration(milliseconds: 200)); + expect(incomingOffset(elapsed), 0); + expect(incomingOffset(remaining), 0); + await positionAt(35900); + expectTimes(35, 145); + await positionAt(36000); + expectTimes(36, 144); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + testWidgets('Mornye volume stays above the bottom actions on a phone', ( + tester, + ) async { + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 700), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + + final volume = find.byType(MornyeVolumeControl); + final lyricsButton = find.byIcon(CupertinoIcons.quote_bubble); + final slider = find.descendant(of: volume, matching: find.byType(Slider)); + expect( + tester.getRect(volume).bottom, + lessThan(tester.getRect(lyricsButton).top), + ); + expect(tester.getSize(slider).height, greaterThanOrEqualTo(44)); + final gesture = await tester.startGesture(tester.getCenter(slider)); + await gesture.moveBy(const Offset(60, 0)); + await tester.pump(const Duration(milliseconds: 16)); + expect(volumeWrites, isNotEmpty); + expect(volumeWrites.last, greaterThan(0.5)); + await gesture.up(); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + expect(volumeWrites, isNotEmpty); + expect(volumeWrites.last, greaterThan(0.5)); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'Mornye landscape shows lyrics automatically without bottom actions', + (tester) async { + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 852), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + + tester.view.physicalSize = const Size(852, 393); + tester.view.padding = FakeViewPadding(left: 59, right: 59, bottom: 21); + addTearDown(tester.view.resetPadding); + await tester.pumpAndSettle(); + + final artwork = find.byType(Hero); + final volume = find.byType(MornyeVolumeControl); + final artRect = tester.getRect(artwork); + expect(artRect.width, closeTo(artRect.height, 0.1)); + expect(artRect.width, greaterThan(140)); + expect(artRect.top, greaterThanOrEqualTo(36)); + final header = find.text('First').hitTestable(); + final lyric = find.text('First lyric').hitTestable(); + expect(header, findsOneWidget); + expect(lyric, findsOneWidget); + expect(tester.getRect(header).left, greaterThan(artRect.right)); + expect(tester.getRect(lyric).left, greaterThan(artRect.right)); + expect( + tester.getRect(lyric).top, + greaterThan(tester.getRect(header).bottom), + ); + expect(volume, findsNothing); + expect(find.byIcon(CupertinoIcons.quote_bubble), findsNothing); + expect(find.byIcon(CupertinoIcons.list_bullet), findsNothing); + expect(tester.takeException(), isNull); + + await tester.tap(artwork); + await tester.pumpAndSettle(); + expect(volume.hitTestable(), findsOneWidget); + expect( + find.byIcon(CupertinoIcons.play_fill).hitTestable(), + findsOneWidget, + ); + final volumeSlider = find.descendant( + of: volume, + matching: find.byType(Slider), + ); + final drag = await tester.startGesture(tester.getCenter(volumeSlider)); + await drag.moveBy(const Offset(30, 0)); + await tester.pump(const Duration(seconds: 5)); + expect(volume.hitTestable(), findsOneWidget); + expect(volumeWrites, isNotEmpty); + await drag.up(); + await tester.pump(const Duration(seconds: 4)); + await tester.pumpAndSettle(); + expect(volume, findsNothing); + expect(find.text('First lyric').hitTestable(), findsOneWidget); + + mediaItems.add(item('second')); + await tester.pumpAndSettle(); + expect(find.text('Second lyric').hitTestable(), findsOneWidget); + expect(find.text('First lyric'), findsNothing); + tester.view.physicalSize = const Size(393, 852); + tester.view.resetPadding(); + await tester.pumpAndSettle(); + expect( + find.byIcon(CupertinoIcons.quote_bubble).hitTestable(), + findsOneWidget, + ); + expect( + find.byIcon(CupertinoIcons.list_bullet).hitTestable(), + findsOneWidget, + ); + expect( + tester.getRect(artwork).bottom, + lessThan(tester.getRect(volume).top), + ); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('Mornye cover stays attached throughout opening and closing', ( + tester, + ) async { + tester.view.padding = FakeViewPadding(top: 59, bottom: 34); + tester.view.viewPadding = FakeViewPadding(top: 59, bottom: 34); + addTearDown(tester.view.resetPadding); + addTearDown(tester.view.resetViewPadding); + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 852), + wrapPlayer: (_) => Consumer( + builder: (context, ref, _) { + ref.watch(currentMediaItemProvider); + return Scaffold( + body: Align( + alignment: Alignment.bottomLeft, + child: TextButton( + onPressed: () => Navigator.of(context).push(NowPlayingRoute()), + child: const Hero( + tag: kNowPlayingArtworkHeroTag, + child: SizedBox.square(dimension: 38, child: Text('Open')), + ), + ), + ), + ); + }, + ), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open')); + await tester.pump(); + + void expectAttached() { + final panel = tester.getRect(find.byType(MornyePlayerBackground)); + final artwork = tester.getRect(find.byType(MornyePlayerArtwork)); + expect(artwork.left, closeTo(panel.left, 0.01)); + expect(artwork.top, closeTo(panel.top, 0.01)); + expect(artwork.width, closeTo(393, 0.01)); + expect(artwork.height, closeTo(393, 0.01)); + } + + for (var frame = 0; frame < 8; frame++) { + await tester.pump(const Duration(milliseconds: 40)); + expectAttached(); + } + await tester.pumpAndSettle(); + expectAttached(); + Navigator.of(tester.element(find.byType(NowPlayingScreen))).pop(); + await tester.pump(); + for (var frame = 0; frame < 5; frame++) { + await tester.pump(const Duration(milliseconds: 40)); + expectAttached(); + expect( + tester.getTopLeft(find.byType(MornyePlayerBackground)).dy, + greaterThan(0), + ); + } + await tester.pumpAndSettle(); + expect(find.byType(NowPlayingScreen), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('Mornye lyrics replace artwork while controls stay in place', ( + tester, + ) async { + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 700), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + + final artwork = find.byType(Hero); + final fullArtwork = tester.getRect(artwork); + expect(fullArtwork.height, fullArtwork.width); + final volume = find.byType(MornyeVolumeControl); + final volumeRect = tester.getRect(volume); + final play = find.byIcon(CupertinoIcons.play_fill); + final playRect = tester.getRect(play); + final lyricsButton = find.byIcon(CupertinoIcons.quote_bubble); + await tester.tap(lyricsButton); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + final fullCover = find.byKey(const ValueKey('full-player-artwork')); + double fullCoverOpacity() => tester + .widget( + find + .ancestor(of: fullCover, matching: find.byType(FadeTransition)) + .first, + ) + .opacity + .value; + expect(fullCover, findsOneWidget); + expect(fullCoverOpacity(), inExclusiveRange(0.0, 1.0)); + await tester.pumpAndSettle(); + + expect(find.byType(PageView), findsNothing); + expect(find.text('First lyric').hitTestable(), findsOneWidget); + expect(tester.getSize(artwork).width, lessThan(fullArtwork.width)); + expect(fullArtwork.top, 0); + expect(fullArtwork.width, 393); + expect(tester.getRect(volume), volumeRect); + expect(tester.getRect(play), playRect); + + mediaItems.add(item('second')); + await tester.pumpAndSettle(); + expect(find.text('Second lyric').hitTestable(), findsOneWidget); + expect(find.text('First lyric'), findsNothing); + + await tester.tap(lyricsButton); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + expect(fullCoverOpacity(), inExclusiveRange(0.0, 1.0)); + expect( + find.descendant( + of: find.byType(MornyePlayerBackground), + matching: find.byType(Hero), + ), + findsOneWidget, + ); + await tester.pumpAndSettle(); + expect(tester.getRect(artwork), fullArtwork); + expect(find.text('Second lyric').hitTestable(), findsNothing); + expect(tester.takeException(), isNull); + }); + testWidgets( 'automatic SAF track change refreshes lyrics while Lyrics page is active', (tester) async { @@ -92,6 +955,68 @@ void main() { }, ); + testWidgets('timed lyric fills text fragments in reading order', ( + tester, + ) async { + const lyricText = 'AAAA BBBB CCCC DDDD EEEE FFFF GGGG HHHH'; + metadataOverrides['lyrics'] = + '
' + '

' + '$lyricText' + '

'; + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(320, 844), + playback: PlaybackState( + processingState: AudioProcessingState.ready, + playing: false, + updatePosition: const Duration(seconds: 2), + ), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); + await tester.pumpAndSettle(); + + final paintFinder = find.descendant( + of: find.bySemanticsLabel(lyricText), + matching: find.byType(CustomPaint), + ); + final painter = tester.widget(paintFinder).painter!; + final size = tester.getSize(paintFinder); + final highlightedPixels = await tester.runAsync(() async { + final recorder = ui.PictureRecorder(); + painter.paint(Canvas(recorder), size); + final picture = recorder.endRecording(); + final image = await picture.toImage( + size.width.ceil(), + size.height.ceil(), + ); + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + var upper = 0; + var lower = 0; + for (var y = 0; y < image.height; y++) { + for (var x = 0; x < image.width; x++) { + if (bytes.getUint8((y * image.width + x) * 4 + 3) < 230) continue; + if (y < image.height ~/ 2) { + upper++; + } else { + lower++; + } + } + } + image.dispose(); + picture.dispose(); + return (upper, lower); + }); + expect(highlightedPixels!.$1, greaterThan(0)); + expect(highlightedPixels.$2, 0); + expect(tester.takeException(), isNull); + }); + testWidgets('short timed lyric remains horizontally centered', ( tester, ) async { @@ -140,3 +1065,27 @@ void main() { expect(find.text('Turn off sleep timer'), findsNothing); }); } + +class _TestCollections extends LibraryCollectionsNotifier { + @override + LibraryCollectionsState build() => LibraryCollectionsState(isLoaded: true); + + @override + Future toggleLoved(Track track) async { + final key = trackCollectionKey(track); + final added = !state.isLoved(track); + state = state.copyWith( + loved: added + ? [ + ...state.loved, + CollectionTrackEntry( + key: key, + track: track, + addedAt: DateTime.now(), + ), + ] + : state.loved.where((entry) => entry.key != key).toList(), + ); + return added; + } +} diff --git a/test/now_playing_route_test.dart b/test/now_playing_route_test.dart new file mode 100644 index 00000000..2575eb58 --- /dev/null +++ b/test/now_playing_route_test.dart @@ -0,0 +1,166 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/screens/now_playing_screen.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +const _sheet = Key('player-sheet'); + +class _TestPlayerRoute extends NowPlayingRoute { + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) => const ColoredBox(key: _sheet, color: Colors.black); +} + +void main() { + for (final brightness in Brightness.values) { + testWidgets( + 'player fills safe areas and reveals the page only when dragged in $brightness', + (tester) async { + tester.view.physicalSize = const Size(393, 852); + tester.view.devicePixelRatio = 1; + tester.view.padding = FakeViewPadding(top: 59, bottom: 34); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPadding); + final navigator = GlobalKey(); + final capture = GlobalKey(); + const background = Color(0xffff00ff); + final theme = MornyeTheme.build(brightness); + final playerBackground = theme.colorScheme.surface; + await tester.pumpWidget( + RepaintBoundary( + key: capture, + child: ProviderScope( + overrides: [ + currentMediaItemProvider.overrideWith( + (ref) => Stream.value(null), + ), + ], + child: MaterialApp( + navigatorKey: navigator, + theme: theme, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ColoredBox( + color: background, + child: const SizedBox.expand(), + ), + ), + ), + ), + ); + final route = NowPlayingRoute(); + navigator.currentState!.push(route); + await tester.pumpAndSettle(); + + Future expectColorAt(int x, int y, Color expected) async { + final pixel = await tester.runAsync(() async { + final boundary = tester.renderObject( + find.byKey(capture), + ); + final image = await boundary.toImage(); + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + final offset = (y * image.width + x) * 4; + final color = Color.fromARGB( + bytes.getUint8(offset + 3), + bytes.getUint8(offset), + bytes.getUint8(offset + 1), + bytes.getUint8(offset + 2), + ); + image.dispose(); + return color; + }); + expect(pixel, expected); + } + + await expectColorAt(196, 30, playerBackground); + await expectColorAt(1, 1, playerBackground); + await expectColorAt(196, 840, playerBackground); + expect(tester.getTopLeft(find.byType(AppBar).last).dy, 0); + expect( + tester.getTopLeft(find.byIcon(Icons.keyboard_arrow_down)).dy, + greaterThanOrEqualTo(59), + ); + route.startDrag(); + route.updateDrag( + DragUpdateDetails( + globalPosition: const Offset(0, 213), + delta: const Offset(0, 213), + primaryDelta: 213, + ), + 852, + ); + await tester.pump(); + await expectColorAt(196, 100, background); + await expectColorAt(196, 243, playerBackground); + route.cancelDrag(); + await tester.pumpAndSettle(); + await expectColorAt(196, 30, playerBackground); + expect(tester.takeException(), isNull); + }, + ); + } + + for (final dismiss in [false, true]) { + testWidgets('Player drag settles without a position jump (pop: $dismiss)', ( + tester, + ) async { + final navigator = GlobalKey(); + await tester.pumpWidget( + MaterialApp(navigatorKey: navigator, home: const SizedBox()), + ); + final route = _TestPlayerRoute(); + navigator.currentState!.push(route); + await tester.pumpAndSettle(); + final height = tester.getSize(find.byKey(_sheet)).height; + + route.startDrag(); + route.updateDrag( + DragUpdateDetails( + globalPosition: Offset(0, height * 0.4), + primaryDelta: height * 0.4, + delta: Offset(0, height * 0.4), + ), + height, + ); + await tester.pump(); + final releasedTop = tester.getTopLeft(find.byKey(_sheet)).dy; + if (dismiss) { + route.endDrag(DragEndDetails(primaryVelocity: 0), height); + } else { + route.cancelDrag(); + } + await tester.pump(); + expect( + tester.getTopLeft(find.byKey(_sheet)).dy, + closeTo(releasedTop, 0.1), + ); + + var previousTop = releasedTop; + for (var frame = 0; frame < 5; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + final top = tester.getTopLeft(find.byKey(_sheet)).dy; + expect(top, dismiss ? greaterThan(previousTop) : lessThan(previousTop)); + previousTop = top; + } + await tester.pumpAndSettle(); + if (dismiss) { + expect(find.byKey(_sheet), findsNothing); + } else { + expect(tester.getTopLeft(find.byKey(_sheet)).dy, 0); + } + expect(tester.takeException(), isNull); + }); + } +} diff --git a/test/playback_artwork_test.dart b/test/playback_artwork_test.dart new file mode 100644 index 00000000..0a7e11aa --- /dev/null +++ b/test/playback_artwork_test.dart @@ -0,0 +1,76 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/utils/playback_artwork.dart'; + +void main() { + late Directory directory; + setUp(() async { + directory = await Directory.systemTemp.createTemp('restored-artwork-'); + }); + tearDown(() => directory.delete(recursive: true)); + + test( + 'restored queue replaces a removed scan cover with current Library art', + () async { + final removed = File( + '${directory.path}/old-scan/cover.jpg', + ).uri.toString(); + final current = File('${directory.path}/current cover #1.jpg'); + await current.writeAsBytes([1]); + final resolved = await resolveRestoredArtworkUri( + removed, + loadLibraryCoverPath: () async => current.path, + ); + expect(resolved, current.uri.toString()); + expect(File(Uri.parse(resolved!).toFilePath()).existsSync(), isTrue); + }, + ); + + test( + 'valid local and remote covers do not trigger Library lookups', + () async { + final current = File('${directory.path}/cover.jpg'); + await current.writeAsBytes([1]); + for (final artwork in [ + current.path, + current.uri.toString(), + 'https://example.com/cover.jpg', + ]) { + var lookups = 0; + final resolved = await resolveRestoredArtworkUri( + artwork, + loadLibraryCoverPath: () async { + lookups++; + return null; + }, + ); + expect(resolved, artwork); + expect(lookups, 0); + } + }, + ); + + test( + 'a missing Library image or lookup failure leaves the session usable', + () async { + final missing = File('${directory.path}/missing.jpg').uri.toString(); + expect( + await resolveRestoredArtworkUri( + missing, + loadLibraryCoverPath: () async => + '${directory.path}/also-missing.jpg', + ), + missing, + ); + expect( + await resolveRestoredArtworkUri( + missing, + loadLibraryCoverPath: () async => + throw StateError('Library unavailable'), + ), + missing, + ); + }, + ); +} diff --git a/test/player_motion_artwork_test.dart b/test/player_motion_artwork_test.dart new file mode 100644 index 00000000..0de4886d --- /dev/null +++ b/test/player_motion_artwork_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart'; + +void main() { + const album = (album: 'An Album', artist: 'An Artist'); + + test( + 'motion lookup checks album and artist before loading metadata', + () async { + final loaded = []; + final result = await findPlayerMotionArtwork( + album: album, + providerIds: ['provider-a'], + search: (_, _) async => [ + { + 'id': 'wrong-artist', + 'name': 'An Album', + 'artists': 'Not An Artist', + }, + { + 'id': 'wrong-album', + 'name': 'Another Album', + 'artists': 'An Artist', + }, + {'id': 'match', 'name': 'AN ALBUM', 'artists': 'An Artist'}, + ], + loadAlbum: (_, id) async { + loaded.add(id); + return { + 'album_info': {'header_video': 'https://example.com/cover.m3u8'}, + }; + }, + ); + expect(loaded, ['match']); + expect(result, 'https://example.com/cover.m3u8'); + }, + ); + + test('motion lookup falls back to another provider after an error', () async { + final result = await findPlayerMotionArtwork( + album: album, + providerIds: ['provider-a', 'provider-b'], + search: (provider, _) async { + if (provider == 'provider-a') throw StateError('offline'); + return [ + { + 'name': 'An Album', + 'artists': 'An Artist', + 'header_video': 'https://example.com/motion.m3u8', + }, + ]; + }, + loadAlbum: (_, _) async => + throw StateError('Direct artwork needs no lookup'), + ); + expect(result, 'https://example.com/motion.m3u8'); + }); + + test('unavailable or unsupported motion keeps the static cover', () async { + final result = await findPlayerMotionArtwork( + album: album, + providerIds: ['provider-a'], + search: (_, _) async => [ + { + 'name': 'An Album', + 'artists': 'An Artist', + 'header_video': 'file:///invalid.m3u8', + }, + ], + loadAlbum: (_, _) async => {}, + ); + expect(result, isNull); + }); +} diff --git a/test/shell_navigation_service_test.dart b/test/shell_navigation_service_test.dart index 382c35a8..01519fb7 100644 --- a/test/shell_navigation_service_test.dart +++ b/test/shell_navigation_service_test.dart @@ -5,6 +5,71 @@ import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/widgets/view_queue_snackbar_action.dart'; void main() { + testWidgets('first search request reaches a lazily mounted Home tab', ( + tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: _SearchShell())); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsNothing); + + await tester.tap(find.text('Search')); + await tester.pumpAndSettle(); + final field = tester.widget(find.byType(TextField)); + expect(field.focusNode!.hasFocus, isTrue); + expect(tester.testTextInput.isVisible, isTrue); + }); + + testWidgets('one search request returns from an album and focuses Home', ( + tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: _SearchShell())); + await tester.tap(find.text('Search')); + await tester.pumpAndSettle(); + final navigator = ShellNavigationService.homeTabNavigatorKey.currentState!; + navigator.push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Album')), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Search')); + await tester.pumpAndSettle(); + expect(find.text('Album'), findsNothing); + final field = tester.widget(find.byType(TextField)); + expect(field.focusNode!.hasFocus, isTrue); + expect(tester.testTextInput.isVisible, isTrue); + }); + + testWidgets( + 'search selects Home before requesting focus and stops when shell is absent', + (tester) async { + final owner = Object(); + var selectedHome = false; + var focusRequests = 0; + void onSearch() { + expect(selectedHome, isTrue); + focusRequests++; + } + + ShellNavigationService.homeSearchRequests.addListener(onSearch); + addTearDown(() { + ShellNavigationService.homeSearchRequests.removeListener(onSearch); + ShellNavigationService.unregisterTabSelectionHandler(owner); + }); + ShellNavigationService.registerTabSelectionHandler( + owner: owner, + handler: (tab) => selectedHome = tab == ShellTab.home, + ); + ShellNavigationService.requestHomeSearch(); + expect(focusRequests, 0); + await tester.pump(); + expect(focusRequests, 1); + ShellNavigationService.unregisterTabSelectionHandler(owner); + ShellNavigationService.requestHomeSearch(); + expect(focusRequests, 1); + }, + ); + group('ShellNavigationService tab requests', () { test('forwards a named tab request to the registered shell', () { final owner = Object(); @@ -88,3 +153,102 @@ void main() { }); }); } + +class _SearchShell extends StatefulWidget { + const _SearchShell(); + + @override + State<_SearchShell> createState() => _SearchShellState(); +} + +class _SearchShellState extends State<_SearchShell> { + final _pages = PageController(initialPage: 1); + late final _observer = ShellChromeObserver( + ShellNavigationService.homeTabNavigatorKey, + ); + + @override + void initState() { + super.initState(); + ShellNavigationService.syncState(currentTabIndex: 1, showRepoTab: false); + ShellNavigationService.registerTabSelectionHandler( + owner: this, + handler: (_) { + ShellNavigationService.syncState( + currentTabIndex: 0, + showRepoTab: false, + ); + _pages.jumpToPage(0); + }, + ); + } + + @override + void dispose() { + ShellNavigationService.unregisterTabSelectionHandler(this); + _observer.detach(); + _pages.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + body: Column( + children: [ + TextButton( + onPressed: ShellNavigationService.requestHomeSearch, + child: const Text('Search'), + ), + Expanded( + child: PageView.builder( + controller: _pages, + itemCount: 2, + itemBuilder: (_, index) => index == 1 + ? const Text('Library') + : Navigator( + key: ShellNavigationService.homeTabNavigatorKey, + observers: [_observer], + onGenerateRoute: (_) => MaterialPageRoute( + builder: (_) => const _SearchHome(), + ), + ), + ), + ), + ], + ), + ); +} + +class _SearchHome extends StatefulWidget { + const _SearchHome(); + + @override + State<_SearchHome> createState() => _SearchHomeState(); +} + +class _SearchHomeState extends State<_SearchHome> { + final _focus = FocusNode(); + + @override + void initState() { + super.initState(); + ShellNavigationService.homeSearchRequests.addListener(_onSearch); + } + + void _onSearch() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _focus.requestFocus(); + }); + } + + @override + void dispose() { + ShellNavigationService.homeSearchRequests.removeListener(_onSearch); + _focus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => + Scaffold(body: TextField(focusNode: _focus)); +} diff --git a/test/track_metadata_header_contrast_test.dart b/test/track_metadata_header_contrast_test.dart index b6c69238..0eb94d8d 100644 --- a/test/track_metadata_header_contrast_test.dart +++ b/test/track_metadata_header_contrast_test.dart @@ -1,13 +1,153 @@ +import 'dart:io'; +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/download_history_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; +import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/mornye_metadata_row.dart'; void main() { + for (final useFileUri in [false, true]) { + testWidgets('Mornye metadata renders local artwork (URI=$useFileUri)', ( + tester, + ) async { + final cover = await tester.runAsync(() async { + final directory = await Directory.systemTemp.createTemp( + 'metadata-cover-', + ); + addTearDown(() => directory.delete(recursive: true)); + final recorder = ui.PictureRecorder(); + Canvas(recorder).drawColor(Colors.green, BlendMode.src); + final picture = recorder.endRecording(); + final image = await picture.toImage(32, 32); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + picture.dispose(); + return File('${directory.path}/cover with spaces #1.png') + ..writeAsBytesSync(bytes!.buffer.asUint8List()); + }); + final item = LocalLibraryItem( + id: 'local-cover-track', + trackName: 'Track with artwork', + artistName: 'Artist', + albumName: 'Album', + filePath: '/missing/local-cover-track.flac', + coverPath: useFileUri ? cover!.uri.toString() : cover!.path, + scannedAt: DateTime(2026), + ); + await tester.runAsync(() async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: TrackMetadataScreen(localItem: item), + ), + ), + ); + await precacheImage( + FileImage(cover), + tester.element(find.byType(TrackMetadataScreen)), + ); + }); + await tester.pumpAndSettle(); + + final artwork = find.descendant( + of: find.byType(Hero), + matching: find.byType(RawImage), + ); + expect(artwork, findsOneWidget); + expect(tester.widget(artwork).image, isNotNull); + expect(find.byIcon(Icons.music_note), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await FileImage(cover).evict(); + }); + } + + for (final brightness in Brightness.values) { + testWidgets( + 'Mornye metadata has single-level sections and stacked values ($brightness)', + (tester) async { + await tester.binding.setSurfaceSize(const Size(320, 780)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final item = DownloadHistoryItem( + id: 'metadata-track', + trackName: 'A long track title for a narrow screen', + artistName: 'Artist', + albumName: 'Album', + filePath: '/missing/track.flac', + service: 'provider-a', + downloadedAt: DateTime(2026), + isrc: 'USAAA2600001', + ); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(brightness), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(1.6)), + child: child!, + ), + home: TrackMetadataScreen(item: item), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byIcon(CupertinoIcons.chevron_back), findsOneWidget); + expect(find.byIcon(CupertinoIcons.ellipsis), findsOneWidget); + expect(find.byType(Card), findsNothing); + final row = find.widgetWithText(MornyeMetadataRow, 'Track name'); + expect( + find.ancestor(of: row, matching: find.byType(MornyeGlass)), + findsNothing, + ); + expect( + find.ancestor( + of: row, + matching: find.byWidgetPredicate( + (widget) => + widget is Material && + widget.color == MornyeTheme.controlFill(tester.element(row)), + ), + ), + findsOneWidget, + ); + final value = find.descendant( + of: row, + matching: find.text(item.trackName), + ); + expect( + tester.getTopLeft(value).dy, + greaterThan(tester.getBottomLeft(find.text('Track name')).dy), + ); + expect( + tester.widget(value).style?.color, + MornyeTheme.build(brightness).colorScheme.onSurface, + ); + await tester.tap(find.byIcon(CupertinoIcons.ellipsis)); + await tester.pumpAndSettle(); + expect(find.byIcon(CupertinoIcons.square_stack), findsOneWidget); + expect(find.byIcon(CupertinoIcons.gear_alt), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + } + testWidgets('metadata hero keeps technical text legible in light theme', ( tester, ) async {