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>
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 288 KiB After Width: | Height: | Size: 263 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 17 KiB |
@@ -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: () {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,47 +1493,25 @@ class NextcloudService {
|
||||
} else if (rawData is Map && rawData['activity'] is List) {
|
||||
list = rawData['activity'];
|
||||
}
|
||||
return list.map((a) {
|
||||
return NextcloudActivity(
|
||||
id: (a['activity_id'] ?? '').toString(),
|
||||
title: a['subject'] ?? 'Activity',
|
||||
subject: a['message'] ?? (a['subject'] ?? ''),
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
(a['timestamp'] as int? ?? 0) * 1000,
|
||||
),
|
||||
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;
|
||||
} catch (e) {
|
||||
debugPrint('[Nextcloud OCS] File activity attempt failed: $e');
|
||||
}
|
||||
return list.where((a) => (a['object_id'] ?? '').toString() == fileId).map(
|
||||
(a) {
|
||||
return NextcloudActivity(
|
||||
id: (a['activity_id'] ?? '').toString(),
|
||||
title: a['subject'] ?? 'Activity',
|
||||
subject: a['message'] ?? (a['subject'] ?? ''),
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
(a['timestamp'] as int? ?? 0) * 1000,
|
||||
),
|
||||
icon: Icons.cloud_outlined,
|
||||
author: a['user'] ?? username,
|
||||
);
|
||||
},
|
||||
).toList();
|
||||
} catch (e) {
|
||||
debugPrint('[Nextcloud OCS] File activity unavailable: $e');
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Lists a file's version history via the DAV versions endpoint.
|
||||
|
||||
@@ -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,48 +754,115 @@ 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: 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,
|
||||
),
|
||||
),
|
||||
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.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Row(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Seek bar style',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
Text(
|
||||
'Seek Bar Style',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
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);
|
||||
},
|
||||
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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -792,6 +871,75 @@ class _MediaPlayerCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CacheSettingsCard extends StatelessWidget {
|
||||
const _CacheSettingsCard();
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,48 +337,29 @@ 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(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: AnimatedSlide(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeInOutCubic,
|
||||
offset: _controlsVisible ? Offset.zero : const Offset(0, 1.4),
|
||||
child: IgnorePointer(
|
||||
ignoring: !_controlsVisible,
|
||||
child: _MediaActionBar(
|
||||
isFavorite: _currentItem.isFavorite,
|
||||
isBusy: _isBusy,
|
||||
opacity: provider.bottomBarOpacity,
|
||||
blurSigma: provider.bottomBarBlur,
|
||||
onShare: () => ShareSheet.show(context, _currentItem),
|
||||
onFavorite: () => _toggleFavorite(provider),
|
||||
onDelete: () => _deleteCurrentItem(provider),
|
||||
onOpenExternally: () => _openExternally(provider),
|
||||
onDownload: () => _downloadToDevice(provider),
|
||||
onDetails: () => DetailsSheet.show(context, _currentItem),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: AnimatedSlide(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeInOutCubic,
|
||||
offset: _controlsVisible ? Offset.zero : const Offset(0, 1.4),
|
||||
child: IgnorePointer(
|
||||
ignoring: !_controlsVisible,
|
||||
child: _MediaActionBar(
|
||||
isFavorite: _currentItem.isFavorite,
|
||||
isBusy: _isBusy,
|
||||
opacity: provider.bottomBarOpacity,
|
||||
blurSigma: provider.bottomBarBlur,
|
||||
onShare: () => ShareSheet.show(context, _currentItem),
|
||||
onFavorite: () => _toggleFavorite(provider),
|
||||
onDelete: () => _deleteCurrentItem(provider),
|
||||
onOpenExternally: () => _openExternally(provider),
|
||||
onDownload: () => _downloadToDevice(provider),
|
||||
onDetails: () => DetailsSheet.show(context, _currentItem),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 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;
|
||||
|
||||
@@ -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,25 +448,51 @@ 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),
|
||||
tooltip: 'Cancel selection',
|
||||
onPressed: _clearSelection,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final action in _buildSelectionActions(
|
||||
provider,
|
||||
selectedItems,
|
||||
))
|
||||
IconButton(
|
||||
icon: Icon(action.icon, size: 20, color: action.color),
|
||||
tooltip: action.label,
|
||||
onPressed: action.onTap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
title: Text('${selectedItems.length} selected'),
|
||||
actions: [
|
||||
for (final action in _buildSelectionActions(
|
||||
provider,
|
||||
selectedItems,
|
||||
))
|
||||
IconButton(
|
||||
icon: Icon(action.icon, color: action.color),
|
||||
tooltip: action.label,
|
||||
onPressed: action.onTap,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
|
||||
// Files List / Grid
|
||||
@@ -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),
|
||||
|
||||
@@ -180,25 +180,51 @@ 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),
|
||||
tooltip: 'Cancel selection',
|
||||
onPressed: _clearSelection,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final action in _buildSelectionActions(
|
||||
provider,
|
||||
selectedItems,
|
||||
))
|
||||
IconButton(
|
||||
icon: Icon(action.icon, size: 20, color: action.color),
|
||||
tooltip: action.label,
|
||||
onPressed: action.onTap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
title: Text('${selectedItems.length} selected'),
|
||||
actions: [
|
||||
for (final action in _buildSelectionActions(
|
||||
provider,
|
||||
selectedItems,
|
||||
))
|
||||
IconButton(
|
||||
icon: Icon(action.icon, color: action.color),
|
||||
tooltip: action.label,
|
||||
onPressed: action.onTap,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 8)),
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||