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(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.cloud_outlined, size: 48, color: colorScheme.primary),
|
ClipRRect(
|
||||||
const SizedBox(height: 20),
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/icon/app_icon.png',
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
CircularProgressIndicator(color: colorScheme.primary),
|
CircularProgressIndicator(color: colorScheme.primary),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -109,6 +116,18 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final provider = context.watch<ServerProvider>();
|
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 visible = provider.visibleTabs;
|
||||||
final selectedTab = visible.contains(_currentTab)
|
final selectedTab = visible.contains(_currentTab)
|
||||||
? _currentTab
|
? _currentTab
|
||||||
@@ -140,8 +159,22 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
opacity: provider.bottomBarOpacity,
|
opacity: provider.bottomBarOpacity,
|
||||||
blurSigma: provider.bottomBarBlur,
|
blurSigma: provider.bottomBarBlur,
|
||||||
onDestinationSelected: (index) {
|
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(() {
|
setState(() {
|
||||||
_currentTab = visible[index];
|
_currentTab = tappedTab;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSearchTap: () {
|
onSearchTap: () {
|
||||||
|
|||||||
@@ -49,10 +49,12 @@ class _CachedDirectory {
|
|||||||
const _CachedDirectory(this.items, this.fetchedAt);
|
const _CachedDirectory(this.items, this.fetchedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The two linear progress indicator styles Material 3 offers for the video
|
/// Thumb/track presets for the video player's seek bar, matching the four
|
||||||
/// player's seek bar: an animated travelling-wave line (matching Android's
|
/// combinations offered by other Material You media players: a Material 3
|
||||||
/// current media player), or a plain flat classic bar.
|
/// slider-style thumb ([classic]), an animated travelling wave with a round
|
||||||
enum MediaProgressBarStyle { wavy, classic }
|
/// 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 {
|
class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
static const _storage = FlutterSecureStorage();
|
static const _storage = FlutterSecureStorage();
|
||||||
@@ -83,6 +85,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style';
|
static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style';
|
||||||
static const _prefCachePolicy = 'ui_cache_policy';
|
static const _prefCachePolicy = 'ui_cache_policy';
|
||||||
static const _prefCacheIntervalMinutes = 'ui_cache_interval_minutes';
|
static const _prefCacheIntervalMinutes = 'ui_cache_interval_minutes';
|
||||||
|
static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top';
|
||||||
|
|
||||||
final Future<SharedPreferences> _prefsFuture =
|
final Future<SharedPreferences> _prefsFuture =
|
||||||
SharedPreferences.getInstance();
|
SharedPreferences.getInstance();
|
||||||
@@ -111,9 +114,15 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
// UI settings
|
// UI settings
|
||||||
double _bottomBarOpacity = 0.55;
|
double _bottomBarOpacity = 0.55;
|
||||||
double _bottomBarBlur = 28;
|
double _bottomBarBlur = 28;
|
||||||
|
bool _tapTabToScrollTop = true;
|
||||||
|
|
||||||
// Navigation state
|
// Navigation state
|
||||||
String _currentFolderPath = '/';
|
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 = ['/'];
|
List<String> _pathStack = ['/'];
|
||||||
bool _isGridView = false;
|
bool _isGridView = false;
|
||||||
bool _showFavoritesOnlyFiles = false;
|
bool _showFavoritesOnlyFiles = false;
|
||||||
@@ -214,8 +223,24 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle;
|
MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle;
|
||||||
double get bottomBarOpacity => _bottomBarOpacity;
|
double get bottomBarOpacity => _bottomBarOpacity;
|
||||||
double get bottomBarBlur => _bottomBarBlur;
|
double get bottomBarBlur => _bottomBarBlur;
|
||||||
|
bool get tapTabToScrollTop => _tapTabToScrollTop;
|
||||||
|
|
||||||
String get currentFolderPath => _currentFolderPath;
|
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;
|
List<String> get pathStack => _pathStack;
|
||||||
bool get isGridView => _isGridView;
|
bool get isGridView => _isGridView;
|
||||||
bool get showFavoritesOnlyFiles => _showFavoritesOnlyFiles;
|
bool get showFavoritesOnlyFiles => _showFavoritesOnlyFiles;
|
||||||
@@ -382,6 +407,8 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_bottomBarOpacity =
|
_bottomBarOpacity =
|
||||||
prefs.getDouble(_prefBottomBarOpacity) ?? _bottomBarOpacity;
|
prefs.getDouble(_prefBottomBarOpacity) ?? _bottomBarOpacity;
|
||||||
_bottomBarBlur = prefs.getDouble(_prefBottomBarBlur) ?? _bottomBarBlur;
|
_bottomBarBlur = prefs.getDouble(_prefBottomBarBlur) ?? _bottomBarBlur;
|
||||||
|
_tapTabToScrollTop =
|
||||||
|
prefs.getBool(_prefTapTabToScrollTop) ?? _tapTabToScrollTop;
|
||||||
_isGridView = prefs.getBool(_prefGridView) ?? _isGridView;
|
_isGridView = prefs.getBool(_prefGridView) ?? _isGridView;
|
||||||
_showFavoritesOnlyFiles =
|
_showFavoritesOnlyFiles =
|
||||||
prefs.getBool(_prefShowFavoritesOnlyFiles) ?? _showFavoritesOnlyFiles;
|
prefs.getBool(_prefShowFavoritesOnlyFiles) ?? _showFavoritesOnlyFiles;
|
||||||
@@ -761,6 +788,24 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
await _navigateTo(_pathStack.last);
|
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
|
/// True if [path]'s cached listing (if any) is still usable under the
|
||||||
/// current [CachePolicy] - never for [CachePolicy.never], indefinitely
|
/// current [CachePolicy] - never for [CachePolicy.never], indefinitely
|
||||||
/// for [CachePolicy.manual], and until it's older than
|
/// for [CachePolicy.manual], and until it's older than
|
||||||
@@ -970,6 +1015,15 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
return success;
|
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 {
|
Future<void> fetchTrash() async {
|
||||||
if (!_isLoggedIn || _service == null) return;
|
if (!_isLoggedIn || _service == null) return;
|
||||||
|
|
||||||
@@ -1239,6 +1293,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_prefsFuture.then((p) => p.setDouble(_prefBottomBarBlur, value));
|
_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) {
|
void setThemeMode(ThemeMode mode) {
|
||||||
_themeMode = mode;
|
_themeMode = mode;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -978,6 +978,30 @@ class NextcloudService {
|
|||||||
return response.statusCode == 204 || response.statusCode == 200;
|
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 {
|
Future<bool> createFolder(String parentPath, String folderName) async {
|
||||||
var cleanPath = parentPath.trim();
|
var cleanPath = parentPath.trim();
|
||||||
if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath';
|
if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath';
|
||||||
@@ -1441,8 +1465,27 @@ class NextcloudService {
|
|||||||
/// Per-file activity feed (Details sheet's Activity tab), unlike
|
/// Per-file activity feed (Details sheet's Activity tab), unlike
|
||||||
/// [fetchActivities] which is the whole-account feed.
|
/// [fetchActivities] which is the whole-account feed.
|
||||||
Future<List<NextcloudActivity>> fetchFileActivity(String fileId) async {
|
Future<List<NextcloudActivity>> fetchFileActivity(String fileId) async {
|
||||||
List<NextcloudActivity> parse(String body) {
|
// The server-side `object_type`/`object_id` filter params on the
|
||||||
final data = jsonDecode(body);
|
// 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'];
|
final rawData = data['ocs']?['data'];
|
||||||
List<dynamic> list = [];
|
List<dynamic> list = [];
|
||||||
if (rawData is List) {
|
if (rawData is List) {
|
||||||
@@ -1450,7 +1493,9 @@ class NextcloudService {
|
|||||||
} else if (rawData is Map && rawData['activity'] is List) {
|
} else if (rawData is Map && rawData['activity'] is List) {
|
||||||
list = rawData['activity'];
|
list = rawData['activity'];
|
||||||
}
|
}
|
||||||
return list.map((a) {
|
|
||||||
|
return list.where((a) => (a['object_id'] ?? '').toString() == fileId).map(
|
||||||
|
(a) {
|
||||||
return NextcloudActivity(
|
return NextcloudActivity(
|
||||||
id: (a['activity_id'] ?? '').toString(),
|
id: (a['activity_id'] ?? '').toString(),
|
||||||
title: a['subject'] ?? 'Activity',
|
title: a['subject'] ?? 'Activity',
|
||||||
@@ -1461,37 +1506,13 @@ class NextcloudService {
|
|||||||
icon: Icons.cloud_outlined,
|
icon: Icons.cloud_outlined,
|
||||||
author: a['user'] ?? username,
|
author: a['user'] ?? username,
|
||||||
);
|
);
|
||||||
}).toList();
|
},
|
||||||
}
|
).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) {
|
} catch (e) {
|
||||||
debugPrint('[Nextcloud OCS] File activity attempt failed: $e');
|
debugPrint('[Nextcloud OCS] File activity unavailable: $e');
|
||||||
}
|
|
||||||
}
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Lists a file's version history via the DAV versions endpoint.
|
/// Lists a file's version history via the DAV versions endpoint.
|
||||||
Future<List<NextcloudFileVersion>> fetchFileVersions(String fileId) async {
|
Future<List<NextcloudFileVersion>> fetchFileVersions(String fileId) async {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import '../models/app_tab.dart';
|
|||||||
import '../providers/server_provider.dart';
|
import '../providers/server_provider.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../widgets/frosted_glass_container.dart';
|
import '../widgets/frosted_glass_container.dart';
|
||||||
|
import '../widgets/seek_bar_painter.dart';
|
||||||
|
|
||||||
class AccountView extends StatelessWidget {
|
class AccountView extends StatelessWidget {
|
||||||
const AccountView({super.key});
|
const AccountView({super.key});
|
||||||
@@ -342,6 +343,17 @@ class AccountView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
const _BottomBarAppearanceCard(),
|
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),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Bottom Nav Tabs
|
// 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 {
|
class _MediaPlayerCard extends StatelessWidget {
|
||||||
const _MediaPlayerCard();
|
const _MediaPlayerCard();
|
||||||
|
|
||||||
String _label(MediaProgressBarStyle style) {
|
Future<void> _openPicker(
|
||||||
switch (style) {
|
BuildContext context,
|
||||||
case MediaProgressBarStyle.wavy:
|
ServerProvider provider,
|
||||||
return 'Wavy';
|
) async {
|
||||||
case MediaProgressBarStyle.classic:
|
final selected = await showDialog<MediaProgressBarStyle>(
|
||||||
return 'Classic';
|
context: context,
|
||||||
}
|
builder: (_) =>
|
||||||
|
_SeekBarStyleDialog(current: provider.mediaProgressBarStyle),
|
||||||
|
);
|
||||||
|
if (selected != null) provider.setMediaProgressBarStyle(selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
final provider = context.watch<ServerProvider>();
|
final provider = context.watch<ServerProvider>();
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: ListTile(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
child: Row(
|
title: Text(
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'Seek bar style',
|
'Seek bar style',
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
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,
|
onTap: () => _openPicker(context, provider),
|
||||||
underline: const SizedBox.shrink(),
|
),
|
||||||
borderRadius: BorderRadius.circular(12),
|
);
|
||||||
items: MediaProgressBarStyle.values
|
}
|
||||||
.map(
|
}
|
||||||
(s) => DropdownMenuItem(value: s, child: Text(_label(s))),
|
|
||||||
)
|
/// A grid of the four [MediaProgressBarStyle] presets, each shown as a live
|
||||||
.toList(),
|
/// preview of the real seek bar painter, matching the preset-picker pattern
|
||||||
onChanged: (s) {
|
/// other Material You media players use for this same setting.
|
||||||
if (s != null) provider.setMediaProgressBarStyle(s);
|
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) {
|
String _description(CachePolicy policy) {
|
||||||
switch (policy) {
|
switch (policy) {
|
||||||
case CachePolicy.never:
|
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:
|
case CachePolicy.interval:
|
||||||
return 'Reuse a folder\'s listing until it\'s a few minutes old';
|
return 'Reuse a folder\'s listing until it\'s a few minutes old';
|
||||||
case CachePolicy.manual:
|
case CachePolicy.manual:
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import 'package:video_player/video_player.dart';
|
|||||||
import '../models/nextcloud_item.dart';
|
import '../models/nextcloud_item.dart';
|
||||||
import '../providers/server_provider.dart';
|
import '../providers/server_provider.dart';
|
||||||
import '../widgets/details/details_sheet.dart';
|
import '../widgets/details/details_sheet.dart';
|
||||||
import '../widgets/details/peek_details_sheet.dart';
|
|
||||||
import '../widgets/frosted_glass_container.dart';
|
import '../widgets/frosted_glass_container.dart';
|
||||||
|
import '../widgets/seek_bar_painter.dart';
|
||||||
import '../widgets/share_sheet.dart';
|
import '../widgets/share_sheet.dart';
|
||||||
|
|
||||||
const _textPreviewExtensions = {
|
const _textPreviewExtensions = {
|
||||||
@@ -337,13 +337,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// The action bar floats just above the details peek below it,
|
Align(
|
||||||
// rather than overlapping it.
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
bottom: MediaQuery.sizeOf(context).height * 0.16,
|
|
||||||
),
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: AnimatedSlide(
|
child: AnimatedSlide(
|
||||||
duration: const Duration(milliseconds: 220),
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final value = widget.controller.value;
|
final value = widget.controller.value;
|
||||||
final isWavy = widget.style == MediaProgressBarStyle.wavy;
|
final animatesWave =
|
||||||
final isPlaying = isWavy && value.isPlaying && _dragRatio == null;
|
widget.style == MediaProgressBarStyle.wavy ||
|
||||||
|
widget.style == MediaProgressBarStyle.squiggly;
|
||||||
|
final isPlaying = animatesWave && value.isPlaying && _dragRatio == null;
|
||||||
if (isPlaying && !_waveController.isAnimating) {
|
if (isPlaying && !_waveController.isAnimating) {
|
||||||
_waveController.repeat();
|
_waveController.repeat();
|
||||||
} else if (!isPlaying && _waveController.isAnimating) {
|
} else if (!isPlaying && _waveController.isAnimating) {
|
||||||
@@ -944,7 +927,8 @@ class _WavySeekBarState extends State<_WavySeekBar>
|
|||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
return CustomPaint(
|
return CustomPaint(
|
||||||
size: Size(constraints.maxWidth, 28),
|
size: Size(constraints.maxWidth, 28),
|
||||||
painter: _WavySeekPainter(
|
painter: SeekBarPainter(
|
||||||
|
style: widget.style,
|
||||||
progress: ratio,
|
progress: ratio,
|
||||||
phase: _waveController.value * 2 * math.pi,
|
phase: _waveController.value * 2 * math.pi,
|
||||||
animate: isPlaying,
|
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 {
|
class _PdfPreview extends StatefulWidget {
|
||||||
final NextcloudItem item;
|
final NextcloudItem item;
|
||||||
final ServerProvider provider;
|
final ServerProvider provider;
|
||||||
|
|||||||
@@ -214,6 +214,12 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
color: Theme.of(context).colorScheme.error,
|
color: Theme.of(context).colorScheme.error,
|
||||||
onTap: () => _confirmDeleteSelected(context, provider, selected),
|
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)
|
if (selected.length == 1)
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
icon: Icons.info_outline_rounded,
|
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(
|
Future<void> _shareSelected(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
ServerProvider provider,
|
ServerProvider provider,
|
||||||
@@ -282,7 +337,10 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
final controlsRow = Padding(
|
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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -390,26 +448,52 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
if (_isSelecting)
|
if (_isSelecting)
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
pinned: true,
|
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,
|
automaticallyImplyLeading: false,
|
||||||
leading: IconButton(
|
toolbarHeight: 48,
|
||||||
icon: const Icon(Icons.close_rounded),
|
// 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',
|
tooltip: 'Cancel selection',
|
||||||
onPressed: _clearSelection,
|
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(
|
for (final action in _buildSelectionActions(
|
||||||
provider,
|
provider,
|
||||||
selectedItems,
|
selectedItems,
|
||||||
))
|
))
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(action.icon, color: action.color),
|
icon: Icon(action.icon, size: 20, color: action.color),
|
||||||
tooltip: action.label,
|
tooltip: action.label,
|
||||||
onPressed: action.onTap,
|
onPressed: action.onTap,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Files List / Grid
|
// Files List / Grid
|
||||||
if (provider.isLoading)
|
if (provider.isLoading)
|
||||||
@@ -727,6 +811,7 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
? card
|
? card
|
||||||
: SwipeableItem(
|
: SwipeableItem(
|
||||||
itemKey: ValueKey('file-${item.id}'),
|
itemKey: ValueKey('file-${item.id}'),
|
||||||
|
itemName: item.name,
|
||||||
provider: provider,
|
provider: provider,
|
||||||
onFavorite: () => provider.toggleItemFavorite(item),
|
onFavorite: () => provider.toggleItemFavorite(item),
|
||||||
onShare: () => ShareSheet.show(context, item),
|
onShare: () => ShareSheet.show(context, item),
|
||||||
|
|||||||
@@ -180,26 +180,52 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
if (_isSelecting)
|
if (_isSelecting)
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
pinned: true,
|
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,
|
automaticallyImplyLeading: false,
|
||||||
leading: IconButton(
|
toolbarHeight: 48,
|
||||||
icon: const Icon(Icons.close_rounded),
|
// 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',
|
tooltip: 'Cancel selection',
|
||||||
onPressed: _clearSelection,
|
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(
|
for (final action in _buildSelectionActions(
|
||||||
provider,
|
provider,
|
||||||
selectedItems,
|
selectedItems,
|
||||||
))
|
))
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(action.icon, color: action.color),
|
icon: Icon(action.icon, size: 20, color: action.color),
|
||||||
tooltip: action.label,
|
tooltip: action.label,
|
||||||
onPressed: action.onTap,
|
onPressed: action.onTap,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 8)),
|
const SliverToBoxAdapter(child: SizedBox(height: 8)),
|
||||||
|
|
||||||
if (provider.isMediaLoading && photos.isEmpty)
|
if (provider.isMediaLoading && photos.isEmpty)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/app_tab.dart';
|
||||||
import '../models/nextcloud_item.dart';
|
import '../models/nextcloud_item.dart';
|
||||||
import '../providers/server_provider.dart';
|
import '../providers/server_provider.dart';
|
||||||
import 'file_viewer_screen.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -106,6 +127,7 @@ class _SearchViewState extends State<SearchView> {
|
|||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
autoFocus: true,
|
autoFocus: true,
|
||||||
hintText: 'Search your files...',
|
hintText: 'Search your files...',
|
||||||
|
elevation: const WidgetStatePropertyAll(0),
|
||||||
onChanged: _onChanged,
|
onChanged: _onChanged,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back_rounded),
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
@@ -151,14 +173,7 @@ class _SearchViewState extends State<SearchView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!_hasSearched) {
|
if (!_hasSearched) {
|
||||||
return Center(
|
return const SizedBox.shrink();
|
||||||
child: Text(
|
|
||||||
'Search across your whole Nextcloud',
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (_results.isEmpty) {
|
if (_results.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
@@ -185,14 +200,7 @@ class _SearchViewState extends State<SearchView> {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () => _openResult(item),
|
||||||
if (item.isFolder) {
|
|
||||||
context.read<ServerProvider>().navigateToFolder(item.path);
|
|
||||||
Navigator.pop(context);
|
|
||||||
} else {
|
|
||||||
Navigator.push(context, FileViewerScreen.route(item: 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.
|
/// which action a given swipe direction maps to and drives the gesture.
|
||||||
class SwipeableItem extends StatelessWidget {
|
class SwipeableItem extends StatelessWidget {
|
||||||
final Key itemKey;
|
final Key itemKey;
|
||||||
|
final String itemName;
|
||||||
final ServerProvider provider;
|
final ServerProvider provider;
|
||||||
final Future<void> Function() onFavorite;
|
final Future<void> Function() onFavorite;
|
||||||
final Future<void> Function() onShare;
|
final Future<void> Function() onShare;
|
||||||
@@ -18,6 +19,7 @@ class SwipeableItem extends StatelessWidget {
|
|||||||
const SwipeableItem({
|
const SwipeableItem({
|
||||||
super.key,
|
super.key,
|
||||||
required this.itemKey,
|
required this.itemKey,
|
||||||
|
required this.itemName,
|
||||||
required this.provider,
|
required this.provider,
|
||||||
required this.onFavorite,
|
required this.onFavorite,
|
||||||
required this.onShare,
|
required this.onShare,
|
||||||
@@ -95,6 +97,33 @@ class SwipeableItem extends StatelessWidget {
|
|||||||
await onShare();
|
await onShare();
|
||||||
return false;
|
return false;
|
||||||
case SwipeAction.delete:
|
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();
|
HapticFeedback.mediumImpact();
|
||||||
return true; // Actual delete happens in onDismissed.
|
return true; // Actual delete happens in onDismissed.
|
||||||
case SwipeAction.none:
|
case SwipeAction.none:
|
||||||
|
|||||||
@@ -55,14 +55,27 @@ class SyncedHeaderScaffold extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
|
class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
|
||||||
static const double _lockThreshold = 100;
|
static const double _lockThreshold = 200;
|
||||||
static const double _syncThreshold = 14;
|
static const double _syncThreshold = 120;
|
||||||
static const double _pullingEpsilon = 8;
|
static const double _pullingEpsilon = 8;
|
||||||
|
|
||||||
bool _headerLocked = false;
|
bool _headerLocked = false;
|
||||||
bool _isPulling = false;
|
bool _isPulling = false;
|
||||||
|
bool _isRefreshing = false;
|
||||||
double _pullDistance = 0;
|
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() {
|
void _revealPanel() {
|
||||||
if (widget.scrollController.hasClients) {
|
if (widget.scrollController.hasClients) {
|
||||||
widget.scrollController.animateTo(
|
widget.scrollController.animateTo(
|
||||||
@@ -78,6 +91,14 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
|
|||||||
_revealPanel();
|
_revealPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _startRefresh() {
|
||||||
|
final refresh = widget.onRefresh ?? widget.provider.refreshData;
|
||||||
|
setState(() => _isRefreshing = true);
|
||||||
|
refresh().whenComplete(() {
|
||||||
|
if (mounted) setState(() => _isRefreshing = false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
bool _handleScrollNotification(ScrollNotification notification) {
|
bool _handleScrollNotification(ScrollNotification notification) {
|
||||||
final metrics = notification.metrics;
|
final metrics = notification.metrics;
|
||||||
if (metrics.pixels < metrics.minScrollExtent) {
|
if (metrics.pixels < metrics.minScrollExtent) {
|
||||||
@@ -86,22 +107,29 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
|
|||||||
if (pulling != _isPulling && !_headerLocked) {
|
if (pulling != _isPulling && !_headerLocked) {
|
||||||
setState(() => _isPulling = pulling);
|
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) {
|
} else if (_isPulling) {
|
||||||
setState(() => _isPulling = false);
|
setState(() => _isPulling = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notification is ScrollEndNotification) {
|
if (notification is ScrollEndNotification) {
|
||||||
final pulled = _pullDistance;
|
|
||||||
_pullDistance = 0;
|
_pullDistance = 0;
|
||||||
|
_syncedThisGesture = false;
|
||||||
|
_lockedThisGesture = false;
|
||||||
if (_isPulling) setState(() => _isPulling = 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;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -124,9 +152,14 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
|
|||||||
leadingWidget = _SyncStatusChip(provider: provider, onTap: _lockOpen);
|
leadingWidget = _SyncStatusChip(provider: provider, onTap: _lockOpen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final topInset = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
color: colorScheme.surfaceContainer,
|
color: colorScheme.surfaceContainer,
|
||||||
child: NotificationListener<ScrollNotification>(
|
child: Stack(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
children: [
|
||||||
|
NotificationListener<ScrollNotification>(
|
||||||
onNotification: _handleScrollNotification,
|
onNotification: _handleScrollNotification,
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
controller: widget.scrollController,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ flutter:
|
|||||||
# the material Icons class.
|
# the material Icons class.
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
|
assets:
|
||||||
|
- assets/icon/app_icon.png
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
# To add assets to your application, add an assets section, like this:
|
||||||
# assets:
|
# assets:
|
||||||
# - images/a_dot_burr.jpeg
|
# - images/a_dot_burr.jpeg
|
||||||
|
|||||||