Polish media viewer, pull-to-sync, search nav, and add seek bar presets

- Remove peek details sheet from media viewer; scope Activity tab to
  the current file via client-side filtering instead of unreliable
  server-side query params
- Add file rename action (WebDAV MOVE); fix search result taps to
  switch to Files tab and rebuild breadcrumbs from an absolute path
- Add swipe-to-delete confirmation dialog; fix a crash from disposing
  the rename dialog's TextEditingController too early
- Rework pull-to-sync gesture so sync and lock-open thresholds fire
  independently within one gesture, raise thresholds, and add a
  classic Material refresh spinner
- Add "tap active tab to scroll to top" behavior with a settings toggle
- Align selection toolbar padding with the sort/filter controls row
- Replace app icon assets (adaptive icon + splash screen) with
  updated IconKitchen export
- Replace the seek bar style dropdown with a Default/Wavy/Slim/Squiggly
  preset picker, each rendered with a live, perpetually-animated
  preview via a new shared SeekBarPainter/SeekBarPreview

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 10:25:52 -04:00
co-authored by Claude Sonnet 5
parent 1e42fcc0e6
commit d5675d853c
37 changed files with 951 additions and 376 deletions
+121 -51
View File
@@ -55,14 +55,27 @@ class SyncedHeaderScaffold extends StatefulWidget {
}
class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
static const double _lockThreshold = 100;
static const double _syncThreshold = 14;
static const double _lockThreshold = 200;
static const double _syncThreshold = 120;
static const double _pullingEpsilon = 8;
bool _headerLocked = false;
bool _isPulling = false;
bool _isRefreshing = false;
double _pullDistance = 0;
// Sync and lock are tracked separately (not one shared flag) so both can
// still fire within a single continuous pull that passes both
// thresholds - sync at the lower one, lock at the higher one - instead
// of the first one reached blocking the other for the rest of the
// gesture. Firing during the drag itself - rather than waiting for it to
// end - is what actually matters here: waiting for ScrollEndNotification
// meant a single deliberate pull often wasn't registering, and only a
// fast second pull (still carrying velocity from the first one's
// spring-back) reliably crossed the threshold before release.
bool _syncedThisGesture = false;
bool _lockedThisGesture = false;
void _revealPanel() {
if (widget.scrollController.hasClients) {
widget.scrollController.animateTo(
@@ -78,6 +91,14 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
_revealPanel();
}
void _startRefresh() {
final refresh = widget.onRefresh ?? widget.provider.refreshData;
setState(() => _isRefreshing = true);
refresh().whenComplete(() {
if (mounted) setState(() => _isRefreshing = false);
});
}
bool _handleScrollNotification(ScrollNotification notification) {
final metrics = notification.metrics;
if (metrics.pixels < metrics.minScrollExtent) {
@@ -86,22 +107,29 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
if (pulling != _isPulling && !_headerLocked) {
setState(() => _isPulling = pulling);
}
if (!_lockedThisGesture && _pullDistance >= _lockThreshold) {
_lockedThisGesture = true;
_lockOpen();
// Only refresh again if this pull hasn't already triggered one at
// the lower sync threshold on its way past it.
if (!_syncedThisGesture) {
_syncedThisGesture = true;
_startRefresh();
}
} else if (!_syncedThisGesture && _pullDistance >= _syncThreshold) {
_syncedThisGesture = true;
_startRefresh();
}
} else if (_isPulling) {
setState(() => _isPulling = false);
}
if (notification is ScrollEndNotification) {
final pulled = _pullDistance;
_pullDistance = 0;
_syncedThisGesture = false;
_lockedThisGesture = false;
if (_isPulling) setState(() => _isPulling = false);
final refresh = widget.onRefresh ?? widget.provider.refreshData;
if (pulled >= _lockThreshold) {
_lockOpen();
refresh();
} else if (pulled >= _syncThreshold) {
refresh();
}
}
return false;
}
@@ -124,52 +152,94 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
leadingWidget = _SyncStatusChip(provider: provider, onTap: _lockOpen);
}
final topInset = MediaQuery.of(context).padding.top;
return ColoredBox(
color: colorScheme.surfaceContainer,
child: NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: CustomScrollView(
controller: widget.scrollController,
// Plain BouncingScrollPhysics only bounces/overscrolls reliably
// once content already fills the viewport — with too little
// content (e.g. a single item) the pull gesture can fail to
// register at all. AlwaysScrollableScrollPhysics keeps the pull
// (and therefore the sync header) working regardless of content
// length.
physics: const AlwaysScrollableScrollPhysics(
parent: BouncingScrollPhysics(),
child: Stack(
alignment: Alignment.topCenter,
children: [
NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: CustomScrollView(
controller: widget.scrollController,
// Plain BouncingScrollPhysics only bounces/overscrolls reliably
// once content already fills the viewport — with too little
// content (e.g. a single item) the pull gesture can fail to
// register at all. AlwaysScrollableScrollPhysics keeps the pull
// (and therefore the sync header) working regardless of content
// length.
physics: const AlwaysScrollableScrollPhysics(
parent: BouncingScrollPhysics(),
),
slivers: [
SliverAppBar(
pinned: true,
stretch: true,
expandedHeight: _headerLocked ? 190 : kToolbarHeight,
collapsedHeight: kToolbarHeight,
backgroundColor: colorScheme.surfaceContainer,
surfaceTintColor: colorScheme.surfaceContainer,
scrolledUnderElevation: 0,
automaticallyImplyLeading: false,
leadingWidth: _headerLocked ? 56 : 160,
leading: leadingWidget,
actions: widget.actions,
flexibleSpace: FlexibleSpaceBar(
background: _SyncedStretchPanel(
provider: provider,
forceVisible: _headerLocked,
),
),
),
DecoratedSliver(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(28),
),
),
sliver: SliverMainAxisGroup(slivers: widget.contentSlivers),
),
],
),
),
slivers: [
SliverAppBar(
pinned: true,
stretch: true,
expandedHeight: _headerLocked ? 190 : kToolbarHeight,
collapsedHeight: kToolbarHeight,
backgroundColor: colorScheme.surfaceContainer,
surfaceTintColor: colorScheme.surfaceContainer,
scrolledUnderElevation: 0,
automaticallyImplyLeading: false,
leadingWidth: _headerLocked ? 56 : 160,
leading: leadingWidget,
actions: widget.actions,
flexibleSpace: FlexibleSpaceBar(
background: _SyncedStretchPanel(
provider: provider,
forceVisible: _headerLocked,
// The classic Material pull-to-refresh "bubble" — a floating
// circular spinner, shown while a pull-triggered sync is in
// flight. Positioned just under the status bar/toolbar area so it
// reads as attached to the header rather than floating over
// content.
Positioned(
top: topInset + 8,
child: IgnorePointer(
child: AnimatedScale(
scale: _isRefreshing ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutBack,
child: AnimatedOpacity(
opacity: _isRefreshing ? 1.0 : 0.0,
duration: const Duration(milliseconds: 150),
child: Material(
elevation: 4,
shape: const CircleBorder(),
color: colorScheme.surfaceContainerHigh,
child: Padding(
padding: const EdgeInsets.all(8),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: colorScheme.primary,
),
),
),
),
),
),
),
DecoratedSliver(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(28),
),
),
sliver: SliverMainAxisGroup(slivers: widget.contentSlivers),
),
],
),
),
],
),
);
}