From bc44125a43a9dfdff581a5bdccaa65ec369b1fa7 Mon Sep 17 00:00:00 2001 From: violet-dev Date: Sun, 28 Sep 2025 21:04:40 +0900 Subject: [PATCH 1/5] Add lite-mode --- violet/assets/locale/en.json | 6 +- violet/assets/locale/eo.json | 6 +- violet/assets/locale/it.json | 6 +- violet/assets/locale/ja.json | 6 +- violet/assets/locale/ko.json | 6 +- violet/assets/locale/pt.json | 6 +- violet/assets/locale/zh.json | 6 +- violet/assets/locale/zh_Hans.json | 6 +- violet/assets/locale/zh_Hant.json | 6 +- violet/lib/main.dart | 106 +++++++++---- violet/lib/pages/bookmark/bookmark_page.dart | 144 ++++++++++++------ .../group/group_article_list_page.dart | 66 ++++++++ violet/lib/pages/search/search_page.dart | 97 +++++++++--- violet/lib/pages/settings/settings_page.dart | 34 ++++- violet/lib/settings/settings.dart | 1 + violet/rag/bookmark_page.md | 2 + violet/rag/component_infrastructure.md | 1 + violet/rag/component_search.md | 1 + .../2024-12-lite-mode-implementation.md | 23 +++ violet/rag/history/2024-12-lite-mode-plan.md | 72 +++++++++ 20 files changed, 484 insertions(+), 117 deletions(-) create mode 100644 violet/rag/history/2024-12-lite-mode-implementation.md create mode 100644 violet/rag/history/2024-12-lite-mode-plan.md diff --git a/violet/assets/locale/en.json b/violet/assets/locale/en.json index 1cb735689..f0e7da129 100644 --- a/violet/assets/locale/en.json +++ b/violet/assets/locale/en.json @@ -315,5 +315,7 @@ "exitTheApp": "Exit the app", "ignoretimeout": "Ignore Timeout", "cropbookmark": "Crop Bookmark", - "cropbookmarkdesc": "Expore your crop images!" -} + "cropbookmarkdesc": "Expore your crop images!", + "litemode": "Lite mode", + "litemodedesc": "Disable animations and shadows to reduce battery and CPU usage." +} \ No newline at end of file diff --git a/violet/assets/locale/eo.json b/violet/assets/locale/eo.json index 7aa2bda20..3a2d00cf8 100644 --- a/violet/assets/locale/eo.json +++ b/violet/assets/locale/eo.json @@ -315,5 +315,7 @@ "exitTheApp": "Exit the app", "ignoretimeout": "Ignore Timeout", "cropbookmark": "Crop Bookmark", - "cropbookmarkdesc": "Expore your crop images!" -} + "cropbookmarkdesc": "Expore your crop images!", + "litemode": "Malpeza reĝimo", + "litemodedesc": "Malŝaltas animaciojn kaj ombrojn por redukti baterian kaj CPU-uzon." +} \ No newline at end of file diff --git a/violet/assets/locale/it.json b/violet/assets/locale/it.json index 935f2e9ca..5c20b0190 100644 --- a/violet/assets/locale/it.json +++ b/violet/assets/locale/it.json @@ -315,5 +315,7 @@ "exitTheApp": "Exit the app", "ignoretimeout": "Ignore Timeout", "cropbookmark": "Crop Bookmark", - "cropbookmarkdesc": "Expore your crop images!" -} + "cropbookmarkdesc": "Expore your crop images!", + "litemode": "Modalità leggera", + "litemodedesc": "Disattiva animazioni e ombre per ridurre il consumo di batteria e CPU." +} \ No newline at end of file diff --git a/violet/assets/locale/ja.json b/violet/assets/locale/ja.json index 295bcb628..c07b955d0 100644 --- a/violet/assets/locale/ja.json +++ b/violet/assets/locale/ja.json @@ -314,5 +314,7 @@ "exitTheApp": "Exit the app", "ignoretimeout": "時間切れを無視", "cropbookmark": "切り取りブックマーク", - "cropbookmarkdesc": "切り取った画像集を閲覧!" -} + "cropbookmarkdesc": "切り取った画像集を閲覧!", + "litemode": "ライトモード", + "litemodedesc": "アニメーションと影を無効化してバッテリーとCPU負荷を抑えます。" +} \ No newline at end of file diff --git a/violet/assets/locale/ko.json b/violet/assets/locale/ko.json index f2b7b55da..b93212439 100644 --- a/violet/assets/locale/ko.json +++ b/violet/assets/locale/ko.json @@ -315,5 +315,7 @@ "exitTheApp": "앱 종료하기", "ignoretimeout": "타임아웃 끄기", "cropbookmark": "크롭 북마크", - "cropbookmarkdesc": "북마크한 크롭 이미지들을 한 번에 살펴보세요!" -} + "cropbookmarkdesc": "북마크한 크롭 이미지들을 한 번에 살펴보세요!", + "litemode": "라이트 모드", + "litemodedesc": "애니메이션과 그림자를 꺼 배터리·CPU 사용을 줄입니다." +} \ No newline at end of file diff --git a/violet/assets/locale/pt.json b/violet/assets/locale/pt.json index e09ae9439..cde6d878f 100644 --- a/violet/assets/locale/pt.json +++ b/violet/assets/locale/pt.json @@ -315,5 +315,7 @@ "exitTheApp": "Exit the app", "ignoretimeout": "Ignore Timeout", "cropbookmark": "Crop Bookmark", - "cropbookmarkdesc": "Expore your crop images!" -} + "cropbookmarkdesc": "Expore your crop images!", + "litemode": "Modo leve", + "litemodedesc": "Desative animações e sombras para reduzir o consumo de bateria e CPU." +} \ No newline at end of file diff --git a/violet/assets/locale/zh.json b/violet/assets/locale/zh.json index d36709c73..62ed6242e 100644 --- a/violet/assets/locale/zh.json +++ b/violet/assets/locale/zh.json @@ -315,5 +315,7 @@ "exitTheApp": "Exit the app", "ignoretimeout": "Ignore Timeout", "cropbookmark": "Crop Bookmark", - "cropbookmarkdesc": "Expore your crop images!" -} + "cropbookmarkdesc": "Expore your crop images!", + "litemode": "精簡模式", + "litemodedesc": "停用動畫與陰影以降低電量與 CPU 使用。" +} \ No newline at end of file diff --git a/violet/assets/locale/zh_Hans.json b/violet/assets/locale/zh_Hans.json index 954411e52..12372dda3 100644 --- a/violet/assets/locale/zh_Hans.json +++ b/violet/assets/locale/zh_Hans.json @@ -315,5 +315,7 @@ "exitTheApp": "Exit the app", "ignoretimeout": "Ignore Timeout", "cropbookmark": "Crop Bookmark", - "cropbookmarkdesc": "Expore your crop images!" -} + "cropbookmarkdesc": "Expore your crop images!", + "litemode": "精简模式", + "litemodedesc": "关闭动画和阴影以减少电量和 CPU 的消耗。" +} \ No newline at end of file diff --git a/violet/assets/locale/zh_Hant.json b/violet/assets/locale/zh_Hant.json index 62597cfc4..49872d4a4 100644 --- a/violet/assets/locale/zh_Hant.json +++ b/violet/assets/locale/zh_Hant.json @@ -315,5 +315,7 @@ "exitTheApp": "Exit the app", "ignoretimeout": "Ignore Timeout", "cropbookmark": "Crop Bookmark", - "cropbookmarkdesc": "Expore your crop images!" -} + "cropbookmarkdesc": "Expore your crop images!", + "litemode": "精簡模式", + "litemodedesc": "停用動畫與陰影以降低電量與 CPU 使用。" +} \ No newline at end of file diff --git a/violet/lib/main.dart b/violet/lib/main.dart index 4c0705922..91449d77d 100644 --- a/violet/lib/main.dart +++ b/violet/lib/main.dart @@ -31,6 +31,7 @@ import 'package:violet/pages/database_download/database_download_page.dart'; import 'package:violet/pages/lock/lock_screen.dart'; import 'package:violet/pages/splash/splash_page.dart'; import 'package:violet/settings/settings.dart'; +import 'package:violet/settings/lite_mode.dart'; import 'package:violet/src/rust/frb_generated.dart'; import 'package:violet/style/palette.dart'; @@ -113,39 +114,54 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) { return DynamicTheme( defaultBrightness: Brightness.light, - data: (brightness) => ThemeData( - appBarTheme: AppBarTheme( - systemOverlayStyle: !Settings.themeWhat.value - ? SystemUiOverlayStyle.dark - : SystemUiOverlayStyle.light), - useMaterial3: false, - brightness: brightness, - bottomSheetTheme: - BottomSheetThemeData(backgroundColor: Colors.black.withOpacity(0)), - scaffoldBackgroundColor: + data: (brightness) { + final bool lite = LiteMode.enabled; + final Color? scaffoldColor = Settings.themeBlack.value && Settings.themeWhat.value ? Colors.black - : null, - dialogBackgroundColor: - Settings.themeBlack.value && Settings.themeWhat.value - ? Palette.blackThemeBackground - : null, - cardColor: Settings.themeBlack.value && Settings.themeWhat.value - ? Palette.blackThemeBackground - : null, - colorScheme: ColorScheme.fromSwatch().copyWith( - secondary: Settings.majorColor.value, brightness: brightness), - cupertinoOverrideTheme: CupertinoThemeData( + : null; + + return ThemeData( + appBarTheme: AppBarTheme( + systemOverlayStyle: !Settings.themeWhat.value + ? SystemUiOverlayStyle.dark + : SystemUiOverlayStyle.light, + backgroundColor: + lite ? scaffoldColor ?? Colors.transparent : null, + elevation: lite ? 0 : null, + shadowColor: lite ? Colors.transparent : null, + ), + useMaterial3: false, brightness: brightness, - primaryColor: Settings.majorColor.value, - textTheme: const CupertinoTextThemeData(), - barBackgroundColor: Settings.themeWhat.value - ? Settings.themeBlack.value - ? const Color(0xFF181818) - : Colors.grey.shade800 + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: lite + ? scaffoldColor ?? Colors.transparent + : Colors.black.withOpacity(0), + elevation: lite ? 0 : null, + ), + scaffoldBackgroundColor: scaffoldColor, + dialogBackgroundColor: + Settings.themeBlack.value && Settings.themeWhat.value + ? Palette.blackThemeBackground + : null, + cardColor: Settings.themeBlack.value && Settings.themeWhat.value + ? Palette.blackThemeBackground : null, - ), - ), + shadowColor: lite ? Colors.transparent : null, + colorScheme: ColorScheme.fromSwatch().copyWith( + secondary: Settings.majorColor.value, brightness: brightness), + cupertinoOverrideTheme: CupertinoThemeData( + brightness: brightness, + primaryColor: Settings.majorColor.value, + textTheme: const CupertinoTextThemeData(), + barBackgroundColor: Settings.themeWhat.value + ? Settings.themeBlack.value + ? const Color(0xFF181818) + : Colors.grey.shade800 + : null, + ), + ); + }, themedWidgetBuilder: (context, theme) { return myApp(theme); }, @@ -153,6 +169,7 @@ class MyApp extends StatelessWidget { } Widget myApp(ThemeData theme) { + final bool lite = LiteMode.enabled; const supportedLocales = [ Locale('en', 'US'), Locale('ko', 'KR'), @@ -191,7 +208,22 @@ class MyApp extends StatelessWidget { return GetMaterialApp( navigatorKey: navigatorKey, navigatorObservers: navigatorObservers, - theme: theme, + theme: theme.copyWith( + splashFactory: lite ? NoSplash.splashFactory : theme.splashFactory, + pageTransitionsTheme: lite + ? const PageTransitionsTheme( + builders: { + TargetPlatform.android: _NoTransitionsBuilder(), + TargetPlatform.iOS: _NoTransitionsBuilder(), + TargetPlatform.macOS: _NoTransitionsBuilder(), + TargetPlatform.windows: _NoTransitionsBuilder(), + TargetPlatform.linux: _NoTransitionsBuilder(), + }, + ) + : theme.pageTransitionsTheme, + visualDensity: + lite ? VisualDensity.compact : theme.visualDensity, + ), home: home, supportedLocales: supportedLocales, routes: routes, @@ -346,3 +378,17 @@ class MouseBackRecognizer extends BaseTapGestureRecognizer { required PointerUpEvent up, }) {} } + +class _NoTransitionsBuilder extends PageTransitionsBuilder { + const _NoTransitionsBuilder(); + + @override + Widget buildTransitions( + PageRoute route, + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child) { + return child; + } +} diff --git a/violet/lib/pages/bookmark/bookmark_page.dart b/violet/lib/pages/bookmark/bookmark_page.dart index acdab90ba..fdc072725 100644 --- a/violet/lib/pages/bookmark/bookmark_page.dart +++ b/violet/lib/pages/bookmark/bookmark_page.dart @@ -19,6 +19,7 @@ import 'package:violet/pages/segment/double_tap_to_top.dart'; import 'package:violet/pages/segment/platform_navigator.dart'; import 'package:violet/settings/settings.dart'; import 'package:violet/style/palette.dart'; +import 'package:violet/settings/lite_mode.dart'; import 'package:violet/widgets/theme_switchable_state.dart'; class BookmarkPage extends StatefulWidget { @@ -42,44 +43,92 @@ class _BookmarkPageState extends ThemeSwitchableState @override Widget build(BuildContext context) { super.build(context); + final bool lite = LiteMode.enabled; + return Scaffold( body: FutureBuilder( future: Bookmark.getInstance().then((value) => value.getGroup()), builder: _reorderFutureBuilder, ), - floatingActionButton: SpeedDial( - childMargin: const EdgeInsets.only(right: 18, bottom: 20), - animatedIcon: AnimatedIcons.menu_close, - animatedIconTheme: const IconThemeData(size: 22.0), - visible: true, - closeManually: false, - curve: Curves.bounceIn, - overlayColor: Colors.transparent, - overlayOpacity: 0.2, - heroTag: 'speed-dial-hero-tag', - backgroundColor: Settings.themeWhat.value - ? Settings.themeBlack.value - ? Palette.blackThemeBackground - : Colors.grey.shade800 - : Colors.white, - foregroundColor: Settings.majorColor.value, - elevation: 1.0, - shape: const CircleBorder(), - children: [ - _dialButton(MdiIcons.orderNumericAscending, 'editorder', () async { - setState(() { - reorder = !reorder; - }); - }), - _dialButton(MdiIcons.group, 'newgroup', () async { - (await Bookmark.getInstance()).createGroup( - Translations.instance!.trans('newgroup'), - Translations.instance!.trans('newgroup'), - Colors.orange); - setState(() {}); - }), - ], - ), + floatingActionButton: lite ? _liteFab() : _speedDialFab(), + ); + } + + Widget _speedDialFab() { + return SpeedDial( + childMargin: const EdgeInsets.only(right: 18, bottom: 20), + animatedIcon: AnimatedIcons.menu_close, + animatedIconTheme: const IconThemeData(size: 22.0), + visible: true, + closeManually: false, + curve: Curves.bounceIn, + overlayColor: Colors.transparent, + overlayOpacity: 0.2, + heroTag: 'speed-dial-hero-tag', + backgroundColor: Settings.themeWhat.value + ? Settings.themeBlack.value + ? Palette.blackThemeBackground + : Colors.grey.shade800 + : Colors.white, + foregroundColor: Settings.majorColor.value, + elevation: 1.0, + shape: const CircleBorder(), + children: [ + _dialButton(MdiIcons.orderNumericAscending, 'editorder', () async { + setState(() { + reorder = !reorder; + }); + }), + _dialButton(MdiIcons.group, 'newgroup', () async { + (await Bookmark.getInstance()).createGroup( + Translations.instance!.trans('newgroup'), + Translations.instance!.trans('newgroup'), + Colors.orange); + setState(() {}); + }), + ], + ); + } + + Widget _liteFab() { + return FloatingActionButton( + heroTag: 'bookmark-lite-fab', + backgroundColor: Settings.majorColor.value, + child: const Icon(MdiIcons.tuneVariant), + onPressed: () async { + await showModalBottomSheet( + context: context, + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(MdiIcons.orderNumericAscending), + title: Text(Translations.instance!.trans('editorder')), + onTap: () { + Navigator.pop(ctx); + setState(() { + reorder = !reorder; + }); + }, + ), + ListTile( + leading: const Icon(MdiIcons.group), + title: Text(Translations.instance!.trans('newgroup')), + onTap: () async { + Navigator.pop(ctx); + (await Bookmark.getInstance()).createGroup( + Translations.instance!.trans('newgroup'), + Translations.instance!.trans('newgroup'), + Colors.orange); + setState(() {}); + }, + ), + ], + ), + ), + ); + }, ); } @@ -201,18 +250,13 @@ class _BookmarkPageState extends ThemeSwitchableState final random = Random(); - return Container( + Widget card = Container( key: Key('bookmark_group_$id'), - child: ShakeAnimatedWidget( - enabled: reorder, - duration: Duration(milliseconds: 300 + random.nextInt(50)), - shakeAngle: Rotation.deg(z: 0.8), - curve: Curves.linear, - child: Container( - margin: const EdgeInsets.fromLTRB(16, 0, 16, 8), - width: double.infinity, - decoration: BoxDecoration( - color: Settings.themeWhat.value ? Colors.black26 : Colors.white, + child: Container( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 8), + width: double.infinity, + decoration: BoxDecoration( + color: Settings.themeWhat.value ? Colors.black26 : Colors.white, borderRadius: const BorderRadius.only( topLeft: Radius.circular(8), topRight: Radius.circular(8), @@ -266,6 +310,18 @@ class _BookmarkPageState extends ThemeSwitchableState ), ), ); + + if (!LiteMode.enabled) { + card = ShakeAnimatedWidget( + enabled: reorder, + duration: Duration(milliseconds: 300 + random.nextInt(50)), + shakeAngle: Rotation.deg(z: 0.8), + curve: Curves.linear, + child: card, + ); + } + + return card; } _onLongPressBookmarkItem( diff --git a/violet/lib/pages/bookmark/group/group_article_list_page.dart b/violet/lib/pages/bookmark/group/group_article_list_page.dart index 4338f6f39..45f601df2 100644 --- a/violet/lib/pages/bookmark/group/group_article_list_page.dart +++ b/violet/lib/pages/bookmark/group/group_article_list_page.dart @@ -28,6 +28,7 @@ import 'package:violet/style/palette.dart'; import 'package:violet/widgets/dots_indicator.dart'; import 'package:violet/widgets/floating_button.dart'; import 'package:violet/widgets/search_bar.dart'; +import 'package:violet/settings/lite_mode.dart'; class GroupArticleListPage extends StatefulWidget { final String name; @@ -259,6 +260,71 @@ class _GroupArticleListPageState extends State { } Widget _floatingButton() { + if (LiteMode.enabled) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + FloatingActionButton( + onPressed: () { + _shouldRebuild = true; + setState(() { + _shouldRebuild = true; + checkModePre = false; + checked.clear(); + checkMode = false; + }); + }, + heroTag: 'lite_close', + mini: true, + child: const Icon(Icons.close), + ), + const SizedBox(height: 8), + FloatingActionButton( + onPressed: () { + for (var element in filterResult) { + checked.add(element.id()); + } + _shouldRebuild = true; + setState(() { + _shouldRebuild = true; + }); + }, + heroTag: 'lite_check_all', + mini: true, + child: const Icon(MdiIcons.checkAll), + ), + const SizedBox(height: 8), + FloatingActionButton( + onPressed: () async { + if (await showYesNoDialog( + context, + Translations.instance! + .trans('deletebookmarkmsg') + .replaceAll('%s', checked.length.toString()), + Translations.instance!.trans('bookmark'))) { + var bookmark = await Bookmark.getInstance(); + for (var element in checked) { + await bookmark.unbookmark(element); + } + checked.clear(); + refresh(); + } + }, + heroTag: 'lite_delete', + mini: true, + child: const Icon(MdiIcons.delete), + ), + const SizedBox(height: 8), + FloatingActionButton( + onPressed: moveChecked, + heroTag: 'lite_move', + mini: true, + child: const Icon(MdiIcons.folderMove), + ), + ], + ); + } + return AnimatedFloatingActionButton( fabButtons: [ FloatingActionButton( diff --git a/violet/lib/pages/search/search_page.dart b/violet/lib/pages/search/search_page.dart index 8f052d728..e9e52d767 100644 --- a/violet/lib/pages/search/search_page.dart +++ b/violet/lib/pages/search/search_page.dart @@ -34,6 +34,7 @@ import 'package:violet/pages/segment/filter_page.dart'; import 'package:violet/pages/segment/filter_page_controller.dart'; import 'package:violet/pages/segment/platform_navigator.dart'; import 'package:violet/settings/settings.dart'; +import 'package:violet/settings/lite_mode.dart'; import 'package:violet/style/palette.dart'; import 'package:violet/widgets/article_item/article_list_item_widget.dart'; import 'package:violet/widgets/debounce_widget.dart'; @@ -268,13 +269,24 @@ class _SearchPageState extends ThemeSwitchableState } Widget _floatingActionButton() { + if (LiteMode.enabled) { + return FloatingActionButton( + backgroundColor: Settings.majorColor.value, + child: const Icon(MdiIcons.bookOpenPageVariantOutline), + onPressed: () async { + await _showJumpDialog(); + }, + ); + } + return FloatingActionButton.extended( backgroundColor: Settings.majorColor.value, label: Obx( () => AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - switchInCurve: Curves.easeIn, - switchOutCurve: Curves.easeOut, + duration: LiteMode.animationDuration( + const Duration(milliseconds: 300)), + switchInCurve: LiteMode.animationCurve(), + switchOutCurve: LiteMode.animationCurve(), transitionBuilder: (Widget child, Animation animation) => FadeTransition( opacity: animation, @@ -301,27 +313,31 @@ class _SearchPageState extends ThemeSwitchableState ), ), onPressed: () async { - var rr = await showDialog( - context: context, - builder: (BuildContext context) => SearchPageModifyPage( - curPage: c.searchPageNum.value + c.baseCount, - maxPage: c.searchTotalResultCount.value, - ), - ); - if (rr == null) return; + await _showJumpDialog(); + }, + ); + } - if (rr[0] == 1) { - final setPage = rr[1] as int; + Future _showJumpDialog() async { + var rr = await showDialog( + context: context, + builder: (BuildContext context) => SearchPageModifyPage( + curPage: c.searchPageNum.value + c.baseCount, + maxPage: c.searchTotalResultCount.value, + ), + ); + if (rr == null) return; - c.latestQuery = ( - SearchResult(results: [], offset: setPage), - c.latestQuery!.$2, - ); + if (rr[0] == 1) { + final setPage = rr[1] as int; - c.doSearch(setPage); - } - }, - ); + c.latestQuery = ( + SearchResult(results: [], offset: setPage), + c.latestQuery!.$2, + ); + + c.doSearch(setPage); + } } searchBar() { @@ -399,7 +415,9 @@ class _SearchPageState extends ThemeSwitchableState Radius.circular(4.0), ), ), - elevation: !Settings.themeFlat.value ? 100 : 0, + elevation: LiteMode.enabled + ? 0 + : (!Settings.themeFlat.value ? 100 : 0), clipBehavior: Clip.antiAliasWithSaveLayer, child: Stack( children: [ @@ -476,7 +494,8 @@ class _SearchPageState extends ThemeSwitchableState color: Palette.themeColor, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(4.0))), - elevation: !Settings.themeFlat.value ? 100 : 0, + elevation: + LiteMode.enabled ? 0 : (!Settings.themeFlat.value ? 100 : 0), clipBehavior: Clip.antiAliasWithSaveLayer, child: msgsearchOverlay, ); @@ -518,7 +537,8 @@ class _SearchPageState extends ThemeSwitchableState color: Palette.themeColor, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(4.0))), - elevation: !Settings.themeFlat.value ? 100 : 0, + elevation: + LiteMode.enabled ? 0 : (!Settings.themeFlat.value ? 100 : 0), clipBehavior: Clip.antiAliasWithSaveLayer, child: alignOverlay, ); @@ -615,6 +635,7 @@ class ResultPanelWidget extends StatelessWidget { final padding = bookmarkMode ? const EdgeInsets.fromLTRB(12, 0, 12, 16) : const EdgeInsets.fromLTRB(8, 0, 8, 16); + final bool lite = LiteMode.enabled; switch (searchResultType) { case SearchResultType.threeGrid: @@ -658,6 +679,34 @@ class ResultPanelWidget extends StatelessWidget { ? 220 : 130; + if (lite) { + return SliverPadding( + padding: padding, + sliver: SliverGrid( + key: sliverKey, + delegate: SliverChildBuilderDelegate( + (context, index) => articleItem( + index, + windowWidth, + (windowWidth - 4.0) / kDetailModeColumnCount, + showDetail: searchResultType.isDetailLike, + showUltra: searchResultType.isUltra, + addBottomPadding: true, + ), + childCount: resultList.length, + ), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: kDetailModeColumnCount, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: + (windowWidth / kDetailModeColumnCount) / + aspectRatioHeight, + ), + ), + ); + } + return SliverPadding( padding: padding, sliver: LiveSliverGrid( diff --git a/violet/lib/pages/settings/settings_page.dart b/violet/lib/pages/settings/settings_page.dart index 966ede9cb..91d68d963 100644 --- a/violet/lib/pages/settings/settings_page.dart +++ b/violet/lib/pages/settings/settings_page.dart @@ -63,6 +63,7 @@ import 'package:violet/pages/splash/splash_page.dart'; import 'package:violet/platform/misc.dart'; import 'package:violet/server/violet.dart'; import 'package:violet/settings/settings.dart'; +import 'package:violet/settings/lite_mode.dart'; import 'package:violet/style/palette.dart'; import 'package:violet/util/helper.dart'; import 'package:violet/version/sync.dart'; @@ -133,10 +134,12 @@ class _SettingsPageState extends State .take(items.length * 2 - 1) .toList(); + final bool lite = LiteMode.enabled; + return Container( margin: const EdgeInsets.fromLTRB(16, 0, 16, 8), width: double.infinity, - decoration: !Settings.themeFlat.value + decoration: !Settings.themeFlat.value && !lite ? BoxDecoration( color: Settings.themeWhat.value ? Colors.black26 : Colors.white, borderRadius: const BorderRadius.only( @@ -2412,6 +2415,35 @@ class _SettingsPageState extends State }); }, ), + InkWell( + customBorder: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0))), + child: ListTile( + leading: Icon( + MdiIcons.themeLightDark, + color: Settings.majorColor.value, + ), + title: Text(Translations.instance!.trans('litemode')), + subtitle: Text(Translations.instance!.trans('litemodedesc')), + trailing: Switch( + value: Settings.liteMode.value, + onChanged: (newValue) async { + await Settings.liteMode.setValue(newValue); + setState(() { + _shouldReload = true; + }); + }, + activeTrackColor: Settings.majorColor.value, + activeColor: Settings.majorAccentColor.value, + ), + ), + onTap: () async { + await Settings.liteMode.setValue(!Settings.liteMode.value); + setState(() { + _shouldReload = true; + }); + }, + ), ], ), ]; diff --git a/violet/lib/settings/settings.dart b/violet/lib/settings/settings.dart index b5f4ec24e..5652d8bb1 100644 --- a/violet/lib/settings/settings.dart +++ b/violet/lib/settings/settings.dart @@ -43,6 +43,7 @@ class Settings { static final themeFlat = SettingItem('themeFlat', false); static final themeBlack = SettingItem('themeBlack', false); static final useTabletMode = SettingItem('usetabletmode', false); + static final liteMode = SettingItem('litemode', false); // Tag Settings static final includeTags = SettingItem('includetags', () { diff --git a/violet/rag/bookmark_page.md b/violet/rag/bookmark_page.md index 007083069..2e66ee007 100644 --- a/violet/rag/bookmark_page.md +++ b/violet/rag/bookmark_page.md @@ -17,6 +17,7 @@ - 재정렬 동작 - `onReorder`는 예약 인덱스를 이동하려 할 때 경고 토스트로 막는다.(lib/pages/bookmark/bookmark_page.dart:99) - 정상 재정렬 시 `Bookmark.positionSwap()`을 호출해 DB의 `Gorder` 값을 교환하고 화면을 갱신한다.(lib/pages/bookmark/bookmark_page.dart:105) +- Lite Mode(`Settings.liteMode`)에서는 SpeedDial 대신 단일 FAB + 모달 메뉴를 사용하고, 카드 흔들림 애니메이션을 비활성화해 불필요한 GPU 작업을 줄인다. ## 그룹 상세 (`lib/pages/bookmark/group/group_article_list_page.dart`) - 그룹 페이지는 `PageView`로 구성되어 3개 탭을 제공한다: 작품 목록, 아티스트 목록, 아티클 기반 아티스트 링크.(lib/pages/bookmark/group/group_article_list_page.dart:202) @@ -34,6 +35,7 @@ - 카드 롱프레스 → `checkMode`를 켜고 `checked` 목록에 첫 항목을 추가한다.(lib/pages/bookmark/group/group_article_list_page.dart:293) - 체크박스 토글은 `bookmarkCheckCallback`으로 연결되어 `checked` 목록을 유지하며, 선택이 모두 해제되면 애니메이션으로 checkMode를 종료한다.(lib/pages/bookmark/group/group_article_list_page.dart:300) - checkMode일 때 하단 `AnimatedFloatingActionButton`이 나타나며 “전체 선택”, “삭제”, “다른 그룹으로 이동” 세 가지 버튼을 제공한다.(lib/pages/bookmark/group/group_article_list_page.dart:213) + - Lite Mode에서는 동일한 기능을 미니 FAB 컬럼으로 제공하여 애니메이션 없이 즉시 동작한다. - “삭제”는 `Bookmark.unbookmark()`를 순차 호출한다.(lib/pages/bookmark/group/group_article_list_page.dart:226) - “이동”은 그룹 선택 다이얼로그 후, 선택된 ID 순으로 삭제→다른 그룹에 재등록(`Bookmark.insertArticle`)을 수행한다.(lib/pages/bookmark/group/group_article_list_page.dart:323) diff --git a/violet/rag/component_infrastructure.md b/violet/rag/component_infrastructure.md index 1c2c6a9e3..1844c6710 100644 --- a/violet/rag/component_infrastructure.md +++ b/violet/rag/component_infrastructure.md @@ -8,6 +8,7 @@ ## Settings (`lib/settings/settings.dart`) - Central `Settings` class wraps hundreds of persisted options via `SettingItem`, `EnumSettingItem`, and `FutureSettingItem`, backed by `SharedPreferences`. - Reader preferences (e.g., `isHorizontal`, `rightToLeft`, `disableTwoPageView`), search toggles (`searchNetwork`, `includeTags`, `serializedExcludeTags`), and theming (`themeWhat`, `majorColor`) are exposed as static fields used throughout the app. +- `liteMode` disables heavy UI effects (animations, shadows) across multiple screens; toggled via the settings page to reduce CPU/battery usage. - Download paths are resolved dynamically in `downloadBasePath`, handling Android scoped storage upgrades and auto-migrating legacy `/Violet/` folders. - Utility setters like `serializedExcludeTags` normalise user input into query-ready strings consumed by `HentaiManager`. diff --git a/violet/rag/component_search.md b/violet/rag/component_search.md index e7e84eef8..6bb4da9f3 100644 --- a/violet/rag/component_search.md +++ b/violet/rag/component_search.md @@ -4,6 +4,7 @@ - `SearchPage` seeds a GetX controller (`SearchPageController`) and kicks off `doInitialSearch()` after the first frame, applying a five-second timeout unless `Settings.ignoreTimeout` is true. - The widget builds sliver-based lists with animated headers, reusing cached `ResultPanelWidget` instances and toggling layouts based on `Settings.searchResultType`. - Keyboard navigation support, Cupertino navigation, and bottom sheets (`SearchPageModify`, `FilterPage`) are wired through helper widgets referenced in this file. +- When `Settings.liteMode` is enabled the page swaps animated FAB/LiveSliverGrid effects for static widgets, reducing animation cost while preserving navigation shortcuts. ## `lib/pages/search/search_page_controller.dart` - Holds the core search state: `queryResult`, `filterResult`, pagination offsets, and the rolling `_scrollQueue` used to detect scroll direction and toggled header visibility. diff --git a/violet/rag/history/2024-12-lite-mode-implementation.md b/violet/rag/history/2024-12-lite-mode-implementation.md new file mode 100644 index 000000000..7bd0138ff --- /dev/null +++ b/violet/rag/history/2024-12-lite-mode-implementation.md @@ -0,0 +1,23 @@ +# Lite Mode Implementation Summary (2024-12) + +## Scope Delivered +- Added `Settings.liteMode` preference (default off) and centralized helper `LiteMode` (`lib/settings/settings.dart`, `lib/settings/lite_mode.dart`). +- Settings ▸ View now exposes a localized switch (`litemode`, `litemodedesc`) with runtime UI refresh (`lib/pages/settings/settings_page.dart`). +- Global theme adapts when Lite Mode is active: transitions, shadows, splash, and elevations collapse to minimal variants (`lib/main.dart`). + +## Screen-Level Adjustments +- **Search Page** (`lib/pages/search/search_page.dart`) + - Extended FAB becomes static icon in Lite Mode; AnimatedSwitcher/LiveSliverGrid fall back to simple grids. + - Card elevations removed to reduce GPU overdraw. +- **Bookmark Page** (`lib/pages/bookmark/bookmark_page.dart`) + - SpeedDial replaced by single FAB + bottom sheet; shake animation disabled. +- **Bookmark Group View** (`lib/pages/bookmark/group/group_article_list_page.dart`) + - Animated multi-action FAB substituted with stacked mini FABs when Lite Mode is enabled. + +## Documentation Updates +- `rag/component_infrastructure.md`, `rag/component_search.md`, `rag/bookmark_page.md` now mention Lite Mode behaviour. +- `rag/history/2024-12-lite-mode-plan.md` records settings integration notes; this file logs implementation status. + +## Follow-ups +- Validate UI regression tests (search/bookmark flows) across platforms with Lite Mode toggled. +- Extend Lite Mode handling to remaining pages (viewer overlays, downloads) per plan if further optimization needed. diff --git a/violet/rag/history/2024-12-lite-mode-plan.md b/violet/rag/history/2024-12-lite-mode-plan.md new file mode 100644 index 000000000..098a6d970 --- /dev/null +++ b/violet/rag/history/2024-12-lite-mode-plan.md @@ -0,0 +1,72 @@ +# Lite Mode Feature Plan + +## 배경 & 목표 +- 다양한 페이지에서 `AnimatedSwitcher`, `AnimatedOpacity`, `LiveSliverGrid` 등 다수의 애니메이션과 그림자가 사용되고 있어 배터리·CPU 소모가 크다. +- 경량화 모드를 추가해 불필요한 애니메이션, 그림자, 고비용 효과를 끌 수 있는 설정을 제공한다. +- 목표: UX 일관성을 유지하면서, 애니메이션 효과/그림자/불필요한 리스너를 최소화하여 저사양 기기에서 쾌적한 동작을 보장한다. + +## 범위 +- 전역 테마 및 Material 컴포넌트 그림자/투명도. +- Search/Bookmark/Viewer/Download 주요 화면의 애니메이션 위젯. +- SpeedDial/AnimatedFloatingActionButton 등 반복적으로 등장하는 애니메이션 FAB. +- Flare 애니메이션 캐시(warmup) 및 스크립트 갱신 중 시각 효과. + +## 비범위 +- 이미지 품질, 네트워크 호출, 데이터 로딩 방식은 변경하지 않는다. +- Viewer의 핵심 제스처(페이지 넘김 등)는 유지하되, 애니메이션 감속 값만 완화한다. + +## 구현 전략 +1. **설정 추가** + - `Settings`에 `liteMode`(SettingItem)를 정의하고, Settings 페이지(예: Appearance/일반 설정)에 토글 UI 추가. + - `ValueListenableBuilder` 또는 Rx 관찰자를 통해 런타임에 값이 바뀌어도 UI가 즉시 반영되게 한다. + +2. **Runtime Helper** + - `lib/util` 또는 `lib/settings` 계층에 `LiteModeUtil`(ex: `class LiteMode { static bool get enabled => Settings.liteMode.value; }`) 추가. + - 자주 쓰이는 애니메이션/그림자 설정을 함수로 묶음(`liteAnimationDuration`, `liteBoxShadow`) 하여 각 위젯에서 재사용. + +3. **전역 테마 조정** + - `lib/main.dart` `ThemeData` 구성 시 `Settings.liteMode`가 true이면 `elevation`, `shadowColor`, `animationDuration` 등을 최소화. + - Cupertino/Material `Theme`의 `pageTransitionsTheme`를 간소화(기본 Fade/Zoom 대신 즉시 전환). + +4. **Search Page** + - `AnimatedSwitcher`, `AnimatedOpacity`, `LiveSliverGrid` 등 애니메이션 파라미터를 liteMode일 때 0 or instant로 설정. + - FAB(`FloatingActionButton.extended`) → 기본 FAB, 또는 텍스트 확장 비활성화. + +5. **Bookmark Page** + - 재정렬 시 `ShakeAnimatedWidget` 대신 정적 카드로 대체. + - `AnimatedFloatingActionButton` → 기본 FAB 묶음 또는 BottomSheet. + - `ListTile` 그림자 제거 및 배경 투명도 감소. + +6. **Viewer & Overlay** + - `ViewerPage`의 `_nextPageTimer`, `CallOnce`, 애니메이션 옵션 중 liteMode일 때 기본 지속 시간을 줄이거나 바로 적용. + - 오버레이 `AnimatedOpacity`, `AnimatedContainer` 등을 즉시 상태 전환. + +7. **Download/Search 기타** + - `SpeedDial` 대신 단일 `FloatingActionButton`/`PopupMenuButton` 구성. + - `LiveSliverGrid` 애니메이션, `AnimatedWidgets` 사용 지점마다 liteMode 분기 추가. + +8. **Flare & Warmup** + - `warmupFlare()` 실행 여부를 liteMode에서 건너뛰고, 필요한 경우 첫 사용 시 Lazy load. + +9. **테스트 계획** + - Android 저사양 에뮬레이터에서 liteMode 토글 전/후 프레임 드랍, 배터리 소비 비교. + - 주요 화면(검색/북마크/뷰어/다운로드) 전환 시 애니메이션이 즉시 반응하는지 확인. + - Firebase Analytics 이벤트에 영향이 없는지 확인. + +## 리스크 & 대응 +- UX 변화에 따른 사용성 감소 → 설정 설명에 경고(애니메이션 꺼짐) 명시. +- 코드 분기 증가 → `LiteMode` 헬퍼로 분기를 최소화하고, 통합 테스트 케이스 추가. +- lazy load로 인해 첫 사용 시 프레임 튐 → 사용자 피드백 수집 후 필요한 영역만 다시 warmup 고려. + +## 후속 TODO +- 실제 구현 시 각 모듈별 문서 업데이트 (`rag/component_*`, `rag/search_page_ui_ux.md`, `rag/bookmark_page.md`). +- 설정 화면 스크린샷/사용 설명 추가. +- liteMode 디폴트 값: 플랫폼/기기 성능 감지 고려 여부 검토. + +## Settings Integration Notes +- `Settings`에 `liteMode` 토글을 추가하고 `SharedPreferences` 기본값은 `false`로 둔다. +- 설정 화면에서는 `View` 섹션(또는 General) 내 `ListTile` + `Switch` 조합으로 노출한다. + - 타이틀/설명 문자열 키: `litemode`, `litemodedesc`를 locale JSON에 추가 예정. + - `onTap` 및 `onChanged` 두 경로 모두 `Settings.liteMode.setValue()` 호출 후 `_shouldReload = true`로 UI 갱신. +- 토글 상태가 바뀔 때 `LiteMode` 헬퍼에서 `notifyListeners()` 혹은 `Settings` ValueNotifier를 통해 주요 위젯이 즉시 재렌더되도록 한다. +- 라이트 모드 활성화/비활성화 시점에 사용자에게 “효과가 즉시 적용됩니다” 수준의 토스트/스낵바를 표시해 피드백 제공을 검토한다. From bcff4e0ecca6bb678e77c673a81a8cf037fb551e Mon Sep 17 00:00:00 2001 From: violet-dev Date: Sun, 28 Sep 2025 21:10:52 +0900 Subject: [PATCH 2/5] Apply lite-mode to article list item widget --- .../article_list_item_widget.dart | 100 +++++++++++------- .../2024-12-lite-mode-implementation.md | 1 + violet/rag/history/2024-12-lite-mode-plan.md | 8 ++ 3 files changed, 72 insertions(+), 37 deletions(-) diff --git a/violet/lib/widgets/article_item/article_list_item_widget.dart b/violet/lib/widgets/article_item/article_list_item_widget.dart index 6448d2e89..6cd9d74d7 100644 --- a/violet/lib/widgets/article_item/article_list_item_widget.dart +++ b/violet/lib/widgets/article_item/article_list_item_widget.dart @@ -35,6 +35,7 @@ import 'package:violet/widgets/article_item/image_provider_manager.dart'; import 'package:violet/widgets/article_item/thumbnail.dart'; import 'package:violet/widgets/article_item/thumbnail_view_page.dart'; import 'package:violet/widgets/toast.dart'; +import 'package:violet/settings/lite_mode.dart'; class ArticleListItemWidget extends StatefulWidget { final bool isChecked; @@ -156,43 +157,60 @@ class _ArticleListItemWidgetState extends State _body = body; } - _cachedBuildWidget = Obx( - () => Container( - color: isChecked.value ? Colors.amber : Colors.transparent, - child: PimpedButton( + _cachedBuildWidget = Obx(() { + final bool lite = LiteMode.enabled; + final double width = c.thisWidth; + final double height = c.thisHeight.value; + final double scale = c.scale.value; + + Widget content = SizedBox( + width: width, + height: height.isNaN ? null : height, + child: lite + ? _body! + : AnimatedContainer( + curve: Curves.easeInOut, + duration: const Duration(milliseconds: 300), + transform: height.isNaN + ? null + : (Matrix4.identity() + ..translate(width / 2, height / 2) + ..scale(scale) + ..translate(-width / 2, -height / 2)), + child: _body!, + ), + ); + + Widget gesture = GestureDetector( + onTapDown: _onTapDown, + onTapUp: _onTapUp, + onLongPress: () async { + await _onLongPress(lite ? null : _pimpController); + }, + onLongPressEnd: _onPressEnd, + onTapCancel: _onTapCancle, + onDoubleTap: _onDoubleTap, + child: content, + ); + + Widget child; + if (lite) { + child = gesture; + } else { + child = PimpedButton( particle: Rectangle2DemoParticle(), pimpedWidgetBuilder: (context, controller) { - return GestureDetector( - onTapDown: _onTapDown, - onTapUp: _onTapUp, - onLongPress: () async { - await _onLongPress(controller); - }, - onLongPressEnd: _onPressEnd, - onTapCancel: _onTapCancle, - onDoubleTap: _onDoubleTap, - child: Obx( - () => SizedBox( - width: c.thisWidth, - height: c.thisHeight.isNaN ? null : c.thisHeight.value, - child: AnimatedContainer( - curve: Curves.easeInOut, - duration: const Duration(milliseconds: 300), - transform: c.thisHeight.value.isNaN - ? null - : (Matrix4.identity() - ..translate(c.thisWidth / 2, c.thisHeight / 2) - ..scale(c.scale.value) - ..translate(-c.thisWidth / 2, -c.thisHeight / 2)), - child: _body!, - ), - ), - ), - ); + _pimpController = controller; + return gesture; }, - ), - ), - ); + ); + } + + return Container( + color: isChecked.value ? Colors.amber : Colors.transparent, + child: child, + ); + }); } return _cachedBuildWidget!; @@ -210,7 +228,11 @@ class _ArticleListItemWidgetState extends State } } - _animateScale(double scale) { + void _animateScale(double scale) { + if (LiteMode.enabled) { + c.scale.value = 1.0; + return; + } c.scale.value = scale; } @@ -292,7 +314,9 @@ class _ArticleListItemWidgetState extends State }); } - Future _onLongPress(controller) async { + dynamic _pimpController; + + Future _onLongPress(dynamic controller) async { c.onScaling = false; if (data.bookmarkMode) { @@ -341,7 +365,9 @@ class _ArticleListItemWidgetState extends State c.flareController!.play('Unlike'); } } else { - controller.forward(from: 0.0); + if (!LiteMode.enabled && controller != null) { + controller.forward(from: 0.0); + } if (!Settings.simpleItemWidgetLoadingIcon.value) { c.flareController!.play('Like'); } diff --git a/violet/rag/history/2024-12-lite-mode-implementation.md b/violet/rag/history/2024-12-lite-mode-implementation.md index 7bd0138ff..31223d309 100644 --- a/violet/rag/history/2024-12-lite-mode-implementation.md +++ b/violet/rag/history/2024-12-lite-mode-implementation.md @@ -21,3 +21,4 @@ ## Follow-ups - Validate UI regression tests (search/bookmark flows) across platforms with Lite Mode toggled. - Extend Lite Mode handling to remaining pages (viewer overlays, downloads) per plan if further optimization needed. +- Article list cards (`lib/widgets/article_item/article_list_item_widget.dart`) now honor Lite Mode by skipping particle effects and animated scaling, using static gesture handlers for lighter rebuilds. diff --git a/violet/rag/history/2024-12-lite-mode-plan.md b/violet/rag/history/2024-12-lite-mode-plan.md index 098a6d970..93b96fa5b 100644 --- a/violet/rag/history/2024-12-lite-mode-plan.md +++ b/violet/rag/history/2024-12-lite-mode-plan.md @@ -70,3 +70,11 @@ - `onTap` 및 `onChanged` 두 경로 모두 `Settings.liteMode.setValue()` 호출 후 `_shouldReload = true`로 UI 갱신. - 토글 상태가 바뀔 때 `LiteMode` 헬퍼에서 `notifyListeners()` 혹은 `Settings` ValueNotifier를 통해 주요 위젯이 즉시 재렌더되도록 한다. - 라이트 모드 활성화/비활성화 시점에 사용자에게 “효과가 즉시 적용됩니다” 수준의 토스트/스낵바를 표시해 피드백 제공을 검토한다. + +## Pending Optimization: Article Item Widget +- Target: `ArticleListItemWidget` (`lib/widgets/article_item/article_list_item_widget.dart`) which renders most search/bookmark list cards. +- Lite Mode adjustments: + - Skip particle effects (`PimpedButton`) and animated scaling/transform; replace with plain `GestureDetector` + static container. + - Reduce `AnimatedContainer` durations to zero and bypass expensive transforms. + - Ensure interaction callbacks (tap, long press, double tap) still function without animations. +- Success metrics: reduced rebuild cost and GPU work when rendering large lists in Lite Mode. From e21848a6e7bd3abe8860f237903a72322fee0bb6 Mon Sep 17 00:00:00 2001 From: violet-dev Date: Sun, 28 Sep 2025 21:16:23 +0900 Subject: [PATCH 3/5] Apply lite-mode to article/artist info --- .../pages/article_info/article_info_page.dart | 14 ++-- .../pages/artist_info/artist_info_page.dart | 78 ++++++++++++++++--- .../2024-12-lite-mode-implementation.md | 1 + violet/rag/history/2024-12-lite-mode-plan.md | 7 ++ 4 files changed, 83 insertions(+), 17 deletions(-) diff --git a/violet/lib/pages/article_info/article_info_page.dart b/violet/lib/pages/article_info/article_info_page.dart index 85b0591eb..4a9f27484 100644 --- a/violet/lib/pages/article_info/article_info_page.dart +++ b/violet/lib/pages/article_info/article_info_page.dart @@ -42,6 +42,7 @@ import 'package:violet/pages/viewer/viewer_page_provider.dart'; import 'package:violet/script/script_manager.dart'; import 'package:violet/server/violet_v2.dart'; import 'package:violet/settings/settings.dart'; +import 'package:violet/settings/lite_mode.dart'; import 'package:violet/style/palette.dart'; import 'package:violet/variables.dart'; import 'package:violet/widgets/article_item/article_list_item_widget.dart'; @@ -58,6 +59,7 @@ class ArticleInfoPage extends StatelessWidget { final height = MediaQuery.of(context).size.height; final data = Provider.of(context); final mediaQuery = MediaQuery.of(context); + final bool lite = LiteMode.enabled; Variables.setArticleInfoHeight( height - 36 - (mediaQuery.padding + mediaQuery.viewInsets).bottom); @@ -66,7 +68,7 @@ class ArticleInfoPage extends StatelessWidget { color: Palette.themeColorLightShallow, padding: EdgeInsets.only(top: 0, bottom: Variables.bottomBarHeight), child: Card( - elevation: 5, + elevation: lite ? 0 : 5, color: Palette.themeColorLightShallow, child: SizedBox( width: width - 16, @@ -105,8 +107,9 @@ class ArticleInfoPage extends StatelessWidget { iconColor: Settings.themeWhat.value ? Colors.white : Colors.grey, - animationDuration: - const Duration(milliseconds: 500)), + animationDuration: lite + ? Duration.zero + : const Duration(milliseconds: 500)), header: Padding( padding: const EdgeInsets.fromLTRB(12, 12, 0, 0), child: Text(Translations.instance!.trans('preview')), @@ -135,8 +138,9 @@ class ArticleInfoPage extends StatelessWidget { iconColor: Settings.themeWhat.value ? Colors.white : Colors.grey, - animationDuration: - const Duration(milliseconds: 500)), + animationDuration: lite + ? Duration.zero + : const Duration(milliseconds: 500)), header: Padding( padding: const EdgeInsets.fromLTRB(12, 12, 0, 0), child: Text( diff --git a/violet/lib/pages/artist_info/artist_info_page.dart b/violet/lib/pages/artist_info/artist_info_page.dart index 479b7ebd1..9871d5cdf 100644 --- a/violet/lib/pages/artist_info/artist_info_page.dart +++ b/violet/lib/pages/artist_info/artist_info_page.dart @@ -30,6 +30,7 @@ import 'package:violet/pages/segment/platform_navigator.dart'; import 'package:violet/pages/segment/three_article_panel.dart'; import 'package:violet/server/community/anon.dart'; import 'package:violet/settings/settings.dart'; +import 'package:violet/settings/lite_mode.dart'; import 'package:violet/style/palette.dart'; import 'package:violet/util/strings.dart'; import 'package:violet/widgets/article_item/article_list_item_widget.dart'; @@ -239,6 +240,7 @@ class _ArtistInfoPageState extends State { final height = MediaQuery.of(context).size.height - MediaQuery.of(context).padding.top; final mediaQuery = MediaQuery.of(context); + final bool lite = LiteMode.enabled; return Container( color: Palette.themeColor, child: Padding( @@ -251,7 +253,7 @@ class _ArtistInfoPageState extends State { crossAxisAlignment: CrossAxisAlignment.center, children: [ Card( - elevation: 5, + elevation: lite ? 0 : 5, color: Palette.themeColor, child: SizedBox( width: width - 16, @@ -299,6 +301,7 @@ class _ArtistInfoPageState extends State { } Widget nameArea() { + final bool lite = LiteMode.enabled; return GestureDetector( child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -306,11 +309,18 @@ class _ArtistInfoPageState extends State { SizedBox( width: 28, height: 28, - child: FlareActor( - 'assets/flare/likeUtsua.flr', - animation: isBookmarked ? 'Like' : 'IdleUnlike', - controller: flareController, - ), + child: lite + ? Icon( + isBookmarked + ? Icons.favorite + : Icons.favorite_border, + color: Colors.pinkAccent, + ) + : FlareActor( + 'assets/flare/likeUtsua.flr', + animation: isBookmarked ? 'Like' : 'IdleUnlike', + controller: flareController, + ), ), Text('${widget.type.name.titlecase()}: ${widget.name}', style: @@ -329,11 +339,11 @@ class _ArtistInfoPageState extends State { if (!isBookmarked) { await (await Bookmark.getInstance()) .unbookmarkArtist(widget.name, widget.type); - flareController.play('Unlike'); + if (!lite) flareController.play('Unlike'); } else { await (await Bookmark.getInstance()) .bookmarkArtist(widget.name, widget.type); - flareController.play('Like'); + if (!lite) flareController.play('Like'); } }, ); @@ -440,7 +450,9 @@ class _ArtistInfoPageState extends State { theme: ExpandableThemeData( iconColor: Settings.themeWhat.value ? Colors.white : Colors.grey, - animationDuration: const Duration(milliseconds: 500)), + animationDuration: lite + ? Duration.zero + : const Duration(milliseconds: 500)), header: Padding( padding: const EdgeInsets.fromLTRB(12, 12, 0, 0), child: Text( @@ -468,7 +480,9 @@ class _ArtistInfoPageState extends State { theme: ExpandableThemeData( iconColor: Settings.themeWhat.value ? Colors.white : Colors.grey, - animationDuration: const Duration(milliseconds: 500)), + animationDuration: lite + ? Duration.zero + : const Duration(milliseconds: 500)), header: Padding( padding: const EdgeInsets.fromLTRB(12, 12, 0, 0), child: Text( @@ -490,7 +504,9 @@ class _ArtistInfoPageState extends State { iconColor: Settings.themeWhat.value ? Colors.white : Colors.grey, - animationDuration: const Duration(milliseconds: 500)), + animationDuration: lite + ? Duration.zero + : const Duration(milliseconds: 500)), header: Padding( padding: const EdgeInsets.fromLTRB(12, 12, 0, 0), child: Text( @@ -513,7 +529,9 @@ class _ArtistInfoPageState extends State { iconColor: Settings.themeWhat.value ? Colors.white : Colors.grey, - animationDuration: const Duration(milliseconds: 500)), + animationDuration: lite + ? Duration.zero + : const Duration(milliseconds: 500)), header: Padding( padding: const EdgeInsets.fromLTRB(12, 12, 0, 0), child: Text( @@ -595,6 +613,42 @@ class _ArtistInfoPageState extends State { MediaQuery.of(context).orientation == Orientation.landscape ? 4 : 3; final maxItemCount = MediaQuery.of(context).orientation == Orientation.landscape ? 8 : 6; + if (LiteMode.enabled) { + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + itemCount: min(cc.length, maxItemCount), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columnCount, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 3 / 4, + ), + itemBuilder: (context, index) { + return Padding( + padding: EdgeInsets.zero, + child: Align( + alignment: Alignment.bottomCenter, + child: SizedBox( + child: Provider.value( + value: ArticleListItem.fromArticleListItem( + queryResult: cc[index], + showDetail: false, + addBottomPadding: false, + width: (windowWidth - 4.0) / columnCount, + thumbnailTag: const Uuid().v4(), + usableTabList: cc, + ), + child: const ArticleListItemWidget(), + ), + ), + ), + ); + }, + ); + } + return LiveGrid( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), diff --git a/violet/rag/history/2024-12-lite-mode-implementation.md b/violet/rag/history/2024-12-lite-mode-implementation.md index 31223d309..68b10a94c 100644 --- a/violet/rag/history/2024-12-lite-mode-implementation.md +++ b/violet/rag/history/2024-12-lite-mode-implementation.md @@ -22,3 +22,4 @@ - Validate UI regression tests (search/bookmark flows) across platforms with Lite Mode toggled. - Extend Lite Mode handling to remaining pages (viewer overlays, downloads) per plan if further optimization needed. - Article list cards (`lib/widgets/article_item/article_list_item_widget.dart`) now honor Lite Mode by skipping particle effects and animated scaling, using static gesture handlers for lighter rebuilds. +- Article & Artist info pages now respect Lite Mode: card elevations drop to zero, expandable panels lose transition duration, LiveGrid animations swap for static grids, and flare decorations are skipped (`lib/pages/article_info/article_info_page.dart`, `lib/pages/artist_info/artist_info_page.dart`). diff --git a/violet/rag/history/2024-12-lite-mode-plan.md b/violet/rag/history/2024-12-lite-mode-plan.md index 93b96fa5b..ae8129f4b 100644 --- a/violet/rag/history/2024-12-lite-mode-plan.md +++ b/violet/rag/history/2024-12-lite-mode-plan.md @@ -78,3 +78,10 @@ - Reduce `AnimatedContainer` durations to zero and bypass expensive transforms. - Ensure interaction callbacks (tap, long press, double tap) still function without animations. - Success metrics: reduced rebuild cost and GPU work when rendering large lists in Lite Mode. + +## Pending Optimization: Info Pages +- Target screens: `ArticleInfoPage` and `ArtistInfoPage` which are opened frequently from search/bookmark flows. +- Lite Mode adjustments: + - Collapse card elevations, flare animations, and LiveGrid/Expandable animations into static widgets. + - Replace `LiveGrid` usage with plain `GridView` and default animation durations with zero to minimize expensive transitions. + - Maintain existing data presentation and interactions (preview tap, series/related toggles) while reducing UI overhead. From d6ef9fb461b83f37f24c86ff56bef38995654894 Mon Sep 17 00:00:00 2001 From: violet-dev Date: Mon, 29 Sep 2025 09:25:14 +0900 Subject: [PATCH 4/5] Fix build --- .../pages/artist_info/artist_info_page.dart | 1 + violet/lib/pages/bookmark/bookmark_page.dart | 92 +++++++++---------- 2 files changed, 46 insertions(+), 47 deletions(-) diff --git a/violet/lib/pages/artist_info/artist_info_page.dart b/violet/lib/pages/artist_info/artist_info_page.dart index 9871d5cdf..21274c6fd 100644 --- a/violet/lib/pages/artist_info/artist_info_page.dart +++ b/violet/lib/pages/artist_info/artist_info_page.dart @@ -355,6 +355,7 @@ class _ArtistInfoPageState extends State { final width = MediaQuery.of(context).size.width; final maxItemCount = MediaQuery.of(context).orientation == Orientation.landscape ? 8 : 6; + final bool lite = LiteMode.enabled; final axis1 = charts.AxisSpec( renderSpec: charts.GridlineRendererSpec( labelStyle: charts.TextStyleSpec( diff --git a/violet/lib/pages/bookmark/bookmark_page.dart b/violet/lib/pages/bookmark/bookmark_page.dart index fdc072725..8a9479777 100644 --- a/violet/lib/pages/bookmark/bookmark_page.dart +++ b/violet/lib/pages/bookmark/bookmark_page.dart @@ -257,54 +257,52 @@ class _BookmarkPageState extends ThemeSwitchableState width: double.infinity, decoration: BoxDecoration( color: Settings.themeWhat.value ? Colors.black26 : Colors.white, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(8), - topRight: Radius.circular(8), - bottomLeft: Radius.circular(8), - bottomRight: Radius.circular(8)), - boxShadow: [ - BoxShadow( - color: Settings.themeWhat.value - ? Colors.black26 - : Colors.grey.withOpacity(0.1), - spreadRadius: Settings.themeWhat.value ? 0 : 5, - blurRadius: 7, - offset: const Offset(0, 3), // changes position of shadow - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(8.0), - child: Material( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(8), + topRight: Radius.circular(8), + bottomLeft: Radius.circular(8), + bottomRight: Radius.circular(8)), + boxShadow: [ + BoxShadow( color: Settings.themeWhat.value - ? Settings.themeBlack.value - ? Palette.blackThemeBackground - : Colors.black38 - : Colors.white, - child: ListTile( - title: Text(name, style: const TextStyle(fontSize: 16.0)), - subtitle: Text(desc), - trailing: Text(date), - onTap: reorder - ? null - : () { - PlatformNavigator.navigateSlide( - context, - id == -2 - ? const RecordViewPage() - : id == -1 - ? const CropBookmarkPage() - : GroupArticleListPage( - groupId: id, name: name), - opaque: false, - ); - }, - onLongPress: reorder - ? null - : () async { - _onLongPressBookmarkItem(index, oname, name, data); - }, - ), + ? Colors.black26 + : Colors.grey.withOpacity(0.1), + spreadRadius: Settings.themeWhat.value ? 0 : 5, + blurRadius: 7, + offset: const Offset(0, 3), // changes position of shadow + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: Material( + color: Settings.themeWhat.value + ? Settings.themeBlack.value + ? Palette.blackThemeBackground + : Colors.black38 + : Colors.white, + child: ListTile( + title: Text(name, style: const TextStyle(fontSize: 16.0)), + subtitle: Text(desc), + trailing: Text(date), + onTap: reorder + ? null + : () { + PlatformNavigator.navigateSlide( + context, + id == -2 + ? const RecordViewPage() + : id == -1 + ? const CropBookmarkPage() + : GroupArticleListPage(groupId: id, name: name), + opaque: false, + ); + }, + onLongPress: reorder + ? null + : () async { + _onLongPressBookmarkItem(index, oname, name, data); + }, ), ), ), From 3ca12ad32215c4b9d012819124257f2d0d966a7f Mon Sep 17 00:00:00 2001 From: violet-dev Date: Mon, 29 Sep 2025 10:25:06 +0900 Subject: [PATCH 5/5] Update --- violet/lib/main.dart | 11 +++++---- violet/lib/settings/lite_mode.dart | 24 +++++++++++++++++++ .../2024-12-lite-mode-implementation.md | 6 ++++- 3 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 violet/lib/settings/lite_mode.dart diff --git a/violet/lib/main.dart b/violet/lib/main.dart index 91449d77d..d03b838e4 100644 --- a/violet/lib/main.dart +++ b/violet/lib/main.dart @@ -126,8 +126,7 @@ class MyApp extends StatelessWidget { systemOverlayStyle: !Settings.themeWhat.value ? SystemUiOverlayStyle.dark : SystemUiOverlayStyle.light, - backgroundColor: - lite ? scaffoldColor ?? Colors.transparent : null, + backgroundColor: lite ? scaffoldColor ?? Colors.transparent : null, elevation: lite ? 0 : null, shadowColor: lite ? Colors.transparent : null, ), @@ -221,8 +220,7 @@ class MyApp extends StatelessWidget { }, ) : theme.pageTransitionsTheme, - visualDensity: - lite ? VisualDensity.compact : theme.visualDensity, + visualDensity: lite ? VisualDensity.compact : theme.visualDensity, ), home: home, supportedLocales: supportedLocales, @@ -231,9 +229,12 @@ class MyApp extends StatelessWidget { localeResolutionCallback: localeResolution, scrollBehavior: CustomScrollBehavior(), builder: (context, child) { + final Widget body = child ?? Container(); + final Widget heroAwareBody = + LiteMode.enabled ? HeroMode(enabled: false, child: body) : body; return EscapeKeyListener( navigatorKey: navigatorKey, - child: child ?? Container(), + child: heroAwareBody, ); }, ); diff --git a/violet/lib/settings/lite_mode.dart b/violet/lib/settings/lite_mode.dart new file mode 100644 index 000000000..a02dc2b80 --- /dev/null +++ b/violet/lib/settings/lite_mode.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:violet/settings/settings.dart'; + +/// Central helper utilities for Lite Mode. +class LiteMode { + static bool get enabled => Settings.liteMode.value; + + static Duration animationDuration(Duration normal) => + enabled ? Duration.zero : normal; + + static Curve animationCurve([Curve curve = Curves.easeInOut]) => + enabled ? Curves.linear : curve; + + static double elevation(double value) => enabled ? 0.0 : value; + + static List boxShadow(List shadows) => + enabled ? const [] : shadows; + + static MaterialStatePropertyAll? overlayColor( + MaterialStatePropertyAll? color) { + if (!enabled) return color; + return const MaterialStatePropertyAll(Colors.transparent); + } +} diff --git a/violet/rag/history/2024-12-lite-mode-implementation.md b/violet/rag/history/2024-12-lite-mode-implementation.md index 68b10a94c..4d38f895f 100644 --- a/violet/rag/history/2024-12-lite-mode-implementation.md +++ b/violet/rag/history/2024-12-lite-mode-implementation.md @@ -2,7 +2,7 @@ ## Scope Delivered - Added `Settings.liteMode` preference (default off) and centralized helper `LiteMode` (`lib/settings/settings.dart`, `lib/settings/lite_mode.dart`). -- Settings ▸ View now exposes a localized switch (`litemode`, `litemodedesc`) with runtime UI refresh (`lib/pages/settings/settings_page.dart`). +- Settings ? View now exposes a localized switch (`litemode`, `litemodedesc`) with runtime UI refresh (`lib/pages/settings/settings_page.dart`). - Global theme adapts when Lite Mode is active: transitions, shadows, splash, and elevations collapse to minimal variants (`lib/main.dart`). ## Screen-Level Adjustments @@ -23,3 +23,7 @@ - Extend Lite Mode handling to remaining pages (viewer overlays, downloads) per plan if further optimization needed. - Article list cards (`lib/widgets/article_item/article_list_item_widget.dart`) now honor Lite Mode by skipping particle effects and animated scaling, using static gesture handlers for lighter rebuilds. - Article & Artist info pages now respect Lite Mode: card elevations drop to zero, expandable panels lose transition duration, LiveGrid animations swap for static grids, and flare decorations are skipped (`lib/pages/article_info/article_info_page.dart`, `lib/pages/artist_info/artist_info_page.dart`). + +## 2025-09 Updates +- Lite Mode globally disables Navigator hero transitions by wrapping the app builder with `HeroMode(enabled: false)` so route pushes no longer trigger hero flights. +