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
@@ -1,86 +0,0 @@
import 'package:flutter/material.dart';
import '../../models/nextcloud_item.dart';
import 'details_sheet.dart';
/// A persistent (non-modal) draggable panel that rests as a small "peek"
/// of a file's details - just the header - so it's always partly visible
/// without blocking the content above it, and expands into the full
/// [DetailsSheet] (with its Info/Versions/Activity tabs) once dragged past
/// [_expandThreshold]. Meant to be placed directly in a `Stack`, not shown
/// via `showModalBottomSheet`/`showBottomSheet` - there's no scrim and
/// nothing behind it is blocked from receiving gestures.
class PeekDetailsSheet extends StatefulWidget {
final NextcloudItem item;
const PeekDetailsSheet({super.key, required this.item});
@override
State<PeekDetailsSheet> createState() => _PeekDetailsSheetState();
}
class _PeekDetailsSheetState extends State<PeekDetailsSheet> {
static const _peekSize = 0.16;
static const _maxSize = 0.9;
static const _expandThreshold = 0.3;
double _size = _peekSize;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final expanded = _size > _expandThreshold;
return DraggableScrollableSheet(
initialChildSize: _peekSize,
minChildSize: _peekSize,
maxChildSize: _maxSize,
snap: true,
snapSizes: const [_peekSize, _maxSize],
builder: (context, scrollController) {
return NotificationListener<DraggableScrollableNotification>(
onNotification: (notification) {
if ((notification.extent - _size).abs() > 0.002) {
setState(() => _size = notification.extent);
}
return false;
},
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(28),
),
),
child: Column(
children: [
const SizedBox(height: 12),
Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
Expanded(
child: expanded
? DetailsSheet(item: widget.item)
// Peek state: just the header, but still wrapped in
// a scrollable using the sheet's own controller - a
// DraggableScrollableSheet only picks up drag-to-
// resize gestures from a Scrollable using that
// controller, even over otherwise-static content.
: SingleChildScrollView(
controller: scrollController,
physics: const AlwaysScrollableScrollPhysics(),
child: DetailsHeader(item: widget.item),
),
),
],
),
),
);
},
);
}
}
+264
View File
@@ -0,0 +1,264 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../providers/server_provider.dart';
/// Draws a video seek bar in one of the four [MediaProgressBarStyle]
/// presets. Shared by the actual in-player seek bar and the small previews
/// in the style picker, so a preview always looks exactly like the real
/// thing.
class SeekBarPainter extends CustomPainter {
final MediaProgressBarStyle style;
final double progress;
final double phase;
final bool animate;
final Color playedColor;
final Color trackColor;
static const _waveLength = 16.0;
static const _amplitude = 3.5;
SeekBarPainter({
required this.style,
required this.progress,
required this.phase,
required this.animate,
required this.playedColor,
required this.trackColor,
});
@override
void paint(Canvas canvas, Size size) {
switch (style) {
case MediaProgressBarStyle.classic:
_paintClassic(canvas, size);
case MediaProgressBarStyle.slim:
_paintSlim(canvas, size);
case MediaProgressBarStyle.wavy:
_paintWave(canvas, size, tickThumb: false);
case MediaProgressBarStyle.squiggly:
_paintWave(canvas, size, tickThumb: true);
}
}
/// Material 3 slider look: a thick rounded active track, a small gap, a
/// pill-shaped thumb, another gap, then a thin inactive track ending in a
/// small stop-indicator dot.
void _paintClassic(Canvas canvas, Size size) {
final midY = size.height / 2;
final playedX = size.width * progress;
const thumbWidth = 4.0;
const thumbHeight = 16.0;
const gap = 5.0;
final activeEnd = (playedX - thumbWidth / 2 - gap).clamp(0.0, size.width);
final inactiveStart = (playedX + thumbWidth / 2 + gap).clamp(
0.0,
size.width,
);
if (activeEnd > 0) {
final activePaint = Paint()
..color = playedColor
..strokeWidth = 6
..strokeCap = StrokeCap.round;
canvas.drawLine(Offset(3, midY), Offset(activeEnd, midY), activePaint);
}
const dotRadius = 2.0;
final inactiveEnd = size.width - dotRadius * 2 - 2;
if (inactiveStart < inactiveEnd) {
final inactivePaint = Paint()
..color = trackColor
..strokeWidth = 3
..strokeCap = StrokeCap.round;
canvas.drawLine(
Offset(inactiveStart, midY),
Offset(inactiveEnd, midY),
inactivePaint,
);
}
if (progress < 1) {
canvas.drawCircle(
Offset(size.width - dotRadius, midY),
dotRadius,
Paint()..color = trackColor,
);
}
final thumbRect = RRect.fromRectAndRadius(
Rect.fromCenter(
center: Offset(playedX, midY),
width: thumbWidth,
height: thumbHeight,
),
const Radius.circular(thumbWidth / 2),
);
canvas.drawRRect(thumbRect, Paint()..color = playedColor);
}
/// A single continuous flat bar with no distinct thumb - just a filled
/// rounded-rect progress fill over a rounded-rect track, like a thicker
/// [LinearProgressIndicator].
void _paintSlim(Canvas canvas, Size size) {
final midY = size.height / 2;
final playedX = size.width * progress;
const thickness = 6.0;
const radius = Radius.circular(thickness / 2);
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTRB(
0,
midY - thickness / 2,
size.width,
midY + thickness / 2,
),
radius,
),
Paint()..color = trackColor,
);
if (playedX > 0) {
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTRB(0, midY - thickness / 2, playedX, midY + thickness / 2),
radius,
),
Paint()..color = playedColor,
);
}
}
/// The played portion animates as a travelling sine wave while playing and
/// settles flat when paused; the remaining track is a thin straight line.
/// The thumb is either a round dot ([MediaProgressBarStyle.wavy]) or a
/// vertical tick ([MediaProgressBarStyle.squiggly]).
void _paintWave(Canvas canvas, Size size, {required bool tickThumb}) {
final midY = size.height / 2;
final playedX = size.width * progress;
final trackPaint = Paint()
..color = trackColor
..strokeWidth = 3
..strokeCap = StrokeCap.round;
if (playedX < size.width) {
canvas.drawLine(
Offset(playedX, midY),
Offset(size.width, midY),
trackPaint,
);
}
final playedPaint = Paint()
..color = playedColor
..strokeWidth = 3
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke;
final path = Path();
var started = false;
for (var x = 0.0; x <= playedX; x += 2) {
final y = animate
? midY +
_amplitude * math.sin((x / _waveLength) * 2 * math.pi + phase)
: midY;
if (!started) {
path.moveTo(x, y);
started = true;
} else {
path.lineTo(x, y);
}
}
canvas.drawPath(path, playedPaint);
if (tickThumb) {
final tickPaint = Paint()
..color = playedColor
..strokeWidth = 3
..strokeCap = StrokeCap.round;
canvas.drawLine(
Offset(playedX, midY - 7),
Offset(playedX, midY + 7),
tickPaint,
);
} else {
canvas.drawCircle(Offset(playedX, midY), 6, Paint()..color = playedColor);
}
}
@override
bool shouldRepaint(covariant SeekBarPainter oldDelegate) {
return oldDelegate.style != style ||
oldDelegate.progress != progress ||
oldDelegate.phase != phase ||
oldDelegate.animate != animate ||
oldDelegate.playedColor != playedColor ||
oldDelegate.trackColor != trackColor;
}
}
/// A small, perpetually-looping demo of [SeekBarPainter] - sweeps the
/// "played" position back and forth and keeps the wave phase animating, so
/// the four [MediaProgressBarStyle] presets are easy to tell apart at a
/// glance in Settings without needing a real video playing.
class SeekBarPreview extends StatefulWidget {
final MediaProgressBarStyle style;
final Color playedColor;
final Color trackColor;
const SeekBarPreview({
super.key,
required this.style,
required this.playedColor,
required this.trackColor,
});
@override
State<SeekBarPreview> createState() => _SeekBarPreviewState();
}
class _SeekBarPreviewState extends State<SeekBarPreview>
with TickerProviderStateMixin {
// Ping-pongs 0->1->0 so the sweep never snaps back - a smooth, continuous
// demo loop rather than an indeterminate-loader-style reset.
late final AnimationController _progressController = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat(reverse: true);
late final Animation<double> _progress = CurvedAnimation(
parent: _progressController,
curve: Curves.easeInOut,
);
// Matches the real in-player wave's ~900ms cycle so the demo motion looks
// consistent with actual playback (see _WavySeekBarState).
late final AnimationController _phaseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat();
@override
void dispose() {
_progressController.dispose();
_phaseController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: Listenable.merge([_progress, _phaseController]),
builder: (context, _) {
return CustomPaint(
painter: SeekBarPainter(
style: widget.style,
progress: _progress.value,
phase: _phaseController.value * 2 * math.pi,
animate: true,
playedColor: widget.playedColor,
trackColor: widget.trackColor,
),
);
},
);
}
}
+29
View File
@@ -9,6 +9,7 @@ import '../providers/server_provider.dart';
/// which action a given swipe direction maps to and drives the gesture.
class SwipeableItem extends StatelessWidget {
final Key itemKey;
final String itemName;
final ServerProvider provider;
final Future<void> Function() onFavorite;
final Future<void> Function() onShare;
@@ -18,6 +19,7 @@ class SwipeableItem extends StatelessWidget {
const SwipeableItem({
super.key,
required this.itemKey,
required this.itemName,
required this.provider,
required this.onFavorite,
required this.onShare,
@@ -95,6 +97,33 @@ class SwipeableItem extends StatelessWidget {
await onShare();
return false;
case SwipeAction.delete:
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Delete Item'),
content: Text(
'Delete "$itemName" from the server? This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(
dialogContext,
).colorScheme.error,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Delete'),
),
],
);
},
);
if (confirmed != true) return false;
HapticFeedback.mediumImpact();
return true; // Actual delete happens in onDismissed.
case SwipeAction.none:
+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),
),
],
),
),
],
),
);
}