diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png index 33c938c..d82688e 100644 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png and b/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png index 2cf34a2..249d494 100644 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png index 901bd0c..3e0b783 100644 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png and b/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png index 4913668..a321425 100644 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png and b/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png index 6187738..a8c63c1 100644 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png index 9fb6561..b467632 100644 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png and b/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png index 0358504..31da161 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png index c9b6c78..592ad9a 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png index 2f4c7d7..165bebe 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png index 0cb1ca0..78c3efa 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png index 2aa793e..d11334c 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png index f3fd5a0..87b3cd8 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png index 6a790e7..02f618b 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png index 0206c5a..d7749bf 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png index e97c97b..bbc0f14 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 6ee490e..900995c 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 92e9c09..0a31fbb 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index f3980b4..09a1cb0 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index e8c8526..f628107 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 6c4a824..9564821 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/assets/icon/app_icon.png b/assets/icon/app_icon.png index 15df0ef..87648b3 100644 Binary files a/assets/icon/app_icon.png and b/assets/icon/app_icon.png differ diff --git a/assets/icon/app_icon_background.png b/assets/icon/app_icon_background.png index 7c4c1a6..463c0ca 100644 Binary files a/assets/icon/app_icon_background.png and b/assets/icon/app_icon_background.png differ diff --git a/assets/icon/app_icon_foreground.png b/assets/icon/app_icon_foreground.png index 15199f9..d557880 100644 Binary files a/assets/icon/app_icon_foreground.png and b/assets/icon/app_icon_foreground.png differ diff --git a/assets/icon/app_icon_monochrome.png b/assets/icon/app_icon_monochrome.png index 10b7ac6..6cde16b 100644 Binary files a/assets/icon/app_icon_monochrome.png and b/assets/icon/app_icon_monochrome.png differ diff --git a/lib/main.dart b/lib/main.dart index aa5c59f..eb2eecf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 { @override Widget build(BuildContext context) { final provider = context.watch(); + + // 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 { 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: () { diff --git a/lib/providers/server_provider.dart b/lib/providers/server_provider.dart index c7f21a9..1b4ad0a 100644 --- a/lib/providers/server_provider.dart +++ b/lib/providers/server_provider.dart @@ -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 _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 _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 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 navigateToAbsoluteFolder(String path) async { + final normalized = path.trim().replaceAll(RegExp(r'/+$'), ''); + final segments = normalized.split('/').where((s) => s.isNotEmpty).toList(); + final stack = ['/']; + 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 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 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(); diff --git a/lib/services/nextcloud_service.dart b/lib/services/nextcloud_service.dart index dcd18b0..0fb553b 100644 --- a/lib/services/nextcloud_service.dart +++ b/lib/services/nextcloud_service.dart @@ -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 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 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> fetchFileActivity(String fileId) async { - List 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 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. diff --git a/lib/views/account_view.dart b/lib/views/account_view.dart index 3c65735..0981e81 100644 --- a/lib/views/account_view.dart +++ b/lib/views/account_view.dart @@ -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 _openPicker( + BuildContext context, + ServerProvider provider, + ) async { + final selected = await showDialog( + 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(); 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( - 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: diff --git a/lib/views/file_viewer_screen.dart b/lib/views/file_viewer_screen.dart index 89f5fa9..f92a10e 100644 --- a/lib/views/file_viewer_screen.dart +++ b/lib/views/file_viewer_screen.dart @@ -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 { ), ), ), - // 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; diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index fc3a948..0c13c24 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -214,6 +214,12 @@ class _FilesViewState extends State { 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 { ]; } + Future _renameItem(ServerProvider provider, NextcloudItem item) async { + final controller = TextEditingController(text: item.name); + final newName = await showDialog( + 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 _shareSelected( BuildContext context, ServerProvider provider, @@ -282,7 +337,10 @@ class _FilesViewState extends State { .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 { 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 { ? card : SwipeableItem( itemKey: ValueKey('file-${item.id}'), + itemName: item.name, provider: provider, onFavorite: () => provider.toggleItemFavorite(item), onShare: () => ShareSheet.show(context, item), diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index 26977b2..f3d9aae 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -180,25 +180,51 @@ class _PhotosViewState extends State { 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)), diff --git a/lib/views/search_view.dart b/lib/views/search_view.dart index 260d52f..662c9bc 100644 --- a/lib/views/search_view.dart +++ b/lib/views/search_view.dart @@ -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 { } } + /// 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 _openResult(NextcloudItem item) async { + final provider = context.read(); + 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 { 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 { ); } 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 { maxLines: 1, overflow: TextOverflow.ellipsis, ), - onTap: () { - if (item.isFolder) { - context.read().navigateToFolder(item.path); - Navigator.pop(context); - } else { - Navigator.push(context, FileViewerScreen.route(item: item)); - } - }, + onTap: () => _openResult(item), ); }, ); diff --git a/lib/widgets/details/peek_details_sheet.dart b/lib/widgets/details/peek_details_sheet.dart deleted file mode 100644 index 89f1982..0000000 --- a/lib/widgets/details/peek_details_sheet.dart +++ /dev/null @@ -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 createState() => _PeekDetailsSheetState(); -} - -class _PeekDetailsSheetState extends State { - 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( - 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), - ), - ), - ], - ), - ), - ); - }, - ); - } -} diff --git a/lib/widgets/seek_bar_painter.dart b/lib/widgets/seek_bar_painter.dart new file mode 100644 index 0000000..a2fc6ed --- /dev/null +++ b/lib/widgets/seek_bar_painter.dart @@ -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 createState() => _SeekBarPreviewState(); +} + +class _SeekBarPreviewState extends State + 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 _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, + ), + ); + }, + ); + } +} diff --git a/lib/widgets/swipeable_item.dart b/lib/widgets/swipeable_item.dart index 67df977..40caca1 100644 --- a/lib/widgets/swipeable_item.dart +++ b/lib/widgets/swipeable_item.dart @@ -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 Function() onFavorite; final Future 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( + 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: diff --git a/lib/widgets/synced_header_scaffold.dart b/lib/widgets/synced_header_scaffold.dart index 6f54211..d7bea9d 100644 --- a/lib/widgets/synced_header_scaffold.dart +++ b/lib/widgets/synced_header_scaffold.dart @@ -55,14 +55,27 @@ class SyncedHeaderScaffold extends StatefulWidget { } class _SyncedHeaderScaffoldState extends State { - 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 { _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 { 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 { leadingWidget = _SyncStatusChip(provider: provider, onTap: _lockOpen); } + final topInset = MediaQuery.of(context).padding.top; + return ColoredBox( color: colorScheme.surfaceContainer, - child: NotificationListener( - 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( + 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), - ), - ], - ), + ), + ], ), ); } diff --git a/pubspec.yaml b/pubspec.yaml index bc390c3..00ee150 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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