Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 9 additions & 6 deletions lib/ui/comments.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
],
),
);
},
);
Expand Down
28 changes: 21 additions & 7 deletions lib/ui/creator_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class _CreatorPageState extends State<CreatorPage>
vsync: this,
);
Future<List<Creator>>? _links;
final _nested = GlobalKey<NestedScrollViewState>();

/// The community's newest page, read with its channel list.
CommunityPage? _community;
Expand Down Expand Up @@ -101,6 +102,11 @@ class _CreatorPageState extends State<CreatorPage>
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)],
Expand All @@ -111,6 +117,7 @@ class _CreatorPageState extends State<CreatorPage>
_bannerHeight + _ProfileHeader.height + tabBar.preferredSize.height;
return Scaffold(
body: NestedScrollView(
key: _nested,
headerSliverBuilder: (context, scrolled) => [
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
Expand All @@ -120,9 +127,12 @@ class _CreatorPageState extends State<CreatorPage>
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(
Expand Down Expand Up @@ -180,17 +190,21 @@ class _CreatorPageState extends State<CreatorPage>
),
),
actions: [
PopupMenuButton<String>(
onSelected: (value) => openExternal(value),
PopupMenuButton<VoidCallback>(
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'),
),
],
),
],
Expand Down
38 changes: 34 additions & 4 deletions lib/ui/home.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ class _HomeScreenState extends State<HomeScreen>
static const _popularTab = 2;

final _scaffold = GlobalKey<ScaffoldState>();
final _nested = GlobalKey<NestedScrollViewState>();
bool _drawerOpen = false;
late final TabController _tabs = TabController(length: 3, vsync: this)
..addListener(_onTab);
int _tab = 0;
Expand Down Expand Up @@ -115,6 +117,19 @@ class _HomeScreenState extends State<HomeScreen>
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 &&
Expand Down Expand Up @@ -209,10 +224,21 @@ class _HomeScreenState extends State<HomeScreen>
void _push(WidgetBuilder builder) =>
Navigator.of(context).push(MaterialPageRoute<void>(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,
Expand All @@ -221,8 +247,7 @@ class _HomeScreenState extends State<HomeScreen>
? NavigationBar(
height: 64,
selectedIndex: _section.index,
onDestinationSelected: (index) =>
setState(() => _section = PopularSection.values[index]),
onDestinationSelected: _onSection,
destinations: const [
NavigationDestination(
icon: Icon(Icons.people_outline_rounded),
Expand All @@ -239,6 +264,7 @@ class _HomeScreenState extends State<HomeScreen>
: const SizedBox(width: double.infinity),
),
body: NestedScrollView(
key: _nested,
floatHeaderSlivers: true,
headerSliverBuilder: (context, scrolled) => [
SliverOverlapAbsorber(
Expand All @@ -255,7 +281,10 @@ class _HomeScreenState extends State<HomeScreen>
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',
Expand All @@ -266,6 +295,7 @@ class _HomeScreenState extends State<HomeScreen>
],
bottom: TabBar(
controller: _tabs,
onTap: _onTabTap,
tabs: const [
Tab(text: 'For You'),
Tab(text: 'Following'),
Expand Down
Loading
Loading