From 7aa553f709dfa716d7554dd5ed4c1895931865ad Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 14:36:16 +0000 Subject: [PATCH] Add viewer hold-to-download, tap-to-top and other QoL touches - Media viewer: holding an image pops a Download pill under it (or inside its bottom edge when there's no room); slide onto it and lift to save. Videos get Download and Open in browser in Chewie's dot menu. - Tapping the open Home tab, the logo, the open Popular section, or a profile's open tab / collapsed name scrolls back to the top. - Back on Following or Popular returns to For You instead of exiting. - Holding a post in a feed opens a quick menu (artist, like, favorite, download, copy link/text, open on Pawchive). - Copy link on posts and profiles; post text and comments are selectable. - Clear button in search; haptics on like and favorite. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LtGkw1AxGnokeZefgJcr1w --- AGENT.md | 14 ++- lib/ui/comments.dart | 15 ++- lib/ui/creator_page.dart | 28 +++-- lib/ui/home.dart | 38 +++++- lib/ui/media_viewer.dart | 252 ++++++++++++++++++++++++++++++++++----- lib/ui/post_card.dart | 92 +++++++++++++- lib/ui/post_page.dart | 55 +++++---- lib/ui/search_page.dart | 36 ++++-- lib/ui/widgets.dart | 35 +++++- 9 files changed, 482 insertions(+), 83 deletions(-) diff --git a/AGENT.md b/AGENT.md index 7abd493..c1dd721 100644 --- a/AGENT.md +++ b/AGENT.md @@ -153,13 +153,25 @@ docs/pawchive-api.md API reference and quirks - **Sidebar gesture.** `HomeScreen._onOverscroll` opens the drawer when the For You tab is dragged past its left edge; the drawer closes with its own drag. +- **Back to top.** Tapping the open tab or the logo on Home, the open + Popular section in the bottom bar, or the open tab or collapsed name on a + profile calls `scrollNestedToTop`. NestedScrollView moves all its inner + lists together, so every tab goes up. Back on Following or Popular + returns to For You (`PopScope` on HomeScreen) instead of leaving. +- **Quick actions.** Holding a post in a feed opens `showPostMenu` (artist, + like, favorite, download, copy link / text, open). Post text and comments + are in a `SelectionArea`; posts and profiles have Copy link (`copyLink`). - **Media viewer.** One `CustomScrollView` centered on the tapped item, so earlier items grow upward without moving it. Each item reports its visibility; the one filling most of the screen is "current" (top bar count, download, the video that may play). Images show the thumbnail at once and load the full file only while on screen, through `_FullFileGate` (two at a time, one second apart). The list stops scrolling while an image is - zoomed. + zoomed. Holding an image shows a Download pill under it (inside its + bottom edge when there's no room on screen); sliding onto it and lifting + downloads that file, and it stays tappable for 4 s after lifting + elsewhere. Videos have Download and Open in browser in Chewie's ⋮ menu + (`additionalOptions`). - **Autoplay.** Each GIF or video tile reports its visibility (visibility_detector) to `FeedAutoplay.instance`, which picks one tile at 60%+ visibility. The tile waits 400 ms, then loads the full file (fetching the post first for diff --git a/lib/ui/comments.dart b/lib/ui/comments.dart index 9bbd99c..10e189c 100644 --- a/lib/ui/comments.dart +++ b/lib/ui/comments.dart @@ -73,12 +73,15 @@ class CommentList extends StatelessWidget { child: Text('No comments.', style: TextStyle(color: ink(.5))), ); } - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final (:comment, :depth) in threadComments(list)) - _CommentRow(comment: comment, depth: depth.clamp(0, _maxDepth)), - ], + // Holding a comment selects its text for copying. + return SelectionArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final (:comment, :depth) in threadComments(list)) + _CommentRow(comment: comment, depth: depth.clamp(0, _maxDepth)), + ], + ), ); }, ); diff --git a/lib/ui/creator_page.dart b/lib/ui/creator_page.dart index c4ed9d2..affe0f4 100644 --- a/lib/ui/creator_page.dart +++ b/lib/ui/creator_page.dart @@ -35,6 +35,7 @@ class _CreatorPageState extends State vsync: this, ); Future>? _links; + final _nested = GlobalKey(); /// The community's newest page, read with its channel list. CommunityPage? _community; @@ -101,6 +102,11 @@ class _CreatorPageState extends State final c = _creator; final tabBar = TabBar( controller: _tabs, + onTap: (index) { + if (index == _tabs.index && !_tabs.indexIsChanging) { + scrollNestedToTop(_nested.currentState); + } + }, isScrollable: true, tabAlignment: TabAlignment.start, tabs: [for (final label in _tabLabels) Tab(text: label)], @@ -111,6 +117,7 @@ class _CreatorPageState extends State _bannerHeight + _ProfileHeader.height + tabBar.preferredSize.height; return Scaffold( body: NestedScrollView( + key: _nested, headerSliverBuilder: (context, scrolled) => [ SliverOverlapAbsorber( handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), @@ -120,9 +127,12 @@ class _CreatorPageState extends State expandedHeight: expanded, backgroundColor: canvas, title: scrolled - ? Text( - c.name, - style: const TextStyle(fontWeight: FontWeight.w700), + ? GestureDetector( + onTap: () => scrollNestedToTop(_nested.currentState), + child: Text( + c.name, + style: const TextStyle(fontWeight: FontWeight.w700), + ), ) : null, flexibleSpace: FlexibleSpaceBar( @@ -180,17 +190,21 @@ class _CreatorPageState extends State ), ), actions: [ - PopupMenuButton( - onSelected: (value) => openExternal(value), + PopupMenuButton( + onSelected: (action) => action(), itemBuilder: (_) => [ PopupMenuItem( - value: c.originalUrl, + value: () => openExternal(c.originalUrl), child: Text('Open on ${c.site.label}'), ), PopupMenuItem( - value: c.pawchiveUrl, + value: () => openExternal(c.pawchiveUrl), child: const Text('Open on Pawchive'), ), + PopupMenuItem( + value: () => copyLink(context, c.pawchiveUrl), + child: const Text('Copy link'), + ), ], ), ], diff --git a/lib/ui/home.dart b/lib/ui/home.dart index a068522..73fdf94 100644 --- a/lib/ui/home.dart +++ b/lib/ui/home.dart @@ -37,6 +37,8 @@ class _HomeScreenState extends State static const _popularTab = 2; final _scaffold = GlobalKey(); + final _nested = GlobalKey(); + bool _drawerOpen = false; late final TabController _tabs = TabController(length: 3, vsync: this) ..addListener(_onTab); int _tab = 0; @@ -115,6 +117,19 @@ class _HomeScreenState extends State if (_tabs.index != _tab) setState(() => _tab = _tabs.index); } + void _scrollToTop() => scrollNestedToTop(_nested.currentState); + + /// Tapping the tab that's already open scrolls it back to the top. + void _onTabTap(int index) { + if (index == _tabs.index && !_tabs.indexIsChanging) _scrollToTop(); + } + + void _onSection(int index) { + final section = PopularSection.values[index]; + if (section == _section) return _scrollToTop(); + setState(() => _section = section); + } + /// Swiping right past the first tab opens the sidebar, as in X. bool _onOverscroll(OverscrollNotification notification) { if (notification.metrics.axis == Axis.horizontal && @@ -209,10 +224,21 @@ class _HomeScreenState extends State void _push(WidgetBuilder builder) => Navigator.of(context).push(MaterialPageRoute(builder: builder)); + /// Back on Following or Popular returns to For You before leaving the + /// app, as in X. @override - Widget build(BuildContext context) => Scaffold( + Widget build(BuildContext context) => PopScope( + canPop: _tab == 0 || _drawerOpen, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _tabs.animateTo(0); + }, + child: _scaffoldView(context), + ); + + Widget _scaffoldView(BuildContext context) => Scaffold( key: _scaffold, drawer: Sidebar(onOpen: _push), + onDrawerChanged: (open) => setState(() => _drawerOpen = open), bottomNavigationBar: AnimatedSize( duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, @@ -221,8 +247,7 @@ class _HomeScreenState extends State ? NavigationBar( height: 64, selectedIndex: _section.index, - onDestinationSelected: (index) => - setState(() => _section = PopularSection.values[index]), + onDestinationSelected: _onSection, destinations: const [ NavigationDestination( icon: Icon(Icons.people_outline_rounded), @@ -239,6 +264,7 @@ class _HomeScreenState extends State : const SizedBox(width: double.infinity), ), body: NestedScrollView( + key: _nested, floatHeaderSlivers: true, headerSliverBuilder: (context, scrolled) => [ SliverOverlapAbsorber( @@ -255,7 +281,10 @@ class _HomeScreenState extends State icon: const _AccountAvatar(size: 32), ), ), - title: Image.asset(logoAsset, height: 26), + title: GestureDetector( + onTap: _scrollToTop, + child: Image.asset(logoAsset, height: 26), + ), actions: [ IconButton( tooltip: 'Search', @@ -266,6 +295,7 @@ class _HomeScreenState extends State ], bottom: TabBar( controller: _tabs, + onTap: _onTabTap, tabs: const [ Tab(text: 'For You'), Tab(text: 'Following'), diff --git a/lib/ui/media_viewer.dart b/lib/ui/media_viewer.dart index f79f440..7b654b3 100644 --- a/lib/ui/media_viewer.dart +++ b/lib/ui/media_viewer.dart @@ -1,7 +1,10 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:chewie/chewie.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:video_player/video_player.dart'; import 'package:visibility_detector/visibility_detector.dart'; @@ -16,7 +19,9 @@ import 'widgets.dart'; /// Full-screen viewer for a post's images and videos, one under another in /// a single vertical scroll. Images zoom with pinch and double-tap (the list /// holds still while one is zoomed); a video plays while it's the item -/// filling most of the screen. +/// filling most of the screen. Holding an image shows a download button +/// under it: slide onto it and lift to save that image. Videos have it in +/// their ⋮ menu. /// /// Full files need the post's signed `temp_url`s, which feed posts lack, so /// the post is fetched again first when needed. Only items on screen load @@ -46,6 +51,13 @@ class _MediaViewerState extends State { String? _error; bool _resolving = false; + /// The long-press download button: the held item and where the button + /// sits in the viewer. + ({int index, Rect button})? _press; + bool _pressHover = false; + Timer? _pressLinger; + static const _pressSize = Size(176, 48); + @override void initState() { super.initState(); @@ -102,10 +114,83 @@ class _MediaViewerState extends State { @override void dispose() { + _pressLinger?.cancel(); _scroll?.dispose(); super.dispose(); } + /// Shows the download button under the held image, or inside its bottom + /// edge when there's no room below it on screen. + void _startPress(int index, BuildContext item) { + final box = item.findRenderObject() as RenderBox?; + final viewer = context.findRenderObject() as RenderBox?; + if (box == null || viewer == null || !box.hasSize) return; + final image = + viewer.globalToLocal(box.localToGlobal(Offset.zero)) & box.size; + final padding = MediaQuery.paddingOf(context); + final floor = viewer.size.height - padding.bottom - 16; + const gap = 12.0; + final below = image.bottom + gap; + var top = below + _pressSize.height <= floor + ? below + : math.min(image.bottom, floor) - gap - _pressSize.height; + top = math.max(top, padding.top + 8); + final left = clampDouble( + image.center.dx - _pressSize.width / 2, + 8, + math.max(8, viewer.size.width - _pressSize.width - 8), + ); + HapticFeedback.mediumImpact(); + _pressLinger?.cancel(); + setState(() { + _press = (index: index, button: Offset(left, top) & _pressSize); + _pressHover = false; + }); + } + + bool _overPress(Offset global) { + final press = _press; + final viewer = context.findRenderObject() as RenderBox?; + if (press == null || viewer == null) return false; + // A little slack around the button, since the finger covers it. + return press.button.inflate(16).contains(viewer.globalToLocal(global)); + } + + void _movePress(LongPressMoveUpdateDetails details) { + final hover = _overPress(details.globalPosition); + if (hover == _pressHover) return; + if (hover) HapticFeedback.selectionClick(); + setState(() => _pressHover = hover); + } + + void _endPress(LongPressEndDetails details) { + if (_overPress(details.globalPosition)) { + _pressDownload(); + return; + } + // Lifted elsewhere: the button stays a moment so it can still be tapped. + setState(() => _pressHover = false); + _pressLinger?.cancel(); + _pressLinger = Timer(const Duration(seconds: 4), _dismissPress); + } + + void _dismissPress() { + _pressLinger?.cancel(); + if (_press == null || !mounted) return; + setState(() { + _press = null; + _pressHover = false; + }); + } + + void _pressDownload() { + final press = _press; + _dismissPress(); + if (press == null || press.index >= _media.length) return; + HapticFeedback.lightImpact(); + downloadFiles(context, widget.post, [_media[press.index]]); + } + Widget _item(int index) { final item = _media[index]; final Widget child; @@ -120,18 +205,27 @@ class _MediaViewerState extends State { url: item.fullUrl!, active: index == _index, autoplay: AppScope.read(context).settings.autoplayVideos, + onDownload: () => downloadFiles(context, widget.post, [item]), ); } else { - child = GestureDetector( - onTap: () => setState(() => _chrome = !_chrome), - child: _ImageItem( - key: ValueKey(item.path), - file: item, - gate: _gate, - loadFull: !_resolving && _visible.containsKey(index), - onZoom: (zoomed) { - if (zoomed != _zoomed) setState(() => _zoomed = zoomed); + child = Builder( + builder: (itemContext) => GestureDetector( + onTap: () { + if (_press != null) return _dismissPress(); + setState(() => _chrome = !_chrome); }, + onLongPressStart: (_) => _startPress(index, itemContext), + onLongPressMoveUpdate: _movePress, + onLongPressEnd: _endPress, + child: _ImageItem( + key: ValueKey(item.path), + file: item, + gate: _gate, + loadFull: !_resolving && _visible.containsKey(index), + onZoom: (zoomed) { + if (zoomed != _zoomed) setState(() => _zoomed = zoomed); + }, + ), ), ); } @@ -146,33 +240,40 @@ class _MediaViewerState extends State { Widget build(BuildContext context) { final file = _media[_index.clamp(0, _media.length - 1)]; final start = widget.initialIndex.clamp(0, _media.length); + final press = _press; return Scaffold( backgroundColor: Colors.black, body: Stack( children: [ - CustomScrollView( - controller: _scroll, - center: _centerKey, - physics: _zoomed ? const NeverScrollableScrollPhysics() : null, - slivers: [ - SliverToBoxAdapter(child: SizedBox(height: _topInset)), - // Above the center: the items before the tapped one, nearest - // first. - SliverList.builder( - itemCount: start, - itemBuilder: (context, i) => _item(start - 1 - i), - ), - SliverList.builder( - key: _centerKey, - itemCount: _media.length - start, - itemBuilder: (context, i) => _item(start + i), - ), - SliverToBoxAdapter( - child: SizedBox( - height: MediaQuery.paddingOf(context).bottom + 24, + NotificationListener( + onNotification: (_) { + _dismissPress(); + return false; + }, + child: CustomScrollView( + controller: _scroll, + center: _centerKey, + physics: _zoomed ? const NeverScrollableScrollPhysics() : null, + slivers: [ + SliverToBoxAdapter(child: SizedBox(height: _topInset)), + // Above the center: the items before the tapped one, nearest + // first. + SliverList.builder( + itemCount: start, + itemBuilder: (context, i) => _item(start - 1 - i), ), - ), - ], + SliverList.builder( + key: _centerKey, + itemCount: _media.length - start, + itemBuilder: (context, i) => _item(start + i), + ), + SliverToBoxAdapter( + child: SizedBox( + height: MediaQuery.paddingOf(context).bottom + 24, + ), + ), + ], + ), ), Positioned( top: 0, @@ -198,12 +299,79 @@ class _MediaViewerState extends State { ), ), ), + // Scrolling, tapping an image or waiting puts it away. + if (press != null) + Positioned.fromRect( + rect: press.button, + child: _PressButton( + key: ValueKey(press), + hover: _pressHover, + onTap: _pressDownload, + ), + ), ], ), ); } } +/// The pill that appears when an image is held: it pops in, and grows and +/// fills pink while the finger is over it. +class _PressButton extends StatelessWidget { + const _PressButton({super.key, required this.hover, required this.onTap}); + + final bool hover; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutBack, + builder: (context, value, child) => Opacity( + opacity: value.clamp(0, 1), + child: Transform.scale(scale: .7 + .3 * value, child: child), + ), + child: AnimatedScale( + scale: hover ? 1.1 : 1, + duration: const Duration(milliseconds: 120), + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + decoration: BoxDecoration( + color: hover ? pawPink : Colors.black.withValues(alpha: .8), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: hover ? pawPink : Colors.white54, + width: 1.5, + ), + boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 12)], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.download_rounded, + color: hover ? pawOutline : Colors.white, + ), + const SizedBox(width: 8), + Text( + 'Download', + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 15, + color: hover ? pawOutline : Colors.white, + ), + ), + ], + ), + ), + ), + ), + ); +} + /// Hands out full-file loads at most [_concurrent] at a time and /// [_spacing] apart, so scrolling through a long post keeps to the file /// host's limits. @@ -521,11 +689,13 @@ class _VideoItem extends StatefulWidget { required this.url, required this.active, required this.autoplay, + required this.onDownload, }); final String url; final bool active; final bool autoplay; + final VoidCallback onDownload; @override State<_VideoItem> createState() => _VideoItemState(); @@ -568,6 +738,24 @@ class _VideoItemState extends State<_VideoItem> { autoPlay: widget.autoplay && widget.active, looping: video.value.duration < const Duration(seconds: 30), allowFullScreen: false, + additionalOptions: (_) => [ + OptionItem( + onTap: (sheet) { + Navigator.of(sheet).pop(); + widget.onDownload(); + }, + iconData: Icons.download_rounded, + title: 'Download', + ), + OptionItem( + onTap: (sheet) { + Navigator.of(sheet).pop(); + openExternal(widget.url); + }, + iconData: Icons.open_in_new_rounded, + title: 'Open in browser', + ), + ], materialProgressColors: ChewieProgressColors( playedColor: pawPink, handleColor: pawPinkLight, diff --git a/lib/ui/post_card.dart b/lib/ui/post_card.dart index 2852de2..2bfe85d 100644 --- a/lib/ui/post_card.dart +++ b/lib/ui/post_card.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../core/models.dart'; import 'app_scope.dart'; import 'autoplay.dart'; import 'comments.dart'; import 'creator_page.dart'; +import 'downloads.dart'; import 'media_viewer.dart'; import 'poll.dart'; import 'post_page.dart'; @@ -12,7 +14,7 @@ import 'theme.dart'; import 'widgets.dart'; /// A post in a feed, laid out like a post on X: avatar column, name line, -/// text, then a media grid. +/// text, then a media grid. Holding it opens [showPostMenu]. class PostCard extends StatelessWidget { const PostCard(this.post, {super.key}); @@ -25,6 +27,7 @@ class PostCard extends StatelessWidget { final others = post.files.length - media.length; return InkWell( onTap: () => openPost(context, post), + onLongPress: () => showPostMenu(context, post), child: Padding( padding: const EdgeInsets.fromLTRB(14, 12, 14, 10), child: Row( @@ -126,6 +129,7 @@ class PostActions extends StatelessWidget { color: liked ? pawPinkLight : ink(.6), onTap: () { final first = state.likes.isEmpty && !liked; + HapticFeedback.lightImpact(); state.toggleLike(post); if (first) { ScaffoldMessenger.of(context).showSnackBar( @@ -146,7 +150,10 @@ class PostActions extends StatelessWidget { : Icons.favorite_border_rounded, color: favorite ? pawPink : ink(.6), label: post.favorites == null ? null : compactCount(post.favorites!), - onTap: () => state.toggleFavorite(post), + onTap: () { + HapticFeedback.lightImpact(); + state.toggleFavorite(post); + }, ), const Spacer(), if (attachments > 0) ...[ @@ -442,6 +449,87 @@ class _Label extends StatelessWidget { ); } +/// Quick actions for a post held in a feed: its artist, like, favorite, +/// download, copy and open. +Future showPostMenu(BuildContext context, Post post) { + HapticFeedback.mediumImpact(); + final state = AppScope.read(context); + final creator = + state.directory.known(post.creatorKey) ?? + Creator(id: post.user, service: post.service, name: post.user); + final text = plainText(post.content); + return showModalBottomSheet( + context: context, + useSafeArea: true, + showDragHandle: true, + backgroundColor: canvas, + builder: (sheet) { + final liked = state.isLiked(post); + final favorite = state.isFavorite(post); + Widget item(IconData icon, String label, VoidCallback onTap) => ListTile( + leading: Icon(icon), + title: Text(label), + onTap: () { + Navigator.of(sheet).pop(); + onTap(); + }, + ); + return SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + item( + Icons.person_outline_rounded, + 'View ${creator.name}', + () => openCreator(context, creator), + ), + item( + liked ? Icons.thumb_up_alt_rounded : Icons.thumb_up_alt_outlined, + liked ? 'Remove like' : 'Like', + () => state.toggleLike(post), + ), + item( + favorite ? Icons.favorite_rounded : Icons.favorite_border_rounded, + favorite ? 'Remove from favorites' : 'Favorite', + () => state.toggleFavorite(post), + ), + if (post.files.isNotEmpty) + item( + Icons.download_rounded, + post.files.length == 1 + ? 'Download file' + : 'Download all ${post.files.length} files', + () => downloadFiles(context, post, post.files), + ), + item( + Icons.link_rounded, + 'Copy link', + () => copyLink(context, post.url), + ), + if (text.isNotEmpty) + item(Icons.content_copy_rounded, 'Copy text', () { + Clipboard.setData( + ClipboardData( + text: post.title.isEmpty ? text : '${post.title}\n\n$text', + ), + ); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(const SnackBar(content: Text('Text copied'))); + }), + item( + Icons.open_in_new_rounded, + 'Open on Pawchive', + () => openExternal(post.url), + ), + ], + ), + ); + }, + ); +} + void openPost(BuildContext context, Post post) => Navigator.of(context) .push(MaterialPageRoute(builder: (_) => PostPage(post: post))); diff --git a/lib/ui/post_page.dart b/lib/ui/post_page.dart index 35a03d3..aa8e6dd 100644 --- a/lib/ui/post_page.dart +++ b/lib/ui/post_page.dart @@ -105,6 +105,12 @@ class _PostPageState extends State { icon: const Icon(Icons.download_rounded), onPressed: () => downloadFiles(context, post, post.files), ), + if (post != null) + IconButton( + tooltip: 'Copy link', + icon: const Icon(Icons.link_rounded), + onPressed: () => copyLink(context, post.url), + ), if (post != null) IconButton( tooltip: 'Open on Pawchive', @@ -164,28 +170,37 @@ class _PostPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (post.title.isNotEmpty) - Text( - post.title, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - height: 1.3, - ), + // Holding the title or text selects it for copying. + SelectionArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (post.title.isNotEmpty) + Text( + post.title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + height: 1.3, + ), + ), + const SizedBox(height: 6), + Text( + [ + if (post.published != null) + 'Published ${longDate(post.published)}', + if (post.added != null) + 'archived ${longDate(post.added)}', + ].join(' · '), + style: TextStyle(color: ink(.5), fontSize: 13), + ), + if (plainText(post.content).isNotEmpty) ...[ + const SizedBox(height: 12), + HtmlContent(post.content), + ], + ], ), - const SizedBox(height: 6), - Text( - [ - if (post.published != null) - 'Published ${longDate(post.published)}', - if (post.added != null) 'archived ${longDate(post.added)}', - ].join(' · '), - style: TextStyle(color: ink(.5), fontSize: 13), ), - if (plainText(post.content).isNotEmpty) ...[ - const SizedBox(height: 12), - HtmlContent(post.content), - ], if (post.poll != null) ...[ const SizedBox(height: 12), PollView(post.poll!), diff --git a/lib/ui/search_page.dart b/lib/ui/search_page.dart index bf0f088..2b2373a 100644 --- a/lib/ui/search_page.dart +++ b/lib/ui/search_page.dart @@ -49,6 +49,12 @@ class _SearchPageState extends State ); } + void _clear() { + _debounce?.cancel(); + _field.clear(); + _search(''); + } + void _search(String query) { if (query == _query) return; final api = AppScope.read(context).api; @@ -69,17 +75,27 @@ class _SearchPageState extends State titleSpacing: 0, title: Padding( padding: const EdgeInsets.only(right: 12), - child: TextField( - controller: _field, - autofocus: true, - textInputAction: TextInputAction.search, - decoration: const InputDecoration( - hintText: 'Search Pawchive', - prefixIcon: Icon(Icons.search_rounded), - isDense: true, + child: ListenableBuilder( + listenable: _field, + builder: (context, _) => TextField( + controller: _field, + autofocus: true, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: 'Search Pawchive', + prefixIcon: const Icon(Icons.search_rounded), + suffixIcon: _field.text.isEmpty + ? null + : IconButton( + tooltip: 'Clear', + icon: const Icon(Icons.close_rounded), + onPressed: _clear, + ), + isDense: true, + ), + onChanged: _changed, + onSubmitted: (value) => _search(value.trim()), ), - onChanged: _changed, - onSubmitted: (value) => _search(value.trim()), ), ), bottom: TabBar( diff --git a/lib/ui/widgets.dart b/lib/ui/widgets.dart index d155c62..f24846e 100644 --- a/lib/ui/widgets.dart +++ b/lib/ui/widgets.dart @@ -1,5 +1,6 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -152,7 +153,39 @@ Future openExternal(String url) { return launchUrl(uri, mode: LaunchMode.externalApplication); } -/// "now", "5m", "3h", "2d", then a date, like X's timestamps. +/// Copies [url] to the clipboard and says so. +Future copyLink(BuildContext context, String url) async { + final messenger = ScaffoldMessenger.of(context); + await Clipboard.setData(ClipboardData(text: url)); + messenger + ..hideCurrentSnackBar() + ..showSnackBar(const SnackBar(content: Text('Link copied'))); +} + +/// Scrolls a [NestedScrollView] back to the top with its header showing, +/// as tapping X's current tab does. NestedScrollView moves its inner lists +/// together, so every tab returns to the top. From far down it jumps close +/// first, so the animation doesn't build (and load images for) every row in +/// between. +void scrollNestedToTop(NestedScrollViewState? nested) { + if (nested == null) return; + final inner = nested.innerController; + if (inner.hasClients) { + final position = inner.positions.reduce( + (a, b) => a.pixels >= b.pixels ? a : b, + ); + final near = position.viewportDimension * 1.5; + if (position.pixels > near) position.jumpTo(near); + } + final outer = nested.outerController; + if (!outer.hasClients) return; + outer.animateTo( + 0, + duration: const Duration(milliseconds: 380), + curve: Curves.easeOutCubic, + ); +} + /// 950, 1.2K, 34K, 1.5M. String compactCount(int count) { if (count < 1000) return '$count';