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:
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user