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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 17 KiB

+36 -3
View File
@@ -65,8 +65,15 @@ class _SplashView extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.cloud_outlined, size: 48, color: colorScheme.primary),
const SizedBox(height: 20),
ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Image.asset(
'assets/icon/app_icon.png',
width: 72,
height: 72,
),
),
const SizedBox(height: 24),
CircularProgressIndicator(color: colorScheme.primary),
],
),
@@ -109,6 +116,18 @@ class _MainShellViewState extends State<MainShellView> {
@override
Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>();
// A one-shot request (e.g. tapping a search result) to switch tabs -
// consumed here so it only fires once, then cleared after this frame
// (clearing it synchronously would call notifyListeners mid-build).
final requestedTab = provider.requestedTab;
if (requestedTab != null) {
_currentTab = requestedTab;
WidgetsBinding.instance.addPostFrameCallback(
(_) => provider.consumeRequestedTab(),
);
}
final visible = provider.visibleTabs;
final selectedTab = visible.contains(_currentTab)
? _currentTab
@@ -140,8 +159,22 @@ class _MainShellViewState extends State<MainShellView> {
opacity: provider.bottomBarOpacity,
blurSigma: provider.bottomBarBlur,
onDestinationSelected: (index) {
final tappedTab = visible[index];
if (tappedTab == _currentTab) {
if (provider.tapTabToScrollTop) {
final controller = _scrollControllers[tappedTab];
if (controller != null && controller.hasClients) {
controller.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
);
}
}
return;
}
setState(() {
_currentTab = visible[index];
_currentTab = tappedTab;
});
},
onSearchTap: () {
+64 -4
View File
@@ -49,10 +49,12 @@ class _CachedDirectory {
const _CachedDirectory(this.items, this.fetchedAt);
}
/// The two linear progress indicator styles Material 3 offers for the video
/// player's seek bar: an animated travelling-wave line (matching Android's
/// current media player), or a plain flat classic bar.
enum MediaProgressBarStyle { wavy, classic }
/// Thumb/track presets for the video player's seek bar, matching the four
/// combinations offered by other Material You media players: a Material 3
/// slider-style thumb ([classic]), an animated travelling wave with a round
/// thumb ([wavy]), a thin flat bar with no distinct thumb ([slim]), and an
/// animated wave with a tick-mark thumb ([squiggly]).
enum MediaProgressBarStyle { classic, wavy, slim, squiggly }
class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
static const _storage = FlutterSecureStorage();
@@ -83,6 +85,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style';
static const _prefCachePolicy = 'ui_cache_policy';
static const _prefCacheIntervalMinutes = 'ui_cache_interval_minutes';
static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top';
final Future<SharedPreferences> _prefsFuture =
SharedPreferences.getInstance();
@@ -111,9 +114,15 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
// UI settings
double _bottomBarOpacity = 0.55;
double _bottomBarBlur = 28;
bool _tapTabToScrollTop = true;
// Navigation state
String _currentFolderPath = '/';
// A one-shot request for the shell to switch its active bottom-nav tab
// (e.g. a search result landing on Files) - consumed and cleared by
// MainShellView the next time it builds, not a persisted preference.
AppTab? _requestedTab;
List<String> _pathStack = ['/'];
bool _isGridView = false;
bool _showFavoritesOnlyFiles = false;
@@ -214,8 +223,24 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle;
double get bottomBarOpacity => _bottomBarOpacity;
double get bottomBarBlur => _bottomBarBlur;
bool get tapTabToScrollTop => _tapTabToScrollTop;
String get currentFolderPath => _currentFolderPath;
AppTab? get requestedTab => _requestedTab;
/// Asks the shell to switch its active bottom-nav tab to [tab] - e.g. so
/// tapping a search result lands the user on the Files tab even if they
/// opened search from somewhere else.
void requestTab(AppTab tab) {
_requestedTab = tab;
notifyListeners();
}
/// Called by the shell once it's consumed [requestedTab].
void consumeRequestedTab() {
_requestedTab = null;
}
List<String> get pathStack => _pathStack;
bool get isGridView => _isGridView;
bool get showFavoritesOnlyFiles => _showFavoritesOnlyFiles;
@@ -382,6 +407,8 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
_bottomBarOpacity =
prefs.getDouble(_prefBottomBarOpacity) ?? _bottomBarOpacity;
_bottomBarBlur = prefs.getDouble(_prefBottomBarBlur) ?? _bottomBarBlur;
_tapTabToScrollTop =
prefs.getBool(_prefTapTabToScrollTop) ?? _tapTabToScrollTop;
_isGridView = prefs.getBool(_prefGridView) ?? _isGridView;
_showFavoritesOnlyFiles =
prefs.getBool(_prefShowFavoritesOnlyFiles) ?? _showFavoritesOnlyFiles;
@@ -761,6 +788,24 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
await _navigateTo(_pathStack.last);
}
/// Navigates directly to an arbitrary absolute folder path (e.g. from a
/// search result) - unlike [navigateToFolder], which assumes [path] is a
/// child of wherever the user is currently browsing and just appends it,
/// this rebuilds the whole breadcrumb trail from root so it's correct
/// regardless of where the user was before.
Future<void> navigateToAbsoluteFolder(String path) async {
final normalized = path.trim().replaceAll(RegExp(r'/+$'), '');
final segments = normalized.split('/').where((s) => s.isNotEmpty).toList();
final stack = <String>['/'];
var current = '';
for (final segment in segments) {
current = '$current/$segment';
stack.add(current);
}
_pathStack = stack;
await _navigateTo(stack.last);
}
/// True if [path]'s cached listing (if any) is still usable under the
/// current [CachePolicy] - never for [CachePolicy.never], indefinitely
/// for [CachePolicy.manual], and until it's older than
@@ -970,6 +1015,15 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
return success;
}
Future<bool> renameItem(NextcloudItem item, String newName) async {
if (_service == null) return false;
final success = await _service!.renameItem(item.path, newName);
if (success) {
await refreshData();
}
return success;
}
Future<void> fetchTrash() async {
if (!_isLoggedIn || _service == null) return;
@@ -1239,6 +1293,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
_prefsFuture.then((p) => p.setDouble(_prefBottomBarBlur, value));
}
void setTapTabToScrollTop(bool value) {
_tapTabToScrollTop = value;
notifyListeners();
_prefsFuture.then((p) => p.setBool(_prefTapTabToScrollTop, value));
}
void setThemeMode(ThemeMode mode) {
_themeMode = mode;
notifyListeners();
+52 -31
View File
@@ -978,6 +978,30 @@ class NextcloudService {
return response.statusCode == 204 || response.statusCode == 200;
}
/// Renames (or moves within the same folder) an item via WebDAV MOVE.
Future<bool> renameItem(String itemPath, String newName) async {
var cleanPath = itemPath.trim();
if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath';
final segments = cleanPath.split('/')..removeLast();
final destPath = '${segments.join('/')}/$newName';
final sourceUrl =
'$_cleanServerUrl/remote.php/dav/files/$username$cleanPath';
final destUrl = '$_cleanServerUrl/remote.php/dav/files/$username$destPath';
debugPrint('[Nextcloud DAV] Renaming $sourceUrl -> $destUrl');
final request = http.Request('MOVE', Uri.parse(sourceUrl))
..headers.addAll({
..._headers,
'Destination': Uri.encodeFull(destUrl),
'Overwrite': 'F',
});
final response = await http.Client().send(request);
debugPrint('[Nextcloud DAV] Rename status: ${response.statusCode}');
return response.statusCode == 201 || response.statusCode == 204;
}
Future<bool> createFolder(String parentPath, String folderName) async {
var cleanPath = parentPath.trim();
if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath';
@@ -1441,8 +1465,27 @@ class NextcloudService {
/// Per-file activity feed (Details sheet's Activity tab), unlike
/// [fetchActivities] which is the whole-account feed.
Future<List<NextcloudActivity>> fetchFileActivity(String fileId) async {
List<NextcloudActivity> parse(String body) {
final data = jsonDecode(body);
// The server-side `object_type`/`object_id` filter params on the
// activity endpoints turned out to be silently ignored (still
// returning the whole account feed) - instead, fetch the same
// proven-working global feed `fetchActivities` uses (with a larger
// page so older file-specific entries aren't cut off) and filter to
// this file ourselves using each entry's own `object_id`, which the
// server always includes for rich-subject substitution regardless of
// whether the query-param filter works.
final url =
'$_cleanServerUrl/ocs/v2.php/apps/activity/api/v2/activity'
'?format=json&limit=200';
debugPrint('[Nextcloud OCS] Fetching activity feed to filter for $fileId');
try {
final response = await http.get(Uri.parse(url), headers: _headers);
debugPrint(
'[Nextcloud OCS] File activity response status: ${response.statusCode}',
);
if (response.statusCode != 200) return [];
final data = jsonDecode(response.body);
final rawData = data['ocs']?['data'];
List<dynamic> list = [];
if (rawData is List) {
@@ -1450,7 +1493,9 @@ class NextcloudService {
} else if (rawData is Map && rawData['activity'] is List) {
list = rawData['activity'];
}
return list.map((a) {
return list.where((a) => (a['object_id'] ?? '').toString() == fileId).map(
(a) {
return NextcloudActivity(
id: (a['activity_id'] ?? '').toString(),
title: a['subject'] ?? 'Activity',
@@ -1461,37 +1506,13 @@ class NextcloudService {
icon: Icons.cloud_outlined,
author: a['user'] ?? username,
);
}).toList();
}
// The activity app doesn't have one single documented way to scope the
// feed to a specific file across server versions - try the same base
// endpoint `fetchActivities` uses (proven to work) with object filter
// params first, then fall back to the `filter/files` route some
// versions expose, rather than betting on just one.
final candidates = [
'$_cleanServerUrl/ocs/v2.php/apps/activity/api/v2/activity'
'?format=json&object_type=files&object_id=${Uri.encodeQueryComponent(fileId)}',
'$_cleanServerUrl/ocs/v2.php/apps/activity/api/v2/activity/filter/files'
'?format=json&object_type=files&object_id=${Uri.encodeQueryComponent(fileId)}',
];
for (final url in candidates) {
try {
debugPrint('[Nextcloud OCS] Fetching activity for file $fileId: $url');
final response = await http.get(Uri.parse(url), headers: _headers);
debugPrint(
'[Nextcloud OCS] File activity response status: ${response.statusCode}',
);
if (response.statusCode != 200) continue;
final items = parse(response.body);
if (items.isNotEmpty) return items;
},
).toList();
} catch (e) {
debugPrint('[Nextcloud OCS] File activity attempt failed: $e');
}
}
debugPrint('[Nextcloud OCS] File activity unavailable: $e');
return [];
}
}
/// Lists a file's version history via the DAV versions endpoint.
Future<List<NextcloudFileVersion>> fetchFileVersions(String fileId) async {
+174 -26
View File
@@ -4,6 +4,7 @@ import '../models/app_tab.dart';
import '../providers/server_provider.dart';
import '../theme/app_theme.dart';
import '../widgets/frosted_glass_container.dart';
import '../widgets/seek_bar_painter.dart';
class AccountView extends StatelessWidget {
const AccountView({super.key});
@@ -342,6 +343,17 @@ class AccountView extends StatelessWidget {
),
const SizedBox(height: 10),
const _BottomBarAppearanceCard(),
const SizedBox(height: 12),
Card(
child: SwitchListTile(
title: const Text('Tap Tab to Scroll to Top'),
subtitle: const Text(
'Tapping the current bottom bar tab scrolls its list back to the top',
),
value: provider.tapTabToScrollTop,
onChanged: provider.setTapTabToScrollTop,
),
),
const SizedBox(height: 24),
// Bottom Nav Tabs
@@ -742,51 +754,187 @@ class _SwipeActionsCard extends StatelessWidget {
}
}
String _seekBarStyleLabel(MediaProgressBarStyle style) {
switch (style) {
case MediaProgressBarStyle.classic:
return 'Default';
case MediaProgressBarStyle.wavy:
return 'Wavy';
case MediaProgressBarStyle.slim:
return 'Slim';
case MediaProgressBarStyle.squiggly:
return 'Squiggly';
}
}
class _MediaPlayerCard extends StatelessWidget {
const _MediaPlayerCard();
String _label(MediaProgressBarStyle style) {
switch (style) {
case MediaProgressBarStyle.wavy:
return 'Wavy';
case MediaProgressBarStyle.classic:
return 'Classic';
}
Future<void> _openPicker(
BuildContext context,
ServerProvider provider,
) async {
final selected = await showDialog<MediaProgressBarStyle>(
context: context,
builder: (_) =>
_SeekBarStyleDialog(current: provider.mediaProgressBarStyle),
);
if (selected != null) provider.setMediaProgressBarStyle(selected);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
Expanded(
child: Text(
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
title: Text(
'Seek bar style',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
subtitle: Text(_seekBarStyleLabel(provider.mediaProgressBarStyle)),
trailing: SizedBox(
width: 64,
height: 24,
child: SeekBarPreview(
style: provider.mediaProgressBarStyle,
playedColor: colorScheme.primary,
trackColor: colorScheme.outlineVariant,
),
DropdownButton<MediaProgressBarStyle>(
value: provider.mediaProgressBarStyle,
underline: const SizedBox.shrink(),
borderRadius: BorderRadius.circular(12),
items: MediaProgressBarStyle.values
.map(
(s) => DropdownMenuItem(value: s, child: Text(_label(s))),
)
.toList(),
onChanged: (s) {
if (s != null) provider.setMediaProgressBarStyle(s);
},
),
onTap: () => _openPicker(context, provider),
),
);
}
}
/// A grid of the four [MediaProgressBarStyle] presets, each shown as a live
/// preview of the real seek bar painter, matching the preset-picker pattern
/// other Material You media players use for this same setting.
class _SeekBarStyleDialog extends StatelessWidget {
final MediaProgressBarStyle current;
const _SeekBarStyleDialog({required this.current});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Seek Bar Style',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 16),
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.35,
children: [
for (final style in MediaProgressBarStyle.values)
_SeekBarStyleOption(
style: style,
isSelected: style == current,
onTap: () => Navigator.pop(context, style),
),
],
),
const SizedBox(height: 4),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
),
],
),
),
);
}
}
class _SeekBarStyleOption extends StatelessWidget {
final MediaProgressBarStyle style;
final bool isSelected;
final VoidCallback onTap;
const _SeekBarStyleOption({
required this.style,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Material(
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.4)
: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outlineVariant,
width: isSelected ? 2 : 1,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Center(
child: SizedBox(
width: 100,
height: 28,
child: SeekBarPreview(
style: style,
playedColor: colorScheme.primary,
trackColor: colorScheme.outlineVariant,
),
),
),
),
const SizedBox(height: 8),
Text(
_seekBarStyleLabel(style),
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
color: isSelected
? colorScheme.primary
: colorScheme.onSurface,
),
),
],
),
),
),
);
}
@@ -809,7 +957,7 @@ class _CacheSettingsCard extends StatelessWidget {
String _description(CachePolicy policy) {
switch (policy) {
case CachePolicy.never:
return 'Every visit to a folder fetches it fresh (current behavior)';
return 'Every visit to a folder fetches it fresh';
case CachePolicy.interval:
return 'Reuse a folder\'s listing until it\'s a few minutes old';
case CachePolicy.manual:
+8 -94
View File
@@ -12,8 +12,8 @@ import 'package:video_player/video_player.dart';
import '../models/nextcloud_item.dart';
import '../providers/server_provider.dart';
import '../widgets/details/details_sheet.dart';
import '../widgets/details/peek_details_sheet.dart';
import '../widgets/frosted_glass_container.dart';
import '../widgets/seek_bar_painter.dart';
import '../widgets/share_sheet.dart';
const _textPreviewExtensions = {
@@ -337,13 +337,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
),
),
),
// The action bar floats just above the details peek below it,
// rather than overlapping it.
Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.sizeOf(context).height * 0.16,
),
child: Align(
Align(
alignment: Alignment.bottomCenter,
child: AnimatedSlide(
duration: const Duration(milliseconds: 220),
@@ -366,19 +360,6 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
),
),
),
),
// Always-present peek of the current item's details, draggable
// up into the full Info/Versions/Activity sheet.
Positioned.fill(
child: IgnorePointer(
ignoring: !_controlsVisible,
child: AnimatedOpacity(
opacity: _controlsVisible ? 1 : 0,
duration: const Duration(milliseconds: 200),
child: PeekDetailsSheet(item: _currentItem),
),
),
),
],
),
),
@@ -903,8 +884,10 @@ class _WavySeekBarState extends State<_WavySeekBar>
@override
Widget build(BuildContext context) {
final value = widget.controller.value;
final isWavy = widget.style == MediaProgressBarStyle.wavy;
final isPlaying = isWavy && value.isPlaying && _dragRatio == null;
final animatesWave =
widget.style == MediaProgressBarStyle.wavy ||
widget.style == MediaProgressBarStyle.squiggly;
final isPlaying = animatesWave && value.isPlaying && _dragRatio == null;
if (isPlaying && !_waveController.isAnimating) {
_waveController.repeat();
} else if (!isPlaying && _waveController.isAnimating) {
@@ -944,7 +927,8 @@ class _WavySeekBarState extends State<_WavySeekBar>
builder: (context, _) {
return CustomPaint(
size: Size(constraints.maxWidth, 28),
painter: _WavySeekPainter(
painter: SeekBarPainter(
style: widget.style,
progress: ratio,
phase: _waveController.value * 2 * math.pi,
animate: isPlaying,
@@ -961,76 +945,6 @@ class _WavySeekBarState extends State<_WavySeekBar>
}
}
class _WavySeekPainter extends CustomPainter {
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;
_WavySeekPainter({
required this.progress,
required this.phase,
required this.animate,
required this.playedColor,
required this.trackColor,
});
@override
void paint(Canvas canvas, Size size) {
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);
canvas.drawCircle(Offset(playedX, midY), 6, Paint()..color = playedColor);
}
@override
bool shouldRepaint(covariant _WavySeekPainter oldDelegate) {
return oldDelegate.progress != progress ||
oldDelegate.phase != phase ||
oldDelegate.animate != animate ||
oldDelegate.playedColor != playedColor ||
oldDelegate.trackColor != trackColor;
}
}
class _PdfPreview extends StatefulWidget {
final NextcloudItem item;
final ServerProvider provider;
+92 -7
View File
@@ -214,6 +214,12 @@ class _FilesViewState extends State<FilesView> {
color: Theme.of(context).colorScheme.error,
onTap: () => _confirmDeleteSelected(context, provider, selected),
),
if (selected.length == 1)
SelectionAction(
icon: Icons.drive_file_rename_outline_rounded,
label: 'Rename',
onTap: () => _renameItem(provider, selected.single),
),
if (selected.length == 1)
SelectionAction(
icon: Icons.info_outline_rounded,
@@ -223,6 +229,55 @@ class _FilesViewState extends State<FilesView> {
];
}
Future<void> _renameItem(ServerProvider provider, NextcloudItem item) async {
final controller = TextEditingController(text: item.name);
final newName = await showDialog<String>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Rename'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(labelText: 'Name'),
onSubmitted: (value) => Navigator.pop(dialogContext, value.trim()),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () =>
Navigator.pop(dialogContext, controller.text.trim()),
child: const Text('Rename'),
),
],
);
},
);
// Not disposed here: `showDialog`'s Future resolves as soon as the pop
// is initiated, while the dialog (and this controller's TextField)
// is still mounted and mid-exit-transition - disposing immediately
// crashes with a "still has listeners" assertion. It's a plain,
// short-lived controller with no ticker/stream to leak, so letting it
// get garbage-collected once the transition finishes is the safe call.
if (newName == null || newName.isEmpty || newName == item.name) return;
if (!mounted) return;
final messenger = ScaffoldMessenger.of(context);
final success = await provider.renameItem(item, newName);
_clearSelection();
messenger.showSnackBar(
SnackBar(
content: Text(
success ? 'Renamed to $newName' : 'Failed to rename ${item.name}',
),
behavior: SnackBarBehavior.floating,
),
);
}
Future<void> _shareSelected(
BuildContext context,
ServerProvider provider,
@@ -282,7 +337,10 @@ class _FilesViewState extends State<FilesView> {
.toList();
final controlsRow = Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
// 16, not 20 - matches the SliverAppBar toolbar's own default
// horizontal content inset when it's selecting, so the two rows'
// content lines up instead of the controls row looking shifted in.
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -390,26 +448,52 @@ class _FilesViewState extends State<FilesView> {
if (_isSelecting)
SliverAppBar(
pinned: true,
// This isn't the scroll view's topmost sliver - the Synced
// header above it already reserves status-bar space, so leaving
// this at its default `primary: true` double-reserves it too,
// showing up as a big empty gap above the toolbar.
primary: false,
automaticallyImplyLeading: false,
leading: IconButton(
icon: const Icon(Icons.close_rounded),
toolbarHeight: 48,
// No leading/actions slots - everything lives in `title`, wrapped
// in the exact same 16px horizontal Padding as the controls row
// above it, so the two rows' content lines up edge-to-edge
// instead of relying on SliverAppBar's own leadingWidth/actions
// insets (which don't match the controls row's).
titleSpacing: 0,
title: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.close_rounded, size: 20),
tooltip: 'Cancel selection',
onPressed: _clearSelection,
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'${selectedItems.length} selected',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
title: Text('${selectedItems.length} selected'),
actions: [
for (final action in _buildSelectionActions(
provider,
selectedItems,
))
IconButton(
icon: Icon(action.icon, color: action.color),
icon: Icon(action.icon, size: 20, color: action.color),
tooltip: action.label,
onPressed: action.onTap,
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 4),
],
),
),
),
// Files List / Grid
if (provider.isLoading)
@@ -727,6 +811,7 @@ class _FilesViewState extends State<FilesView> {
? card
: SwipeableItem(
itemKey: ValueKey('file-${item.id}'),
itemName: item.name,
provider: provider,
onFavorite: () => provider.toggleItemFavorite(item),
onShare: () => ShareSheet.show(context, item),
+32 -6
View File
@@ -180,26 +180,52 @@ class _PhotosViewState extends State<PhotosView> {
if (_isSelecting)
SliverAppBar(
pinned: true,
// This isn't the scroll view's topmost sliver - the Synced
// header above it already reserves status-bar space, so leaving
// this at its default `primary: true` double-reserves it too,
// showing up as a big empty gap above the toolbar.
primary: false,
automaticallyImplyLeading: false,
leading: IconButton(
icon: const Icon(Icons.close_rounded),
toolbarHeight: 48,
// No leading/actions slots - everything lives in `title`, wrapped
// in the exact same 16px horizontal Padding as the controls row
// above it, so the two rows' content lines up edge-to-edge
// instead of relying on SliverAppBar's own leadingWidth/actions
// insets (which don't match the controls row's).
titleSpacing: 0,
title: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.close_rounded, size: 20),
tooltip: 'Cancel selection',
onPressed: _clearSelection,
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'${selectedItems.length} selected',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
title: Text('${selectedItems.length} selected'),
actions: [
for (final action in _buildSelectionActions(
provider,
selectedItems,
))
IconButton(
icon: Icon(action.icon, color: action.color),
icon: Icon(action.icon, size: 20, color: action.color),
tooltip: action.label,
onPressed: action.onTap,
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 4),
],
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (provider.isMediaLoading && photos.isEmpty)
+24 -16
View File
@@ -1,7 +1,9 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:path/path.dart' as p;
import 'package:provider/provider.dart';
import '../models/app_tab.dart';
import '../models/nextcloud_item.dart';
import '../providers/server_provider.dart';
import 'file_viewer_screen.dart';
@@ -90,6 +92,25 @@ class _SearchViewState extends State<SearchView> {
}
}
/// Navigates the Files tab to [item] (its own folder if it's a folder,
/// its parent folder otherwise - rebuilding the whole breadcrumb trail
/// from root rather than assuming it's under wherever the user was
/// browsing before), switches the shell to the Files tab, and closes
/// search. For a file, also opens it once back on Files.
Future<void> _openResult(NextcloudItem item) async {
final provider = context.read<ServerProvider>();
final navigator = Navigator.of(context);
final folderPath = item.isFolder ? item.path : p.dirname(item.path);
await provider.navigateToAbsoluteFolder(folderPath);
provider.requestTab(AppTab.files);
navigator.popUntil((route) => route.isFirst);
if (!item.isFolder) {
navigator.push(FileViewerScreen.route(item: item));
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -106,6 +127,7 @@ class _SearchViewState extends State<SearchView> {
focusNode: _focusNode,
autoFocus: true,
hintText: 'Search your files...',
elevation: const WidgetStatePropertyAll(0),
onChanged: _onChanged,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
@@ -151,14 +173,7 @@ class _SearchViewState extends State<SearchView> {
);
}
if (!_hasSearched) {
return Center(
child: Text(
'Search across your whole Nextcloud',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
);
return const SizedBox.shrink();
}
if (_results.isEmpty) {
return Center(
@@ -185,14 +200,7 @@ class _SearchViewState extends State<SearchView> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () {
if (item.isFolder) {
context.read<ServerProvider>().navigateToFolder(item.path);
Navigator.pop(context);
} else {
Navigator.push(context, FileViewerScreen.route(item: item));
}
},
onTap: () => _openResult(item),
);
},
);
@@ -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:
+82 -12
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,9 +152,14 @@ 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>(
child: Stack(
alignment: Alignment.topCenter,
children: [
NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: CustomScrollView(
controller: widget.scrollController,
@@ -171,6 +204,43 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
],
),
),
// 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,
),
),
),
),
),
),
),
),
],
),
);
}
}
+3
View File
@@ -84,6 +84,9 @@ flutter:
# the material Icons class.
uses-material-design: true
assets:
- assets/icon/app_icon.png
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg