From 1fed0dfe7eb3bc434ec4e3d6e8ed7d6579e41bf7 Mon Sep 17 00:00:00 2001 From: Robert Date: Wed, 30 Sep 2026 17:24:40 +0800 Subject: [PATCH 1/2] feat: refine desktop layouts and interactions for 3.1.0 --- lib/Models/post_detail_response.dart | 54 ++- .../Info/favorite_folder_list_screen.dart | 1 + lib/Screens/Info/history_screen.dart | 2 + lib/Screens/Info/like_screen.dart | 1 + lib/Screens/Info/share_screen.dart | 1 + lib/Screens/Info/user_detail_screen.dart | 77 ++-- lib/Screens/Navigation/dynamic_screen.dart | 4 + lib/Screens/Navigation/home_screen.dart | 2 + lib/Screens/Navigation/mine_screen.dart | 9 +- lib/Screens/Navigation/search_screen.dart | 42 ++- lib/Screens/Post/post_detail_screen.dart | 127 +++---- lib/Screens/Post/search_result_screen.dart | 113 +++--- lib/Screens/Post/video_detail_screen.dart | 81 +++-- lib/Screens/Setting/about_setting_screen.dart | 2 +- .../Setting/apperance_setting_screen.dart | 2 +- .../Setting/blacklist_setting_screen.dart | 2 +- .../Setting/experiment_setting_screen.dart | 2 +- .../Setting/filename_setting_screen.dart | 2 +- .../Setting/general_setting_screen.dart | 23 +- lib/Screens/Setting/image_setting_screen.dart | 2 +- .../Setting/lofter_basic_setting_screen.dart | 2 +- .../mobile_setting_navigation_screen.dart | 5 +- lib/Screens/Setting/select_font_screen.dart | 2 +- lib/Screens/Setting/select_theme_screen.dart | 2 +- .../Setting/setting_navigation_screen.dart | 3 +- lib/Screens/Setting/setting_screen.dart | 8 +- .../Setting/tagshield_setting_screen.dart | 2 +- .../userdynamicshield_setting_screen.dart | 2 +- lib/Screens/main_screen.dart | 195 ++++++---- lib/Utils/app_provider.dart | 7 - lib/Utils/clipboard_link_controller.dart | 47 +++ lib/Utils/lottie_files.dart | 5 +- lib/Utils/uri_util.dart | 69 ++++ lib/Utils/utils.dart | 1 - .../BottomSheet/shield_bottom_sheet.dart | 2 +- lib/Widgets/Design/loftify_content_frame.dart | 28 ++ lib/Widgets/Design/loftify_lottie.dart | 5 + lib/Widgets/Dialog/clipboard_link_dialog.dart | 99 ++++++ lib/Widgets/Item/item_builder.dart | 5 + lib/Widgets/Item/loftify_item_builder.dart | 14 +- .../loftify_glass_navigation_bar.dart | 115 ++++++ lib/Widgets/PostDetail/comment_content.dart | 195 ++++++++++ lib/Widgets/PostDetail/lazy_comment_jump.dart | 63 ---- .../PostDetail/post_content_sliver.dart | 44 +++ .../post_swipe_gesture_detector.dart | 18 +- .../Profile/profile_header_components.dart | 71 +++- lib/Widgets/Tag/tag_detail_components.dart | 8 +- .../Video/video_controls_visibility.dart | 92 +++++ lib/l10n/intl_en.arb | 4 + lib/l10n/intl_zh.arb | 4 + lib/l10n/intl_zh_CN.arb | 4 + lib/l10n/intl_zh_TW.arb | 4 + pubspec.yaml | 2 +- test/clipboard_link_controller_test.dart | 174 +++++++++ .../search_navigation_layout_test.dart | 124 ++++--- .../navigation/search_suggestions_test.dart | 62 ++++ .../setting/general_setting_route_test.dart | 113 ++++++ .../setting/update_log_layout_test.dart | 129 +++++++ test/video_list_controller_test.dart | 79 +++++ .../widgets/design/loftify_surfaces_test.dart | 25 ++ .../item/search_bar_alignment_test.dart | 57 ++- .../navigation/desktop_context_menu_test.dart | 125 +++++++ .../loftify_glass_navigation_bar_test.dart | 77 ++++ .../post_detail/detail_components_test.dart | 335 ++++++++++++++++++ .../post_detail/lazy_comment_jump_test.dart | 71 ---- .../post_detail/post_comment_jump_test.dart | 143 ++++++++ .../post_swipe_gesture_detector_test.dart | 70 ++++ .../profile_header_components_test.dart | 67 ++++ .../easy_refresh_extent_shrink_test.dart | 52 +++ .../tag/tag_detail_components_test.dart | 32 ++ .../lib/src/Screens/update_log_screen.dart | 208 ++++++----- .../lib/src/Widgets/Basic/item_builder.dart | 4 +- .../Widgets/Button/shadow_icon_button.dart | 8 +- .../Widgets/Component/custom_html_widget.dart | 25 ++ .../lib/src/Widgets/Dialog/custom_dialog.dart | 4 + .../src/Widgets/Dialog/dialog_builder.dart | 7 +- .../widgets/custom_confirm_dialog_widget.dart | 6 +- .../Widgets/General/responsive_app_bar.dart | 8 +- .../Module/EasyRefresh/src/easy_refresh.dart | 39 +- .../core/utils/helpers.dart | 11 +- .../FlutterContextMenu/core/utils/utils.dart | 8 +- .../widgets/context_menu_state.dart | 14 +- .../widgets/context_menu_widget.dart | 7 +- .../src/Widgets/Window/window_caption.dart | 1 + .../lib/src/Widgets/Window/window_title.dart | 8 +- tools/windows_tools/Loftify.iss | 2 +- 86 files changed, 3145 insertions(+), 626 deletions(-) create mode 100644 lib/Utils/clipboard_link_controller.dart create mode 100644 lib/Widgets/Design/loftify_content_frame.dart create mode 100644 lib/Widgets/Dialog/clipboard_link_dialog.dart create mode 100644 lib/Widgets/PostDetail/comment_content.dart delete mode 100644 lib/Widgets/PostDetail/lazy_comment_jump.dart create mode 100644 lib/Widgets/PostDetail/post_content_sliver.dart create mode 100644 lib/Widgets/Video/video_controls_visibility.dart create mode 100644 test/clipboard_link_controller_test.dart create mode 100644 test/screens/setting/general_setting_route_test.dart create mode 100644 test/screens/setting/update_log_layout_test.dart create mode 100644 test/widgets/navigation/desktop_context_menu_test.dart delete mode 100644 test/widgets/post_detail/lazy_comment_jump_test.dart create mode 100644 test/widgets/post_detail/post_comment_jump_test.dart diff --git a/lib/Models/post_detail_response.dart b/lib/Models/post_detail_response.dart index 0306c2e1..8d2b4a61 100644 --- a/lib/Models/post_detail_response.dart +++ b/lib/Models/post_detail_response.dart @@ -907,6 +907,7 @@ class Comment { int blogId; String content; List emotes; + List images; int id; String ipLocation; List l2Comments; @@ -927,6 +928,7 @@ class Comment { required this.blogId, required this.content, required this.emotes, + this.images = const [], required this.id, required this.ipLocation, required this.l2Comments, @@ -950,6 +952,14 @@ class Comment { l2CommentOffset: -1, blogId: _detailInt(json['blogId']), content: _detailString(json['content']), + images: json['images'] is List + ? (json['images'] as List) + .whereType() + .map((value) => CommentImage.fromJson( + Map.from(value), + )) + .toList() + : [], emotes: json['emotes'] is List ? (json['emotes'] as List) .whereType() @@ -992,6 +1002,7 @@ class Comment { data['blogId'] = blogId; data['content'] = content; data['emotes'] = emotes.map((v) => v.toJson()).toList(); + data['images'] = images.map((v) => v.toJson()).toList(); data['id'] = id; data['ipLocation'] = ipLocation; data['l2Comments'] = l2Comments; @@ -1008,6 +1019,41 @@ class Comment { } } +/// Uploaded comment pictures/stickers are separate from named emotes. +class CommentImage { + const CommentImage({ + required this.orign, + this.raw = '', + this.ow = 0, + this.oh = 0, + this.type = 0, + }); + + final String orign; + final String raw; + final int ow; + final int oh; + final int type; + + String get url => orign.trim().isNotEmpty ? orign.trim() : raw.trim(); + + factory CommentImage.fromJson(Map json) => CommentImage( + orign: _detailString(json['orign']), + raw: _detailString(json['raw']), + ow: _detailInt(json['ow']), + oh: _detailInt(json['oh']), + type: _detailInt(json['type']), + ); + + Map toJson() => { + 'orign': orign, + 'raw': raw, + 'ow': ow, + 'oh': oh, + 'type': type, + }; +} + class CommentEmote { int id; String name; @@ -1023,10 +1069,10 @@ class CommentEmote { factory CommentEmote.fromJson(Map json) { return CommentEmote( - id: json['id'], - name: json['name'], - sizeType: json['sizeType'], - url: json['url'], + id: _detailInt(json['id']), + name: _detailString(json['name']), + sizeType: _detailInt(json['sizeType']), + url: _detailString(json['url']), ); } diff --git a/lib/Screens/Info/favorite_folder_list_screen.dart b/lib/Screens/Info/favorite_folder_list_screen.dart index 74f8a85a..a2358a6d 100644 --- a/lib/Screens/Info/favorite_folder_list_screen.dart +++ b/lib/Screens/Info/favorite_folder_list_screen.dart @@ -359,6 +359,7 @@ class _FavoriteFolderListScreenState ? Column( children: [ ShadowIconButton( + boxShadow: const [], icon: const ChewieIcon(LoftifyIcons.add), onTap: handleAdd, ), diff --git a/lib/Screens/Info/history_screen.dart b/lib/Screens/Info/history_screen.dart index 852fa742..7e934cbc 100644 --- a/lib/Screens/Info/history_screen.dart +++ b/lib/Screens/Info/history_screen.dart @@ -391,6 +391,8 @@ class _HistoryScreenState extends BaseDynamicState ? Column( children: [ ShadowIconButton( + boxShadow: const [], + backgroundColor: ChewieTheme.canvasColor, icon: const ChewieIcon(LoftifyIcons.moreVertical), onTap: () { BottomSheetBuilder.showContextMenu( diff --git a/lib/Screens/Info/like_screen.dart b/lib/Screens/Info/like_screen.dart index eb3aebd2..25ed54d4 100644 --- a/lib/Screens/Info/like_screen.dart +++ b/lib/Screens/Info/like_screen.dart @@ -450,6 +450,7 @@ class _LikeScreenState extends BaseDynamicState ? Column( children: [ ShadowIconButton( + boxShadow: const [], icon: const ChewieIcon(LoftifyIcons.moreVertical), onTap: () { BottomSheetBuilder.showContextMenu( diff --git a/lib/Screens/Info/share_screen.dart b/lib/Screens/Info/share_screen.dart index c789c69e..c0fc8fc7 100644 --- a/lib/Screens/Info/share_screen.dart +++ b/lib/Screens/Info/share_screen.dart @@ -438,6 +438,7 @@ class _ShareScreenState extends BaseDynamicState ? Column( children: [ ShadowIconButton( + boxShadow: const [], icon: const ChewieIcon(LoftifyIcons.moreVertical), onTap: () { BottomSheetBuilder.showContextMenu( diff --git a/lib/Screens/Info/user_detail_screen.dart b/lib/Screens/Info/user_detail_screen.dart index 8395a112..8d9f0fee 100644 --- a/lib/Screens/Info/user_detail_screen.dart +++ b/lib/Screens/Info/user_detail_screen.dart @@ -198,7 +198,9 @@ class UserDetailScreenState extends BaseDynamicState ? ExtendedNestedScrollView( onlyOneScrollInBody: true, headerSliverBuilder: (_, __) => _buildHeaderSlivers(), - body: _mainContent(), + body: ResponsiveUtil.isLandscapeLayout() + ? LoftifyProfileContentFrame(child: _mainContent()) + : _mainContent(), ) : LoadingWidget( background: ChewieTheme.getBackground(context), @@ -269,18 +271,11 @@ class UserDetailScreenState extends BaseDynamicState } else { return [ SliverToBoxAdapter( - child: SizedBox( - height: _profileHeaderHeight( - context, - includesAppBar: false, - ), - child: Stack( - fit: StackFit.expand, - children: [ - _buildHeaderBackground(), - _buildInfo(12), - ], - ), + child: Stack( + children: [ + Positioned.fill(child: _buildHeaderBackground()), + _buildInfo(16), + ], ), ), SliverPersistentHeader( @@ -289,13 +284,17 @@ class UserDetailScreenState extends BaseDynamicState delegate: SliverAppBarDelegate( radius: 0, background: context.design.colors.page, - tabBar: TabBarWrapper( - tabController: _tabController, - tabs: tabList, - width: MediaQuery.sizeOf(context).width, - isScrollable: false, - showBorder: true, - onTap: _setCurrentTab, + tabBar: PreferredSize( + preferredSize: const Size.fromHeight(56), + child: LoftifyProfileContentFrame( + child: TabBarWrapper( + tabController: _tabController, + tabs: tabList, + isScrollable: false, + showBorder: true, + onTap: _setCurrentTab, + ), + ), ), ), ), @@ -593,20 +592,30 @@ class UserDetailScreenState extends BaseDynamicState design.spacing.lg, ), child: LoftifyProfileHeaderLayout( - summary: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - _buildIdentitySummary( - hasRemarkName: hasRemarkName, - hasDescription: hasDescription, - ), - SizedBox(height: design.spacing.lg), - _buildStatisticsCard(), - SizedBox(height: design.spacing.sectionTop), - _buildProfileAction(), - ], - ), + stackShowcase: ResponsiveUtil.isLandscapeLayout(), + summary: ResponsiveUtil.isLandscapeLayout() + ? LoftifyWideProfileSummary( + identity: _buildIdentitySummary( + hasRemarkName: hasRemarkName, + hasDescription: hasDescription, + ), + statistics: _buildStatisticsCard(), + action: _buildProfileAction(), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + _buildIdentitySummary( + hasRemarkName: hasRemarkName, + hasDescription: hasDescription, + ), + SizedBox(height: design.spacing.lg), + _buildStatisticsCard(), + SizedBox(height: design.spacing.sectionTop), + _buildProfileAction(), + ], + ), showcase: showCases.isNotEmpty ? _buildShowCases() : null, ), ); diff --git a/lib/Screens/Navigation/dynamic_screen.dart b/lib/Screens/Navigation/dynamic_screen.dart index 9cec4ac8..39dd30e7 100644 --- a/lib/Screens/Navigation/dynamic_screen.dart +++ b/lib/Screens/Navigation/dynamic_screen.dart @@ -252,6 +252,8 @@ class DynamicScreenState extends BaseDynamicState ? Column( children: [ ShadowIconButton( + boxShadow: const [], + backgroundColor: ChewieTheme.canvasColor, icon: RotationTransition( turns: Tween(begin: 0.0, end: 1.0) .animate(_refreshRotationController), @@ -263,6 +265,8 @@ class DynamicScreenState extends BaseDynamicState ), const SizedBox(height: 10), ShadowIconButton( + boxShadow: const [], + backgroundColor: ChewieTheme.canvasColor, icon: const ChewieIcon(LoftifyIcons.scrollTop), onTap: () { scrollToTop(); diff --git a/lib/Screens/Navigation/home_screen.dart b/lib/Screens/Navigation/home_screen.dart index d06b2efd..1748f54f 100644 --- a/lib/Screens/Navigation/home_screen.dart +++ b/lib/Screens/Navigation/home_screen.dart @@ -389,6 +389,7 @@ class HomeScreenState extends BaseDynamicState ? Column( children: [ ShadowIconButton( + boxShadow: const [], icon: RotationTransition( turns: Tween(begin: 0.0, end: 1.0) .animate(_refreshRotationController), @@ -400,6 +401,7 @@ class HomeScreenState extends BaseDynamicState ), const SizedBox(height: 10), ShadowIconButton( + boxShadow: const [], icon: const ChewieIcon(LoftifyIcons.scrollTop), onTap: () { scrollToTop(); diff --git a/lib/Screens/Navigation/mine_screen.dart b/lib/Screens/Navigation/mine_screen.dart index 6b46395e..952d7068 100644 --- a/lib/Screens/Navigation/mine_screen.dart +++ b/lib/Screens/Navigation/mine_screen.dart @@ -300,7 +300,7 @@ class _MineScreenState extends BaseDynamicState Widget _buildFollowingCard() { return ContainerItem( - backgroundColor: Theme.of(context).canvasColor, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, child: Column( children: [ ItemBuilder.buildTitle( @@ -321,6 +321,7 @@ class _MineScreenState extends BaseDynamicState ); }, ), + const SizedBox(height: 8), ListView( shrinkWrap: true, padding: EdgeInsets.zero, @@ -344,7 +345,7 @@ class _MineScreenState extends BaseDynamicState Widget _buildFollowerCard() { return ContainerItem( - backgroundColor: Theme.of(context).canvasColor, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, child: Column( children: [ ItemBuilder.buildTitle( @@ -365,6 +366,7 @@ class _MineScreenState extends BaseDynamicState ); }, ), + const SizedBox(height: 8), ListView( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), @@ -755,7 +757,8 @@ class _MineScreenState extends BaseDynamicState return ResponsiveAppBar( titleWidget: const SizedBox.shrink(), actions: actions, - landscapeActions: actions, + // These actions already live in the desktop navigation rail. + landscapeActions: ResponsiveUtil.isDesktop() ? const [] : actions, ); } diff --git a/lib/Screens/Navigation/search_screen.dart b/lib/Screens/Navigation/search_screen.dart index f0fcd428..135fdca9 100644 --- a/lib/Screens/Navigation/search_screen.dart +++ b/lib/Screens/Navigation/search_screen.dart @@ -19,6 +19,7 @@ import '../../Utils/hive_util.dart'; import '../../Utils/tab_state_util.dart'; import '../../Utils/uri_util.dart'; import '../../Utils/utils.dart'; +import '../../Widgets/Design/loftify_content_frame.dart'; import '../../Widgets/Item/item_builder.dart'; import '../../Widgets/Item/loftify_item_builder.dart'; import '../../Widgets/loftify_icons.dart'; @@ -205,20 +206,33 @@ class SearchScreenState extends BaseDynamicState @override Widget build(BuildContext context) { super.build(context); + final isWideLayout = ResponsiveUtil.isLandscapeLayout(); + final content = Stack( + children: [ + _buildMainBody(), + if (_sugList.isNotEmpty) _buildSuggestList(), + ], + ); return Scaffold( backgroundColor: ChewieTheme.getBackground(context), appBar: ResponsiveAppBar( showBack: widget.showBack, - titleWidget: _buildSearchBar(), - titleLeftMargin: 0, + title: isWideLayout ? appLocalizations.search : '', + titleWidget: isWideLayout ? null : _buildSearchBar(), + titleLeftMargin: isWideLayout ? 16 : 0, rightSpacing: 0, ), - body: Stack( - children: [ - _buildMainBody(), - if (_sugList.isNotEmpty) _buildSuggestList(), - ], - ), + body: isWideLayout + ? LoftifyContentFrame( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildSearchBar(), + Expanded(child: content), + ], + ), + ) + : content, extendBody: true, ); } @@ -748,15 +762,15 @@ class SearchScreenState extends BaseDynamicState } Widget _buildSearchBar() { - final isCompactWidth = MediaQuery.sizeOf(context).width < 600; + final isWideLayout = ResponsiveUtil.isLandscapeLayout(); return Container( key: const ValueKey('search-navigation-bar'), - margin: const EdgeInsets.all(10), + margin: isWideLayout + ? const EdgeInsets.fromLTRB(16, 12, 16, 12) + : const EdgeInsets.all(10), constraints: BoxConstraints( - maxWidth: !isCompactWidth && ResponsiveUtil.isLandscapeLayout() - ? searchBarWidth - 80 - : double.infinity, - maxHeight: 56, + minHeight: isWideLayout ? 48 : 0, + maxHeight: isWideLayout ? 48 : 56, ), child: ItemBuilder.buildSearchBar( context: context, diff --git a/lib/Screens/Post/post_detail_screen.dart b/lib/Screens/Post/post_detail_screen.dart index f025c5f8..a685d790 100644 --- a/lib/Screens/Post/post_detail_screen.dart +++ b/lib/Screens/Post/post_detail_screen.dart @@ -4,7 +4,6 @@ import 'dart:math'; import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:card_swiper/card_swiper.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:loftify/Api/post_api.dart'; import 'package:loftify/Api/user_api.dart'; @@ -40,12 +39,13 @@ import '../../Utils/uri_util.dart'; import '../../Utils/utils.dart'; import '../../Theme/loftify_design_theme.dart'; import '../../Widgets/Design/loftify_content_reference.dart'; +import '../../Widgets/Design/loftify_content_frame.dart'; import '../../Widgets/Design/loftify_reading.dart'; import '../../Widgets/Item/item_builder.dart'; import '../../Widgets/Item/loftify_item_builder.dart'; import '../../Widgets/PostItem/recommend_flow_item_builder.dart'; import '../../Widgets/PostDetail/detail_bottom_bar.dart'; -import '../../Widgets/PostDetail/lazy_comment_jump.dart'; +import '../../Widgets/PostDetail/post_content_sliver.dart'; import '../../Widgets/PostDetail/post_content_section.dart'; import '../../Widgets/PostDetail/post_download_action_icon.dart'; import '../../Widgets/PostDetail/post_swipe_gesture_detector.dart'; @@ -137,8 +137,8 @@ class _PostDetailScreenState extends BaseDynamicState final GlobalKey _operationViewportKey = GlobalKey(); final GlobalKey _commentListViewportKey = GlobalKey(); final GlobalKey _commentEndViewportKey = GlobalKey(); - final GlobalKey _commonContentSliverKey = GlobalKey(); final GlobalKey _imageSwiperViewportKey = GlobalKey(); + final GlobalKey _postBodyPaneKey = GlobalKey(); final ResizableController _resizableController = ResizableController(); DownloadState downloadState = DownloadState.none; double _downloadProgress = 0; @@ -954,16 +954,19 @@ class _PostDetailScreenState extends BaseDynamicState Widget build(BuildContext context) { super.build(context); final design = context.design; + final content = Stack( + fit: StackFit.expand, + children: [ + _buildPostSwipeLayer(_buildBody()), + _buildFloatingOperationOverlay(), + ], + ); return Scaffold( appBar: _buildAppBar(), backgroundColor: design.colors.page, - body: Stack( - fit: StackFit.expand, - children: [ - _buildPostSwipeLayer(_buildBody()), - _buildFloatingOperationOverlay(), - ], - ), + body: ResponsiveUtil.isLandscapeLayout() + ? LoftifyContentFrame(child: content) + : content, ); } @@ -998,6 +1001,7 @@ class _PostDetailScreenState extends BaseDynamicState return PostSwipeGestureDetector( behavior: HitTestBehavior.translucent, excludedRegions: [_imageSwiperViewportKey], + activeRegion: _postBodyPaneKey, onHorizontalDragStart: _handlePostSwipeStart, onHorizontalDragUpdate: _handlePostSwipeUpdate, onHorizontalDragEnd: _handlePostSwipeEnd, @@ -1295,8 +1299,7 @@ class _PostDetailScreenState extends BaseDynamicState controller: _scrollController, physics: physics, slivers: [ - SliverList.list( - key: _commonContentSliverKey, + PostContentSliver( children: _buildCommonContent(false), ), _buildRecommendFlow(), @@ -1306,50 +1309,45 @@ class _PostDetailScreenState extends BaseDynamicState } _buildTabletMainBody() { - return ResizableContainer( - direction: Axis.horizontal, - controller: _resizableController, - // divider: ResizableDivider( - // color: Theme - // .of(context) - // .dividerColor, - // thickness: ResponsiveUtil.isMobile() ? 2 : 1, - // size: 6, - // onHoverEnter: () { - // if (ResponsiveUtil.isMobile()) { - // HapticFeedback.lightImpact(); - // } - // }, - // ), - children: [ - ResizableChild( - size: ResizableSize.pixels( - isArticle - ? MediaQuery.sizeOf(context).width * 2 / 3 - : max(MediaQuery.sizeOf(context).width * 1 / 3, 400), - ), - // minSize: 300, - child: EasyRefresh.builder( - onRefresh: _onRefresh, - triggerAxis: Axis.vertical, - childBuilder: (context, physics) => - NotificationListener( - onNotification: _handlePostScrollNotification, - child: ListView( - controller: _tabletScrollController, - physics: physics, - children: _buildCommonContent(true), - ), - ), - ), - ), - ResizableChild( - // minSize: 300, - size: const ResizableSize.expand(), - child: _buildRecommendFlow(sliver: false), - ), - ], - ); + return LayoutBuilder( + builder: (context, constraints) => ResizableContainer( + direction: Axis.horizontal, + controller: _resizableController, + children: [ + ResizableChild( + size: ResizableSize.pixels( + isArticle + ? constraints.maxWidth * 2 / 3 + : max(constraints.maxWidth * 1 / 3, 400), + ), + divider: const ResizableDivider(padding: 12), + child: EasyRefresh.builder( + key: _postBodyPaneKey, + onRefresh: _onRefresh, + triggerAxis: Axis.vertical, + childBuilder: (context, physics) => + NotificationListener( + onNotification: _handlePostScrollNotification, + child: CustomScrollView( + controller: _tabletScrollController, + physics: physics, + slivers: [ + PostContentSliver(children: _buildCommonContent(true)), + ], + ), + ), + ), + ), + ResizableChild( + // minSize: 300, + size: const ResizableSize.expand(), + child: NotificationListener( + onNotification: _handlePostScrollNotification, + child: _buildRecommendFlow(sliver: false), + ), + ), + ], + )); } void _handleDoubleTapDown(TapDownDetails details) { @@ -1729,24 +1727,9 @@ class _PostDetailScreenState extends BaseDynamicState ? _scrollController : null; if (controller == null) return; - final requestedPostId = postId; - - // The heading may be unmounted by the lazy sliver. Its parent sliver - // remains mounted, so approach the end of the post content first. This - // works from both a long article above and recommendations below. - final mainSliver = - _commonContentSliverKey.currentContext?.findRenderObject(); - final contentExtent = tabletBody - ? controller.position.maxScrollExtent + - controller.position.viewportDimension - : mainSliver is RenderSliver - ? mainSliver.geometry?.scrollExtent - : null; - await revealLazyComment( + await revealPostComment( controller: controller, anchorKey: commentKey, - contentExtent: contentExtent, - isActive: () => mounted && requestedPostId == postId, ); } diff --git a/lib/Screens/Post/search_result_screen.dart b/lib/Screens/Post/search_result_screen.dart index 9d801d75..e46585ea 100644 --- a/lib/Screens/Post/search_result_screen.dart +++ b/lib/Screens/Post/search_result_screen.dart @@ -16,6 +16,8 @@ import '../../Utils/utils.dart'; import '../../Utils/hive_util.dart'; import '../../Utils/paged_data_controller.dart'; import '../../Utils/tab_state_util.dart'; +import '../../Widgets/Design/loftify_state_view.dart'; +import '../../Widgets/Design/loftify_content_frame.dart'; import '../../Widgets/Item/item_builder.dart'; import '../../Widgets/Item/loftify_item_builder.dart'; import '../../Widgets/PostItem/recommend_flow_item_builder.dart'; @@ -241,45 +243,61 @@ class _SearchResultScreenState extends BaseDynamicState @override Widget build(BuildContext context) { super.build(context); + final isWideLayout = ResponsiveUtil.isLandscapeLayout(); + final content = Stack( + children: [ + _buildTabView(), + if (_sugList.isNotEmpty) _buildSuggestList(), + ], + ); return Scaffold( backgroundColor: ChewieTheme.getBackground(context), appBar: ResponsiveAppBar( showBack: true, - titleLeftMargin: 0, - titleWidget: _buildSearchBar(), - bottomHeight: 56, - bottomWidget: TabBarWrapper( - tabController: _tabController, - tabs: _tabLabelList - .asMap() - .entries - .map( - (entry) => ItemBuilder.buildAnimatedTab( - context, - selected: entry.key == _currentTabIndex, - text: entry.value, - controller: _tabController, - tabIndex: entry.key, - ), - ) - .toList(), - showBorder: true, - width: MediaQuery.sizeOf(context).width, - isScrollable: false, - onTap: (index) { - _setCurrentTab(index); - }, - ), - ), - body: Stack( - children: [ - _buildTabView(), - if (_sugList.isNotEmpty) _buildSuggestList(), - ], + title: isWideLayout ? appLocalizations.searchResultPage : '', + titleLeftMargin: isWideLayout ? 16 : 0, + titleWidget: isWideLayout ? null : _buildSearchBar(), + bottomHeight: isWideLayout ? null : 56, + bottomWidget: isWideLayout ? null : _buildTabs(), ), + body: isWideLayout + ? LoftifyContentFrame( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildSearchBar(), + SizedBox(height: 56, child: _buildTabs()), + Expanded(child: content), + ], + ), + ) + : content, ); } + Widget _buildTabs() => TabBarWrapper( + tabController: _tabController, + tabs: _tabLabelList + .asMap() + .entries + .map( + (entry) => ItemBuilder.buildAnimatedTab( + context, + selected: entry.key == _currentTabIndex, + text: entry.value, + controller: _tabController, + tabIndex: entry.key, + ), + ) + .toList(), + showBorder: true, + width: MediaQuery.sizeOf(context).width, + isScrollable: false, + onTap: (index) { + _setCurrentTab(index); + }, + ); + void initTab() { _tabController = TabController( length: _tabIdList.length, @@ -767,7 +785,6 @@ class _SearchResultScreenState extends BaseDynamicState SliverList( delegate: SliverChildListDelegate( [ - const SizedBox(height: 10), if (_allResult!.tags.isEmpty && _allResult!.tagRank == null && _allResult!.posts.isEmpty) @@ -866,7 +883,14 @@ class _SearchResultScreenState extends BaseDynamicState SliverFillRemaining( hasScrollBody: false, child: _allPagingController.lastError == null - ? LoadingWidget( + ? LoftifyStateView( + visual: LoftifyStateVisual.loading, + title: chewieLocalizations.loading, + indicatorSize: 50, + padding: const EdgeInsets.only(bottom: 100), + // The sliver measures intrinsic height and owns + // scrolling; don't nest the adaptive scroll body. + scrollWhenConstrained: false, background: ChewieTheme.getBackground(context), ) : Center( @@ -906,7 +930,6 @@ class _SearchResultScreenState extends BaseDynamicState SliverList( delegate: SliverChildListDelegate( [ - const SizedBox(height: 10), if (_tagList.isEmpty && _tagRank == null) Container( margin: const EdgeInsets.symmetric(vertical: 16), @@ -968,7 +991,6 @@ class _SearchResultScreenState extends BaseDynamicState SliverList( delegate: SliverChildListDelegate( [ - const SizedBox(height: 10), if (_collectionList.isEmpty) Container( margin: const EdgeInsets.symmetric(vertical: 16), @@ -1030,7 +1052,6 @@ class _SearchResultScreenState extends BaseDynamicState SliverList( delegate: SliverChildListDelegate( [ - const SizedBox(height: 10), if (_postList.isEmpty) Container( margin: const EdgeInsets.symmetric(vertical: 16), @@ -1090,7 +1111,6 @@ class _SearchResultScreenState extends BaseDynamicState SliverList( delegate: SliverChildListDelegate( [ - const SizedBox(height: 10), if (_grainList.isEmpty) Container( margin: const EdgeInsets.symmetric(vertical: 16), @@ -1152,7 +1172,6 @@ class _SearchResultScreenState extends BaseDynamicState SliverList( delegate: SliverChildListDelegate( [ - const SizedBox(height: 10), if (_userList.isEmpty) Container( margin: const EdgeInsets.symmetric(vertical: 16), @@ -1199,13 +1218,21 @@ class _SearchResultScreenState extends BaseDynamicState } Widget _buildSearchBar() { - double width = ResponsiveUtil.isLandscapeLayout() - ? searchBarWidth - 100 + final isWideLayout = ResponsiveUtil.isLandscapeLayout(); + final width = isWideLayout + ? loftifyPageMaxContentWidth : min(MediaQuery.of(context).size.width, searchBarWidth); return Container( - margin: const EdgeInsets.all(10), - constraints: - BoxConstraints(maxWidth: width, minWidth: width, maxHeight: 56), + key: const ValueKey('search-results-bar'), + margin: isWideLayout + ? const EdgeInsets.fromLTRB(16, 12, 16, 12) + : const EdgeInsets.all(10), + constraints: BoxConstraints( + maxWidth: width, + minWidth: isWideLayout ? 0 : width, + minHeight: isWideLayout ? 48 : 0, + maxHeight: isWideLayout ? 48 : 56, + ), child: ItemBuilder.buildSearchBar( context: context, borderRadius: 8, diff --git a/lib/Screens/Post/video_detail_screen.dart b/lib/Screens/Post/video_detail_screen.dart index c8fece75..f4c9ee09 100644 --- a/lib/Screens/Post/video_detail_screen.dart +++ b/lib/Screens/Post/video_detail_screen.dart @@ -27,6 +27,7 @@ import '../../Widgets/BottomSheet/comment_bottom_sheet.dart'; import '../../Widgets/Item/item_builder.dart'; import '../../Widgets/loftify_icons.dart'; import '../../Widgets/loftify_reaction_icon.dart'; +import '../../Widgets/Video/video_controls_visibility.dart'; import '../../l10n/l10n.dart'; class VideoDetailScreen extends StatefulWidget { @@ -80,6 +81,7 @@ class _VideoDetailScreenState extends BaseDynamicState int offset = 0; final PageController _pageController = PageController(); final VideoListController _videoListController = VideoListController(); + final VideoControlsController _controls = VideoControlsController(); final GlobalKey _authorSwipeKey = GlobalKey(); final Map _downloadProgress = {}; @@ -114,6 +116,7 @@ class _VideoDetailScreenState extends BaseDynamicState WidgetsBinding.instance.removeObserver(this); _videoListController.removeListener(_handleVideoControllerChanged); _videoListController.dispose(); + _controls.dispose(); _pageController.dispose(); // Leaving the video page must also release the portrait override used // after exiting full screen (not only an active full-screen lock). @@ -186,6 +189,7 @@ class _VideoDetailScreenState extends BaseDynamicState void _handleVideoControllerChanged() { final value = _videoListController.currentPlayerOrNull?.controllerOrNull?.value; + _controls.setPlaying((value?.isPlaying ?? false) && _routeVisible); if (_continuousPlayback && !_autoAdvanceInProgress && (value?.isCompleted ?? false)) { @@ -195,6 +199,7 @@ class _VideoDetailScreenState extends BaseDynamicState } void _pauseForInterruption() { + _controls.setPlaying(false); final player = _videoListController.currentPlayerOrNull; _resumeAfterInterruption = _resumeAfterInterruption || (player?.isPlaying ?? false); @@ -454,11 +459,20 @@ class _VideoDetailScreenState extends BaseDynamicState } Widget _buildInteractiveVideoExperience() { - final content = Stack( - children: [ - _buildBody(), - _buildTopWidget(), - ], + final content = Listener( + onPointerDown: (_) => _controls.pointerDown(), + onPointerUp: (_) => _controls.pointerUp(), + onPointerCancel: (_) => _controls.pointerUp(), + child: MouseRegion( + onHover: (_) => _controls.show(), + child: Stack( + children: [ + _buildBody(), + VideoControlsVisibility( + controller: _controls, child: _buildTopWidget()), + ], + ), + ), ); final item = _currentPostItem; if (item?.blogInfo == null) return content; @@ -503,6 +517,7 @@ class _VideoDetailScreenState extends BaseDynamicState scrollDirection: Axis.vertical, itemCount: _videoListController.videoCount, onPageChanged: (index) { + _controls.show(); final player = _videoListController.playerOfIndex(index); _currentPostItem = player?.videoInfo; unawaited(_uploadHistory()); @@ -517,7 +532,9 @@ class _VideoDetailScreenState extends BaseDynamicState return _buildVideoPage( item, hidePauseIcon: !player.showPauseIcon, - onSingleTap: () async { + onSingleTap: _controls.toggle, + onDoubleTap: () async { + _controls.show(); if (player.loadState == VideoLoadState.failed) { await player.retry( autoplay: index == _videoListController.index.value, @@ -638,6 +655,7 @@ class _VideoDetailScreenState extends BaseDynamicState bool hidePauseIcon = false, required Widget video, Function()? onSingleTap, + VoidCallback? onDoubleTap, required CustomVideoController progressPlayer, required bool Function() canResumeAfterScrub, }) { @@ -677,23 +695,26 @@ class _VideoDetailScreenState extends BaseDynamicState ), ), ), - const Positioned( + Positioned( left: 0, right: 0, bottom: 0, - child: IgnorePointer( - child: SizedBox( - height: 260, - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Color(0x33000000), - Color(0xB3000000), - ], + child: VideoControlsVisibility( + controller: _controls, + child: const IgnorePointer( + child: SizedBox( + height: 260, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Color(0x33000000), + Color(0xB3000000), + ], + ), ), ), ), @@ -704,16 +725,22 @@ class _VideoDetailScreenState extends BaseDynamicState left: 0, right: 0, bottom: bottomSafeInset + 14, - child: _buildVideoMeta(postListItem), + child: VideoControlsVisibility( + controller: _controls, + child: _buildVideoMeta(postListItem), + ), ), Positioned( left: 0, right: 0, bottom: bottomSafeInset, - child: ImmersiveVideoProgressBar( - player: progressPlayer, - canResume: canResumeAfterScrub, - semanticLabel: appLocalizations.video, + child: VideoControlsVisibility( + controller: _controls, + child: ImmersiveVideoProgressBar( + player: progressPlayer, + canResume: canResumeAfterScrub, + semanticLabel: appLocalizations.video, + ), ), ), ], @@ -721,6 +748,7 @@ class _VideoDetailScreenState extends BaseDynamicState return VideoLongPressGesture( player: progressPlayer, onTap: onSingleTap, + onDoubleTap: onDoubleTap, onOpenMenu: () => unawaited(_showVideoActions(postListItem)), child: body, ); @@ -1426,6 +1454,7 @@ class VideoLongPressGesture extends StatefulWidget { required this.child, required this.onOpenMenu, this.onTap, + this.onDoubleTap, this.edgeFraction = 0.16, this.minimumEdgeWidth = 52, this.maximumEdgeWidth = 92, @@ -1436,6 +1465,7 @@ class VideoLongPressGesture extends StatefulWidget { final Widget child; final VoidCallback onOpenMenu; final VoidCallback? onTap; + final VoidCallback? onDoubleTap; final double edgeFraction; final double minimumEdgeWidth; final double maximumEdgeWidth; @@ -1498,6 +1528,7 @@ class _VideoLongPressGestureState extends State { builder: (context, constraints) => GestureDetector( behavior: HitTestBehavior.opaque, onTap: widget.onTap, + onDoubleTap: widget.onDoubleTap, onLongPressStart: (details) => _handleLongPressStart(details, constraints.maxWidth), onLongPressEnd: (_) => _stopTemporarySpeed(), diff --git a/lib/Screens/Setting/about_setting_screen.dart b/lib/Screens/Setting/about_setting_screen.dart index 080b9ffc..3ea8fbf5 100644 --- a/lib/Screens/Setting/about_setting_screen.dart +++ b/lib/Screens/Setting/about_setting_screen.dart @@ -92,7 +92,7 @@ class _AboutSettingScreenState extends BaseDynamicState context: context, title: appLocalizations.about, showTitleBar: widget.showTitleBar, - showBack: !ResponsiveUtil.isLandscapeLayout(), + showBack: true, padding: widget.padding, overrideBody: EasyRefresh( child: ListView( diff --git a/lib/Screens/Setting/apperance_setting_screen.dart b/lib/Screens/Setting/apperance_setting_screen.dart index cca0e5a5..0b7abfcf 100644 --- a/lib/Screens/Setting/apperance_setting_screen.dart +++ b/lib/Screens/Setting/apperance_setting_screen.dart @@ -69,7 +69,7 @@ class _AppearanceSettingScreenState context: context, title: appLocalizations.appearanceSetting, showTitleBar: widget.showTitleBar, - showBack: !ResponsiveUtil.isLandscapeLayout(), + showBack: true, padding: widget.padding, children: [ CaptionItem( diff --git a/lib/Screens/Setting/blacklist_setting_screen.dart b/lib/Screens/Setting/blacklist_setting_screen.dart index 99f5f5ec..7057e4c4 100644 --- a/lib/Screens/Setting/blacklist_setting_screen.dart +++ b/lib/Screens/Setting/blacklist_setting_screen.dart @@ -77,7 +77,7 @@ class _BlacklistSettingScreenState context: context, title: appLocalizations.blacklistSetting, showTitleBar: widget.showTitleBar, - showBack: !ResponsiveUtil.isLandscapeLayout(), + showBack: true, padding: widget.padding, overrideBody: EasyRefresh( controller: _refreshController, diff --git a/lib/Screens/Setting/experiment_setting_screen.dart b/lib/Screens/Setting/experiment_setting_screen.dart index 3a6db7fa..035c65f2 100644 --- a/lib/Screens/Setting/experiment_setting_screen.dart +++ b/lib/Screens/Setting/experiment_setting_screen.dart @@ -90,7 +90,7 @@ class _ExperimentSettingScreenState context: context, title: appLocalizations.experimentSetting, showTitleBar: widget.showTitleBar, - showBack: !ResponsiveUtil.isLandscapeLayout(), + showBack: true, padding: widget.padding, children: [ Selector( diff --git a/lib/Screens/Setting/filename_setting_screen.dart b/lib/Screens/Setting/filename_setting_screen.dart index b410fc8b..30a842e2 100644 --- a/lib/Screens/Setting/filename_setting_screen.dart +++ b/lib/Screens/Setting/filename_setting_screen.dart @@ -76,7 +76,7 @@ class _FilenameSettingScreenState context: context, title: appLocalizations.filenameFormat, showTitleBar: widget.showTitleBar, - showBack: !ResponsiveUtil.isLandscapeLayout(), + showBack: true, padding: widget.padding, children: [ CaptionItem( diff --git a/lib/Screens/Setting/general_setting_screen.dart b/lib/Screens/Setting/general_setting_screen.dart index c2a75f05..e5f37973 100644 --- a/lib/Screens/Setting/general_setting_screen.dart +++ b/lib/Screens/Setting/general_setting_screen.dart @@ -1,5 +1,8 @@ +import 'dart:async'; + import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; import 'package:launch_at_startup/launch_at_startup.dart'; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; @@ -51,6 +54,7 @@ class GeneralSettingScreenState extends BaseDynamicState String _logSize = ""; bool launchAtStartup = ChewieHiveUtil.getBool(HiveUtil.launchAtStartupKey); bool showTray = ChewieHiveUtil.getBool(HiveUtil.showTrayKey); + late final StreamSubscription _startupSettingSubscription; Future getLogSize() async { double size = await FileOutput.getLogsSize(); @@ -60,21 +64,34 @@ class GeneralSettingScreenState extends BaseDynamicState }); } - void refreshLauchAtStartup() { + void _refreshLaunchAtStartup() { if (!mounted) return; + final value = ChewieHiveUtil.getBool(HiveUtil.launchAtStartupKey); + if (launchAtStartup == value) return; setState(() { - launchAtStartup = ChewieHiveUtil.getBool(HiveUtil.launchAtStartupKey); + launchAtStartup = value; }); } @override void initState() { super.initState(); + // Settings routes can coexist during transitions or in the back stack. + // Each instance observes tray changes without sharing a global widget key. + _startupSettingSubscription = Hive.box(ChewieHiveUtil.settingsBox) + .watch(key: HiveUtil.launchAtStartupKey) + .listen((_) => _refreshLaunchAtStartup()); getLogSize(); if (ResponsiveUtil.isMobile()) getCacheSize(); fetchReleases(false); } + @override + void dispose() { + _startupSettingSubscription.cancel(); + super.dispose(); + } + void getCacheSize() { CacheUtil.loadCache().then((value) { if (!mounted) return; @@ -125,7 +142,7 @@ class GeneralSettingScreenState extends BaseDynamicState context: context, title: appLocalizations.generalSetting, showTitleBar: widget.showTitleBar, - showBack: !ResponsiveUtil.isLandscapeLayout(), + showBack: true, padding: widget.padding, children: [ CaptionItem( diff --git a/lib/Screens/Setting/image_setting_screen.dart b/lib/Screens/Setting/image_setting_screen.dart index b2f53725..67f7e819 100644 --- a/lib/Screens/Setting/image_setting_screen.dart +++ b/lib/Screens/Setting/image_setting_screen.dart @@ -53,7 +53,7 @@ class _ImageSettingScreenState extends BaseDynamicState context: context, title: appLocalizations.imageSetting, showTitleBar: widget.showTitleBar, - showBack: !ResponsiveUtil.isLandscapeLayout(), + showBack: true, padding: widget.padding, children: [ if (showImageQualitySettings) ..._imageQualitySettings(), diff --git a/lib/Screens/Setting/lofter_basic_setting_screen.dart b/lib/Screens/Setting/lofter_basic_setting_screen.dart index 41c43127..3b838285 100644 --- a/lib/Screens/Setting/lofter_basic_setting_screen.dart +++ b/lib/Screens/Setting/lofter_basic_setting_screen.dart @@ -153,7 +153,7 @@ class _LofterBasicSettingScreenState context: context, title: appLocalizations.lofterBasicSetting, showTitleBar: widget.showTitleBar, - showBack: !ResponsiveUtil.isLandscapeLayout(), + showBack: true, padding: widget.padding, overrideBody: EasyRefresh.builder( controller: _refreshController, diff --git a/lib/Screens/Setting/mobile_setting_navigation_screen.dart b/lib/Screens/Setting/mobile_setting_navigation_screen.dart index 1c0766c4..be2c89de 100644 --- a/lib/Screens/Setting/mobile_setting_navigation_screen.dart +++ b/lib/Screens/Setting/mobile_setting_navigation_screen.dart @@ -16,7 +16,6 @@ import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; -import '../../Utils/app_provider.dart'; import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; import 'apperance_setting_screen.dart'; @@ -50,7 +49,7 @@ class _MobileSettingNavigationScreenState context: context, title: appLocalizations.setting, showTitleBar: widget.showTitleBar, - showBack: !ResponsiveUtil.isLandscapeLayout(), + showBack: true, padding: widget.padding, children: [ CaptionItem( @@ -64,7 +63,7 @@ class _MobileSettingNavigationScreenState onTap: () { RouteUtil.pushCupertinoRoute( context, - GeneralSettingScreen(key: generalSettingScreenKey), + const GeneralSettingScreen(), ); }, ), diff --git a/lib/Screens/Setting/select_font_screen.dart b/lib/Screens/Setting/select_font_screen.dart index 4707f9d6..774398c5 100644 --- a/lib/Screens/Setting/select_font_screen.dart +++ b/lib/Screens/Setting/select_font_screen.dart @@ -33,7 +33,7 @@ class _SelectFontScreenState extends BaseDynamicState context: context, title: appLocalizations.chooseFontFamily, showTitleBar: widget.showTitleBar, - showBack: !ResponsiveUtil.isLandscapeLayout(), + showBack: true, padding: widget.padding, children: [ CaptionItem( diff --git a/lib/Screens/Setting/select_theme_screen.dart b/lib/Screens/Setting/select_theme_screen.dart index 39cb8593..6271ca37 100644 --- a/lib/Screens/Setting/select_theme_screen.dart +++ b/lib/Screens/Setting/select_theme_screen.dart @@ -49,7 +49,7 @@ class _SelectThemeScreenState extends BaseDynamicState context: context, title: appLocalizations.selectTheme, showTitleBar: widget.showTitleBar, - showBack: !ResponsiveUtil.isLandscapeLayout(), + showBack: true, padding: widget.padding, children: [ CaptionItem( diff --git a/lib/Screens/Setting/setting_navigation_screen.dart b/lib/Screens/Setting/setting_navigation_screen.dart index 5dd8d814..8a5566d8 100644 --- a/lib/Screens/Setting/setting_navigation_screen.dart +++ b/lib/Screens/Setting/setting_navigation_screen.dart @@ -10,7 +10,6 @@ import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; -import '../../Utils/app_provider.dart'; import '../../Widgets/loftify_icons.dart'; import '../../l10n/l10n.dart'; import 'about_setting_screen.dart'; @@ -40,7 +39,7 @@ class _SettingNavigationScreenState late int _selectedIndex; late final List _pages = [ - GeneralSettingScreen(key: generalSettingScreenKey), + const GeneralSettingScreen(), const AppearanceSettingScreen(), const ImageSettingScreen(), const LofterBasicSettingScreen(), diff --git a/lib/Screens/Setting/setting_screen.dart b/lib/Screens/Setting/setting_screen.dart index 74616ab7..d8ac6f9e 100644 --- a/lib/Screens/Setting/setting_screen.dart +++ b/lib/Screens/Setting/setting_screen.dart @@ -43,7 +43,7 @@ class _SettingScreenState extends BaseDynamicState context: context, title: appLocalizations.setting, showTitleBar: widget.showTitleBar, - showBack: !ResponsiveUtil.isLandscapeLayout(), + showBack: true, padding: widget.padding, children: [ CaptionItem( @@ -54,8 +54,8 @@ class _SettingScreenState extends BaseDynamicState title: appLocalizations.generalSetting, showLeading: true, onTap: () { - RouteUtil.pushPanelCupertinoRoute(context, - GeneralSettingScreen(key: generalSettingScreenKey)); + RouteUtil.pushPanelCupertinoRoute( + context, const GeneralSettingScreen()); }, leading: LoftifyIcons.generalSettings, ), @@ -142,7 +142,7 @@ class _SettingScreenState extends BaseDynamicState RouteUtil.pushPanelCupertinoRoute( context, const TagShieldSettingScreen()); }, - leading: LoftifyIcons.tag, + leading: LoftifyIcons.hash, ), EntryItem( showLeading: true, diff --git a/lib/Screens/Setting/tagshield_setting_screen.dart b/lib/Screens/Setting/tagshield_setting_screen.dart index b65175bb..30ad2839 100644 --- a/lib/Screens/Setting/tagshield_setting_screen.dart +++ b/lib/Screens/Setting/tagshield_setting_screen.dart @@ -68,7 +68,7 @@ class _TagShieldSettingScreenState context: context, title: appLocalizations.tagShieldSetting, showTitleBar: widget.showTitleBar, - showBack: !ResponsiveUtil.isLandscapeLayout(), + showBack: true, padding: widget.padding, actions: [ CircleIconButton( diff --git a/lib/Screens/Setting/userdynamicshield_setting_screen.dart b/lib/Screens/Setting/userdynamicshield_setting_screen.dart index 1454584a..5c338a0d 100644 --- a/lib/Screens/Setting/userdynamicshield_setting_screen.dart +++ b/lib/Screens/Setting/userdynamicshield_setting_screen.dart @@ -73,7 +73,7 @@ class _UserDynamicShieldSettingScreenState context: context, title: appLocalizations.userDynamicShieldSetting, showTitleBar: widget.showTitleBar, - showBack: !ResponsiveUtil.isLandscapeLayout(), + showBack: true, padding: widget.padding, overrideBody: EasyRefresh( controller: _refreshController, diff --git a/lib/Screens/main_screen.dart b/lib/Screens/main_screen.dart index 1428f1da..7f3cf9ae 100644 --- a/lib/Screens/main_screen.dart +++ b/lib/Screens/main_screen.dart @@ -8,10 +8,12 @@ import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:loftify/Api/server_api.dart'; import 'package:loftify/Screens/Login/login_by_captcha_screen.dart'; import 'package:loftify/Screens/panel_screen.dart'; +import 'package:loftify/Theme/loftify_design_theme.dart'; import 'package:loftify/Utils/cloud_control_provider.dart'; import 'package:loftify/Utils/lottie_files.dart'; import 'package:loftify/Widgets/Design/loftify_state_view.dart'; import 'package:loftify/Widgets/Item/item_builder.dart'; +import 'package:loftify/Widgets/Navigation/loftify_glass_navigation_bar.dart'; import 'package:loftify/Widgets/loftify_icons.dart'; import 'package:provider/provider.dart'; import 'package:tray_manager/tray_manager.dart'; @@ -25,6 +27,8 @@ import '../Utils/app_provider.dart'; import '../Utils/enums.dart'; import '../Utils/hive_util.dart'; import '../Utils/utils.dart'; +import '../Utils/clipboard_link_controller.dart'; +import '../Widgets/Dialog/clipboard_link_dialog.dart'; import 'Info/system_notice_screen.dart'; import 'Info/user_detail_screen.dart'; import 'Lock/pin_verify_screen.dart'; @@ -47,6 +51,8 @@ class MainScreenState extends BaseWindowState TrayListener, AutomaticKeepAliveClientMixin { Timer? _timer; + Timer? _clipboardTimer; + late final ClipboardLinkController _clipboardLinks; late AnimationController darkModeController; Widget? darkModeWidget; FullBlogInfo? blogInfo; @@ -70,6 +76,7 @@ class MainScreenState extends BaseWindowState void onWindowFocus() { cancleTimer(); super.onWindowFocus(); + _scheduleClipboardCheck(); } @override @@ -135,12 +142,26 @@ class MainScreenState extends BaseWindowState @override void initState() { super.initState(); + _clipboardLinks = ClipboardLinkController( + canPrompt: () => + mounted && + !_hasJumpedToPinVerify && + (WidgetsBinding.instance.lifecycleState == null || + WidgetsBinding.instance.lifecycleState == + AppLifecycleState.resumed) && + (ModalRoute.of(context)?.isCurrent ?? false), + confirm: (url) => ClipboardLinkDialog.show(context, url), + open: (url) async { + await UriUtil.processUrl(context, url, pass: false); + }, + ); windowManager.addListener(this); WidgetsBinding.instance.addObserver(this); darkModeController = AnimationController(vsync: this); WidgetsBinding.instance.addPostFrameCallback((_) async { showQQGroupDialog(); jumpToLogin(); + _scheduleClipboardCheck(); darkModeWidget = LottieFiles.buildAnimation( LottieFiles.sunLight, size: 25, @@ -283,10 +304,18 @@ class MainScreenState extends BaseWindowState showWindowTitle: true, ), onThen: (_) { _hasJumpedToPinVerify = false; + _scheduleClipboardCheck(); }); } } + void _scheduleClipboardCheck() { + _clipboardTimer?.cancel(); + _clipboardTimer = Timer(const Duration(milliseconds: 600), () { + if (mounted) unawaited(_clipboardLinks.check()); + }); + } + @override Widget build(BuildContext context) { super.build(context); @@ -308,21 +337,25 @@ class MainScreenState extends BaseWindowState } _buildDesktopBody() { - return Row( - children: [ - _sideBar(leftPadding: 8, rightPadding: 8), - Expanded( - child: Stack( - children: [ - PanelScreen(key: panelScreenKey), - Positioned( - right: 0, - child: _titleBar(), - ), - ], + return ColoredBox( + color: Theme.of(context).scaffoldBackgroundColor, + child: Row( + children: [ + _sideBar(leftPadding: 8, rightPadding: 8), + Expanded( + child: Stack( + children: [ + PanelScreen(key: panelScreenKey), + Positioned( + right: 0, + width: desktopWindowControlsWidth, + child: _titleBar(), + ), + ], + ), ), - ), - ], + ], + ), ); } @@ -342,7 +375,8 @@ class MainScreenState extends BaseWindowState FlutterContextMenuItem.divider(), FlutterContextMenuItem( appLocalizations.logout, - status: MenuItemStatus.warning, + status: MenuItemStatus.error, + style: MenuItemStyle(errorColor: Theme.of(context).colorScheme.error), iconData: LoftifyIcons.logout, onPressed: () async { HiveUtil.confirmLogout(context); @@ -379,6 +413,42 @@ class MainScreenState extends BaseWindowState } } + Widget _buildSidebarNavigationItem({ + required SideBarChoice choice, + required LoftifyNavigationDestination destination, + required bool selected, + }) { + return LoftifyNavigationRailItem( + destination: destination, + selected: selected, + onTap: () { + appProvider.sidebarChoice = choice; + panelScreenState?.popAll(false); + }, + ); + } + + Widget _buildSidebarActionButton({ + required IconData icon, + required VoidCallback onPressed, + }) { + final iconColor = LoftifyDesignThemeData.of(context).colors.textPrimary; + return ToolButton( + context: context, + icon: icon, + // The theme Lottie uses a 25px canvas with transparent margins; these + // glyphs are visually larger at the same nominal size. + iconSize: 20, + padding: const EdgeInsets.all(7), + colors: ChewieColors.getNormalButtonColors(context).copyWith( + iconNormal: iconColor, + iconMouseOver: iconColor, + iconMouseDown: iconColor, + ), + onPressed: onPressed, + ); + } + _sideBar({ double leftPadding = 0, double rightPadding = 0, @@ -416,57 +486,51 @@ class MainScreenState extends BaseWindowState children: [ ResponsiveUtil.selectByPlatform( desktop: const SizedBox(height: 5)), - ResponsiveUtil.selectByPlatform(desktop: _buildLogo()), const SizedBox(height: 8), - ToolButton( - context: context, + _buildSidebarNavigationItem( + choice: SideBarChoice.Home, selected: hideNavigator && preferences.sidebarChoice == SideBarChoice.Home, - icon: LoftifyIcons.home, - selectedIcon: LoftifyIcons.home, - onPressed: () async { - appProvider.sidebarChoice = SideBarChoice.Home; - panelScreenState?.popAll(false); - }, - iconSize: 24, + destination: LoftifyNavigationDestination( + icon: LoftifyIcons.home, + lottieAsset: LottieFiles.navHome, + label: appLocalizations.home, + ), ), if (!preferences.hideSearch) ...[ const SizedBox(height: 8), - ToolButton( - context: context, + _buildSidebarNavigationItem( + choice: SideBarChoice.Search, selected: hideNavigator && preferences.sidebarChoice == SideBarChoice.Search, - icon: LoftifyIcons.search, - selectedIcon: LoftifyIcons.search, - onPressed: () async { - appProvider.sidebarChoice = SideBarChoice.Search; - panelScreenState?.popAll(false); - }, + destination: LoftifyNavigationDestination( + icon: LoftifyIcons.search, + lottieAsset: LottieFiles.navSearch, + label: appLocalizations.search, + ), ), ], const SizedBox(height: 8), - ToolButton( - context: context, + _buildSidebarNavigationItem( + choice: SideBarChoice.Dynamic, selected: hideNavigator && preferences.sidebarChoice == SideBarChoice.Dynamic, - icon: LoftifyIcons.activity, - selectedIcon: LoftifyIcons.activity, - onPressed: () async { - appProvider.sidebarChoice = SideBarChoice.Dynamic; - panelScreenState?.popAll(false); - }, + destination: LoftifyNavigationDestination( + icon: LoftifyIcons.activity, + lottieAsset: LottieFiles.navHeart, + label: appLocalizations.dynamicTab, + ), ), const SizedBox(height: 8), - ToolButton( - context: context, + _buildSidebarNavigationItem( + choice: SideBarChoice.Mine, selected: hideNavigator && preferences.sidebarChoice == SideBarChoice.Mine, - icon: LoftifyIcons.profile, - selectedIcon: LoftifyIcons.profile, - onPressed: () async { - appProvider.sidebarChoice = SideBarChoice.Mine; - panelScreenState?.popAll(false); - }, + destination: LoftifyNavigationDestination( + icon: LoftifyIcons.profile, + lottieAsset: LottieFiles.navUser, + label: appLocalizations.mine, + ), ), const Spacer(), const SizedBox(height: 8), @@ -511,8 +575,7 @@ class MainScreenState extends BaseWindowState ), const SizedBox(height: 2), if (cloudControlProvider.globalControl.showDress) ...[ - ToolButton( - context: context, + _buildSidebarActionButton( icon: LoftifyIcons.dress, onPressed: () { RouteUtil.pushPanelCupertinoRoute( @@ -521,8 +584,7 @@ class MainScreenState extends BaseWindowState ), const SizedBox(height: 2), ], - ToolButton( - context: context, + _buildSidebarActionButton( icon: LoftifyIcons.notifications, onPressed: () { RouteUtil.pushPanelCupertinoRoute( @@ -530,8 +592,7 @@ class MainScreenState extends BaseWindowState }, ), const SizedBox(height: 2), - ToolButton( - context: context, + _buildSidebarActionButton( icon: LoftifyIcons.settings, onPressed: () { RouteUtil.pushPanelCupertinoRoute( @@ -549,27 +610,6 @@ class MainScreenState extends BaseWindowState ); } - _buildLogo({ - double size = 50, - }) { - return IgnorePointer( - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - clipBehavior: Clip.antiAlias, - child: Container( - width: size, - height: size, - decoration: const BoxDecoration( - image: DecorationImage( - image: AssetImage('assets/logo-transparent.png'), - fit: BoxFit.contain, - ), - ), - ), - ), - ); - } - void cancleTimer() { if (_timer != null) { _timer!.cancel(); @@ -610,6 +650,7 @@ class MainScreenState extends BaseWindowState case AppLifecycleState.resumed: fetchData(); cancleTimer(); + _scheduleClipboardCheck(); break; case AppLifecycleState.paused: setTimer(); @@ -623,6 +664,8 @@ class MainScreenState extends BaseWindowState @override void dispose() { + _clipboardTimer?.cancel(); + _clipboardLinks.dispose(); trayManager.removeListener(this); WidgetsBinding.instance.removeObserver(this); windowManager.removeListener(this); diff --git a/lib/Utils/app_provider.dart b/lib/Utils/app_provider.dart index 3b4d5c29..5c14f67e 100644 --- a/lib/Utils/app_provider.dart +++ b/lib/Utils/app_provider.dart @@ -1,7 +1,6 @@ import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'package:loftify/Screens/Setting/general_setting_screen.dart'; import 'package:tuple/tuple.dart'; import '../Screens/Navigation/home_screen.dart'; @@ -33,12 +32,6 @@ GlobalKey homeScreenKey = GlobalKey(); HomeScreenState? get homeScreenState => homeScreenKey.currentState; -GlobalKey generalSettingScreenKey = - GlobalKey(); - -GeneralSettingScreenState? get generalSettingScreenState => - generalSettingScreenKey.currentState; - GlobalKey dialogNavigatorKey = GlobalKey(); diff --git a/lib/Utils/clipboard_link_controller.dart b/lib/Utils/clipboard_link_controller.dart new file mode 100644 index 00000000..0533f66a --- /dev/null +++ b/lib/Utils/clipboard_link_controller.dart @@ -0,0 +1,47 @@ +import 'package:flutter/services.dart'; + +import 'uri_util.dart'; + +/// Reads only on explicit lifecycle events; clipboard contents are never logged. +class ClipboardLinkController { + ClipboardLinkController({ + required this.canPrompt, + required this.confirm, + required this.open, + Future Function()? readText, + }) : readText = readText ?? _readClipboard; + + final bool Function() canPrompt; + final Future Function(String url) confirm; + final Future Function(String url) open; + final Future Function() readText; + final Set _seen = {}; + bool _checking = false; + bool _disposed = false; + + static Future _readClipboard() async => + (await Clipboard.getData(Clipboard.kTextPlain))?.text; + + Future check() async { + if (_disposed || _checking || !canPrompt()) return; + _checking = true; + try { + final text = await readText(); + if (_disposed || !canPrompt() || text == null) return; + final url = LoftifyUriUtil.extractSupportedClipboardUrl(text); + if (url == null || _seen.contains(url)) return; + _seen.add(url); + if (_seen.length > 64) _seen.remove(_seen.first); + final accepted = await confirm(url); + if (accepted && !_disposed && canPrompt()) await open(url); + } on PlatformException { + // Clipboard permission/access can be denied; it must not interrupt the app. + } on MissingPluginException { + // Some embedded targets do not implement clipboard access. + } finally { + _checking = false; + } + } + + void dispose() => _disposed = true; +} diff --git a/lib/Utils/lottie_files.dart b/lib/Utils/lottie_files.dart index 1821570f..b33b5ce3 100644 --- a/lib/Utils/lottie_files.dart +++ b/lib/Utils/lottie_files.dart @@ -353,16 +353,19 @@ class LottieFiles { bool? repeat, Color? tint, double? strokeWidth, + LoftifyLottieSpec? specOverride, + int? opacity, }) { return Builder( key: key, builder: (context) => LoftifyLottie( - spec: specFor(path), + spec: specOverride ?? specFor(path), size: size, controller: controller, repeat: repeat, tint: tint, strokeWidth: strokeWidth, + opacity: opacity, onLoaded: (_) { if (controller != null && autoForward == true) controller.value = 1; onLoaded?.call(); diff --git a/lib/Utils/uri_util.dart b/lib/Utils/uri_util.dart index bb451e23..0f173cd0 100644 --- a/lib/Utils/uri_util.dart +++ b/lib/Utils/uri_util.dart @@ -11,9 +11,55 @@ import 'package:share_plus/share_plus.dart'; import '../Screens/Info/user_detail_screen.dart'; import '../Screens/Post/post_detail_screen.dart'; +import '../Screens/Post/video_detail_screen.dart'; import '../l10n/l10n.dart'; class LoftifyUriUtil { + /// Pure recognition: never open a page or resolve a short URL while scanning. + static String? extractSupportedClipboardUrl(String text) { + final candidates = RegExp(r'''(?:https?|lofter)://[^\s<>"'`()\[\]]+'''); + for (final match in candidates.allMatches(text)) { + final url = match[0]! + .replaceAll('&', '&') + .replaceFirst(RegExp(r'[.,;!,。;!、)\])】》]+$'), ''); + final uri = Uri.tryParse(url); + if (uri == null || + uri.userInfo.isNotEmpty || + !(uri.host == 'lofter.com' || uri.host.endsWith('.lofter.com'))) { + continue; + } + var recognitionUrl = url; + try { + if (url.contains('%')) recognitionUrl = Uri.decodeComponent(url); + } on FormatException { + // Match processUrl: malformed escapes must not break clipboard checks. + } on ArgumentError { + // Uri.decodeComponent also uses ArgumentError for malformed escapes. + } + // Match the URL itself, not another URL embedded in its query string. + final path = uri.path; + if ((path.startsWith('/post/') && isPostUrl(recognitionUrl)) || + (path == '/mentionredirect.do' && + isMentionBlogIdUrl(recognitionUrl)) || + (path == '/videoDetail' && isVideoUrl(recognitionUrl)) || + (path == '/front/blog/collection/share' && + isCollectionUrl(recognitionUrl)) || + (path.startsWith('/collection/') && + isCollectionShareUrl(recognitionUrl)) || + ((path == '/grain/detail' || path == '/front/blog/grain/detail') && + isGrainShareUrl(recognitionUrl)) || + ((path.startsWith('/tag/') || path.startsWith('/front/blog/tag/')) && + isTagUrl(recognitionUrl)) || + (uri.host == 's.lofter.com' && isShortLinkUrl(recognitionUrl)) || + (!['www.lofter.com', 's.lofter.com', 'api.lofter.com'] + .contains(uri.host) && + isHomePageUrl(recognitionUrl))) { + return url; + } + } + return null; + } + static bool isShortLinkUrl(String url) { var reg = RegExp(r"(http|https|lofter)://s\.lofter\.com/-s/[0-9a-zA-Z]+"); return reg.hasMatch(url); @@ -210,6 +256,8 @@ class LoftifyUriUtil { url = Uri.decodeComponent(url); } on FormatException { // 保留原始文本,让调用方按普通搜索词继续处理。 + } on ArgumentError { + // 不完整的百分号编码也可能抛出 ArgumentError。 } } if (LoftifyUriUtil.isShortLinkUrl(url)) { @@ -228,6 +276,27 @@ class LoftifyUriUtil { ), ); return true; + } else if (isVideoUrl(url)) { + if (!quiet) await CustomLoadingDialog.dismissLoading(); + if (!context.mounted) return false; + RouteUtil.pushPanelCupertinoRoute( + context, + VideoDetailScreen(meta: extractVideoInfo(url)), + ); + return true; + } else if (isCollectionUrl(url)) { + if (!quiet) await CustomLoadingDialog.dismissLoading(); + if (!context.mounted) return false; + RouteUtil.pushPanelCupertinoRoute( + context, + CollectionDetailScreen( + blogName: '', + postId: 0, + blogId: 0, + collectionId: extractCollectionId(url), + ), + ); + return true; } else if (LoftifyUriUtil.isPostUrl(url)) { Map map = LoftifyUriUtil.extractPostInfo(url); if (!quiet) await CustomLoadingDialog.dismissLoading(); diff --git a/lib/Utils/utils.dart b/lib/Utils/utils.dart index b45370c1..f41f41c2 100644 --- a/lib/Utils/utils.dart +++ b/lib/Utils/utils.dart @@ -355,7 +355,6 @@ class Utils { } else if (menuItem.key == TrayKey.launchAtStartup.key) { menuItem.checked = !(menuItem.checked == true); ChewieHiveUtil.put(HiveUtil.launchAtStartupKey, menuItem.checked); - generalSettingScreenState?.refreshLauchAtStartup(); if (menuItem.checked == true) { await LaunchAtStartup.instance.enable(); } else { diff --git a/lib/Widgets/BottomSheet/shield_bottom_sheet.dart b/lib/Widgets/BottomSheet/shield_bottom_sheet.dart index 99f48b02..321c28bf 100644 --- a/lib/Widgets/BottomSheet/shield_bottom_sheet.dart +++ b/lib/Widgets/BottomSheet/shield_bottom_sheet.dart @@ -71,7 +71,7 @@ class ShieldBottomSheetState extends State { for (final tag in tags) LoftifyTag( label: tag, - leading: LoftifyIcons.tag, + leading: LoftifyIcons.hash, showSelectedIcon: false, onPressed: () => widget.onShieldTag?.call(tag), ), diff --git a/lib/Widgets/Design/loftify_content_frame.dart b/lib/Widgets/Design/loftify_content_frame.dart new file mode 100644 index 00000000..4c6396d7 --- /dev/null +++ b/lib/Widgets/Design/loftify_content_frame.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +import '../../Theme/loftify_design_theme.dart'; + +const double loftifyPageMaxContentWidth = 1180; + +/// A shared centered content column for wide pages. Callers keep the original +/// phone tree and opt in only for their desktop/tablet layouts. +class LoftifyContentFrame extends StatelessWidget { + const LoftifyContentFrame({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: context.design.spacing.xl), + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: + const BoxConstraints(maxWidth: loftifyPageMaxContentWidth), + child: child, + ), + ), + ); + } +} diff --git a/lib/Widgets/Design/loftify_lottie.dart b/lib/Widgets/Design/loftify_lottie.dart index e7bfcdba..200e4f6c 100644 --- a/lib/Widgets/Design/loftify_lottie.dart +++ b/lib/Widgets/Design/loftify_lottie.dart @@ -81,6 +81,7 @@ class LoftifyLottie extends StatelessWidget { this.repeat, this.tint, this.strokeWidth, + this.opacity, this.onLoaded, }); @@ -91,6 +92,7 @@ class LoftifyLottie extends StatelessWidget { final bool? repeat; final Color? tint; final double? strokeWidth; + final int? opacity; final ValueChanged? onLoaded; static bool shouldReduceMotion(BuildContext context) { @@ -162,6 +164,9 @@ class LoftifyLottie extends StatelessWidget { ValueDelegate.strokeWidth(['**', '描边 1'], value: strokeWidth), ); } + if (opacity != null) { + colorDelegates.add(ValueDelegate.opacity(['**'], value: opacity)); + } final delegates = colorDelegates.isEmpty ? null : LottieDelegates(values: colorDelegates); final animation = Lottie.asset( diff --git a/lib/Widgets/Dialog/clipboard_link_dialog.dart b/lib/Widgets/Dialog/clipboard_link_dialog.dart new file mode 100644 index 00000000..eb335cd2 --- /dev/null +++ b/lib/Widgets/Dialog/clipboard_link_dialog.dart @@ -0,0 +1,99 @@ +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +import '../../Theme/loftify_design_theme.dart'; +import '../../generated/app_localizations.dart'; + +/// Content only: the shared confirmation dialog owns its surface and route. +class ClipboardLinkDialog extends StatelessWidget { + const ClipboardLinkDialog({super.key, required this.url}); + + final String url; + + static Future show(BuildContext context, String url) async { + final strings = AppLocalizations.of(context)!; + var accepted = false; + await DialogBuilder.showConfirmDialog( + context, + messageChild: ClipboardLinkDialog(url: url), + confirmButtonText: strings.clipboardLinkOpen, + cancelButtonText: strings.clipboardLinkDismiss, + onTapConfirm: () => accepted = true, + ); + return accepted; + } + + @override + Widget build(BuildContext context) { + final colors = context.design.colors; + final strings = AppLocalizations.of(context)!; + final uri = Uri.tryParse(url); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(11), + decoration: BoxDecoration( + color: colors.accentContainer, + borderRadius: BorderRadius.circular(14), + ), + child: Icon(LucideIcons.link, size: 22, color: colors.accent), + ), + const SizedBox(width: 12), + Expanded( + child: Text(strings.clipboardLinkTitle, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w600)), + ), + ], + ), + const SizedBox(height: 16), + Text(strings.clipboardLinkMessage, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: colors.textSecondary, height: 1.5)), + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.surfaceMuted, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colors.outline), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(LucideIcons.globe, size: 15, color: colors.textMuted), + const SizedBox(width: 8), + Expanded( + child: Text(uri?.host ?? 'LOFTER', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium)), + ], + ), + const SizedBox(height: 6), + Text(url, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: colors.textMuted, height: 1.4)), + ], + ), + ), + ], + ); + } +} diff --git a/lib/Widgets/Item/item_builder.dart b/lib/Widgets/Item/item_builder.dart index 754dc061..10120507 100644 --- a/lib/Widgets/Item/item_builder.dart +++ b/lib/Widgets/Item/item_builder.dart @@ -595,6 +595,11 @@ class ItemBuilder { fontSizeDelta: hintFontSizeDelta, ), decoration: InputDecoration( + // Desktop compact density offsets a dense, borderless + // field's baseline even when textAlignVertical is center. + visualDensity: ResponsiveUtil.isLandscapeLayout() + ? VisualDensity.standard + : null, isDense: true, filled: false, contentPadding: const EdgeInsets.symmetric(horizontal: 8), diff --git a/lib/Widgets/Item/loftify_item_builder.dart b/lib/Widgets/Item/loftify_item_builder.dart index 0fa70e11..25b05681 100644 --- a/lib/Widgets/Item/loftify_item_builder.dart +++ b/lib/Widgets/Item/loftify_item_builder.dart @@ -20,6 +20,7 @@ import '../../Utils/app_provider.dart'; import '../../Utils/asset_util.dart'; import '../../Utils/enums.dart'; import '../PostDetail/comment_item.dart'; +import '../PostDetail/comment_content.dart'; import '../Design/loftify_controls.dart'; import '../../l10n/l10n.dart'; import '../loftify_icons.dart'; @@ -242,24 +243,13 @@ class LoftifyItemBuilder { } static Widget _buildCommentContent(BuildContext context, Comment comment) { - var richContent = comment.content; - for (final emote in comment.emotes) { - final image = - ''; - richContent = richContent.replaceAll(emote.name, image); - } return ItemBuilder.buildCopyable( context, text: comment.content, toastText: appLocalizations.haveCopiedComment( comment.publisherBlogInfo.blogNickName, ), - child: CustomHtmlWidget( - content: richContent, - parseImage: false, - showLoading: false, - style: Theme.of(context).textTheme.bodyMedium, - ), + child: CommentContent(comment: comment), ); } diff --git a/lib/Widgets/Navigation/loftify_glass_navigation_bar.dart b/lib/Widgets/Navigation/loftify_glass_navigation_bar.dart index a7204fb2..ab0a7a56 100644 --- a/lib/Widgets/Navigation/loftify_glass_navigation_bar.dart +++ b/lib/Widgets/Navigation/loftify_glass_navigation_bar.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import '../../Utils/enums.dart'; import '../../Utils/lottie_files.dart'; +import 'package:loftify/Widgets/Design/loftify_lottie.dart'; @immutable class LoftifyNavigationDestination { @@ -313,6 +314,104 @@ class _LoftifyNavigationItemState extends State<_LoftifyNavigationItem> { } } +/// Compact desktop counterpart to the bottom navigation item. It shares the +/// same Lottie icon and selection colors without changing the mobile layout. +class LoftifyNavigationRailItem extends StatefulWidget { + const LoftifyNavigationRailItem({ + super.key, + required this.destination, + required this.selected, + required this.onTap, + }); + + final LoftifyNavigationDestination destination; + final bool selected; + final VoidCallback onTap; + + @override + State createState() => + _LoftifyNavigationRailItemState(); +} + +class _LoftifyNavigationRailItemState extends State { + bool _pressed = false; + + void _setPressed(bool value) { + if (_pressed == value) return; + setState(() => _pressed = value); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final selectedColor = theme.colorScheme.primary; + final foregroundColor = + widget.selected ? selectedColor : theme.colorScheme.onSurfaceVariant; + final reduceMotion = MediaQuery.disableAnimationsOf(context); + return Tooltip( + message: widget.destination.label, + excludeFromSemantics: true, + child: Semantics( + button: true, + selected: widget.selected, + label: widget.destination.label, + excludeSemantics: true, + onTap: widget.onTap, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + excludeFromSemantics: true, + onTap: widget.onTap, + onTapDown: (_) => _setPressed(true), + onTapUp: (_) => _setPressed(false), + onTapCancel: () => _setPressed(false), + child: AnimatedScale( + scale: _pressed ? 0.94 : 1, + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 90), + curve: Curves.easeOutCubic, + child: AnimatedContainer( + key: ValueKey( + 'loftify-rail-selection-${widget.destination.label}', + ), + width: 42, + height: 42, + alignment: Alignment.center, + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + decoration: BoxDecoration( + color: widget.selected + ? selectedColor.withValues(alpha: 0.11) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: widget.selected + ? selectedColor.withValues(alpha: 0.18) + : Colors.transparent, + width: 0.8, + ), + ), + child: _NavigationIcon( + rail: true, + icon: widget.destination.icon, + lottieAsset: widget.destination.lottieAsset, + selected: widget.selected, + badgeCount: widget.destination.badgeCount, + color: foregroundColor, + ), + ), + ), + ), + ), + ), + ); + } +} + class _NavigationLabel extends StatelessWidget { const _NavigationLabel({ required this.label, @@ -366,6 +465,7 @@ class _NavigationLabel extends StatelessWidget { class _NavigationIcon extends StatelessWidget { const _NavigationIcon({ + this.rail = false, required this.icon, required this.lottieAsset, required this.selected, @@ -374,6 +474,7 @@ class _NavigationIcon extends StatelessWidget { }); final IconData icon; + final bool rail; final String? lottieAsset; final bool selected; final int badgeCount; @@ -392,6 +493,7 @@ class _NavigationIcon extends StatelessWidget { ChewieIcon(icon, size: 22, color: color) else LoftifyNavigationLottieIcon( + rail: rail, asset: lottieAsset!, selected: selected, color: color, @@ -453,12 +555,14 @@ class LoftifyNavigationLottieIcon extends StatefulWidget { required this.selected, required this.color, this.size = 22, + this.rail = false, }); final String asset; final bool selected; final Color color; final double size; + final bool rail; @override State createState() => @@ -526,6 +630,17 @@ class _LoftifyNavigationLottieIconState size: widget.size, controller: _controller, tint: widget.color, + // The source loading glyph has 70% layer opacity and extends to the + // crop boundary. Normalize only the rail variant; mobile stays intact. + opacity: widget.rail && _usesLoadingMark ? 100 : null, + specOverride: widget.rail && _usesLoadingMark + ? const LoftifyLottieSpec( + asset: LottieFiles.navHome, + sourceSize: Size.square(128), + contentBounds: Rect.fromLTWH(32, 20, 64, 82), + opticalFill: 0.94, + ) + : null, strokeWidth: _usesLoadingMark && widget.selected ? 6.8 : null, onLoaded: () { _loaded = true; diff --git a/lib/Widgets/PostDetail/comment_content.dart b/lib/Widgets/PostDetail/comment_content.dart new file mode 100644 index 00000000..1239466a --- /dev/null +++ b/lib/Widgets/PostDetail/comment_content.dart @@ -0,0 +1,195 @@ +import 'dart:math' as math; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart' as html; + +import '../../Models/post_detail_response.dart'; +import '../../Theme/loftify_design_theme.dart'; + +/// Comment media is compact; emoji must remain inline with the surrounding text. +class CommentContent extends StatelessWidget { + const CommentContent({super.key, required this.comment}); + + final Comment comment; + + static String? _imageUrl(String value) { + var url = value.trim(); + if (url.startsWith('//')) url = 'https:$url'; + final uri = Uri.tryParse(url); + return uri != null && + (uri.scheme == 'https' || uri.scheme == 'http') && + uri.host.isNotEmpty + ? url + : null; + } + + String _prepareContent() { + final fragment = html.parseFragment(comment.content); + final emotes = { + for (final emote in comment.emotes) + if (emote.name.isNotEmpty && emote.url.trim().isNotEmpty) + emote.name: emote.url, + }; + // These placeholders refer to ordered attachments, not named emotes. + final attachments = comment.images; + var attachmentIndex = 0; + dom.Element attachmentElement(CommentImage image, String marker) => + dom.Element.tag('img') + ..attributes['src'] = image.url + ..attributes['alt'] = marker + ..attributes['width'] = '${image.ow}' + ..attributes['height'] = '${image.oh}' + ..attributes['data-comment-sticker'] = '${marker == '[表情]'}'; + + final names = { + ...emotes.keys, + if (attachments.isNotEmpty) ...['[表情]', '[图片]'], + }.toList() + ..sort((a, b) => b.length.compareTo(a.length)); + if (names.isEmpty) return fragment.outerHtml; + final pattern = RegExp(names.map(RegExp.escape).join('|')); + + void replaceEmotes(dom.Node node) { + // Replace only text, never attributes, URLs or already-rendered images. + for (final child in node.nodes.toList()) { + if (child is dom.Text) { + final replacements = []; + var offset = 0; + for (final match in pattern.allMatches(child.data)) { + replacements + .add(dom.Text(child.data.substring(offset, match.start))); + final marker = match[0]!; + if ((marker == '[表情]' || marker == '[图片]') && + attachmentIndex < attachments.length) { + final attachment = attachments[attachmentIndex++]; + replacements.add( + _imageUrl(attachment.url) == null + ? dom.Text(marker) + : attachmentElement(attachment, marker), + ); + } else if (emotes.containsKey(marker)) { + replacements.add(dom.Element.tag('img') + ..attributes['src'] = emotes[marker]! + ..attributes['alt'] = marker + ..attributes['data-comment-emote'] = 'true'); + } else { + replacements.add(dom.Text(marker)); + } + offset = match.end; + } + if (replacements.isNotEmpty) { + replacements.add(dom.Text(child.data.substring(offset))); + final index = node.nodes.indexOf(child); + child.remove(); + node.nodes.insertAll(index, replacements); + } + } else { + replaceEmotes(child); + } + } + } + + replaceEmotes(fragment); + // Some responses supply attachments without placeholders in content. + for (final image in attachments.skip(attachmentIndex)) { + if (_imageUrl(image.url) == null) continue; + fragment.nodes.add(attachmentElement( + image, + image.type == 1 ? '[表情]' : '[图片]', + )); + } + return fragment.outerHtml; + } + + @override + Widget build(BuildContext context) { + return CustomHtmlWidget( + content: _prepareContent(), + showLoading: false, + inlineLinks: true, + heightDelta: 0, + letterSpacingDelta: 0, + style: Theme.of(context).textTheme.bodyMedium, + imageBuilder: (context, element) { + var url = element.attributes['src']?.trim() ?? ''; + if (url.isEmpty) url = element.attributes['data-src']?.trim() ?? ''; + final resolvedUrl = _imageUrl(url); + final isEmote = element.attributes['data-comment-emote'] == 'true'; + final fallback = Text(element.attributes['alt'] ?? '[图片]'); + if (resolvedUrl == null) return InlineCustomWidget(child: fallback); + url = resolvedUrl; + final isSticker = element.attributes['data-comment-sticker'] == 'true'; + final limit = isSticker ? 120.0 : 200.0; + final width = double.tryParse(element.attributes['width'] ?? '') ?? 0; + final height = double.tryParse(element.attributes['height'] ?? '') ?? 0; + final hasSize = + width > 0 && height > 0 && width.isFinite && height.isFinite; + final scale = + hasSize ? math.min(1.0, limit / math.max(width, height)) : 1.0; + final placeholderColor = Theme.of(context) + .extension() + ?.colors + .surfaceMuted ?? + Theme.of(context).colorScheme.surfaceContainerHighest; + + Widget placeholder({bool failed = false}) => DecoratedBox( + key: ValueKey( + failed ? 'comment-image-error' : 'comment-image-placeholder'), + decoration: BoxDecoration( + color: placeholderColor, + borderRadius: BorderRadius.circular(8), + ), + child: SizedBox.expand( + child: failed ? Center(child: fallback) : null, + ), + ); + + final image = CachedNetworkImage( + imageUrl: url, + width: isEmote ? 38 : (hasSize ? width * scale : limit), + height: isEmote ? 38 : (hasSize ? height * scale : limit), + fit: BoxFit.contain, + placeholder: (_, __) => placeholder(), + errorWidget: (_, __, ___) => placeholder(failed: true), + ); + if (isEmote) { + return InlineCustomWidget( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 3), + child: SelectionContainer.disabled(child: image), + ), + ); + } + return SelectionContainer.disabled( + child: Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + child: ClickableGestureDetector( + onTap: () => RouteUtil.pushDialogRoute( + context, + HeroPhotoViewScreen(imageUrls: [url], initIndex: 0), + showClose: false, + fullScreen: true, + useFade: true, + opaque: false, + barrierDismissible: false, + animation: false, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: image, + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/Widgets/PostDetail/lazy_comment_jump.dart b/lib/Widgets/PostDetail/lazy_comment_jump.dart deleted file mode 100644 index b52c6c14..00000000 --- a/lib/Widgets/PostDetail/lazy_comment_jump.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Reveals an anchor inside lazy post content from either side of it. -/// [contentExtent] is the end of the post-content sliver (before recommendations). -Future revealLazyComment({ - required ScrollController controller, - required GlobalKey anchorKey, - required bool Function() isActive, - double? contentExtent, -}) async { - Future revealIfMounted() async { - final anchorContext = anchorKey.currentContext; - if (anchorContext == null) return false; - await Scrollable.ensureVisible( - anchorContext, - duration: const Duration(milliseconds: 240), - curve: Curves.easeOutCubic, - ); - return true; - } - - if (!isActive() || !controller.hasClients) return; - if (await revealIfMounted()) return; - - if (contentExtent != null) { - final position = controller.position; - final approach = (contentExtent - position.viewportDimension * 0.9) - .clamp(position.minScrollExtent, position.maxScrollExtent); - if ((position.pixels - approach).abs() > - position.viewportDimension * 0.25) { - await controller.animateTo( - approach, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - ); - if (!isActive()) return; - await WidgetsBinding.instance.endOfFrame; - } - } - - // The anchor is before the recommendation sliver. Once near that boundary, - // move backward by less than one viewport so the lazy child can mount. - for (var step = 0; step < 80 && isActive(); step++) { - if (await revealIfMounted()) return; - if (!controller.hasClients) return; - final position = controller.position; - final direction = - contentExtent != null || position.pixels > position.maxScrollExtent / 2 - ? -1.0 - : 1.0; - final target = - (position.pixels + direction * position.viewportDimension * 0.8) - .clamp(position.minScrollExtent, position.maxScrollExtent); - if ((target - position.pixels).abs() < 0.5) return; - await controller.animateTo( - target, - duration: const Duration(milliseconds: 45), - curve: Curves.linear, - ); - if (!isActive()) return; - await WidgetsBinding.instance.endOfFrame; - } -} diff --git a/lib/Widgets/PostDetail/post_content_sliver.dart b/lib/Widgets/PostDetail/post_content_sliver.dart new file mode 100644 index 00000000..ed4500c8 --- /dev/null +++ b/lib/Widgets/PostDetail/post_content_sliver.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +/// Keeps the finite post body and comment preview laid out, so their anchors +/// have exact offsets even off screen. Recommendations remain a separate lazy +/// sliver; they are deliberately not included here. +class PostContentSliver extends StatelessWidget { + const PostContentSliver({super.key, required this.children}); + + final List children; + + @override + Widget build(BuildContext context) => SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + ); +} + +Future revealPostComment({ + required ScrollController controller, + required GlobalKey anchorKey, +}) async { + if (!controller.hasClients) return; + final anchorContext = anchorKey.currentContext; + final anchor = anchorContext?.findRenderObject(); + if (anchor == null || !anchor.attached) return; + final position = controller.position; + // Only move the post pane, never the recommendations or an outer scrollable. + if (Scrollable.maybeOf(anchorContext!)?.position != position) return; + final viewport = RenderAbstractViewport.maybeOf(anchor); + if (viewport == null) return; + final target = viewport.getOffsetToReveal(anchor, 0).offset.clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + if ((target - position.pixels).abs() < 0.5) return; + await controller.animateTo( + target, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ); +} diff --git a/lib/Widgets/PostDetail/post_swipe_gesture_detector.dart b/lib/Widgets/PostDetail/post_swipe_gesture_detector.dart index 26d5cedc..1e11a9d5 100644 --- a/lib/Widgets/PostDetail/post_swipe_gesture_detector.dart +++ b/lib/Widgets/PostDetail/post_swipe_gesture_detector.dart @@ -10,6 +10,7 @@ class PostSwipeGestureDetector extends StatefulWidget { super.key, required this.child, this.excludedRegions = const [], + this.activeRegion, this.behavior = HitTestBehavior.translucent, this.edgeActivationWidth = 28, this.onHorizontalDragStart, @@ -20,6 +21,10 @@ class PostSwipeGestureDetector extends StatefulWidget { final Widget child; final List excludedRegions; + + /// When mounted, only drags starting inside this region can switch posts. + /// An unmounted region leaves the single-pane/mobile behavior unchanged. + final GlobalKey? activeRegion; final HitTestBehavior behavior; final double edgeActivationWidth; final GestureDragStartCallback? onHorizontalDragStart; @@ -40,6 +45,7 @@ class _PostSwipeGestureDetectorState extends State { bool _dragging = false; bool _canStartAt(Offset globalPosition) { + if (!_isInActiveRegion(globalPosition)) return false; if (_isEdge(globalPosition)) return true; for (final key in widget.excludedRegions) { final region = key.currentContext?.findRenderObject(); @@ -50,6 +56,15 @@ class _PostSwipeGestureDetectorState extends State { return true; } + bool _isInActiveRegion(Offset globalPosition) { + final region = widget.activeRegion?.currentContext?.findRenderObject(); + if (region is! RenderBox || !region.attached || !region.hasSize) { + return true; + } + return (Offset.zero & region.size) + .contains(region.globalToLocal(globalPosition)); + } + bool _isEdge(Offset globalPosition) { final renderObject = context.findRenderObject(); if (widget.edgeActivationWidth > 0 && @@ -139,7 +154,8 @@ class _PostSwipeGestureDetectorState extends State { GestureRecognizerFactoryWithHandlers<_EdgePostSwipeRecognizer>( _EdgePostSwipeRecognizer.new, (recognizer) => recognizer - ..isEdge = _isEdge + ..isEdge = + ((position) => _isInActiveRegion(position) && _isEdge(position)) ..onStart = (_) {}, ), }, diff --git a/lib/Widgets/Profile/profile_header_components.dart b/lib/Widgets/Profile/profile_header_components.dart index 5eaecac6..394baac4 100644 --- a/lib/Widgets/Profile/profile_header_components.dart +++ b/lib/Widgets/Profile/profile_header_components.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../Theme/loftify_design_theme.dart'; import '../Design/loftify_surfaces.dart'; +import '../Design/loftify_content_frame.dart'; import '../loftify_icons.dart'; const _coverTextShadows = [ @@ -12,18 +13,83 @@ const _coverTextShadows = [ ), ]; +const double loftifyProfileMaxContentWidth = loftifyPageMaxContentWidth; + +/// Align wide tabs and every tab's viewport to the profile heading. +class LoftifyProfileContentFrame extends StatelessWidget { + const LoftifyProfileContentFrame({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return LoftifyContentFrame(child: child); + } +} + +/// Wide profiles size to their contents instead of stretching a follow action +/// and four statistics across the entire desktop window. +class LoftifyWideProfileSummary extends StatelessWidget { + const LoftifyWideProfileSummary({ + super.key, + required this.identity, + required this.statistics, + required this.action, + }); + + final Widget identity; + final Widget statistics; + final Widget action; + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (context, constraints) { + final largeText = MediaQuery.textScalerOf(context).scale(14) > 21; + final compactAction = ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 240), + child: action, + ); + final inline = constraints.maxWidth >= 600 && !largeText; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + identity, + const SizedBox(height: 16), + if (inline) + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: statistics), + const SizedBox(width: 16), + compactAction, + ], + ) + else ...[ + statistics, + const SizedBox(height: 12), + Align(alignment: Alignment.centerLeft, child: compactAction), + ], + ], + ); + }); + } +} + /// Content-width and reflow contract for the complete profile heading. class LoftifyProfileHeaderLayout extends StatelessWidget { const LoftifyProfileHeaderLayout({ super.key, required this.summary, this.showcase, - this.maxContentWidth = 1180, + this.maxContentWidth = loftifyProfileMaxContentWidth, + this.stackShowcase = false, }); final Widget summary; final Widget? showcase; final double maxContentWidth; + final bool stackShowcase; static bool usesSideBySide( BuildContext context, { @@ -43,7 +109,8 @@ class LoftifyProfileHeaderLayout extends StatelessWidget { constraints: BoxConstraints(maxWidth: maxContentWidth), child: LayoutBuilder( builder: (context, constraints) { - final sideBySide = showcase != null && + final sideBySide = !stackShowcase && + showcase != null && constraints.maxWidth >= 820 && MediaQuery.textScalerOf(context).scale(14) / 14 <= 1.35; if (sideBySide) { diff --git a/lib/Widgets/Tag/tag_detail_components.dart b/lib/Widgets/Tag/tag_detail_components.dart index ee88e77e..f32d8e73 100644 --- a/lib/Widgets/Tag/tag_detail_components.dart +++ b/lib/Widgets/Tag/tag_detail_components.dart @@ -57,7 +57,7 @@ class LoftifyTagHero extends StatelessWidget { (design.typography.label.fontSize ?? 13); final stackActions = constraints.maxWidth < 360 || textScale > 1.3 || - trailing.length > 1; + (trailing.length > 1 && constraints.maxWidth < 600); if (stackActions) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -116,7 +116,7 @@ class LoftifyTagHero extends StatelessWidget { ), alignment: Alignment.center, child: Icon( - LoftifyIcons.tag, + LoftifyIcons.hash, size: design.icons.regular, color: design.colors.onAccentContainer, ), @@ -135,11 +135,13 @@ class LoftifyTagHero extends StatelessWidget { } Widget _buildSubscriptionButton() { - return LoftifyCompactToggleButton( + final button = LoftifyCompactToggleButton( label: subscribed ? subscribedLabel : subscribeLabel, selected: subscribed, onPressed: onSubscriptionPressed, ); + // Keep the toggle's internal Center from consuming a complete Wrap run. + return trailing.isEmpty ? button : IntrinsicWidth(child: button); } } diff --git a/lib/Widgets/Video/video_controls_visibility.dart b/lib/Widgets/Video/video_controls_visibility.dart new file mode 100644 index 00000000..d5c9a4f4 --- /dev/null +++ b/lib/Widgets/Video/video_controls_visibility.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +/// Playback ticks must not restart the inactivity countdown. +class VideoControlsController extends ChangeNotifier { + VideoControlsController({this.hideDelay = const Duration(seconds: 4)}); + + final Duration hideDelay; + Timer? _timer; + bool _visible = true; + bool _playing = false; + int _pointers = 0; + bool get visible => _visible; + + void setPlaying(bool playing) { + if (_playing == playing) return; + _playing = playing; + _restartTimer(); + } + + void show() { + if (!_visible) { + _visible = true; + notifyListeners(); + } + _restartTimer(); + } + + void toggle() { + if (!_visible) { + show(); + } else { + _timer?.cancel(); + _visible = false; + notifyListeners(); + } + } + + void pointerDown() { + _pointers++; + _timer?.cancel(); + } + + void pointerUp() { + if (_pointers > 0) _pointers--; + _restartTimer(); + } + + void _restartTimer() { + _timer?.cancel(); + if (!_playing || !_visible || _pointers > 0) return; + _timer = Timer(hideDelay, () { + _visible = false; + notifyListeners(); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } +} + +class VideoControlsVisibility extends StatelessWidget { + const VideoControlsVisibility({ + super.key, + required this.controller, + required this.child, + }); + + final VideoControlsController controller; + final Widget child; + + @override + Widget build(BuildContext context) => AnimatedBuilder( + animation: controller, + child: child, + builder: (context, child) => IgnorePointer( + ignoring: !controller.visible, + child: ExcludeSemantics( + excluding: !controller.visible, + child: AnimatedOpacity( + opacity: controller.visible ? 1 : 0, + duration: const Duration(milliseconds: 180), + child: child, + ), + ), + ), + ); +} diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index a09591a3..377a6c77 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1,5 +1,9 @@ { "@@locale": "en", + "clipboardLinkTitle": "Loftify link found", + "clipboardLinkMessage": "Your clipboard contains a supported link. Would you like to open it in Loftify?", + "clipboardLinkDismiss": "Not now", + "clipboardLinkOpen": "Open link", "appName": "Loftify", "all": "All", "save": "Save", diff --git a/lib/l10n/intl_zh.arb b/lib/l10n/intl_zh.arb index ccf16f47..b88ecd79 100644 --- a/lib/l10n/intl_zh.arb +++ b/lib/l10n/intl_zh.arb @@ -1,5 +1,9 @@ { "@@locale": "zh", + "clipboardLinkTitle": "发现 Loftify 链接", + "clipboardLinkMessage": "剪贴板中有可以打开的链接,要在 Loftify 中查看吗?", + "clipboardLinkDismiss": "暂不打开", + "clipboardLinkOpen": "打开链接", "appName": "Loftify", "all": "全部", "save": "保存", diff --git a/lib/l10n/intl_zh_CN.arb b/lib/l10n/intl_zh_CN.arb index 088f2b22..1d6cfdc2 100644 --- a/lib/l10n/intl_zh_CN.arb +++ b/lib/l10n/intl_zh_CN.arb @@ -1,5 +1,9 @@ { "@@locale": "zh_CN", + "clipboardLinkTitle": "发现 Loftify 链接", + "clipboardLinkMessage": "剪贴板中有可以打开的链接,要在 Loftify 中查看吗?", + "clipboardLinkDismiss": "暂不打开", + "clipboardLinkOpen": "打开链接", "appName": "Loftify", "all": "全部", "save": "保存", diff --git a/lib/l10n/intl_zh_TW.arb b/lib/l10n/intl_zh_TW.arb index 0470f5b8..7fcc52f5 100644 --- a/lib/l10n/intl_zh_TW.arb +++ b/lib/l10n/intl_zh_TW.arb @@ -1,5 +1,9 @@ { "@@locale": "zh_TW", + "clipboardLinkTitle": "發現 Loftify 連結", + "clipboardLinkMessage": "剪貼簿中有可以開啟的連結,要在 Loftify 中查看嗎?", + "clipboardLinkDismiss": "暫不開啟", + "clipboardLinkOpen": "開啟連結", "appName": "Loftify", "all": "全部", "save": "儲存", diff --git a/pubspec.yaml b/pubspec.yaml index e3146225..fbdc28bb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: loftify -version: 3.0.0+3000 +version: 3.1.0+3100 description: An third-party application for LOFTER publish_to: none diff --git a/test/clipboard_link_controller_test.dart b/test/clipboard_link_controller_test.dart new file mode 100644 index 00000000..0ec2e0ab --- /dev/null +++ b/test/clipboard_link_controller_test.dart @@ -0,0 +1,174 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:loftify/Theme/loftify_design_theme.dart'; +import 'package:loftify/Utils/clipboard_link_controller.dart'; +import 'package:loftify/Utils/uri_util.dart'; +import 'package:loftify/Widgets/Dialog/clipboard_link_dialog.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +void main() { + setUpAll(() async { + final directory = Directory('build/test_hive/clipboard_links'); + await directory.create(recursive: true); + Hive.init(directory.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + }); + const url = 'https://ruiiiiii.lofter.com/post/1dd2a51a_34f468525'; + test('extract only supported Loftify URLs from copied share text', () { + for (final input in [url, '分享给你:$url。', '[$url]($url)', '查看 <$url>']) { + expect(LoftifyUriUtil.extractSupportedClipboardUrl(input), url); + } + for (final input in [ + 'hello', + 'https://example.com', + 'https://ruiiiiii.lofter.com.evil.test/post/1dd2a51a_34f468525', + 'https://evil.test/?next=$url', + 'https://www.lofter.com/login', + 'https://www.lofter.com/path?next=$url', + 'https://attacker@ruiiiiii.lofter.com/post/1dd2a51a_34f468525' + ]) { + expect(LoftifyUriUtil.extractSupportedClipboardUrl(input), isNull); + } + for (final input in [ + 'lofter://ruiiiiii.lofter.com/post/1dd2a51a_34f468525', + 'https://www.lofter.com/videoDetail?permalink=1dd2a51a_34f468525', + 'https://www.lofter.com/front/blog/collection/share?collectionId=123', + 'https://www.lofter.com/collection/blog/?op=collectionDetail&collectionId=123', + 'https://www.lofter.com/front/blog/grain/detail?grainId=1&grainUserId=2&incantation=abc', + 'https://www.lofter.com/mentionredirect.do?blogId=123', + 'https://www.lofter.com/tag/原神', + 'https://www.lofter.com/tag/%E5%8E%9F%E7%A5%9E', + 'https://ruiiiiii.lofter.com/', + 'https://s.lofter.com/-s/abc123', + ]) { + expect(LoftifyUriUtil.extractSupportedClipboardUrl(input), input); + } + }); + + test('dismiss and accept are both deduplicated, with no automatic navigation', + () async { + var prompts = 0; + var opens = 0; + var accepted = false; + var text = url; + final controller = ClipboardLinkController( + canPrompt: () => true, + readText: () async => text, + confirm: (_) async { + prompts++; + return accepted; + }, + open: (_) async { + opens++; + }, + ); + await controller.check(); + await controller.check(); + expect(prompts, 1); + expect(opens, 0); + accepted = true; + text = 'https://other.lofter.com/'; + await controller.check(); + await controller.check(); + expect(prompts, 2); + expect(opens, 1); + controller.dispose(); + }); + + test('overlapping resume events and disposal cannot open duplicate dialogs', + () async { + final read = Completer(); + var prompts = 0; + var reads = 0; + final controller = ClipboardLinkController( + canPrompt: () => true, + readText: () { + reads++; + return read.future; + }, + confirm: (_) async { + prompts++; + return true; + }, + open: (_) async => fail('disposed controller must not navigate'), + ); + final pending = controller.check(); + await controller.check(); + expect(reads, 1); + controller.dispose(); + read.complete(url); + await pending; + expect(prompts, 0); + }); + + test('locked state and permission denial are harmless', () async { + var allowed = false; + var reads = 0; + final controller = ClipboardLinkController( + canPrompt: () => allowed, + readText: () async { + reads++; + throw PlatformException(code: 'denied'); + }, + confirm: (_) async => fail('no prompt expected'), + open: (_) async => fail('no navigation expected'), + ); + await controller.check(); + expect(reads, 0); + allowed = true; + await controller.check(); + expect(reads, 1); + controller.dispose(); + }); + + testWidgets('clipboard dialog stays bounded and cancel returns false', + (tester) async { + await tester.binding.setSurfaceSize(const Size(320, 600)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + bool? result; + await tester.pumpWidget(MaterialApp( + theme: LoftifyTheme.build(ChewieThemeColorData.defaultLightThemes.first), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates + ], + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return Scaffold( + body: TextButton( + onPressed: () async { + result = await ClipboardLinkDialog.show(context, url); + }, + child: const Text('Show'), + )); + }), + )); + await tester.tap(find.text('Show')); + await tester.pumpAndSettle(); + expect(find.text('Loftify link found'), findsOneWidget); + expect(find.byType(CustomConfirmDialogWidget), findsOneWidget); + expect(find.byIcon(LucideIcons.link), findsOneWidget); + expect(find.byType(BackdropFilter), findsWidgets); + final route = + ModalRoute.of(tester.element(find.byType(ClipboardLinkDialog)))!; + expect(route.barrierColor, ChewieTheme.barrierColor); + expect(tester.takeException(), isNull); + await tester.tap(find.text('Not now')); + await tester.pumpAndSettle(); + expect(result, isFalse); + await tester.tap(find.text('Show')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open link')); + await tester.pumpAndSettle(); + expect(result, isTrue); + }); +} diff --git a/test/screens/navigation/search_navigation_layout_test.dart b/test/screens/navigation/search_navigation_layout_test.dart index 5411ede9..a5548b16 100644 --- a/test/screens/navigation/search_navigation_layout_test.dart +++ b/test/screens/navigation/search_navigation_layout_test.dart @@ -21,63 +21,79 @@ void main() { RequestUtil.cookieManager = _UnusedCookieManager(); }); - for (final showBack in [false, true]) { - testWidgets( - 'search bar fills the available app bar width (back: $showBack)', - (tester) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); + for (final width in [390.0, 985.0, 1440.0]) { + for (final showBack in [false, true]) { + testWidgets( + 'search bar uses responsive placement at $width (back: $showBack)', + (tester) async { + tester.view.physicalSize = Size(width, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); - RequestUtil.instance.dio.interceptors.clear(); - RequestUtil.instance.dio.interceptors.add( - InterceptorsWrapper(onRequest: (options, handler) { - handler.resolve(Response( - requestOptions: options, - data: { - 'code': 0, - 'data': { - 'guessKeywords': [], - 'rankList': [], - 'configList': [], + RequestUtil.instance.dio.interceptors.clear(); + RequestUtil.instance.dio.interceptors.add( + InterceptorsWrapper(onRequest: (options, handler) { + handler.resolve(Response( + requestOptions: options, + data: { + 'code': 0, + 'data': { + 'guessKeywords': [], + 'rankList': [], + 'configList': [], + }, }, - }, - )); - }), - ); + )); + }), + ); - await tester.pumpWidget(MaterialApp( - theme: ChewieThemeColorData.defaultLightThemes.first.toThemeData(), - localizationsDelegates: const [ - ChewieLocalizations.delegate, - ...AppLocalizations.localizationsDelegates, - ], - supportedLocales: AppLocalizations.supportedLocales, - home: Builder(builder: (context) { - chewieProvider.setRootContext(context); - return SearchScreen(showBack: showBack); - }), - )); - await tester.pump(const Duration(seconds: 1)); + await tester.pumpWidget(MaterialApp( + theme: ChewieThemeColorData.defaultLightThemes.first.toThemeData(), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return SearchScreen(showBack: showBack); + }), + )); + await tester.pump(const Duration(seconds: 1)); - final rightEdge = tester - .getTopRight(find.byKey(const ValueKey('search-navigation-bar'))) - .dx; - final barWidth = tester - .getSize(find.byKey(const ValueKey('search-navigation-bar'))) - .width; - // Desktop app bars reserve a 44px trailing window-control area; compact - // search bars should fill every remaining pixel instead of subtracting - // another fixed-width action slot. - final trailingSpace = ResponsiveUtil.isLandscapeLayout() ? 44.0 : 0.0; - final leadingSpace = showBack ? 52.0 : 0.0; - expect(rightEdge, greaterThanOrEqualTo(390 - trailingSpace - 1)); - expect( - barWidth, greaterThanOrEqualTo(390 - trailingSpace - leadingSpace)); - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox()); - await tester.pump(const Duration(seconds: 1)); - }); + final rightEdge = tester + .getTopRight(find.byKey(const ValueKey('search-navigation-bar'))) + .dx; + final barWidth = tester + .getSize(find.byKey(const ValueKey('search-navigation-bar'))) + .width; + final bar = find.byKey(const ValueKey('search-navigation-bar')); + if (ResponsiveUtil.isLandscapeLayout()) { + expect( + find.ancestor(of: bar, matching: find.byType(ResponsiveAppBar)), + findsNothing); + expect(barWidth, lessThanOrEqualTo(1180)); + expect(tester.getCenter(bar).dx, closeTo(width / 2, 1)); + expect( + tester.getTopLeft(bar).dy, + greaterThanOrEqualTo( + tester.getBottomLeft(find.byType(ResponsiveAppBar)).dy)); + if (width == 1440) expect(barWidth, 1180); + } else { + final trailingSpace = + ResponsiveUtil.isDesktop() ? desktopWindowControlsWidth : 0.0; + final leadingSpace = showBack ? 52.0 : 0.0; + expect(rightEdge, closeTo(width - trailingSpace, 1)); + expect(barWidth, closeTo(width - trailingSpace - leadingSpace, 1)); + expect( + find.ancestor(of: bar, matching: find.byType(ResponsiveAppBar)), + findsOneWidget); + } + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }); + } } } diff --git a/test/screens/navigation/search_suggestions_test.dart b/test/screens/navigation/search_suggestions_test.dart index a46008a4..edbebe55 100644 --- a/test/screens/navigation/search_suggestions_test.dart +++ b/test/screens/navigation/search_suggestions_test.dart @@ -7,7 +7,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hive/hive.dart'; import 'package:loftify/Screens/Navigation/search_screen.dart'; +import 'package:loftify/Screens/Post/collection_detail_screen.dart'; +import 'package:loftify/Screens/Post/grain_detail_screen.dart'; import 'package:loftify/Screens/Post/search_result_screen.dart'; +import 'package:loftify/Widgets/Design/loftify_state_view.dart'; import 'package:loftify/Utils/request_util.dart'; import 'package:loftify/Utils/hive_util.dart'; import 'package:loftify/generated/app_localizations.dart'; @@ -155,6 +158,65 @@ void main() { await tester.pump(const Duration(milliseconds: 100)); } + for (final width in [390.0, 1000.0, 1800.0]) { + testWidgets('pending search and detail routes lay out at width $width', + (tester) async { + final previous = chewieProvider.stateWidgetBuilder; + chewieProvider.stateWidgetBuilder = LoftifyStateView.fromChewie; + addTearDown(() => chewieProvider.stateWidgetBuilder = previous); + holdInitial = true; + await mount(tester, resultsPage: true, size: Size(width, 844)); + expect(find.byType(LoftifyStateView), findsOneWidget); + expect(tester.takeException(), isNull); + final searchBar = find.byKey(const ValueKey('search-results-bar')); + expect( + find.ancestor(of: searchBar, matching: find.byType(ResponsiveAppBar)), + ResponsiveUtil.isLandscapeLayout() ? findsNothing : findsOneWidget, + ); + if (width == 1800) { + final tabs = tester.getRect(find.byType(TabBarWrapper)); + final content = tester.getRect( + find.byKey(const PageStorageKey('search-all-initial')), + ); + expect(tabs.width, 1180); + expect(content.width, 1180); + expect(tabs.center.dx, width / 2); + expect(content.center.dx, width / 2); + expect(tester.getSize(searchBar).width, 1180); + expect(tester.getCenter(searchBar).dx, width / 2); + expect(tester.getBottomLeft(searchBar).dy, lessThanOrEqualTo(tabs.top)); + } + + final navigator = + tester.state(find.byType(Navigator).first); + for (final page in const [ + CollectionDetailScreen( + collectionId: 1, + postId: 1, + blogId: 1, + blogName: 'test', + ), + GrainDetailScreen(grainId: 1, blogId: 1), + ]) { + navigator.push(MaterialPageRoute(builder: (_) => page)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(LoadingWidget), findsOneWidget); + expect(tester.takeException(), isNull); + + // Relayout the retained search route as well as the visible detail. + tester.view.physicalSize = Size(width, 700); + await tester.pump(); + expect(tester.takeException(), isNull); + navigator.pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(tester.takeException(), isNull); + } + await tester.pumpWidget(const SizedBox.shrink()); + }); + } + testWidgets('rank row fills page and horizontal swipe stays on next board', (tester) async { serveRanks = true; diff --git a/test/screens/setting/general_setting_route_test.dart b/test/screens/setting/general_setting_route_test.dart new file mode 100644 index 00000000..dd870493 --- /dev/null +++ b/test/screens/setting/general_setting_route_test.dart @@ -0,0 +1,113 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Setting/general_setting_screen.dart'; +import 'package:loftify/Screens/Setting/mobile_setting_navigation_screen.dart'; +import 'package:loftify/Screens/Setting/setting_screen.dart'; +import 'package:loftify/Utils/app_provider.dart'; +import 'package:loftify/Utils/hive_util.dart'; +import 'package:loftify/generated/app_localizations.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +// Test-only fake for the platform interface supplied by path_provider. +// ignore: depend_on_referenced_packages +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:provider/provider.dart'; + +class _TestPaths extends PathProviderPlatform { + @override + Future getApplicationSupportPath() async => + Directory('build/test_hive/general_setting_routes/support').absolute.path; +} + +void main() { + setUpAll(() async { + final directory = Directory('build/test_hive/general_setting_routes'); + await directory.create(recursive: true); + Hive.init(directory.absolute.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + final previousPaths = PathProviderPlatform.instance; + PathProviderPlatform.instance = _TestPaths(); + addTearDown(() => PathProviderPlatform.instance = previousPaths); + PackageInfo.setMockInitialValues( + appName: 'Loftify', + packageName: 'com.test.loftify', + version: '3.0.0', + buildNumber: '1', + buildSignature: '', + ); + }); + + for (final mobileEntry in [false, true]) { + testWidgets('general settings routes own separate state ($mobileEntry)', + (tester) async { + final settings = Hive.box(ChewieHiveUtil.settingsBox); + settings.put(HiveUtil.launchAtStartupKey, false); + tester.view.physicalSize = const Size(1000, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final navigator = GlobalKey(); + await tester.pumpWidget(ChangeNotifierProvider.value( + value: appProvider, + child: MaterialApp( + navigatorKey: navigator, + locale: const Locale('en'), + theme: ChewieThemeColorData.defaultLightThemes.first.toThemeData(), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return mobileEntry + ? const MobileSettingNavigationScreen() + : const SettingScreen(); + }), + ), + )); + final openGeneral = tester + .widget(find.byWidgetPredicate( + (widget) => widget is EntryItem && widget.title == 'General')) + .onTap!; + + Future open() async { + openGeneral(); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(tester.takeException(), isNull); + } + + await open(); + final firstState = tester + .state(find.byType(GeneralSettingScreen)); + await open(); + final states = tester + .stateList( + find.byType(GeneralSettingScreen, skipOffstage: false)) + .toList(); + expect(states, hasLength(2)); + expect(identical(states[0], states[1]), isFalse); + expect(states, contains(firstState)); + + // The tray writes this same setting. Both retained routes must observe it. + settings.put(HiveUtil.launchAtStartupKey, true); + await tester.pump(); + expect(states.every((state) => state.launchAtStartup), isTrue); + + // Reopen while the previous route's reverse transition is still mounted. + navigator.currentState!.pop(); + await tester.pump(const Duration(milliseconds: 20)); + await open(); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 500)); + settings.put(HiveUtil.launchAtStartupKey, false); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/test/screens/setting/update_log_layout_test.dart b/test/screens/setting/update_log_layout_test.dart new file mode 100644 index 00000000..8bd819ce --- /dev/null +++ b/test/screens/setting/update_log_layout_test.dart @@ -0,0 +1,129 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +void main() { + setUpAll(() async { + final dir = Directory('build/test_hive/update_log_layout'); + await dir.create(recursive: true); + Hive.init(dir.absolute.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + PackageInfo.setMockInitialValues( + appName: 'Loftify', + packageName: 'com.test.loftify', + version: '3.0.0', + buildNumber: '1', + buildSignature: '', + ); + }); + + testWidgets('changelog app bar back only pops the nested page', + (tester) async { + tester.view.physicalSize = const Size(1000, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final nestedNavigator = GlobalKey(); + await tester.pumpWidget(MaterialApp( + navigatorKey: chewieProvider.globalNavigatorKey, + theme: ChewieThemeColorData.defaultLightThemes.first.toThemeData(), + localizationsDelegates: const [ChewieLocalizations.delegate], + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return Scaffold( + body: Navigator( + key: nestedNavigator, + onGenerateRoute: (_) => MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('About page')), + ), + ), + ); + }), + )); + for (var i = 0; i < 2; i++) { + nestedNavigator.currentState! + .push(RouteUtil.getFadeRoute(const UpdateLogScreen())); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(nestedNavigator.currentState!.canPop(), isTrue); + await tester.tap(find.byWidgetPredicate((widget) => + widget is ChewieIconButton && widget.icon == ChewieIcons.back)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(UpdateLogScreen), findsNothing); + expect(find.text('About page'), findsOneWidget); + expect(nestedNavigator.currentState!.canPop(), isFalse); + expect(chewieProvider.globalNavigatorKey.currentState, isNotNull); + expect(tester.takeException(), isNull); + } + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 1)); + }); + + for (final width in [390.0, 1000.0]) { + testWidgets('selectable changelog timeline lays out at $width', + (tester) async { + tester.view.physicalSize = Size(width, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final semantics = tester.ensureSemantics(); + final focus = FocusNode(); + addTearDown(focus.dispose); + final navigator = GlobalKey(); + await tester.pumpWidget(MaterialApp( + navigatorKey: navigator, + theme: ChewieThemeColorData.defaultLightThemes.first.toThemeData(), + localizationsDelegates: const [ChewieLocalizations.delegate], + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return const Scaffold(body: Text('About')); + }), + )); + navigator.currentState!.push(RouteUtil.getFadeRoute(Scaffold( + body: ListView(children: [ + UpdateLogTimeline( + isLast: false, + marker: const SizedBox(width: 14, height: 14), + child: + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('3.0.0'), + SelectableAreaWrapper( + focusNode: focus, + child: CustomMarkdownWidget( + '# Changelog\n\n- A feature with enough text to wrap on a narrow screen.\n' + '- Another feature\n\n```dart\nfinal version = "3.0.0";\n```\n', + ), + ), + ]), + ), + const UpdateLogTimeline( + isLast: true, + marker: SizedBox(width: 14, height: 14), + child: Text('2.0.0'), + ), + ]), + ))); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + final timeline = find.byType(UpdateLogTimeline).first; + final line = find + .descendant(of: timeline, matching: find.byType(Positioned)) + .first; + expect(tester.getBottomLeft(line).dy, tester.getBottomLeft(timeline).dy); + expect(tester.getSize(timeline).height, greaterThan(100)); + + tester.view.physicalSize = Size(width + 180, 760); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + navigator.currentState!.pop(); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + semantics.dispose(); + }); + } +} diff --git a/test/video_list_controller_test.dart b/test/video_list_controller_test.dart index 9b6ae75b..83a9163f 100644 --- a/test/video_list_controller_test.dart +++ b/test/video_list_controller_test.dart @@ -11,6 +11,7 @@ import 'package:loftify/Models/recommend_response.dart'; import 'package:loftify/Screens/Post/video_detail_screen.dart'; import 'package:loftify/Screens/Post/video_list_controller.dart'; import 'package:loftify/Widgets/loftify_icons.dart'; +import 'package:loftify/Widgets/Video/video_controls_visibility.dart'; import 'package:loftify/l10n/l10n.dart'; import 'package:video_player/video_player.dart'; import 'package:video_player_platform_interface/video_player_platform_interface.dart'; @@ -274,6 +275,84 @@ void main() { await tester.pump(const Duration(milliseconds: 100)); }); + testWidgets('single tap changes chrome, double tap changes playback', + (tester) async { + final player = createPlayer(); + await player.play(); + final controls = VideoControlsController(); + await tester.pumpWidget(MaterialApp( + home: VideoLongPressGesture( + player: player, + onOpenMenu: () {}, + onTap: controls.toggle, + onDoubleTap: () { + controls.show(); + unawaited(player.isPlaying + ? player.pause(showPauseIcon: true) + : player.play()); + }, + child: const ColoredBox(color: Colors.black), + ))); + await tester.tapAt(const Offset(200, 200)); + await tester.pump(kDoubleTapTimeout + const Duration(milliseconds: 50)); + expect(controls.visible, isFalse); + expect(player.isPlaying, isTrue); + await tester.tapAt(const Offset(200, 200)); + await tester.pump(const Duration(milliseconds: 80)); + await tester.tapAt(const Offset(200, 200)); + await tester.pump(const Duration(milliseconds: 80)); + expect(player.isPlaying, isFalse); + expect(controls.visible, isTrue); + await tester.pump(kDoubleTapTimeout); + await tester.tapAt(const Offset(200, 200)); + await tester.pump(const Duration(milliseconds: 80)); + await tester.tapAt(const Offset(200, 200)); + await tester.pump(const Duration(milliseconds: 80)); + expect(player.isPlaying, isTrue); + await tester.pumpWidget(const SizedBox.shrink()); + controls.dispose(); + unawaited(player.close()); + await tester.pump(const Duration(milliseconds: 100)); + }); + + testWidgets('inactivity hides chrome without playback ticks extending timer', + (tester) async { + final controls = VideoControlsController(); + addTearDown(controls.dispose); + controls.setPlaying(true); + await tester.pumpWidget(MaterialApp( + home: VideoControlsVisibility( + controller: controls, + child: const Text('Controls'), + ))); + await tester.pump(const Duration(seconds: 3)); + controls.setPlaying(true); + await tester.pump(const Duration(seconds: 1)); + expect(controls.visible, isFalse); + expect( + tester + .widget(find + .descendant( + of: find.byType(VideoControlsVisibility), + matching: find.byType(IgnorePointer), + ) + .first) + .ignoring, + isTrue); + controls.show(); + controls.pointerDown(); + await tester.pump(const Duration(seconds: 8)); + expect(controls.visible, isTrue); + controls.pointerUp(); + await tester.pump(const Duration(seconds: 4)); + expect(controls.visible, isFalse); + controls.setPlaying(false); + controls.show(); + await tester.pump(const Duration(seconds: 8)); + expect(controls.visible, isTrue); + await tester.pumpWidget(const SizedBox.shrink()); + }); + testWidgets('danmaku lanes do not overlap and pause with the video', (tester) async { final player = createPlayer(); diff --git a/test/widgets/design/loftify_surfaces_test.dart b/test/widgets/design/loftify_surfaces_test.dart index c5d3d8bb..852a54ba 100644 --- a/test/widgets/design/loftify_surfaces_test.dart +++ b/test/widgets/design/loftify_surfaces_test.dart @@ -265,6 +265,31 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('archive empty sliver supports intrinsic layout and scrolling', + (tester) async { + final previous = chewieProvider.stateWidgetBuilder; + chewieProvider.stateWidgetBuilder = LoftifyStateView.fromChewie; + addTearDown(() => chewieProvider.stateWidgetBuilder = previous); + + for (final width in [390.0, 1000.0]) { + await tester.pumpWidget(_TestApp( + width: width, + child: const CustomScrollView( + slivers: [ + SliverToBoxAdapter(child: SizedBox(height: 180)), + SliverEmptyPlaceholder(text: 'No articles'), + ], + ), + )); + await tester.pump(); + expect(find.text('No articles'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.drag(find.text('No articles'), const Offset(0, -100)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + } + }); + testWidgets('icon-only loading state keeps its localized semantic label', (tester) async { final previous = chewieProvider.stateWidgetBuilder; diff --git a/test/widgets/item/search_bar_alignment_test.dart b/test/widgets/item/search_bar_alignment_test.dart index 3eb05ee5..c9a04583 100644 --- a/test/widgets/item/search_bar_alignment_test.dart +++ b/test/widgets/item/search_bar_alignment_test.dart @@ -3,7 +3,62 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:loftify/Widgets/Item/item_builder.dart'; void main() { - for (final height in [48, 56]) { + testWidgets('desktop search hint and caret stay centered with compact theme', + (tester) async { + final controller = TextEditingController(); + final focusNode = FocusNode(); + const hint = '搜标签、合集、文章、讨论、粮单、用户'; + await tester.pumpWidget(MaterialApp( + theme: ThemeData( + platform: TargetPlatform.windows, + visualDensity: VisualDensity.compact, + textTheme: const TextTheme( + titleSmall: TextStyle(fontSize: 15, height: 1.2), + ), + ), + home: Scaffold( + body: Center( + child: SizedBox( + key: const ValueKey('desktop-search-bar'), + width: 500, + height: 36, + child: Builder( + builder: (context) => ItemBuilder.buildSearchBar( + context: context, + hintText: hint, + focusNode: focusNode, + controller: controller, + onSubmitted: (_) {}, + ), + ), + ), + ), + ), + )); + focusNode.requestFocus(); + await tester.pump(); + + final centerY = + tester.getCenter(find.byKey(const ValueKey('desktop-search-bar'))).dy; + expect((tester.getCenter(find.text(hint)).dy - centerY).abs(), lessThan(1)); + for (final text in ['', '搜索文字 Abc']) { + controller.text = text; + await tester.pump(); + final editable = tester + .state(find.byType(EditableText)) + .renderEditable; + final caret = + editable.getLocalRectForCaret(const TextPosition(offset: 0)); + expect((editable.localToGlobal(caret.center).dy - centerY).abs(), + lessThan(1), + reason: 'Text: $text'); + } + await tester.pumpWidget(const SizedBox()); + controller.dispose(); + focusNode.dispose(); + }); + + for (final height in [28, 36, 48, 56]) { testWidgets('shared search hint is centered at height $height', (tester) async { const hint = '多个搜索词以空格隔开'; diff --git a/test/widgets/navigation/desktop_context_menu_test.dart b/test/widgets/navigation/desktop_context_menu_test.dart new file mode 100644 index 00000000..5fe36e05 --- /dev/null +++ b/test/widgets/navigation/desktop_context_menu_test.dart @@ -0,0 +1,125 @@ +import 'dart:io'; + +import 'package:hive/hive.dart'; +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:awesome_chewie/src/Widgets/Module/FlutterContextMenu/core/utils/helpers.dart' + as context_menu; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + setUpAll(() async { + final directory = Directory('build/test_hive/desktop_context_menu'); + await directory.create(recursive: true); + Hive.init(directory.absolute.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + }); + testWidgets('window controls end eight pixels from the window edge', + (tester) async { + await tester.pumpWidget(MaterialApp( + localizationsDelegates: const [ChewieLocalizations.delegate], + builder: (context, child) { + chewieProvider.setRootContext(context); + return child!; + }, + home: Scaffold( + body: Align( + alignment: Alignment.topRight, + child: SizedBox( + width: desktopWindowControlsWidth, + child: WindowTitleWrapper( + backgroundColor: Colors.white, + isStayOnTop: false, + isMaximized: false, + onStayOnTopTap: () {}, + ), + ), + )), + )); + expect( + tester.getRect(find.byType(WindowTitleWrapper)).right - + tester.getRect(find.byType(CloseWindowButton)).right, + closeTo(8, 0.1)); + expect(tester.takeException(), isNull); + }); + + testWidgets('desktop app bar leaves room for window controls', + (tester) async { + tester.view.physicalSize = const Size(985, 891); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + appBar: ResponsiveAppBar( + backgroundColor: Colors.white, + titleWidget: ColoredBox( + key: Key('search-field'), + color: Colors.grey, + child: SizedBox(height: 40), + ), + ), + ), + ), + ); + + expect(tester.getRect(find.byKey(const Key('search-field'))).right, + lessThanOrEqualTo(985 - desktopWindowControlsWidth)); + }); + + testWidgets('a menu in the desktop panel stays inside the panel viewport', + (tester) async { + tester.view.physicalSize = const Size(985, 891); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + MaterialApp( + home: Row( + children: [ + const SizedBox(width: 72), + Expanded( + child: Navigator( + onGenerateRoute: (_) => MaterialPageRoute( + builder: (context) => Scaffold( + body: Align( + alignment: Alignment.bottomRight, + child: Builder( + builder: (panelContext) => TextButton( + onPressed: () { + context_menu.showContextMenu( + panelContext, + contextMenu: FlutterContextMenu( + position: const Offset(970, 870), + entries: const [ + FlutterContextMenuItem('Desktop action'), + ], + ), + ); + }, + child: const Text('Open menu'), + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + + await tester.tap(find.text('Open menu')); + await tester.pumpAndSettle(); + + final itemRect = tester.getRect(find.text('Desktop action')); + expect(itemRect.left, greaterThan(600)); + expect(itemRect.left, greaterThanOrEqualTo(72)); + expect(itemRect.right, lessThanOrEqualTo(985)); + expect(itemRect.bottom, lessThanOrEqualTo(891)); + }); +} diff --git a/test/widgets/navigation/loftify_glass_navigation_bar_test.dart b/test/widgets/navigation/loftify_glass_navigation_bar_test.dart index 7001fc91..f74c9c48 100644 --- a/test/widgets/navigation/loftify_glass_navigation_bar_test.dart +++ b/test/widgets/navigation/loftify_glass_navigation_bar_test.dart @@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:loftify/Utils/enums.dart'; import 'package:loftify/Utils/lottie_files.dart'; import 'package:loftify/Widgets/Navigation/loftify_glass_navigation_bar.dart'; +import 'package:loftify/Widgets/Design/loftify_lottie.dart'; import 'package:loftify/Widgets/loftify_icons.dart'; const _destinations = [ @@ -95,6 +96,82 @@ Widget _host({ } void main() { + testWidgets('desktop rail uses mobile Lottie icons and selection colors', + (tester) async { + final tapped = []; + const selectedColor = Color(0xFF2A9C93); + final scheme = ColorScheme.fromSeed(seedColor: selectedColor).copyWith( + primary: selectedColor, + ); + await tester.pumpWidget(MaterialApp( + theme: ThemeData(colorScheme: scheme), + home: Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + LoftifyNavigationRailItem( + destination: _destinations[0], + selected: true, + onTap: () => tapped.add('Home'), + ), + LoftifyNavigationRailItem( + destination: _destinations[1], + selected: false, + onTap: () => tapped.add('Search'), + ), + ], + ), + ), + ), + )); + + final home = find.byType(LoftifyNavigationRailItem).first; + final search = find.byType(LoftifyNavigationRailItem).last; + final homeIcon = tester.widget(find.descendant( + of: home, + matching: find.byType(LoftifyNavigationLottieIcon), + )); + final searchIcon = + tester.widget(find.descendant( + of: search, + matching: find.byType(LoftifyNavigationLottieIcon), + )); + expect(homeIcon.asset, LottieFiles.navHome); + expect(homeIcon.selected, isTrue); + expect(homeIcon.color, selectedColor); + expect(homeIcon.size, 22); + final homeAnimation = tester.widget(find.descendant( + of: home, + matching: find.byType(LoftifyLottie), + )); + expect(homeAnimation.opacity, 100); + expect( + homeAnimation.spec.contentBounds, const Rect.fromLTWH(32, 20, 64, 82)); + expect(searchIcon.asset, LottieFiles.navSearch); + expect(searchIcon.color, scheme.onSurfaceVariant); + expect(tester.getSize(home), const Size(42, 42)); + final selection = tester.widget( + find.byKey(const ValueKey('loftify-rail-selection-Home')), + ); + expect((selection.decoration as BoxDecoration).color, + selectedColor.withValues(alpha: 0.11)); + + await tester.tap(search); + expect(tapped, ['Search']); + expect(tester.takeException(), isNull); + }); + + testWidgets('mobile home animation retains its original opacity and framing', + (tester) async { + await tester.pumpWidget(_host()); + final home = tester + .widgetList(find.byType(LoftifyLottie)) + .firstWhere((widget) => widget.spec.asset == LottieFiles.navHome); + expect(home.opacity, isNull); + expect(home.spec.contentBounds, const Rect.fromLTWH(34, 22, 60, 76)); + }); + testWidgets('three visible destinations can leave search unselected', (tester) async { final selected = []; diff --git a/test/widgets/post_detail/detail_components_test.dart b/test/widgets/post_detail/detail_components_test.dart index 15655c27..e800ba15 100644 --- a/test/widgets/post_detail/detail_components_test.dart +++ b/test/widgets/post_detail/detail_components_test.dart @@ -1,7 +1,9 @@ import 'dart:io'; import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hive/hive.dart'; import 'package:loftify/Models/post_detail_response.dart'; @@ -9,6 +11,7 @@ import 'package:loftify/Theme/loftify_design_theme.dart'; import 'package:loftify/Widgets/Design/loftify_reading.dart'; import 'package:loftify/Widgets/Item/loftify_item_builder.dart'; import 'package:loftify/Widgets/PostDetail/comment_item.dart'; +import 'package:loftify/Widgets/PostDetail/comment_content.dart'; import 'package:loftify/Widgets/PostDetail/detail_bottom_bar.dart'; import 'package:loftify/Widgets/PostDetail/post_content_section.dart'; import 'package:loftify/Widgets/PostDetail/post_download_action_icon.dart'; @@ -128,6 +131,338 @@ void main() { expect(comment.l2Comments, hasLength(1)); }); + test('incomplete emoji metadata does not discard its comment', () { + final comment = Comment.fromJson({ + 'content': 'Text [smile]', + 'emotes': [ + {'id': '10000', 'name': '[smile]', 'url': '//example.com/smile.png'}, + {'name': null, 'url': null}, + ], + }); + expect(comment.emotes.first.id, 10000); + expect(comment.emotes.first.sizeType, 0); + expect(comment.emotes.last.name, isEmpty); + }); + + testWidgets('comment mentions stay on the text baseline and keep navigation', + (tester) async { + const url = 'https://www.lofter.com/mentionredirect.do?blogId=2923652384'; + final processUrl = UriUtil.processUrl; + String? openedUrl; + UriUtil.processUrl = + (context, url, {bool pass = false, bool quiet = false}) async { + openedUrl = url; + return true; + }; + addTearDown(() => UriUtil.processUrl = processUrl); + final comment = Comment.fromJson({ + 'content': '前文@Z-尘后文', + }); + await tester.pumpWidget(buildApp(Scaffold( + body: SizedBox(width: 280, child: CommentContent(comment: comment)), + ))); + await tester.pump(); + final text = find.byWidgetPredicate((widget) => + widget is RichText && widget.text.toPlainText() == '前文@Z-尘后文'); + expect(text, findsOneWidget); + expect(find.byType(Icon), findsNothing); + final paragraph = tester.renderObject(text); + final before = paragraph + .getBoxesForSelection( + const TextSelection(baseOffset: 0, extentOffset: 2)) + .first; + final mention = paragraph + .getBoxesForSelection( + const TextSelection(baseOffset: 2, extentOffset: 6)) + .first; + expect(mention.top, closeTo(before.top, 1)); + expect(mention.bottom, closeTo(before.bottom, 1)); + expect(tester.getSize(find.byType(CustomHtmlWidget)).height, lessThan(32)); + await tester.tapAt(paragraph.localToGlobal(mention.toRect().center)); + await tester.pump(); + expect(openedUrl, url); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'long comment links wrap within narrow content at large text scale', + (tester) async { + final comment = Comment.fromJson({ + 'content': '前文这是一条很长的链接文字需要在评论区域自然换行后文', + }); + await tester.pumpWidget(buildApp(Scaffold( + body: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(1.6)), + child: SizedBox(width: 160, child: CommentContent(comment: comment)), + ), + ))); + await tester.pump(); + expect(tester.getSize(find.byType(CustomHtmlWidget)).width, 160); + expect( + tester.getSize(find.byType(CustomHtmlWidget)).height, greaterThan(40)); + expect(find.byType(Icon), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('comment picture placeholders use a full rounded themed surface', + (tester) async { + final comment = Comment.fromJson({ + 'content': '[图片]', + 'images': [ + {'orign': 'https://example.com/placeholder.png', 'ow': 400, 'oh': 200} + ], + }); + for (final dark in [false, true]) { + await tester.pumpWidget(buildApp( + Scaffold( + body: CommentContent(comment: comment), + ), + dark: dark)); + final placeholder = + find.byKey(const ValueKey('comment-image-placeholder')); + expect(placeholder, findsOneWidget); + expect(tester.getSize(placeholder), const Size(200, 100)); + final box = + tester.widget(placeholder).decoration as BoxDecoration; + final context = tester.element(placeholder); + expect( + box.color, + Theme.of(context) + .extension()! + .colors + .surfaceMuted); + expect(box.borderRadius, BorderRadius.circular(8)); + expect(find.byIcon(Icons.image_outlined), findsNothing); + expect(tester.takeException(), isNull); + } + }); + + // Shape returned by /comment/l1/hotnew.json for comment 7131656534. + const stickerUrl = 'https://imglf3.lf127.net/img/7e4fb8d0ab6d9e4f/' + 'SmFTRmhPQ2N4cVdaYmUxR1Btb2VCNGoveThSdmZmZ0YxQU1qdklVeHFIND0.jpg'; + Map uploadedStickerFixture() => { + 'id': 7131656534, + 'content': '三只我都要了![表情]', + 'images': [ + {'orign': stickerUrl, 'ow': 198, 'oh': 142, 'raw': null, 'type': 1}, + ], + }; + + test('uploaded stickers survive parsing and serialization without emotes', + () { + final comment = Comment.fromJson(uploadedStickerFixture()); + expect(comment.emotes, isEmpty); + expect(comment.images.single.url, stickerUrl); + expect(comment.images.single.ow, 198); + expect(comment.images.single.oh, 142); + expect(comment.images.single.raw, isEmpty); + final restored = Comment.fromJson(comment.toJson()); + expect(restored.images.single.url, stickerUrl); + expect(restored.images.single.type, 1); + expect( + Comment.fromJson({ + 'images': [null, false] + }).images, + isEmpty); + expect(Comment.fromJson({'images': null}).images, isEmpty); + }); + + testWidgets('real uploaded sticker replaces placeholder in comment and reply', + (tester) async { + final comment = Comment.fromJson({ + ...uploadedStickerFixture(), + 'l2Count': 1, + 'l2Comments': [uploadedStickerFixture()], + }); + await tester.binding.setSurfaceSize(const Size(320, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(buildApp(Scaffold( + body: SingleChildScrollView( + child: Builder( + builder: (context) => LoftifyItemBuilder.buildCommentRow( + context, + comment, + writerId: 1, + ), + ), + ), + ))); + await tester.pump(); + final stickers = find.byWidgetPredicate((widget) => + widget is CachedNetworkImage && widget.imageUrl == stickerUrl); + expect(stickers, findsNWidgets(2)); + for (final element in stickers.evaluate()) { + final size = tester.getSize(find.byWidget(element.widget)); + expect(size.width, 120); + expect(size.height, closeTo(120 * 142 / 198, 0.01)); + } + for (final widget in tester.widgetList( + find.byType(CustomHtmlWidget), + )) { + expect(widget.content, contains('三只我都要了!(find.byType(CustomHtmlWidget)); + expect(html.content, contains('A(find.byType(CachedNetworkImage)) + .toList(); + expect(images.map((image) => image.imageUrl), [ + 'https://example.com/photo.png', + 'https://example.com/smile.png', + 'https://example.com/sticker.gif', + ]); + expect(images.first.width, 200); + expect(images.first.height, 100); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'attachments without markers render while invalid ones preserve text', + (tester) async { + final comment = Comment.fromJson({ + 'content': 'Attachment', + 'images': [ + {'orign': stickerUrl, 'type': 1} + ], + }); + await tester + .pumpWidget(buildApp(Scaffold(body: CommentContent(comment: comment)))); + await tester.pump(); + expect(find.byType(CachedNetworkImage), findsOneWidget); + final invalid = Comment.fromJson({ + 'content': 'Keep [表情]', + 'images': [ + {'orign': '://'}, + {'orign': null} + ], + }); + await tester + .pumpWidget(buildApp(Scaffold(body: CommentContent(comment: invalid)))); + await tester.pump(); + expect(find.byType(CachedNetworkImage), findsNothing); + expect( + tester.widget(find.byType(CustomHtmlWidget)).content, + 'Keep [表情]'); + expect(tester.takeException(), isNull); + }); + + for (final width in [320.0, 1000.0]) { + testWidgets('comment and reply media stay visible and bounded at $width', + (tester) async { + await tester.binding.setSurfaceSize(Size(width, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + Map fixture(int id) => { + 'id': id, + 'content': 'Before [smile] after [smile]' + 'photo', + 'emotes': [ + { + 'name': '[smile]', + 'url': '//example.com/smile.png', + 'sizeType': 1, + }, + {'name': '', 'url': 'https://example.com/unused.png'}, + ], + }; + final comment = Comment.fromJson({ + ...fixture(1), + 'l2Count': 1, + 'l2Comments': [fixture(2)], + }); + await tester.pumpWidget(buildApp(Scaffold( + body: SingleChildScrollView( + child: Builder( + builder: (context) => LoftifyItemBuilder.buildCommentRow( + context, + comment, + writerId: 1, + ), + ), + ), + ))); + await tester.pump(); + final emoji = find.byWidgetPredicate((widget) => + widget is CachedNetworkImage && + widget.imageUrl == 'https://example.com/smile.png'); + final photos = find.byWidgetPredicate((widget) => + widget is CachedNetworkImage && + widget.imageUrl == 'https://example.com/comment.png'); + expect(emoji, findsNWidgets(4)); + expect(photos, findsNWidgets(2)); + for (final element in emoji.evaluate()) { + expect( + tester.getSize(find.byWidget(element.widget)), const Size(38, 38)); + expect( + element.findAncestorWidgetOfExactType()?.padding, + const EdgeInsets.symmetric(horizontal: 4, vertical: 3), + ); + } + for (final element in photos.evaluate()) { + final size = tester.getSize(find.byWidget(element.widget)); + expect(size.width, lessThanOrEqualTo(200)); + expect(size.height, 200); + } + expect(find.textContaining('Error rendering content'), findsNothing); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('emoji replacement preserves HTML attributes and bad media text', + (tester) async { + final comment = Comment.fromJson({ + 'content': '[smile]' + '[bad image] [unknown]', + 'emotes': [ + {'name': '[smile]', 'url': 'https://example.com/smile.png?a=1&b=2'}, + {'name': '[unknown]', 'url': ''}, + ], + }); + await tester.pumpWidget(buildApp(Scaffold( + body: CommentContent(comment: comment), + ))); + await tester.pump(); + final rendered = + tester.widget(find.byType(CustomHtmlWidget)); + expect(rendered.content, contains('href="https://example.com/[smile]"')); + expect(rendered.content, contains('a=1&b=2')); + expect(rendered.content, contains('[unknown]')); + expect(find.text('[bad image]'), findsOneWidget); + expect(find.textContaining('Error rendering content'), findsNothing); + expect(tester.takeException(), isNull); + }); + testWidgets('four detail actions stay separated above the safe area', (tester) async { await tester.binding.setSurfaceSize(const Size(320, 568)); diff --git a/test/widgets/post_detail/lazy_comment_jump_test.dart b/test/widgets/post_detail/lazy_comment_jump_test.dart deleted file mode 100644 index 0f885380..00000000 --- a/test/widgets/post_detail/lazy_comment_jump_test.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:loftify/Widgets/PostDetail/lazy_comment_jump.dart'; - -void main() { - for (final startInRecommendations in [false, true]) { - testWidgets( - 'comment jump finds lazy heading from ${startInRecommendations ? 'recommendations' : 'article'}', - (tester) async { - final controller = ScrollController(); - final anchorKey = GlobalKey(); - final contentKey = GlobalKey(); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: CustomScrollView( - controller: controller, - slivers: [ - SliverList.list( - key: contentKey, - children: [ - const SizedBox(height: 2200, child: Text('Article')), - SizedBox( - key: anchorKey, - height: 56, - child: const Text('Comments'), - ), - const SizedBox(height: 500, child: Text('Replies')), - const SizedBox(height: 56, child: Text('More')), - ], - ), - SliverList.builder( - itemCount: 60, - itemBuilder: (_, index) => SizedBox( - height: 100, - child: Text('Recommendation $index'), - ), - ), - ], - ), - ), - ), - ); - - controller.jumpTo(startInRecommendations ? 4300 : 100); - await tester.pump(); - expect(anchorKey.currentContext, isNull); - final contentSliver = - contentKey.currentContext!.findRenderObject()! as RenderSliver; - final jump = revealLazyComment( - controller: controller, - anchorKey: anchorKey, - contentExtent: contentSliver.geometry!.scrollExtent, - isActive: () => true, - ); - for (var frame = 0; frame < 50; frame++) { - await tester.pump(const Duration(milliseconds: 50)); - } - await jump; - - expect(anchorKey.currentContext, isNotNull); - final heading = tester.getRect(find.text('Comments')); - expect(heading.top, greaterThanOrEqualTo(0)); - expect(heading.top, lessThan(tester.view.physicalSize.height)); - expect(tester.takeException(), isNull); - controller.dispose(); - }, - ); - } -} diff --git a/test/widgets/post_detail/post_comment_jump_test.dart b/test/widgets/post_detail/post_comment_jump_test.dart new file mode 100644 index 00000000..1cb0e93a --- /dev/null +++ b/test/widgets/post_detail/post_comment_jump_test.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Widgets/PostDetail/post_content_sliver.dart'; + +void main() { + for (final wide in [false, true]) { + for (final startBelow in [false, true]) { + testWidgets( + '${wide ? 'two pane' : 'single pane'} jumps directly from ' + '${startBelow ? 'below comments' : 'long article'} without reversing', + (tester) async { + final controller = ScrollController(); + final recommendations = ScrollController(); + final anchorKey = GlobalKey(); + final viewportKey = GlobalKey(); + var recommendationBuilds = 0; + var articleHeight = 2600.0; + late StateSetter updateLayout; + + Widget recommendationSliver() => SliverList.builder( + itemCount: 100, + itemBuilder: (_, index) { + recommendationBuilds++; + return SizedBox( + height: 100, + child: Text('Recommendation $index'), + ); + }, + ); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Post')), + body: StatefulBuilder(builder: (context, setState) { + updateLayout = setState; + final post = CustomScrollView( + key: viewportKey, + controller: controller, + slivers: [ + PostContentSliver(children: [ + SizedBox( + height: articleHeight, child: const Text('Article')), + SizedBox( + key: anchorKey, + height: 56, + child: const Text('Comments'), + ), + const SizedBox(height: 1700, child: Text('Replies')), + ]), + if (!wide) recommendationSliver(), + ], + ); + return wide + ? Row(children: [ + Expanded(child: post), + Expanded( + child: CustomScrollView( + controller: recommendations, + slivers: [recommendationSliver()], + ), + ), + ]) + : post; + }), + ), + )); + // The exact anchor must exist before scrolling. The large article + // must not cause eager building of all recommendation cards. + expect(anchorKey.currentContext, isNotNull); + expect(recommendationBuilds, lessThan(20)); + if (wide) recommendations.jumpTo(1000); + + // Image loading or pane resizing can change the article's height; + // the jump must use current layout, not a previously cached offset. + updateLayout(() => articleHeight = 3100); + await tester.pump(); + final start = startBelow ? (wide ? 4000.0 : 5500.0) : 100.0; + controller.jumpTo(start); + await tester.pump(); + final actualStart = controller.offset; + final samples = [actualStart]; + controller.addListener(() => samples.add(controller.offset)); + final jump = revealPostComment( + controller: controller, + anchorKey: anchorKey, + ); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + await jump; + + expect(controller.offset, closeTo(articleHeight, 0.01)); + expect(tester.getTopLeft(find.byKey(anchorKey)).dy, + closeTo(tester.getTopLeft(find.byKey(viewportKey)).dy, 0.01)); + expect(samples.length, greaterThan(2)); + for (var i = 1; i < samples.length; i++) { + if (startBelow) { + expect(samples[i], lessThanOrEqualTo(samples[i - 1])); + expect(samples[i], greaterThanOrEqualTo(articleHeight - 0.01)); + } else { + expect(samples[i], greaterThanOrEqualTo(samples[i - 1])); + expect(samples[i], lessThanOrEqualTo(articleHeight + 0.01)); + } + } + if (wide) expect(recommendations.offset, 1000); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + controller.dispose(); + recommendations.dispose(); + }, + ); + } + } + + testWidgets('short or empty comments clamp to bottom without overscroll', + (tester) async { + final controller = ScrollController(); + final anchorKey = GlobalKey(); + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + controller: controller, + slivers: [ + PostContentSliver(children: [ + const SizedBox(height: 1800), + SizedBox( + key: anchorKey, height: 56, child: const Text('No comments')), + ]), + ], + ), + )); + final jump = + revealPostComment(controller: controller, anchorKey: anchorKey); + await tester.pumpAndSettle(); + await jump; + expect(controller.offset, controller.position.maxScrollExtent); + expect(tester.getBottomLeft(find.byKey(anchorKey)).dy, closeTo(600, 0.01)); + await revealPostComment(controller: controller, anchorKey: anchorKey); + expect(tester.hasRunningAnimations, isFalse); + await tester.pumpWidget(const SizedBox.shrink()); + // Disposed routes / unavailable anchors must not start a search loop. + await revealPostComment(controller: controller, anchorKey: anchorKey); + controller.dispose(); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widgets/post_detail/post_swipe_gesture_detector_test.dart b/test/widgets/post_detail/post_swipe_gesture_detector_test.dart index 61fd8bb9..a395ceb1 100644 --- a/test/widgets/post_detail/post_swipe_gesture_detector_test.dart +++ b/test/widgets/post_detail/post_swipe_gesture_detector_test.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:loftify/Widgets/Design/loftify_content_frame.dart'; import 'package:loftify/Widgets/PostDetail/post_swipe_gesture_detector.dart'; Widget _host(Widget child) => MaterialApp( @@ -7,6 +10,73 @@ Widget _host(Widget child) => MaterialApp( ); void main() { + for (final kind in [PointerDeviceKind.mouse, PointerDeviceKind.touch]) { + testWidgets('split divider resizes without switching posts ($kind)', + (tester) async { + final bodyKey = GlobalKey(); + final recommendationsKey = GlobalKey(); + var postUpdates = 0; + tester.view.physicalSize = const Size(1800, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(_host(LoftifyContentFrame( + child: PostSwipeGestureDetector( + activeRegion: bodyKey, + onHorizontalDragUpdate: (_) => postUpdates++, + child: ResizableContainer( + direction: Axis.horizontal, + children: [ + ResizableChild( + size: const ResizableSize.ratio(0.6), + divider: const ResizableDivider(padding: 12), + child: ColoredBox(key: bodyKey, color: Colors.white), + ), + ResizableChild( + child: ColoredBox(key: recommendationsKey, color: Colors.grey), + ), + ], + ), + )))); + await tester.pumpAndSettle(); + final split = tester.getRect(find.byType(ResizableContainer)); + expect(split.width, 1180); + expect(split.center.dx, 900); + final initialBody = tester.getRect(find.byKey(bodyKey)); + final dividerStart = Offset(initialBody.right + 6, initialBody.center.dy); + final gesture = await tester.startGesture(dividerStart, kind: kind); + await gesture.moveBy(const Offset(30, 0)); + await gesture.moveBy(const Offset(70, 0)); + await gesture.up(); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byKey(bodyKey)).width, + greaterThan(initialBody.width)); + expect(postUpdates, 0); + + // The entire drag belongs to the pane in which it began, even when it + // crosses into the article. The outer screen edge must not bypass it. + final right = tester.getRect(find.byKey(recommendationsKey)); + await tester.dragFrom( + Offset(right.right - 8, right.center.dy), const Offset(-350, 0)); + expect(postUpdates, 0); + await tester.drag(find.byKey(bodyKey), const Offset(100, 0)); + expect(postUpdates, greaterThan(0)); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('unmounted desktop pane keeps phone swipes enabled', + (tester) async { + var updates = 0; + await tester.pumpWidget(_host(PostSwipeGestureDetector( + activeRegion: GlobalKey(), + onHorizontalDragUpdate: (_) => updates++, + child: const SizedBox.expand(), + ))); + await tester.dragFrom(const Offset(80, 400), const Offset(150, 0)); + expect(updates, greaterThan(0)); + }); + testWidgets('interactive horizontal child owns drags that start inside it', ( tester, ) async { diff --git a/test/widgets/profile/profile_header_components_test.dart b/test/widgets/profile/profile_header_components_test.dart index 36718047..f413cf01 100644 --- a/test/widgets/profile/profile_header_components_test.dart +++ b/test/widgets/profile/profile_header_components_test.dart @@ -11,6 +11,73 @@ import 'package:loftify/Widgets/Profile/profile_overview_card.dart'; import 'package:loftify/Widgets/loftify_icons.dart'; void main() { + testWidgets('profile tabs and content share the centered heading width', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1800, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(_TestApp( + width: 1800, + child: Builder( + builder: (context) => Column(children: [ + Padding( + padding: EdgeInsets.symmetric( + horizontal: context.design.spacing.xl), + child: const LoftifyProfileHeaderLayout( + summary: SizedBox( + key: ValueKey('heading'), + height: 100, + width: double.infinity), + ), + ), + for (final name in ['tabs', 'content']) + LoftifyProfileContentFrame( + child: SizedBox( + key: ValueKey(name), + height: 56, + width: double.infinity), + ), + ])), + )); + final heading = tester.getRect(find.byKey(const ValueKey('heading'))); + expect(heading.width, loftifyProfileMaxContentWidth); + for (final name in ['tabs', 'content']) { + final rect = tester.getRect(find.byKey(ValueKey(name))); + expect(rect.left, heading.left); + expect(rect.right, heading.right); + } + expect(tester.takeException(), isNull); + }); + + testWidgets('wide profile reflows naturally and keeps action compact', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1300, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + for (final width in [500.0, 1100.0]) { + await tester.pumpWidget(_TestApp( + width: width, + child: const LoftifyWideProfileSummary( + identity: SizedBox(key: ValueKey('identity'), height: 100), + statistics: SizedBox(key: ValueKey('statistics'), height: 64), + action: SizedBox( + key: ValueKey('action'), height: 48, width: double.infinity), + ), + )); + final identity = tester.getRect(find.byKey(const ValueKey('identity'))); + final stats = tester.getRect(find.byKey(const ValueKey('statistics'))); + final action = tester.getRect(find.byKey(const ValueKey('action'))); + expect(stats.top, greaterThan(identity.bottom)); + if (width >= 600) { + expect(action.left, greaterThan(stats.right)); + expect(action.center.dy, closeTo(stats.center.dy, 1)); + } else { + expect(action.top, greaterThan(stats.bottom)); + } + expect(tester.getSize(find.byKey(const ValueKey('action'))).width, + lessThanOrEqualTo(240)); + expect(tester.takeException(), isNull); + } + }); + setUpAll(() async { final directory = Directory( '${Directory.current.path}/build/test_hive/profile_header_components', diff --git a/test/widgets/refresh/easy_refresh_extent_shrink_test.dart b/test/widgets/refresh/easy_refresh_extent_shrink_test.dart index 1cba80ce..74bba0fe 100644 --- a/test/widgets/refresh/easy_refresh_extent_shrink_test.dart +++ b/test/widgets/refresh/easy_refresh_extent_shrink_test.dart @@ -1,8 +1,60 @@ +import 'dart:async'; + import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + testWidgets('wide wheel scrolling loads near the end once until completion', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 600)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + var loads = 0; + final pending = Completer(); + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: EasyRefresh( + footer: const LottieCupertinoFooter( + infiniteOffset: 240, + indicator: SizedBox.square(dimension: 36), + ), + onLoad: () { + loads++; + return pending.future; + }, + child: ListView.builder( + controller: controller, + itemCount: 20, + itemExtent: 100, + itemBuilder: (_, index) => Text('Item $index'), + ), + ), + ))); + await tester.pumpAndSettle(); + Future wheel(double dy) async { + await tester.sendEventToBinding(PointerScrollEvent( + position: const Offset(400, 300), + scrollDelta: Offset(0, dy), + )); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + } + + await wheel(1250); + expect(controller.position.extentAfter, lessThan(240)); + expect(loads, 1); + await wheel(100); + expect(loads, 1); + pending.complete(IndicatorResult.noMore); + await tester.pumpAndSettle(); + await wheel(100); + expect(loads, 1); + expect(tester.takeException(), isNull); + }); + testWidgets( 'refresh physics allows a ballistic move back after content extent shrinks', (tester) async { diff --git a/test/widgets/tag/tag_detail_components_test.dart b/test/widgets/tag/tag_detail_components_test.dart index 90e645d8..795faa14 100644 --- a/test/widgets/tag/tag_detail_components_test.dart +++ b/test/widgets/tag/tag_detail_components_test.dart @@ -54,6 +54,38 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('wide tag hero keeps subscription and tools on the title row', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1200, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(_TestApp( + width: 1100, + child: LoftifyTagHero( + tag: 'Photography', + metrics: const [LoftifyTagMetric('100 views')], + subscribed: false, + subscribeLabel: 'Subscribe', + subscribedLabel: 'Subscribed', + onSubscriptionPressed: _noop, + trailing: [ + for (var i = 0; i < 3; i++) + IconButton( + key: ValueKey('tool-$i'), + onPressed: _noop, + icon: const Icon(Icons.search)), + ], + ), + )); + final titleY = tester.getCenter(find.text('Photography')).dy; + expect(tester.getCenter(find.text('Subscribe')).dy, closeTo(titleY, 1)); + expect(tester.getCenter(find.byKey(const ValueKey('tool-2'))).dy, + closeTo(titleY, 1)); + expect( + tester.getSize(find.byKey(const ValueKey('loftify-tag-hero'))).height, + lessThan(150)); + expect(tester.takeException(), isNull); + }); + testWidgets('unsubscribed and subscribed actions keep restrained states', (tester) async { await tester.pumpWidget( diff --git a/third-party/chewie/lib/src/Screens/update_log_screen.dart b/third-party/chewie/lib/src/Screens/update_log_screen.dart index 91f14268..afd56247 100644 --- a/third-party/chewie/lib/src/Screens/update_log_screen.dart +++ b/third-party/chewie/lib/src/Screens/update_log_screen.dart @@ -39,6 +39,16 @@ class _UpdateLogScreenState extends BaseDynamicState final EasyRefreshController _refreshController = EasyRefreshController(); String currentVersion = ""; String latestVersion = ""; + final Map _releaseFocusNodes = {}; + + @override + void dispose() { + _refreshController.dispose(); + for (final node in _releaseFocusNodes.values) { + node.dispose(); + } + super.dispose(); + } @override void initState() { @@ -48,6 +58,7 @@ class _UpdateLogScreenState extends BaseDynamicState void getAppInfo() { PackageInfo.fromPlatform().then((PackageInfo packageInfo) { + if (!mounted) return; setState(() { currentVersion = packageInfo.version; }); @@ -62,16 +73,19 @@ class _UpdateLogScreenState extends BaseDynamicState showLatestToast: false, noUpdateToastText: chewieLocalizations.failedToGetChangelog, onGetCurrentVersion: (currentVersion) { + if (!mounted) return; setState(() { this.currentVersion = currentVersion; }); }, onGetLatestRelease: (latestVersion, latestReleaseItem) { + if (!mounted) return; setState(() { this.latestVersion = latestVersion; }); }, onGetReleases: (releases) { + if (!mounted) return; setState(() { releaseItems = releases; }); @@ -86,13 +100,6 @@ class _UpdateLogScreenState extends BaseDynamicState ? ResponsiveAppBar( title: chewieLocalizations.changelog, showBack: true, - onTapBack: () { - if (ResponsiveUtil.isLandscapeLayout()) { - DialogNavigatorHelper.popPage(); - } else { - Navigator.pop(context); - } - }, backgroundColor: ResponsiveUtil.isLandscapeLayout() ? ChewieTheme.canvasColor : ChewieTheme.scaffoldBackgroundColor, @@ -133,100 +140,113 @@ class _UpdateLogScreenState extends BaseDynamicState isCurrent ? 0.5 : 0.4, ).toColor(); - return IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - children: [ + return UpdateLogTimeline( + isLast: isLast, + marker: Container( + width: 14, + height: 14, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isCurrent ? ChewieTheme.primaryColor : color, + border: Border.all(color: Colors.grey.shade300, width: 2), + ), + ).animate().fadeIn(duration: 400.ms).scale(delay: 50.ms), + child: Container( + margin: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + "${item.tagName} $releaseDate", + style: ChewieTheme.bodyMedium, + ), + const SizedBox(width: 6), + if (isCurrent) + RoundIconTextButton( + height: 20, + text: chewieLocalizations.currentVersion, + background: ChewieTheme.primaryColor, + textStyle: ChewieTheme.labelMedium.apply( + color: Colors.white, + ), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + radius: 4, + ), + const Spacer(), + ClickableGestureDetector( + // padding: const EdgeInsets.symmetric( + // horizontal: 6, + // vertical: 2, + // ), + child: Icon( + LucideIcons.chevronRight, + size: 16, + color: ChewieTheme.labelMedium.color, + ), + onTap: () { + UriUtil.launchUrlUri(context, item.htmlUrl); + }, + ), + ], + ), + if ((item.body ?? "").isNotEmpty) ...[ + const SizedBox(height: 8), Container( - width: 14, - height: 14, + padding: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( - shape: BoxShape.circle, - color: isCurrent ? ChewieTheme.primaryColor : color, - border: Border.all(color: Colors.grey.shade300, width: 2), + color: ChewieTheme.cardColor, + borderRadius: ChewieDimens.borderRadius8, ), - ).animate().fadeIn(duration: 400.ms).scale(delay: 50.ms), - if (!isLast) - Expanded( - child: Container( - width: 2, - margin: const EdgeInsets.only(top: 2), - color: Colors.grey.shade300, + child: SelectableAreaWrapper( + focusNode: _releaseFocusNodes.putIfAbsent( + item.htmlUrl, FocusNode.new), + child: CustomMarkdownWidget( + item.body ?? "", + baseStyle: ChewieTheme.bodyMedium, ), ), - ], - ), - const SizedBox(width: 16), - Expanded( - child: Container( - margin: const EdgeInsets.only(bottom: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - "${item.tagName} $releaseDate", - style: ChewieTheme.bodyMedium, - ), - const SizedBox(width: 6), - if (isCurrent) - RoundIconTextButton( - height: 20, - text: chewieLocalizations.currentVersion, - background: ChewieTheme.primaryColor, - textStyle: ChewieTheme.labelMedium.apply( - color: ChewieTheme.primaryButtonColor, - ), - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - radius: 4, - ), - const Spacer(), - ClickableGestureDetector( - // padding: const EdgeInsets.symmetric( - // horizontal: 6, - // vertical: 2, - // ), - child: Icon( - LucideIcons.chevronRight, - size: 16, - color: ChewieTheme.labelMedium.color, - ), - onTap: () { - UriUtil.launchUrlUri(context, item.htmlUrl); - }, - ), - ], - ), - if ((item.body ?? "").isNotEmpty) ...[ - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - color: ChewieTheme.cardColor, - borderRadius: ChewieDimens.borderRadius8, - ), - child: SelectableAreaWrapper( - focusNode: FocusNode(), - child: CustomMarkdownWidget( - item.body ?? "", - baseStyle: ChewieTheme.bodyMedium, - ), - ), - ), - ], - ], ), - ), - ), - ], + ], + ], + ), ), ); } } + +/// The content determines the height; the timeline is painted alongside it. +/// Markdown can contain LayoutBuilder and cannot be measured intrinsically. +class UpdateLogTimeline extends StatelessWidget { + const UpdateLogTimeline({ + super.key, + required this.marker, + required this.child, + required this.isLast, + }); + + final Widget marker; + final Widget child; + final bool isLast; + + @override + Widget build(BuildContext context) => Stack( + children: [ + if (!isLast) + Positioned( + left: 6, + top: 16, + bottom: 0, + width: 2, + child: ColoredBox(color: Colors.grey.shade300), + ), + Positioned(left: 0, top: 0, child: marker), + Padding(padding: const EdgeInsets.only(left: 30), child: child), + ], + ); +} diff --git a/third-party/chewie/lib/src/Widgets/Basic/item_builder.dart b/third-party/chewie/lib/src/Widgets/Basic/item_builder.dart index b9e7afad..e2547b8c 100644 --- a/third-party/chewie/lib/src/Widgets/Basic/item_builder.dart +++ b/third-party/chewie/lib/src/Widgets/Basic/item_builder.dart @@ -27,7 +27,7 @@ class ChewieItemBuilder { List children = const [], bool showBack = true, Color? backgroundColor, - double titleLeftMargin = 5, + double? titleLeftMargin = 5, bool showBorder = false, Function()? onTapBack, Widget? overrideBody, @@ -39,7 +39,7 @@ class ChewieItemBuilder { return Scaffold( appBar: showTitleBar ? ResponsiveAppBar( - titleLeftMargin: titleLeftMargin, + titleLeftMargin: titleLeftMargin ?? (ResponsiveUtil.isLandscapeLayout() ? 15 : 5), showBack: showBack, title: title, backgroundColor: backgroundColor, diff --git a/third-party/chewie/lib/src/Widgets/Button/shadow_icon_button.dart b/third-party/chewie/lib/src/Widgets/Button/shadow_icon_button.dart index 829f8155..1231d29f 100644 --- a/third-party/chewie/lib/src/Widgets/Button/shadow_icon_button.dart +++ b/third-party/chewie/lib/src/Widgets/Button/shadow_icon_button.dart @@ -8,6 +8,8 @@ class ShadowIconButton extends StatelessWidget { final Function()? onLongPress; final double radius; final EdgeInsets? padding; + final Color? backgroundColor; + final List? boxShadow; const ShadowIconButton({ super.key, @@ -16,6 +18,8 @@ class ShadowIconButton extends StatelessWidget { this.onLongPress, this.radius = 8, this.padding, + this.backgroundColor, + this.boxShadow, }); @override @@ -26,10 +30,10 @@ class ShadowIconButton extends StatelessWidget { decoration: BoxDecoration( border: Border.all(color: ChewieTheme.dividerColor, width: 0.8), borderRadius: BorderRadius.circular(radius + 1), - boxShadow: ChewieTheme.defaultBoxShadow, + boxShadow: boxShadow ?? ChewieTheme.defaultBoxShadow, ), child: InkAnimation( - color: ChewieTheme.scaffoldBackgroundColor, + color: backgroundColor ?? ChewieTheme.scaffoldBackgroundColor, borderRadius: BorderRadius.circular(radius), onTap: onTap, onLongPress: onLongPress, diff --git a/third-party/chewie/lib/src/Widgets/Component/custom_html_widget.dart b/third-party/chewie/lib/src/Widgets/Component/custom_html_widget.dart index ed862800..ae5a22f2 100644 --- a/third-party/chewie/lib/src/Widgets/Component/custom_html_widget.dart +++ b/third-party/chewie/lib/src/Widgets/Component/custom_html_widget.dart @@ -44,6 +44,8 @@ class CustomHtmlWidget extends StatefulWidget { required this.content, this.style, this.parseImage = true, + this.imageBuilder, + this.inlineLinks = false, this.showLoading = true, this.onDownloadSuccess, this.heightDelta, @@ -62,6 +64,10 @@ class CustomHtmlWidget extends StatefulWidget { final String content; final TextStyle? style; final bool parseImage; + /// Keep links in the text flow rather than embedding a decorated link widget. + final bool inlineLinks; + /// Overrides image rendering without changing other HTML content. + final Widget? Function(BuildContext context, dom.Element element)? imageBuilder; final bool showLoading; final Function()? onDownloadSuccess; final double? heightDelta; @@ -236,6 +242,7 @@ class CustomHtmlWidgetState extends State { customWidgetBuilder: (element) { bool isElementEmpty = element.children.isEmpty && element.text.isEmpty; if (element.localName == 'a') { + if (widget.inlineLinks) return null; return _renderA( element, renderType: renderType, @@ -254,6 +261,8 @@ class CustomHtmlWidgetState extends State { style: style, ); } else if (element.localName == 'img' && widget.parseImage) { + final customImage = widget.imageBuilder?.call(context, element); + if (customImage != null) return customImage; return SelectionContainer.disabled( child: _renderImg( element, @@ -361,6 +370,18 @@ class CustomHtmlWidgetState extends State { return null; }, customStylesBuilder: (e) { + if (widget.inlineLinks && e.localName == 'a') { + final color = ChewieColors.getLinkColor(context) + .toARGB32() + .toRadixString(16) + .padLeft(8, '0') + .substring(2); + return { + 'color': '#$color', + 'text-decoration': 'none', + 'font-weight': '500', + }; + } if (e.attributes.containsKey("data-f-id") && e.attributes["data-f-id"] == "pbf") { return { @@ -396,6 +417,10 @@ class CustomHtmlWidgetState extends State { return null; }, onTapUrl: (url) async { + if (widget.inlineLinks && WebUtil.isHashOnlyLink(url)) { + widget.onHashtagTap?.call(url.substring(1)); + return true; + } UriUtil.processUrl(context, url); return true; }, diff --git a/third-party/chewie/lib/src/Widgets/Dialog/custom_dialog.dart b/third-party/chewie/lib/src/Widgets/Dialog/custom_dialog.dart index d6c42cfb..4725a209 100644 --- a/third-party/chewie/lib/src/Widgets/Dialog/custom_dialog.dart +++ b/third-party/chewie/lib/src/Widgets/Dialog/custom_dialog.dart @@ -116,6 +116,7 @@ class CustomConfirmDialog { BuildContext context, { String? title, required String message, + Widget? messageChild, String? imagePath, TextAlign messageTextAlign = TextAlign.center, required String confirmButtonText, @@ -143,6 +144,7 @@ class CustomConfirmDialog { DialogAnimation( animation: animation, child: CustomConfirmDialogWidget( + messageChild: messageChild, renderHtml: renderHtml, title: title, message: message, @@ -167,6 +169,7 @@ class CustomConfirmDialog { BuildContext context, { String? title, required String message, + Widget? messageChild, String? imagePath, required String confirmButtonText, TextAlign messageTextAlign = TextAlign.center, @@ -198,6 +201,7 @@ class CustomConfirmDialog { }, pageBuilder: (animation, secondaryAnimation, child) => CustomConfirmDialogWidget( + messageChild: messageChild, title: title, message: message, confirmButtonText: confirmButtonText, diff --git a/third-party/chewie/lib/src/Widgets/Dialog/dialog_builder.dart b/third-party/chewie/lib/src/Widgets/Dialog/dialog_builder.dart index 7a56baa2..14c2c6db 100644 --- a/third-party/chewie/lib/src/Widgets/Dialog/dialog_builder.dart +++ b/third-party/chewie/lib/src/Widgets/Dialog/dialog_builder.dart @@ -6,6 +6,7 @@ class DialogBuilder { BuildContext context, { String? title, String? message, + Widget? messageChild, String? imagePath, TextAlign messageTextAlign = TextAlign.center, String? confirmButtonText, @@ -24,9 +25,10 @@ class DialogBuilder { bool responsive = true, }) { if (responsive && ResponsiveUtil.isWideDevice()) { - CustomConfirmDialog.show( + return CustomConfirmDialog.show( context, message: message ?? "", + messageChild: messageChild, messageTextAlign: messageTextAlign, imagePath: imagePath, title: title, @@ -45,9 +47,10 @@ class DialogBuilder { customDialogType: customDialogType ?? CustomDialogType.normal, ); } else { - CustomConfirmDialog.showAnimatedFromBottom( + return CustomConfirmDialog.showAnimatedFromBottom( context, message: message ?? "", + messageChild: messageChild, imagePath: imagePath, title: title, messageTextAlign: messageTextAlign, diff --git a/third-party/chewie/lib/src/Widgets/Dialog/widgets/custom_confirm_dialog_widget.dart b/third-party/chewie/lib/src/Widgets/Dialog/widgets/custom_confirm_dialog_widget.dart index 96b1dcc1..0146b5b8 100644 --- a/third-party/chewie/lib/src/Widgets/Dialog/widgets/custom_confirm_dialog_widget.dart +++ b/third-party/chewie/lib/src/Widgets/Dialog/widgets/custom_confirm_dialog_widget.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; class CustomConfirmDialogWidget extends StatefulWidget { final String? title; final String message; + final Widget? messageChild; final String? imagePath; final String confirmButtonText; final String cancelButtonText; @@ -27,6 +28,7 @@ class CustomConfirmDialogWidget extends StatefulWidget { super.key, this.title, required this.message, + this.messageChild, required this.confirmButtonText, required this.cancelButtonText, required this.onTapConfirm, @@ -95,7 +97,9 @@ class _CustomConfirmDialogWidgetState ), const SizedBox(height: 20), ], - if (widget.message.notNullOrEmpty) + if (widget.messageChild != null) + widget.messageChild! + else if (widget.message.notNullOrEmpty) widget.renderHtml ? CustomHtmlWidget( content: widget.message, diff --git a/third-party/chewie/lib/src/Widgets/General/responsive_app_bar.dart b/third-party/chewie/lib/src/Widgets/General/responsive_app_bar.dart index 1de98595..41028408 100644 --- a/third-party/chewie/lib/src/Widgets/General/responsive_app_bar.dart +++ b/third-party/chewie/lib/src/Widgets/General/responsive_app_bar.dart @@ -51,6 +51,10 @@ class ResponsiveAppBar extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) { final bool isLandscape = ResponsiveUtil.isLandscapeLayout(); + // Desktop window controls are painted over the right side of the panel. + // Keep the page title and actions out of their hit/paint area. + final double windowControlsSpacing = + ResponsiveUtil.isDesktop() ? desktopWindowControlsWidth : rightSpacing; void handleBack() { if (onTapBack != null) { onTapBack!(); @@ -106,11 +110,11 @@ class ResponsiveAppBar extends StatelessWidget implements PreferredSizeWidget { .backButtonTooltip, ), ), - Expanded(child: titleContent), + Flexible(child: titleContent), ...[ ...desktopActions, ...landscapeActions, - const SizedBox(width: 44), + SizedBox(width: windowControlsSpacing), ], ], ), diff --git a/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/easy_refresh.dart b/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/easy_refresh.dart index 1e799ca1..9330150b 100644 --- a/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/easy_refresh.dart +++ b/third-party/chewie/lib/src/Widgets/Module/EasyRefresh/src/easy_refresh.dart @@ -653,7 +653,10 @@ class _EasyRefreshState extends State } final content = _InheritedEasyRefresh( data: _data, - child: child, + child: NotificationListener( + onNotification: _handlePointerScrollUpdate, + child: child, + ), ); // In clamping mode the header owns the reveal distance rather than the // scroll position. Move the content by that same distance so it cannot @@ -674,6 +677,40 @@ class _EasyRefreshState extends State ); } + bool _handlePointerScrollUpdate(ScrollUpdateNotification notification) { + // Wheel/scrollbar movements use forcePixels rather than applying scroll + // physics. Feed their real position into the existing footer state machine + // so loading, failure and no-more guards remain shared with touch scrolling. + final desktop = defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.linux; + final wide = + MediaQuery.maybeOf(context)?.orientation == Orientation.landscape; + if ((!desktop && !wide) || + notification.depth != 0 || + notification.dragDetails != null || + (notification.scrollDelta ?? 0) <= 0 || + widget.onLoad == null || + _footerNotifier.infiniteOffset == null) { + return false; + } + final position = notification.context == null + ? null + : Scrollable.maybeOf(notification.context!)?.position; + if (position == null) return false; + scheduleMicrotask(() { + // Wheel updates settle synchronously. Let active drag/ballistic scrolling + // continue to use its existing physics path. + if (!mounted || + !position.hasContentDimensions || + position.isScrollingNotifier.value) { + return; + } + _footerNotifier._updateOffset(position, position.pixels, false); + }); + return false; + } + @override Widget build(BuildContext context) { final contentWidget = _buildContent(); diff --git a/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/core/utils/helpers.dart b/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/core/utils/helpers.dart index e87b4df4..6d2aa2d8 100644 --- a/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/core/utils/helpers.dart +++ b/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/core/utils/helpers.dart @@ -20,7 +20,16 @@ Future showContextMenu( bool maintainState = false, FocusNode? focusNode, }) async { - final menuState = ContextMenuState(menu: contextMenu); + // Pointer positions are global, while this route may be hosted by a nested + // navigator (the desktop panel starts to the right of the navigation rail). + final overlayBox = Navigator.of(context).overlay?.context.findRenderObject(); + final menuPosition = contextMenu.position; + final localPosition = menuPosition != null && overlayBox is RenderBox + ? overlayBox.globalToLocal(menuPosition) + : menuPosition; + final menuState = ContextMenuState( + menu: contextMenu.copyWith(position: localPosition), + ); return await Navigator.push( context, PageRouteBuilder( diff --git a/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/core/utils/utils.dart b/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/core/utils/utils.dart index 31983943..198d9b55 100644 --- a/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/core/utils/utils.dart +++ b/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/core/utils/utils.dart @@ -3,7 +3,6 @@ import 'dart:math'; import 'package:flutter/widgets.dart'; import '../models/context_menu.dart'; -import '../utils/extensions.dart'; /// Calculates the position of the context menu based on the position of the /// menu and the position of the parent menu. To prevent the menu from @@ -11,12 +10,15 @@ import '../utils/extensions.dart'; ({Offset pos, AlignmentGeometry alignment}) calculateContextMenuBoundaries( BuildContext context, FlutterContextMenu menu, + Size menuSize, Rect? parentRect, AlignmentGeometry spawnAlignment, bool isSubmenu, ) { final mediaQuery = MediaQuery.of(context); - final screenSize = mediaQuery.size; + final overlayBox = Navigator.of(context).overlay?.context.findRenderObject(); + final screenSize = + overlayBox is RenderBox ? overlayBox.size : mediaQuery.size; final obscuredBottom = max( mediaQuery.padding.bottom, mediaQuery.viewInsets.bottom, @@ -27,7 +29,7 @@ import '../utils/extensions.dart'; screenSize.width - mediaQuery.padding.right - 8, screenSize.height - obscuredBottom - 8, ); - final menuRect = context.getWidgetBounds()!; + final menuRect = (menu.position ?? Offset.zero) & menuSize; AlignmentGeometry nextSpawnAlignment = spawnAlignment; // final parentRect = menu.parentItemRect; diff --git a/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/widgets/context_menu_state.dart b/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/widgets/context_menu_state.dart index e2a3fe93..04a4fc02 100644 --- a/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/widgets/context_menu_state.dart +++ b/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/widgets/context_menu_state.dart @@ -13,6 +13,7 @@ import 'context_menu_widget.dart'; /// This class is used to manage the state of the context menu. It provides methods to /// show and hide the context menu, and to update the position of the context menu. class ContextMenuState extends ChangeNotifier { + final GlobalKey menuKey = GlobalKey(); final focusScopeNode = FocusScopeNode(); final overlayController = OverlayPortalController(debugLabel: 'ContextMenu'); @@ -135,8 +136,14 @@ class ContextMenuState extends ChangeNotifier { closeSubmenu(); final items = parent.items; - final submenuParentRect = context.getWidgetBounds(); - if (submenuParentRect == null) return; + final globalParentRect = context.getWidgetBounds(); + if (globalParentRect == null) return; + final overlayBox = + Navigator.of(context).overlay?.context.findRenderObject(); + final submenuParentRect = overlayBox is RenderBox + ? overlayBox.globalToLocal(globalParentRect.topLeft) & + globalParentRect.size + : globalParentRect; final submenuPosition = _calculateSubmenuPosition(submenuParentRect, spawnAlignment); @@ -167,9 +174,12 @@ class ContextMenuState extends ChangeNotifier { focusScopeNode.requestFocus(); WidgetsBinding.instance.addPostFrameCallback((_) { + final menuBox = menuKey.currentContext?.findRenderObject(); + if (menuBox is! RenderBox) return; final boundaries = calculateContextMenuBoundaries( context, menu, + menuBox.size, parentItemRect, _spawnAlignment, _isSubmenu, diff --git a/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/widgets/context_menu_widget.dart b/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/widgets/context_menu_widget.dart index 7b21e66c..67c06bd4 100644 --- a/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/widgets/context_menu_widget.dart +++ b/third-party/chewie/lib/src/Widgets/Module/FlutterContextMenu/widgets/context_menu_widget.dart @@ -74,9 +74,13 @@ class ContextMenuWidget extends StatelessWidget { ); final mediaQuery = MediaQuery.of(context); + final overlayBox = + Navigator.of(context).overlay?.context.findRenderObject(); + final availableWidth = + overlayBox is RenderBox ? overlayBox.size.width : mediaQuery.size.width; final safeWidth = max( 0.0, - mediaQuery.size.width - mediaQuery.padding.horizontal - 16, + availableWidth - mediaQuery.padding.horizontal - 16, ); return TweenAnimationBuilder( @@ -90,6 +94,7 @@ class ContextMenuWidget extends StatelessWidget { alignment: state.spawnAlignment, scale: value, child: Container( + key: state.menuKey, padding: state.padding, constraints: BoxConstraints( maxWidth: min(state.maxWidth, safeWidth), diff --git a/third-party/chewie/lib/src/Widgets/Window/window_caption.dart b/third-party/chewie/lib/src/Widgets/Window/window_caption.dart index 05e33e67..185cecc6 100644 --- a/third-party/chewie/lib/src/Widgets/Window/window_caption.dart +++ b/third-party/chewie/lib/src/Widgets/Window/window_caption.dart @@ -33,6 +33,7 @@ class WindowMoveHandle extends StatelessWidget { return _MoveWindow( onDoubleTap: onDoubleTap, child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [Expanded(child: child!)]), ); diff --git a/third-party/chewie/lib/src/Widgets/Window/window_title.dart b/third-party/chewie/lib/src/Widgets/Window/window_title.dart index 370b8f79..fe666559 100644 --- a/third-party/chewie/lib/src/Widgets/Window/window_title.dart +++ b/third-party/chewie/lib/src/Widgets/Window/window_title.dart @@ -17,6 +17,9 @@ import 'package:awesome_chewie/awesome_chewie.dart'; import 'package:flutter/material.dart'; import 'package:window_manager/window_manager.dart'; +/// Width reserved for the desktop pin, minimize, maximize and close controls. +const double desktopWindowControlsWidth = 169; + class WindowTitleWrapper extends StatelessWidget { final Color? backgroundColor; final List leftWidgets; @@ -36,7 +39,7 @@ class WindowTitleWrapper extends StatelessWidget { required this.isMaximized, required this.onStayOnTopTap, this.forceClose = false, - this.height = 56, + this.height = 48, }); @override @@ -50,6 +53,7 @@ class WindowTitleWrapper extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ ...leftWidgets, Row( @@ -102,9 +106,9 @@ class WindowTitleWrapper extends StatelessWidget { } }, ), + const SizedBox(width: 8), ], ), - const SizedBox(width: 8), ], ), ), diff --git a/tools/windows_tools/Loftify.iss b/tools/windows_tools/Loftify.iss index 73004f19..7ef506bf 100644 --- a/tools/windows_tools/Loftify.iss +++ b/tools/windows_tools/Loftify.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "Loftify" -#define MyAppVersion "3.0.0" +#define MyAppVersion "3.1.0" #define MyAppPublisher "Cloudchewie" #define MyAppURL "https://apps.cloudchewie.com/loftify" #define MyAppExeName "Loftify.exe" From 3f5aae291b97ebce32d2e85d69c6c64e1ec02950 Mon Sep 17 00:00:00 2001 From: Robert Date: Wed, 30 Sep 2026 21:53:22 +0800 Subject: [PATCH 2/2] fix: persist clipboard decisions and scope mobile navigation preferences --- .../com/cloudchewie/loftify/MainActivity.kt | 20 ++ ios/Runner.xcodeproj/project.pbxproj | 3 + ios/Runner/AppDelegate.swift | 15 ++ ios/Runner/PrivacyInfo.xcprivacy | 15 ++ lib/Screens/Navigation/home_screen.dart | 15 +- .../Setting/apperance_setting_screen.dart | 33 ++-- lib/Screens/main_screen.dart | 2 +- lib/Screens/panel_screen.dart | 33 +++- lib/Utils/app_provider.dart | 31 +++- lib/Utils/clipboard_link_controller.dart | 66 +++++-- lib/Utils/clipboard_snapshot.dart | 111 +++++++++++ lib/Widgets/Dialog/clipboard_link_dialog.dart | 11 +- macos/Runner.xcodeproj/project.pbxproj | 3 + macos/Runner/MainFlutterWindow.swift | 13 ++ macos/Runner/PrivacyInfo.xcprivacy | 15 ++ test/clipboard_link_controller_test.dart | 175 ++++++++++++------ test/clipboard_snapshot_test.dart | 99 ++++++++++ test/l10n/localization_resources_test.dart | 2 +- .../home_navigation_refresh_test.dart | 17 +- test/screens/panel_screen_back_test.dart | 60 +++++- .../appearance_mobile_options_test.dart | 69 +++++++ .../dialog/clipboard_link_dialog_test.dart | 76 ++++++++ .../general/appearance_refresh_test.dart | 42 +++++ .../primary_navigation_app_bar_test.dart | 2 +- windows/runner/flutter_window.cpp | 25 +++ windows/runner/flutter_window.h | 3 + 26 files changed, 836 insertions(+), 120 deletions(-) create mode 100644 ios/Runner/PrivacyInfo.xcprivacy create mode 100644 lib/Utils/clipboard_snapshot.dart create mode 100644 macos/Runner/PrivacyInfo.xcprivacy create mode 100644 test/clipboard_snapshot_test.dart create mode 100644 test/screens/setting/appearance_mobile_options_test.dart create mode 100644 test/widgets/dialog/clipboard_link_dialog_test.dart diff --git a/android/app/src/main/java/com/cloudchewie/loftify/MainActivity.kt b/android/app/src/main/java/com/cloudchewie/loftify/MainActivity.kt index cd4172dd..e6be0cb8 100644 --- a/android/app/src/main/java/com/cloudchewie/loftify/MainActivity.kt +++ b/android/app/src/main/java/com/cloudchewie/loftify/MainActivity.kt @@ -1,6 +1,8 @@ package com.cloudchewie.loftify; import android.os.Build +import android.content.ClipboardManager +import android.content.Context import android.view.Surface import android.view.SurfaceHolder import android.view.SurfaceView @@ -21,6 +23,24 @@ class MainActivity : FlutterFragmentActivity() { override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { registerWith(flutterEngine); + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "loftify/clipboard" + ).setMethodCallHandler { call, result -> + if (call.method != "getMetadata") { + result.notImplemented() + return@setMethodCallHandler + } + try { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + clipboard.primaryClipDescription?.timestamp ?: 0L + } else 0L + result.success(mapOf("revision" to timestamp.takeIf { it > 0 }?.toString())) + } catch (_: SecurityException) { + result.error("clipboard_unavailable", "Clipboard access unavailable", null) + } + } MethodChannel( flutterEngine.dartExecutor.binaryMessenger, backDesktopChannel diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index e82ac347..679f7a28 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + A71000000000000000000001 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A71000000000000000000002 /* PrivacyInfo.xcprivacy */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; @@ -40,6 +41,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + A71000000000000000000002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Runner/PrivacyInfo.xcprivacy; sourceTree = SOURCE_ROOT; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; @@ -213,6 +215,7 @@ buildActionMask = 2147483647; files = ( 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + A71000000000000000000001 /* PrivacyInfo.xcprivacy in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 9074fee9..b7e3e93f 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -3,11 +3,26 @@ import UIKit @UIApplicationMain @objc class AppDelegate: FlutterAppDelegate { + private var clipboardChannel: FlutterMethodChannel? override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) + if let controller = window?.rootViewController as? FlutterViewController { + clipboardChannel = FlutterMethodChannel( + name: "loftify/clipboard", binaryMessenger: controller.binaryMessenger) + clipboardChannel?.setMethodCallHandler { call, result in + guard call.method == "getMetadata" else { + result(FlutterMethodNotImplemented) + return + } + result([ + "revision": String(UIPasteboard.general.changeCount), + "uptimeMs": Int64(ProcessInfo.processInfo.systemUptime * 1000) + ]) + } + } return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } diff --git a/ios/Runner/PrivacyInfo.xcprivacy b/ios/Runner/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..408ec825 --- /dev/null +++ b/ios/Runner/PrivacyInfo.xcprivacy @@ -0,0 +1,15 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + 35F9.1 + + + + diff --git a/lib/Screens/Navigation/home_screen.dart b/lib/Screens/Navigation/home_screen.dart index 1748f54f..41135253 100644 --- a/lib/Screens/Navigation/home_screen.dart +++ b/lib/Screens/Navigation/home_screen.dart @@ -158,12 +158,13 @@ class HomeScreenState extends BaseDynamicState @override Widget build(BuildContext context) { super.build(context); + MediaQuery.sizeOf(context); final design = context.design; final hideAppBar = context.select( - (provider) => provider.hideHomeAppBarOnScroll, + (provider) => provider.shouldHideHomeAppBarOnScroll, ); final showSearchAction = context.select( - (provider) => provider.hideSearchNavigation, + (provider) => provider.shouldHideSearchNavigation, ); return Scaffold( backgroundColor: design.colors.page, @@ -358,7 +359,7 @@ class HomeScreenState extends BaseDynamicState double get _homeDistanceFromTop { final feed = _feedScrollController; final feedDistance = feed.hasClients && feed.offset > 0 ? feed.offset : 0.0; - final appBarDistance = appProvider.hideHomeAppBarOnScroll && + final appBarDistance = appProvider.shouldHideHomeAppBarOnScroll && _nestedScrollController.hasClients && !identical(feed, _nestedScrollController) ? (_nestedScrollController.offset > 0 @@ -378,7 +379,7 @@ class HomeScreenState extends BaseDynamicState lastRefreshTime = nowTime; await _refreshController.callRefresh( scrollController: feed, - jumpToEdge: !appProvider.hideHomeAppBarOnScroll, + jumpToEdge: !appProvider.shouldHideHomeAppBarOnScroll, ); } @@ -413,7 +414,7 @@ class HomeScreenState extends BaseDynamicState } ScrollController get _feedScrollController => - appProvider.hideHomeAppBarOnScroll + appProvider.shouldHideHomeAppBarOnScroll ? _nestedInnerScrollController ?? _nestedScrollController : _scrollController; @@ -423,7 +424,7 @@ class HomeScreenState extends BaseDynamicState await feed.animateTo(0, duration: const Duration(milliseconds: 500), curve: Curves.easeInOut); } - if (appProvider.hideHomeAppBarOnScroll && + if (appProvider.shouldHideHomeAppBarOnScroll && _nestedScrollController.hasClients && _nestedScrollController.offset > 0) { await _nestedScrollController.animateTo(0, @@ -439,7 +440,7 @@ class HomeScreenState extends BaseDynamicState @override List getScrollControllers() { - if (!appProvider.hideHomeAppBarOnScroll) return [_scrollController]; + if (!appProvider.shouldHideHomeAppBarOnScroll) return [_scrollController]; final inner = _nestedInnerScrollController; return [ _nestedScrollController, diff --git a/lib/Screens/Setting/apperance_setting_screen.dart b/lib/Screens/Setting/apperance_setting_screen.dart index 0b7abfcf..e3db281f 100644 --- a/lib/Screens/Setting/apperance_setting_screen.dart +++ b/lib/Screens/Setting/apperance_setting_screen.dart @@ -65,6 +65,7 @@ class _AppearanceSettingScreenState @override Widget build(BuildContext context) { + MediaQuery.sizeOf(context); return ChewieItemBuilder.buildSettingScreen( context: context, title: appLocalizations.appearanceSetting, @@ -175,23 +176,25 @@ class _AppearanceSettingScreenState CaptionItem( title: appLocalizations.home, children: [ - Selector( - selector: (context, provider) => provider.hideHomeAppBarOnScroll, - builder: (context, value, child) => CheckboxItem( - value: value, - title: appLocalizations.hideHomeAppBarOnScroll, - onTap: () => appProvider.hideHomeAppBarOnScroll = !value, + if (!ResponsiveUtil.isLandscapeLayout()) ...[ + Selector( + selector: (context, provider) => provider.hideHomeAppBarOnScroll, + builder: (context, value, child) => CheckboxItem( + value: value, + title: appLocalizations.hideHomeAppBarOnScroll, + onTap: () => appProvider.hideHomeAppBarOnScroll = !value, + ), ), - ), - Selector( - selector: (context, provider) => provider.hideSearchNavigation, - builder: (context, value, child) => CheckboxItem( - value: value, - title: appLocalizations.hideSearchNavigation, - description: appLocalizations.hideSearchNavigationDescription, - onTap: () => appProvider.hideSearchNavigation = !value, + Selector( + selector: (context, provider) => provider.hideSearchNavigation, + builder: (context, value, child) => CheckboxItem( + value: value, + title: appLocalizations.hideSearchNavigation, + description: appLocalizations.hideSearchNavigationDescription, + onTap: () => appProvider.hideSearchNavigation = !value, + ), ), - ), + ], CheckboxItem( value: _showRecommendArticle, title: appLocalizations.showArticleInRecommendFlow, diff --git a/lib/Screens/main_screen.dart b/lib/Screens/main_screen.dart index 7f3cf9ae..35fa6a17 100644 --- a/lib/Screens/main_screen.dart +++ b/lib/Screens/main_screen.dart @@ -474,7 +474,7 @@ class MainScreenState extends BaseWindowState ({SideBarChoice sidebarChoice, bool hideSearch})>( selector: (context, appProvider) => ( sidebarChoice: appProvider.sidebarChoice, - hideSearch: appProvider.hideSearchNavigation, + hideSearch: appProvider.shouldHideSearchNavigation, ), builder: (context, preferences, child) => Selector( diff --git a/lib/Screens/panel_screen.dart b/lib/Screens/panel_screen.dart index 0cba51a7..59f12e01 100644 --- a/lib/Screens/panel_screen.dart +++ b/lib/Screens/panel_screen.dart @@ -90,13 +90,14 @@ class PanelScreenState extends BasePanelScreenState List _keyList = []; bool unlogin = false; int _currentIndex = 0; + bool _searchNavigationHidden = false; List get _visibleChoices => - visiblePanelChoices(hideSearch: appProvider.hideSearchNavigation); + visiblePanelChoices(hideSearch: appProvider.shouldHideSearchNavigation); int _visibleIndexFor(int logicalIndex) { final index = visiblePanelPageIndex( SideBarChoice.fromInt(logicalIndex), - hideSearch: appProvider.hideSearchNavigation, + hideSearch: appProvider.shouldHideSearchNavigation, ); return index < 0 ? 0 : index; } @@ -120,6 +121,7 @@ class PanelScreenState extends BasePanelScreenState } void _configurePages() { + _searchNavigationHidden = appProvider.shouldHideSearchNavigation; _keyList = [ homeScreenKey, searchScreenKey, @@ -127,10 +129,7 @@ class PanelScreenState extends BasePanelScreenState GlobalKey(), ]; _pageList = _buildVisiblePages(); - _currentIndex = appProvider.hideSearchNavigation && - appProvider.sidebarChoice == SideBarChoice.Search - ? SideBarChoice.Home.index - : appProvider.sidebarChoice.index; + _currentIndex = appProvider.sidebarChoice.index; _replacePageController(_currentIndex); } @@ -260,7 +259,8 @@ class PanelScreenState extends BasePanelScreenState void updateSearchNavigationVisibility() { if (_keyList.length != SideBarChoice.values.length) return; - if (appProvider.hideSearchNavigation && + _searchNavigationHidden = appProvider.shouldHideSearchNavigation; + if (appProvider.shouldHideSearchNavigation && _currentIndex == SideBarChoice.Search.index) { _currentIndex = SideBarChoice.Home.index; } @@ -272,7 +272,7 @@ class PanelScreenState extends BasePanelScreenState @override void jumpToPage(int index) { if (index < 0 || index >= SideBarChoice.values.length) return; - if (appProvider.hideSearchNavigation && + if (appProvider.shouldHideSearchNavigation && index == SideBarChoice.Search.index) { index = SideBarChoice.Home.index; } @@ -305,6 +305,18 @@ class PanelScreenState extends BasePanelScreenState _scrollToHideController.show(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Subscribe locally as well: the layout helper reads the root MediaQuery. + // A tablet rotation can change the visible pages without a preference edit. + MediaQuery.sizeOf(context); + if (_searchNavigationHidden != appProvider.shouldHideSearchNavigation) { + _currentIndex = appProvider.sidebarChoice.index; + updateSearchNavigationVisibility(); + } + } + @override Widget build(BuildContext context) { super.build(context); @@ -333,6 +345,9 @@ class PanelScreenState extends BasePanelScreenState ], ) : PageView( + // The same pixel offset refers to a different logical tab + // when search is inserted/removed. Use the new initial page. + key: ValueKey(_searchNavigationHidden), physics: const NeverScrollableScrollPhysics(), controller: _pageController, children: _pageList, @@ -388,7 +403,7 @@ class PanelScreenState extends BasePanelScreenState selector: (context, appProvider) => ( reduceTransparency: appProvider.reduceTransparency, displayStyle: appProvider.navigationBarDisplayStyle, - hideSearchNavigation: appProvider.hideSearchNavigation, + hideSearchNavigation: appProvider.shouldHideSearchNavigation, ), builder: (context, preferences, child) { final visibleChoices = visiblePanelChoices( diff --git a/lib/Utils/app_provider.dart b/lib/Utils/app_provider.dart index 5c14f67e..b9786f32 100644 --- a/lib/Utils/app_provider.dart +++ b/lib/Utils/app_provider.dart @@ -43,13 +43,14 @@ RouteObserver routeObserver = RouteObserver(); AppProvider appProvider = AppProvider(); class AppProvider with ChangeNotifier { - AppProvider() { + AppProvider({bool Function()? isLandscapeLayout}) + : _isLandscapeLayout = + isLandscapeLayout ?? ResponsiveUtil.isLandscapeLayout { Intl.defaultLocale = (_locale ?? resolveSystemAppLocale()).toString(); - if (_hideSearchNavigation && _sidebarChoice == SideBarChoice.Search) { - _sidebarChoice = SideBarChoice.Home; - } } + final bool Function() _isLandscapeLayout; + bool _pinSettled = HiveUtil.hasGuesturePasswd(); bool get pinSettled => _pinSettled; @@ -99,10 +100,13 @@ class AppProvider with ChangeNotifier { SideBarChoice _sidebarChoice = SideBarChoice.fromString( ChewieHiveUtil.getString(HiveUtil.sidebarChoiceKey) ?? ""); - SideBarChoice get sidebarChoice => _sidebarChoice; + SideBarChoice get sidebarChoice => + shouldHideSearchNavigation && _sidebarChoice == SideBarChoice.Search + ? SideBarChoice.Home + : _sidebarChoice; set sidebarChoice(SideBarChoice value) { - if (_hideSearchNavigation && value == SideBarChoice.Search) { + if (shouldHideSearchNavigation && value == SideBarChoice.Search) { value = SideBarChoice.Home; } _sidebarChoice = value; @@ -341,6 +345,11 @@ class AppProvider with ChangeNotifier { bool get hideHomeAppBarOnScroll => _hideHomeAppBarOnScroll; + // Keep the stored preference independent of the current layout. Wide layouts + // must not inherit mobile toolbar/navigation behavior when the device rotates. + bool get shouldHideHomeAppBarOnScroll => + !_isLandscapeLayout() && _hideHomeAppBarOnScroll; + set hideHomeAppBarOnScroll(bool value) { if (value == _hideHomeAppBarOnScroll) return; _hideHomeAppBarOnScroll = value; @@ -355,15 +364,21 @@ class AppProvider with ChangeNotifier { bool get hideSearchNavigation => _hideSearchNavigation; + bool get shouldHideSearchNavigation => + !_isLandscapeLayout() && _hideSearchNavigation; + set hideSearchNavigation(bool value) { if (value == _hideSearchNavigation) return; + final wasHidden = shouldHideSearchNavigation; _hideSearchNavigation = value; ChewieHiveUtil.put(HiveUtil.hideSearchNavigationKey, value); - if (value && _sidebarChoice == SideBarChoice.Search) { + if (shouldHideSearchNavigation && _sidebarChoice == SideBarChoice.Search) { _sidebarChoice = SideBarChoice.Home; ChewieHiveUtil.put(HiveUtil.sidebarChoiceKey, _sidebarChoice.key); } - panelScreenState?.updateSearchNavigationVisibility(); + if (wasHidden != shouldHideSearchNavigation) { + panelScreenState?.updateSearchNavigationVisibility(); + } notifyListeners(); } diff --git a/lib/Utils/clipboard_link_controller.dart b/lib/Utils/clipboard_link_controller.dart index 0533f66a..93779860 100644 --- a/lib/Utils/clipboard_link_controller.dart +++ b/lib/Utils/clipboard_link_controller.dart @@ -1,39 +1,75 @@ import 'package:flutter/services.dart'; +import 'package:hive/hive.dart'; +import 'clipboard_snapshot.dart'; +import 'hive_util.dart'; import 'uri_util.dart'; +enum ClipboardLinkDecision { open, dismiss } + +class ClipboardLinkStore { + static const key = 'handledClipboardLinkV1'; + + Map? read() { + final value = Hive.box(HiveUtil.settingsBox).get(key); + return value is Map ? value : null; + } + + Future write(Map record) async { + final box = Hive.box(HiveUtil.settingsBox); + await box.put(key, record); + await box.flush(); + } +} + /// Reads only on explicit lifecycle events; clipboard contents are never logged. class ClipboardLinkController { ClipboardLinkController({ required this.canPrompt, required this.confirm, required this.open, - Future Function()? readText, - }) : readText = readText ?? _readClipboard; + Future Function()? readSnapshot, + ClipboardLinkStore? store, + }) : readSnapshot = readSnapshot ?? ClipboardSnapshotReader().read, + _store = store ?? ClipboardLinkStore(); final bool Function() canPrompt; - final Future Function(String url) confirm; + final Future Function(String url) confirm; final Future Function(String url) open; - final Future Function() readText; - final Set _seen = {}; + final Future Function() readSnapshot; + final ClipboardLinkStore _store; + Map? _lastHandled; bool _checking = false; bool _disposed = false; - static Future _readClipboard() async => - (await Clipboard.getData(Clipboard.kTextPlain))?.text; - Future check() async { if (_disposed || _checking || !canPrompt()) return; _checking = true; try { - final text = await readText(); - if (_disposed || !canPrompt() || text == null) return; + final snapshot = await readSnapshot(); + final text = snapshot?.text; + if (_disposed || !canPrompt() || snapshot == null || text == null) return; final url = LoftifyUriUtil.extractSupportedClipboardUrl(text); - if (url == null || _seen.contains(url)) return; - _seen.add(url); - if (_seen.length > 64) _seen.remove(_seen.first); - final accepted = await confirm(url); - if (accepted && !_disposed && canPrompt()) await open(url); + if (url == null) return; + try { + _lastHandled ??= _store.read(); + } catch (_) { + // A damaged/unavailable settings store must not prevent opening links. + } + if (snapshot.wasHandled(url, _lastHandled)) return; + final decision = await confirm(url); + // Barrier/back dismissal or app termination is not an explicit decision. + if (decision == null) return; + final record = snapshot.handledRecord(url); + _lastHandled = record; + try { + await _store.write(record); + } catch (_) { + // Retain in-memory deduplication if disk persistence fails. + } + if (decision == ClipboardLinkDecision.open && !_disposed && canPrompt()) { + await open(url); + } } on PlatformException { // Clipboard permission/access can be denied; it must not interrupt the app. } on MissingPluginException { diff --git a/lib/Utils/clipboard_snapshot.dart b/lib/Utils/clipboard_snapshot.dart new file mode 100644 index 00000000..7409687a --- /dev/null +++ b/lib/Utils/clipboard_snapshot.dart @@ -0,0 +1,111 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +class ClipboardSnapshot { + const ClipboardSnapshot({ + required this.text, + required this.platform, + required this.observedAtMs, + this.revision, + this.uptimeMs, + }); + + final String? text; + final String platform; + final String? revision; + final int? uptimeMs; + final int observedAtMs; + + Map handledRecord(String url) => { + 'schema': 1, + 'fingerprint': sha256.convert(utf8.encode(url)).toString(), + 'platform': platform, + 'revision': revision, + 'uptimeMs': uptimeMs, + 'observedAtMs': observedAtMs, + }; + + bool wasHandled(String url, Map? record) { + if (record == null || + record['schema'] != 1 || + record['platform'] != platform || + record['fingerprint'] != sha256.convert(utf8.encode(url)).toString()) { + return false; + } + // Older Android / unsupported platforms cannot distinguish recopying the + // same URL. Use content-only deduplication only when both lack metadata. + if (revision == null || record['revision'] == null) { + return revision == null && record['revision'] == null; + } + if (record['revision'] != revision) return false; + // Android's revision is a wall-clock copy timestamp, not a resettable count. + if (platform == 'android') return true; + + final previousUptime = record['uptimeMs']; + final previousTime = record['observedAtMs']; + if (uptimeMs == null || previousUptime is! int || previousTime is! int) { + return false; + } + final elapsed = observedAtMs - previousTime; + final runningElapsed = uptimeMs! - previousUptime; + // Counter values may be reused after a reboot. Compare elapsed times + // between observations as well. Clock changes / uncertain sessions prompt + // again instead of silently suppressing a newly copied link. + return elapsed >= 0 && + runningElapsed >= 0 && + (elapsed - runningElapsed).abs() <= 5000; + } +} + +class ClipboardSnapshotReader { + ClipboardSnapshotReader({ + Future?> Function()? readMetadata, + Future Function()? readText, + int Function()? now, + String? platform, + }) : _readMetadata = readMetadata ?? _nativeMetadata, + _readText = readText ?? _clipboardText, + _now = now ?? (() => DateTime.now().millisecondsSinceEpoch), + _platform = platform ?? (kIsWeb ? 'web' : defaultTargetPlatform.name); + + static const channel = MethodChannel('loftify/clipboard'); + final Future?> Function() _readMetadata; + final Future Function() _readText; + final int Function() _now; + final String _platform; + + static Future?> _nativeMetadata() async { + try { + return await channel.invokeMapMethod('getMetadata'); + } on MissingPluginException { + return null; + } + } + + static Future _clipboardText() async => + (await Clipboard.getData(Clipboard.kTextPlain))?.text; + + Future read() async { + // Text is read through Flutter's platform implementation. Read the version + // on both sides so a concurrent copy cannot pair old text with a new count. + for (var attempt = 0; attempt < 2; attempt++) { + final before = await _readMetadata(); + final text = await _readText(); + final after = await _readMetadata(); + if (before?['revision'] != after?['revision']) continue; + final revision = after?['revision']; + final uptime = after?['uptimeMs']; + return ClipboardSnapshot( + text: text, + platform: _platform, + revision: revision is String && revision.isNotEmpty ? revision : null, + uptimeMs: uptime is int && uptime >= 0 ? uptime : null, + observedAtMs: _now(), + ); + } + return null; + } +} diff --git a/lib/Widgets/Dialog/clipboard_link_dialog.dart b/lib/Widgets/Dialog/clipboard_link_dialog.dart index eb335cd2..ab207f30 100644 --- a/lib/Widgets/Dialog/clipboard_link_dialog.dart +++ b/lib/Widgets/Dialog/clipboard_link_dialog.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; import '../../Theme/loftify_design_theme.dart'; +import '../../Utils/clipboard_link_controller.dart'; import '../../generated/app_localizations.dart'; /// Content only: the shared confirmation dialog owns its surface and route. @@ -11,17 +12,19 @@ class ClipboardLinkDialog extends StatelessWidget { final String url; - static Future show(BuildContext context, String url) async { + static Future show( + BuildContext context, String url) async { final strings = AppLocalizations.of(context)!; - var accepted = false; + ClipboardLinkDecision? decision; await DialogBuilder.showConfirmDialog( context, messageChild: ClipboardLinkDialog(url: url), confirmButtonText: strings.clipboardLinkOpen, cancelButtonText: strings.clipboardLinkDismiss, - onTapConfirm: () => accepted = true, + onTapConfirm: () => decision = ClipboardLinkDecision.open, + onTapCancel: () => decision = ClipboardLinkDecision.dismiss, ); - return accepted; + return decision; } @override diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 5e678ccb..26a25fe1 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + A71000000000000000000001 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A71000000000000000000002 /* PrivacyInfo.xcprivacy */; }; 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; @@ -60,6 +61,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + A71000000000000000000002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Runner/PrivacyInfo.xcprivacy; sourceTree = SOURCE_ROOT; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; @@ -284,6 +286,7 @@ buildActionMask = 2147483647; files = ( 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + A71000000000000000000001 /* PrivacyInfo.xcprivacy in Resources */, 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 3cc05eb2..70a3eff9 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -2,6 +2,7 @@ import Cocoa import FlutterMacOS class MainFlutterWindow: NSWindow { + private var clipboardChannel: FlutterMethodChannel? override func awakeFromNib() { let flutterViewController = FlutterViewController() let windowFrame = self.frame @@ -9,6 +10,18 @@ class MainFlutterWindow: NSWindow { self.setFrame(windowFrame, display: true) RegisterGeneratedPlugins(registry: flutterViewController) + clipboardChannel = FlutterMethodChannel( + name: "loftify/clipboard", binaryMessenger: flutterViewController.engine.binaryMessenger) + clipboardChannel?.setMethodCallHandler { call, result in + guard call.method == "getMetadata" else { + result(FlutterMethodNotImplemented) + return + } + result([ + "revision": String(NSPasteboard.general.changeCount), + "uptimeMs": Int64(ProcessInfo.processInfo.systemUptime * 1000) + ]) + } super.awakeFromNib() } diff --git a/macos/Runner/PrivacyInfo.xcprivacy b/macos/Runner/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..408ec825 --- /dev/null +++ b/macos/Runner/PrivacyInfo.xcprivacy @@ -0,0 +1,15 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + 35F9.1 + + + + diff --git a/test/clipboard_link_controller_test.dart b/test/clipboard_link_controller_test.dart index 0ec2e0ab..5b90f4c7 100644 --- a/test/clipboard_link_controller_test.dart +++ b/test/clipboard_link_controller_test.dart @@ -1,26 +1,32 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hive/hive.dart'; -import 'package:lucide_icons/lucide_icons.dart'; import 'package:awesome_chewie/awesome_chewie.dart'; -import 'package:loftify/Theme/loftify_design_theme.dart'; import 'package:loftify/Utils/clipboard_link_controller.dart'; +import 'package:loftify/Utils/clipboard_snapshot.dart'; import 'package:loftify/Utils/uri_util.dart'; -import 'package:loftify/Widgets/Dialog/clipboard_link_dialog.dart'; -import 'package:loftify/generated/app_localizations.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); setUpAll(() async { - final directory = Directory('build/test_hive/clipboard_links'); - await directory.create(recursive: true); + final directory = + await Directory.systemTemp.createTemp('loftify_clipboard_test_'); Hive.init(directory.path); await Hive.openBox(ChewieHiveUtil.settingsBox); }); + setUp(() async => Hive.box(ChewieHiveUtil.settingsBox).clear()); + tearDownAll(Hive.close); const url = 'https://ruiiiiii.lofter.com/post/1dd2a51a_34f468525'; + ClipboardSnapshot snapshot(String text, {String? revision = '100'}) => + ClipboardSnapshot( + text: text, + platform: 'android', + revision: revision, + observedAtMs: 1000, + ); test('extract only supported Loftify URLs from copied share text', () { for (final input in [url, '分享给你:$url。', '[$url]($url)', '查看 <$url>']) { expect(LoftifyUriUtil.extractSupportedClipboardUrl(input), url); @@ -60,10 +66,12 @@ void main() { var text = url; final controller = ClipboardLinkController( canPrompt: () => true, - readText: () async => text, + readSnapshot: () async => snapshot(text), confirm: (_) async { prompts++; - return accepted; + return accepted + ? ClipboardLinkDecision.open + : ClipboardLinkDecision.dismiss; }, open: (_) async { opens++; @@ -84,18 +92,18 @@ void main() { test('overlapping resume events and disposal cannot open duplicate dialogs', () async { - final read = Completer(); + final read = Completer(); var prompts = 0; var reads = 0; final controller = ClipboardLinkController( canPrompt: () => true, - readText: () { + readSnapshot: () { reads++; return read.future; }, confirm: (_) async { prompts++; - return true; + return ClipboardLinkDecision.open; }, open: (_) async => fail('disposed controller must not navigate'), ); @@ -103,7 +111,7 @@ void main() { await controller.check(); expect(reads, 1); controller.dispose(); - read.complete(url); + read.complete(snapshot(url)); await pending; expect(prompts, 0); }); @@ -113,7 +121,7 @@ void main() { var reads = 0; final controller = ClipboardLinkController( canPrompt: () => allowed, - readText: () async { + readSnapshot: () async { reads++; throw PlatformException(code: 'denied'); }, @@ -128,47 +136,104 @@ void main() { controller.dispose(); }); - testWidgets('clipboard dialog stays bounded and cancel returns false', - (tester) async { - await tester.binding.setSurfaceSize(const Size(320, 600)); - addTearDown(() => tester.binding.setSurfaceSize(null)); - bool? result; - await tester.pumpWidget(MaterialApp( - theme: LoftifyTheme.build(ChewieThemeColorData.defaultLightThemes.first), - localizationsDelegates: const [ - ChewieLocalizations.delegate, - ...AppLocalizations.localizationsDelegates - ], - supportedLocales: AppLocalizations.supportedLocales, - locale: const Locale('en'), - home: Builder(builder: (context) { - chewieProvider.setRootContext(context); - return Scaffold( - body: TextButton( - onPressed: () async { - result = await ClipboardLinkDialog.show(context, url); + for (final decision in ClipboardLinkDecision.values) { + test('$decision persists across restart; recopying the URL prompts again', + () async { + var prompts = 0; + var opens = 0; + var revision = '100'; + ClipboardLinkController create() => ClipboardLinkController( + canPrompt: () => true, + readSnapshot: () async => snapshot(url, revision: revision), + confirm: (_) async { + prompts++; + return decision; + }, + open: (_) async { + // The explicit choice must already be on disk before navigation. + expect(ClipboardLinkStore().read(), isNotNull); + opens++; + }, + ); + final first = create(); + await first.check(); + first.dispose(); + await Hive.box(ChewieHiveUtil.settingsBox).close(); + await Hive.openBox(ChewieHiveUtil.settingsBox); + final restarted = create(); + await restarted.check(); + expect(prompts, 1); + expect(opens, decision == ClipboardLinkDecision.open ? 1 : 0); + expect(ClipboardLinkStore().read().toString(), isNot(contains(url))); + revision = '200'; + await restarted.check(); + expect(prompts, 2); + restarted.dispose(); + }); + } + + test('no decision is not persisted or suppressed on next foreground check', + () async { + var prompts = 0; + ClipboardLinkController create() => ClipboardLinkController( + canPrompt: () => true, + readSnapshot: () async => snapshot(url), + confirm: (_) async { + prompts++; + return null; + }, + open: (_) async => fail('must not open'), + ); + final first = create(); + await first.check(); + await first.check(); + expect(prompts, 2); + expect(ClipboardLinkStore().read(), isNull); + first.dispose(); + final restarted = create(); + await restarted.check(); + expect(prompts, 3); + restarted.dispose(); + }); + + test('copy during a dialog does not mark the new clipboard as handled', + () async { + var revision = '100'; + var prompts = 0; + final controller = ClipboardLinkController( + canPrompt: () => true, + readSnapshot: () async => snapshot(url, revision: revision), + confirm: (_) async { + prompts++; + revision = '200'; + return ClipboardLinkDecision.dismiss; + }, + open: (_) async => fail('must not open'), + ); + await controller.check(); + await controller.check(); + await controller.check(); + expect(prompts, 2); + controller.dispose(); + }); + + test('missing metadata falls back to persisted URL fingerprint', () async { + var prompts = 0; + ClipboardLinkController create() => ClipboardLinkController( + canPrompt: () => true, + readSnapshot: () async => snapshot(url, revision: null), + confirm: (_) async { + prompts++; + return ClipboardLinkDecision.dismiss; }, - child: const Text('Show'), - )); - }), - )); - await tester.tap(find.text('Show')); - await tester.pumpAndSettle(); - expect(find.text('Loftify link found'), findsOneWidget); - expect(find.byType(CustomConfirmDialogWidget), findsOneWidget); - expect(find.byIcon(LucideIcons.link), findsOneWidget); - expect(find.byType(BackdropFilter), findsWidgets); - final route = - ModalRoute.of(tester.element(find.byType(ClipboardLinkDialog)))!; - expect(route.barrierColor, ChewieTheme.barrierColor); - expect(tester.takeException(), isNull); - await tester.tap(find.text('Not now')); - await tester.pumpAndSettle(); - expect(result, isFalse); - await tester.tap(find.text('Show')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Open link')); - await tester.pumpAndSettle(); - expect(result, isTrue); + open: (_) async => fail('must not open'), + ); + final first = create(); + await first.check(); + first.dispose(); + final second = create(); + await second.check(); + expect(prompts, 1); + second.dispose(); }); } diff --git a/test/clipboard_snapshot_test.dart b/test/clipboard_snapshot_test.dart new file mode 100644 index 00000000..ef2d8cad --- /dev/null +++ b/test/clipboard_snapshot_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loftify/Utils/clipboard_snapshot.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const url = 'https://example.lofter.com/'; + for (final platform in ['windows', 'iOS', 'macOS']) { + test( + '$platform handles restart, recopy, counter reset and uncertain clocks', + () { + ClipboardSnapshot sample( + {String revision = '20', + int time = 100000, + int? uptime = 50000}) => + ClipboardSnapshot( + text: url, + platform: platform, + revision: revision, + observedAtMs: time, + uptimeMs: uptime); + final saved = sample().handledRecord(url); + expect( + sample(time: 200000, uptime: 150000).wasHandled(url, saved), isTrue); + expect(sample(revision: '21').wasHandled(url, saved), isFalse); + expect( + sample(revision: '1', time: 200000, uptime: 1000) + .wasHandled(url, saved), + isFalse); + // Even coincidentally equal counters after reboot must not suppress. + expect( + sample(time: 200000, uptime: 1000).wasHandled(url, saved), isFalse); + expect( + sample(time: 500000, uptime: 150000).wasHandled(url, saved), isFalse); + expect(sample(uptime: null).wasHandled(url, saved), isFalse); + expect( + sample().wasHandled('https://another.lofter.com/', saved), isFalse); + expect(sample().wasHandled(url, {'schema': 0}), isFalse); + }); + } + + test( + 'reader retries concurrent clipboard changes before pairing text and metadata', + () async { + var reads = 0; + final reader = ClipboardSnapshotReader( + platform: 'windows', + now: () => 1000, + readText: () async => reads == 1 ? 'old text' : url, + readMetadata: () async => { + 'revision': (++reads == 1 ? '1' : '2'), + 'uptimeMs': 500, + }, + ); + final result = await reader.read(); + expect(reads, 4); + expect(result!.text, url); + expect(result.revision, '2'); + expect(result.uptimeMs, 500); + }); + + test('unstable clipboard is skipped instead of showing mismatched content', + () async { + var reads = 0; + final reader = ClipboardSnapshotReader( + readText: () async => url, + readMetadata: () async => {'revision': '${++reads}'}, + ); + expect(await reader.read(), isNull); + expect(reads, 4); + }); + + test('native metadata channel and unsupported-platform fallback', () async { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + SystemChannels.platform, (call) async => {'text': url}); + messenger.setMockMethodCallHandler(ClipboardSnapshotReader.channel, + (call) async { + expect(call.method, 'getMetadata'); + return {'revision': '1234', 'uptimeMs': 500}; + }); + addTearDown(() { + messenger.setMockMethodCallHandler(SystemChannels.platform, null); + messenger.setMockMethodCallHandler(ClipboardSnapshotReader.channel, null); + }); + final reader = ClipboardSnapshotReader(platform: 'android'); + expect((await reader.read())!.revision, '1234'); + messenger.setMockMethodCallHandler(ClipboardSnapshotReader.channel, null); + final fallback = await reader.read(); + expect(fallback!.text, url); + expect(fallback.revision, isNull); + messenger.setMockMethodCallHandler(ClipboardSnapshotReader.channel, + (call) async { + throw PlatformException(code: 'clipboard_unavailable'); + }); + await expectLater(reader.read(), throwsA(isA())); + }); +} diff --git a/test/l10n/localization_resources_test.dart b/test/l10n/localization_resources_test.dart index 4b8c0ee0..2c99ec01 100644 --- a/test/l10n/localization_resources_test.dart +++ b/test/l10n/localization_resources_test.dart @@ -33,7 +33,7 @@ void main() { final resources = localeFiles.map(_loadArb).toList(); final expectedKeys = _messageKeys(resources.first).toSet(); - expect(expectedKeys, hasLength(768)); + expect(expectedKeys, hasLength(772)); for (var index = 1; index < resources.length; index++) { expect( _messageKeys(resources[index]).toSet(), diff --git a/test/screens/navigation/home_navigation_refresh_test.dart b/test/screens/navigation/home_navigation_refresh_test.dart index 4ee277cc..2e4af723 100644 --- a/test/screens/navigation/home_navigation_refresh_test.dart +++ b/test/screens/navigation/home_navigation_refresh_test.dart @@ -18,11 +18,14 @@ import 'package:provider/provider.dart'; class _UnusedCookieManager extends Fake implements CookieManager {} void main() { + var landscapeLayout = false; setUpAll(() async { final directory = Directory('build/test_hive/home_navigation_refresh'); await directory.create(recursive: true); Hive.init(directory.absolute.path); await Hive.openBox(ChewieHiveUtil.settingsBox); + // Exercise both layout policies independently of the test host platform. + appProvider = AppProvider(isLandscapeLayout: () => landscapeLayout); RequestUtil.cookieManager = _UnusedCookieManager(); EasyRefresh.defaultHeaderBuilder = () => LottieCupertinoHeader( backgroundColor: Colors.transparent, @@ -34,16 +37,22 @@ void main() { ); }); - for (final hideAppBar in [false, true]) { + for (final config in [ + (landscape: false, preference: false), + (landscape: false, preference: true), + (landscape: true, preference: true), + ]) { + final hideAppBar = !config.landscape && config.preference; for (final startDistance in [250.0, 600.0]) { testWidgets( - 'home navigation from $startDistance px (hide app bar: $hideAppBar)', + 'home navigation from $startDistance px ($config)', (tester) async { + landscapeLayout = config.landscape; tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - appProvider.hideHomeAppBarOnScroll = hideAppBar; + appProvider.hideHomeAppBarOnScroll = config.preference; addTearDown(() => appProvider.hideHomeAppBarOnScroll = false); var requests = 0; @@ -119,6 +128,8 @@ void main() { } expect(requests, 1); expect(haptics, isEmpty); + expect(find.byKey(const ValueKey('home-floating-app-bar')), + hideAppBar ? findsOneWidget : findsNothing); final state = tester.state(find.byType(HomeScreen)); if (hideAppBar) { diff --git a/test/screens/panel_screen_back_test.dart b/test/screens/panel_screen_back_test.dart index d32ae403..47fe48fd 100644 --- a/test/screens/panel_screen_back_test.dart +++ b/test/screens/panel_screen_back_test.dart @@ -114,11 +114,69 @@ void main() { )); final controller = tester.widget(find.byType(PageView)).controller!; - final expectedIndex = hideSearch ? 1 : 2; + // Desktop always keeps the search page, regardless of the mobile setting. + const expectedIndex = 2; expect(controller.initialPage, expectedIndex); await tester.pump(); expect(controller.page, expectedIndex.toDouble()); await tester.pumpWidget(const SizedBox()); }); } + + testWidgets('layout changes restore search without changing the selected tab', + (tester) async { + var landscape = false; + final originalProvider = appProvider; + appProvider = AppProvider(isLandscapeLayout: () => landscape); + appProvider.hideSearchNavigation = true; + appProvider.sidebarChoice = SideBarChoice.Dynamic; + addTearDown(() { + appProvider.hideSearchNavigation = false; + appProvider.sidebarChoice = SideBarChoice.Home; + appProvider.dispose(); + appProvider = originalProvider; + }); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(ChangeNotifierProvider.value( + value: appProvider, + child: MaterialApp( + theme: ChewieThemeColorData.defaultLightThemes.first.toThemeData(), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return const PanelScreen(); + }), + ), + )); + await tester.pump(); + PageController controller() => + tester.widget(find.byType(PageView)).controller!; + expect(controller().page, 1); + + landscape = true; + tester.view.physicalSize = const Size(1200, 800); + await tester.pump(); + await tester.pump(); + expect(controller().page, 2); + expect(appProvider.sidebarChoice, SideBarChoice.Dynamic); + expect(appProvider.hideSearchNavigation, isTrue); + expect(appProvider.shouldHideSearchNavigation, isFalse); + + landscape = false; + tester.view.physicalSize = const Size(390, 844); + await tester.pump(); + await tester.pump(); + expect(controller().page, 1); + expect(appProvider.sidebarChoice, SideBarChoice.Dynamic); + expect(appProvider.shouldHideSearchNavigation, isTrue); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); } diff --git a/test/screens/setting/appearance_mobile_options_test.dart b/test/screens/setting/appearance_mobile_options_test.dart new file mode 100644 index 00000000..e78ceeda --- /dev/null +++ b/test/screens/setting/appearance_mobile_options_test.dart @@ -0,0 +1,69 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:loftify/Screens/Setting/apperance_setting_screen.dart'; +import 'package:loftify/Utils/app_provider.dart'; +import 'package:loftify/Utils/hive_util.dart'; +import 'package:loftify/generated/app_localizations.dart'; +import 'package:provider/provider.dart'; + +void main() { + setUpAll(() async { + final directory = + await Directory.systemTemp.createTemp('appearance_mobile_'); + Hive.init(directory.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + appProvider.hideHomeAppBarOnScroll = true; + appProvider.hideSearchNavigation = true; + await Hive.box(ChewieHiveUtil.settingsBox).flush(); + }); + + testWidgets('desktop hides mobile home options without clearing preferences', + (tester) async { + tester.view.physicalSize = const Size(1000, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(ChangeNotifierProvider.value( + value: appProvider, + child: MaterialApp( + locale: const Locale('en'), + theme: ChewieThemeColorData.defaultLightThemes.first.toThemeData(), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates, + ], + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return const AppearanceSettingScreen(); + }), + ), + )); + await tester.pump(const Duration(milliseconds: 100)); + + final context = tester.element(find.byType(AppearanceSettingScreen)); + final strings = AppLocalizations.of(context)!; + final home = tester.widget(find.byWidgetPredicate( + (widget) => widget is CaptionItem && widget.title == strings.home, + )); + expect(home.children, hasLength(2)); + expect((home.children.first as CheckboxItem).title, + strings.showArticleInRecommendFlow); + expect((home.children.last as CheckboxItem).title, + strings.showVideoInRecommendFlow); + expect(find.text(strings.hideHomeAppBarOnScroll), findsNothing); + expect(find.text(strings.hideSearchNavigation), findsNothing); + expect(appProvider.hideHomeAppBarOnScroll, isTrue); + expect(appProvider.hideSearchNavigation, isTrue); + expect(ChewieHiveUtil.getBool(HiveUtil.hideHomeAppBarOnScrollKey), isTrue); + expect(ChewieHiveUtil.getBool(HiveUtil.hideSearchNavigationKey), isTrue); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 100)); + }); +} diff --git a/test/widgets/dialog/clipboard_link_dialog_test.dart b/test/widgets/dialog/clipboard_link_dialog_test.dart new file mode 100644 index 00000000..4b95a1e9 --- /dev/null +++ b/test/widgets/dialog/clipboard_link_dialog_test.dart @@ -0,0 +1,76 @@ +import 'dart:io'; + +import 'package:awesome_chewie/awesome_chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:loftify/Theme/loftify_design_theme.dart'; +import 'package:loftify/Utils/clipboard_link_controller.dart'; +import 'package:loftify/Widgets/Dialog/clipboard_link_dialog.dart'; +import 'package:loftify/generated/app_localizations.dart'; + +void main() { + setUpAll(() async { + final directory = + await Directory.systemTemp.createTemp('loftify_clipboard_ui_'); + Hive.init(directory.path); + await Hive.openBox(ChewieHiveUtil.settingsBox); + }); + + testWidgets('dialog distinguishes both buttons, barrier and back dismissal', + (tester) async { + const url = 'https://ruiiiiii.lofter.com/post/1dd2a51a_34f468525'; + await tester.binding.setSurfaceSize(const Size(320, 600)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + ClipboardLinkDecision? result; + await tester.pumpWidget(MaterialApp( + theme: LoftifyTheme.build(ChewieThemeColorData.defaultLightThemes.first), + localizationsDelegates: const [ + ChewieLocalizations.delegate, + ...AppLocalizations.localizationsDelegates + ], + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Builder(builder: (context) { + chewieProvider.setRootContext(context); + return Scaffold( + body: TextButton( + onPressed: () async => + result = await ClipboardLinkDialog.show(context, url), + child: const Text('Show'), + )); + }), + )); + await tester.tap(find.text('Show')); + await tester.pumpAndSettle(); + expect(find.text('Loftify link found'), findsOneWidget); + expect(find.byType(CustomConfirmDialogWidget), findsOneWidget); + expect(find.byIcon(LucideIcons.link), findsOneWidget); + expect(find.byType(BackdropFilter), findsWidgets); + final route = + ModalRoute.of(tester.element(find.byType(ClipboardLinkDialog)))!; + expect(route.barrierColor, ChewieTheme.barrierColor); + expect(tester.takeException(), isNull); + await tester.tap(find.text('Not now')); + await tester.pumpAndSettle(); + expect(result, ClipboardLinkDecision.dismiss); + await tester.tap(find.text('Show')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open link')); + await tester.pumpAndSettle(); + expect(result, ClipboardLinkDecision.open); + await tester.tap(find.text('Show')); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(2, 2)); + await tester.pumpAndSettle(); + expect(result, isNull); + await tester.tap(find.text('Show')); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(result, isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + }); +} diff --git a/test/widgets/general/appearance_refresh_test.dart b/test/widgets/general/appearance_refresh_test.dart index 6c8fc710..3baeb398 100644 --- a/test/widgets/general/appearance_refresh_test.dart +++ b/test/widgets/general/appearance_refresh_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hive/hive.dart'; import 'package:intl/intl.dart'; import 'package:loftify/Utils/app_provider.dart'; +import 'package:loftify/Utils/enums.dart'; import 'package:loftify/Utils/hive_util.dart'; import 'package:loftify/l10n/l10n.dart'; @@ -55,6 +56,47 @@ void main() { expect(ChewieHiveUtil.getBool(HiveUtil.hideSearchNavigationKey), isTrue); }); + test('mobile home preferences only take effect in the portrait layout', + () async { + var landscape = true; + final provider = AppProvider(isLandscapeLayout: () => landscape); + addTearDown(provider.dispose); + provider.hideHomeAppBarOnScroll = true; + provider.hideSearchNavigation = true; + provider.sidebarChoice = SideBarChoice.Search; + expect(provider.sidebarChoice, SideBarChoice.Search); + expect(provider.shouldHideHomeAppBarOnScroll, isFalse); + expect(provider.shouldHideSearchNavigation, isFalse); + + landscape = false; + expect(provider.shouldHideHomeAppBarOnScroll, isTrue); + expect(provider.shouldHideSearchNavigation, isTrue); + expect(provider.sidebarChoice, SideBarChoice.Home); + provider.sidebarChoice = SideBarChoice.Search; + expect(provider.sidebarChoice, SideBarChoice.Home); + + landscape = true; + expect(provider.shouldHideHomeAppBarOnScroll, isFalse); + expect(provider.shouldHideSearchNavigation, isFalse); + provider.sidebarChoice = SideBarChoice.Search; + expect(provider.sidebarChoice, SideBarChoice.Search); + expect(provider.hideHomeAppBarOnScroll, isTrue); + expect(provider.hideSearchNavigation, isTrue); + await Hive.box(ChewieHiveUtil.settingsBox).flush(); + + final restored = AppProvider(isLandscapeLayout: () => true); + addTearDown(restored.dispose); + expect(restored.sidebarChoice, SideBarChoice.Search); + expect(restored.hideHomeAppBarOnScroll, isTrue); + expect(restored.hideSearchNavigation, isTrue); + expect(restored.shouldHideHomeAppBarOnScroll, isFalse); + expect(restored.shouldHideSearchNavigation, isFalse); + // Reset the independent provider's persisted preferences for later tests. + provider.hideHomeAppBarOnScroll = false; + provider.hideSearchNavigation = false; + provider.sidebarChoice = SideBarChoice.Home; + }); + test('custom font selection can be restored from persisted metadata', () async { const customFont = CustomFont( diff --git a/test/widgets/navigation/primary_navigation_app_bar_test.dart b/test/widgets/navigation/primary_navigation_app_bar_test.dart index 0ddfe633..2aa9fa84 100644 --- a/test/widgets/navigation/primary_navigation_app_bar_test.dart +++ b/test/widgets/navigation/primary_navigation_app_bar_test.dart @@ -50,7 +50,7 @@ void main() { expect(sources['home'], isNot(contains('_refreshFeedDirectly'))); expect(sources['home'], contains('floating: true')); expect(sources['home'], contains('snap: true')); - expect(sources['home'], contains('hideSearchNavigation')); + expect(sources['home'], contains('shouldHideSearchNavigation')); expect(sources['home'], contains('SearchScreen(showBack: true)')); expect( sources['home'], diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index 955ee303..24993b4a 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -1,6 +1,7 @@ #include "flutter_window.h" #include +#include #include "flutter/generated_plugin_registrant.h" @@ -25,6 +26,29 @@ bool FlutterWindow::OnCreate() { return false; } RegisterPlugins(flutter_controller_->engine()); + clipboard_channel_ = + std::make_unique>( + flutter_controller_->engine()->messenger(), "loftify/clipboard", + &flutter::StandardMethodCodec::GetInstance()); + clipboard_channel_->SetMethodCallHandler( + [](const flutter::MethodCall& call, + std::unique_ptr> result) { + if (call.method_name() != "getMetadata") { + result->NotImplemented(); + return; + } + const DWORD sequence = GetClipboardSequenceNumber(); + if (sequence == 0) { + result->Error("clipboard_unavailable", "Clipboard access unavailable"); + return; + } + result->Success(flutter::EncodableValue(flutter::EncodableMap{ + {flutter::EncodableValue("revision"), + flutter::EncodableValue(std::to_string(sequence))}, + {flutter::EncodableValue("uptimeMs"), + flutter::EncodableValue(static_cast(GetTickCount64()))}, + })); + }); SetChildContent(flutter_controller_->view()->GetNativeWindow()); flutter_controller_->engine()->SetNextFrameCallback([&]() { @@ -40,6 +64,7 @@ bool FlutterWindow::OnCreate() { } void FlutterWindow::OnDestroy() { + clipboard_channel_ = nullptr; if (flutter_controller_) { flutter_controller_ = nullptr; } diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h index 6da0652f..cb812a44 100644 --- a/windows/runner/flutter_window.h +++ b/windows/runner/flutter_window.h @@ -3,6 +3,8 @@ #include #include +#include +#include #include @@ -28,6 +30,7 @@ class FlutterWindow : public Win32Window { // The Flutter instance hosted by this window. std::unique_ptr flutter_controller_; + std::unique_ptr> clipboard_channel_; }; #endif // RUNNER_FLUTTER_WINDOW_H_