Polish media viewer, pull-to-sync, search nav, and add seek bar presets
- Remove peek details sheet from media viewer; scope Activity tab to the current file via client-side filtering instead of unreliable server-side query params - Add file rename action (WebDAV MOVE); fix search result taps to switch to Files tab and rebuild breadcrumbs from an absolute path - Add swipe-to-delete confirmation dialog; fix a crash from disposing the rename dialog's TextEditingController too early - Rework pull-to-sync gesture so sync and lock-open thresholds fire independently within one gesture, raise thresholds, and add a classic Material refresh spinner - Add "tap active tab to scroll to top" behavior with a settings toggle - Align selection toolbar padding with the sort/filter controls row - Replace app icon assets (adaptive icon + splash screen) with updated IconKitchen export - Replace the seek bar style dropdown with a Default/Wavy/Slim/Squiggly preset picker, each rendered with a live, perpetually-animated preview via a new shared SeekBarPainter/SeekBarPreview Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+176
-28
@@ -4,6 +4,7 @@ import '../models/app_tab.dart';
|
||||
import '../providers/server_provider.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/frosted_glass_container.dart';
|
||||
import '../widgets/seek_bar_painter.dart';
|
||||
|
||||
class AccountView extends StatelessWidget {
|
||||
const AccountView({super.key});
|
||||
@@ -342,6 +343,17 @@ class AccountView extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const _BottomBarAppearanceCard(),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
child: SwitchListTile(
|
||||
title: const Text('Tap Tab to Scroll to Top'),
|
||||
subtitle: const Text(
|
||||
'Tapping the current bottom bar tab scrolls its list back to the top',
|
||||
),
|
||||
value: provider.tapTabToScrollTop,
|
||||
onChanged: provider.setTapTabToScrollTop,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Bottom Nav Tabs
|
||||
@@ -742,48 +754,115 @@ class _SwipeActionsCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
String _seekBarStyleLabel(MediaProgressBarStyle style) {
|
||||
switch (style) {
|
||||
case MediaProgressBarStyle.classic:
|
||||
return 'Default';
|
||||
case MediaProgressBarStyle.wavy:
|
||||
return 'Wavy';
|
||||
case MediaProgressBarStyle.slim:
|
||||
return 'Slim';
|
||||
case MediaProgressBarStyle.squiggly:
|
||||
return 'Squiggly';
|
||||
}
|
||||
}
|
||||
|
||||
class _MediaPlayerCard extends StatelessWidget {
|
||||
const _MediaPlayerCard();
|
||||
|
||||
String _label(MediaProgressBarStyle style) {
|
||||
switch (style) {
|
||||
case MediaProgressBarStyle.wavy:
|
||||
return 'Wavy';
|
||||
case MediaProgressBarStyle.classic:
|
||||
return 'Classic';
|
||||
}
|
||||
Future<void> _openPicker(
|
||||
BuildContext context,
|
||||
ServerProvider provider,
|
||||
) async {
|
||||
final selected = await showDialog<MediaProgressBarStyle>(
|
||||
context: context,
|
||||
builder: (_) =>
|
||||
_SeekBarStyleDialog(current: provider.mediaProgressBarStyle),
|
||||
);
|
||||
if (selected != null) provider.setMediaProgressBarStyle(selected);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final provider = context.watch<ServerProvider>();
|
||||
|
||||
return Card(
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
title: Text(
|
||||
'Seek bar style',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
subtitle: Text(_seekBarStyleLabel(provider.mediaProgressBarStyle)),
|
||||
trailing: SizedBox(
|
||||
width: 64,
|
||||
height: 24,
|
||||
child: SeekBarPreview(
|
||||
style: provider.mediaProgressBarStyle,
|
||||
playedColor: colorScheme.primary,
|
||||
trackColor: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
onTap: () => _openPicker(context, provider),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A grid of the four [MediaProgressBarStyle] presets, each shown as a live
|
||||
/// preview of the real seek bar painter, matching the preset-picker pattern
|
||||
/// other Material You media players use for this same setting.
|
||||
class _SeekBarStyleDialog extends StatelessWidget {
|
||||
final MediaProgressBarStyle current;
|
||||
|
||||
const _SeekBarStyleDialog({required this.current});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Row(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Seek bar style',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
Text(
|
||||
'Seek Bar Style',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
DropdownButton<MediaProgressBarStyle>(
|
||||
value: provider.mediaProgressBarStyle,
|
||||
underline: const SizedBox.shrink(),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
items: MediaProgressBarStyle.values
|
||||
.map(
|
||||
(s) => DropdownMenuItem(value: s, child: Text(_label(s))),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (s) {
|
||||
if (s != null) provider.setMediaProgressBarStyle(s);
|
||||
},
|
||||
const SizedBox(height: 16),
|
||||
GridView.count(
|
||||
crossAxisCount: 2,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 1.35,
|
||||
children: [
|
||||
for (final style in MediaProgressBarStyle.values)
|
||||
_SeekBarStyleOption(
|
||||
style: style,
|
||||
isSelected: style == current,
|
||||
onTap: () => Navigator.pop(context, style),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -792,6 +871,75 @@ class _MediaPlayerCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SeekBarStyleOption extends StatelessWidget {
|
||||
final MediaProgressBarStyle style;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SeekBarStyleOption({
|
||||
required this.style,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Material(
|
||||
color: isSelected
|
||||
? colorScheme.primaryContainer.withValues(alpha: 0.4)
|
||||
: colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.outlineVariant,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 100,
|
||||
height: 28,
|
||||
child: SeekBarPreview(
|
||||
style: style,
|
||||
playedColor: colorScheme.primary,
|
||||
trackColor: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_seekBarStyleLabel(style),
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CacheSettingsCard extends StatelessWidget {
|
||||
const _CacheSettingsCard();
|
||||
|
||||
@@ -809,7 +957,7 @@ class _CacheSettingsCard extends StatelessWidget {
|
||||
String _description(CachePolicy policy) {
|
||||
switch (policy) {
|
||||
case CachePolicy.never:
|
||||
return 'Every visit to a folder fetches it fresh (current behavior)';
|
||||
return 'Every visit to a folder fetches it fresh';
|
||||
case CachePolicy.interval:
|
||||
return 'Reuse a folder\'s listing until it\'s a few minutes old';
|
||||
case CachePolicy.manual:
|
||||
|
||||
@@ -12,8 +12,8 @@ import 'package:video_player/video_player.dart';
|
||||
import '../models/nextcloud_item.dart';
|
||||
import '../providers/server_provider.dart';
|
||||
import '../widgets/details/details_sheet.dart';
|
||||
import '../widgets/details/peek_details_sheet.dart';
|
||||
import '../widgets/frosted_glass_container.dart';
|
||||
import '../widgets/seek_bar_painter.dart';
|
||||
import '../widgets/share_sheet.dart';
|
||||
|
||||
const _textPreviewExtensions = {
|
||||
@@ -337,48 +337,29 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// The action bar floats just above the details peek below it,
|
||||
// rather than overlapping it.
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.sizeOf(context).height * 0.16,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: AnimatedSlide(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeInOutCubic,
|
||||
offset: _controlsVisible ? Offset.zero : const Offset(0, 1.4),
|
||||
child: IgnorePointer(
|
||||
ignoring: !_controlsVisible,
|
||||
child: _MediaActionBar(
|
||||
isFavorite: _currentItem.isFavorite,
|
||||
isBusy: _isBusy,
|
||||
opacity: provider.bottomBarOpacity,
|
||||
blurSigma: provider.bottomBarBlur,
|
||||
onShare: () => ShareSheet.show(context, _currentItem),
|
||||
onFavorite: () => _toggleFavorite(provider),
|
||||
onDelete: () => _deleteCurrentItem(provider),
|
||||
onOpenExternally: () => _openExternally(provider),
|
||||
onDownload: () => _downloadToDevice(provider),
|
||||
onDetails: () => DetailsSheet.show(context, _currentItem),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: AnimatedSlide(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeInOutCubic,
|
||||
offset: _controlsVisible ? Offset.zero : const Offset(0, 1.4),
|
||||
child: IgnorePointer(
|
||||
ignoring: !_controlsVisible,
|
||||
child: _MediaActionBar(
|
||||
isFavorite: _currentItem.isFavorite,
|
||||
isBusy: _isBusy,
|
||||
opacity: provider.bottomBarOpacity,
|
||||
blurSigma: provider.bottomBarBlur,
|
||||
onShare: () => ShareSheet.show(context, _currentItem),
|
||||
onFavorite: () => _toggleFavorite(provider),
|
||||
onDelete: () => _deleteCurrentItem(provider),
|
||||
onOpenExternally: () => _openExternally(provider),
|
||||
onDownload: () => _downloadToDevice(provider),
|
||||
onDetails: () => DetailsSheet.show(context, _currentItem),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Always-present peek of the current item's details, draggable
|
||||
// up into the full Info/Versions/Activity sheet.
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
ignoring: !_controlsVisible,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _controlsVisible ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: PeekDetailsSheet(item: _currentItem),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -903,8 +884,10 @@ class _WavySeekBarState extends State<_WavySeekBar>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final value = widget.controller.value;
|
||||
final isWavy = widget.style == MediaProgressBarStyle.wavy;
|
||||
final isPlaying = isWavy && value.isPlaying && _dragRatio == null;
|
||||
final animatesWave =
|
||||
widget.style == MediaProgressBarStyle.wavy ||
|
||||
widget.style == MediaProgressBarStyle.squiggly;
|
||||
final isPlaying = animatesWave && value.isPlaying && _dragRatio == null;
|
||||
if (isPlaying && !_waveController.isAnimating) {
|
||||
_waveController.repeat();
|
||||
} else if (!isPlaying && _waveController.isAnimating) {
|
||||
@@ -944,7 +927,8 @@ class _WavySeekBarState extends State<_WavySeekBar>
|
||||
builder: (context, _) {
|
||||
return CustomPaint(
|
||||
size: Size(constraints.maxWidth, 28),
|
||||
painter: _WavySeekPainter(
|
||||
painter: SeekBarPainter(
|
||||
style: widget.style,
|
||||
progress: ratio,
|
||||
phase: _waveController.value * 2 * math.pi,
|
||||
animate: isPlaying,
|
||||
@@ -961,76 +945,6 @@ class _WavySeekBarState extends State<_WavySeekBar>
|
||||
}
|
||||
}
|
||||
|
||||
class _WavySeekPainter extends CustomPainter {
|
||||
final double progress;
|
||||
final double phase;
|
||||
final bool animate;
|
||||
final Color playedColor;
|
||||
final Color trackColor;
|
||||
|
||||
static const _waveLength = 16.0;
|
||||
static const _amplitude = 3.5;
|
||||
|
||||
_WavySeekPainter({
|
||||
required this.progress,
|
||||
required this.phase,
|
||||
required this.animate,
|
||||
required this.playedColor,
|
||||
required this.trackColor,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final midY = size.height / 2;
|
||||
final playedX = size.width * progress;
|
||||
|
||||
final trackPaint = Paint()
|
||||
..color = trackColor
|
||||
..strokeWidth = 3
|
||||
..strokeCap = StrokeCap.round;
|
||||
if (playedX < size.width) {
|
||||
canvas.drawLine(
|
||||
Offset(playedX, midY),
|
||||
Offset(size.width, midY),
|
||||
trackPaint,
|
||||
);
|
||||
}
|
||||
|
||||
final playedPaint = Paint()
|
||||
..color = playedColor
|
||||
..strokeWidth = 3
|
||||
..strokeCap = StrokeCap.round
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
final path = Path();
|
||||
var started = false;
|
||||
for (var x = 0.0; x <= playedX; x += 2) {
|
||||
final y = animate
|
||||
? midY +
|
||||
_amplitude * math.sin((x / _waveLength) * 2 * math.pi + phase)
|
||||
: midY;
|
||||
if (!started) {
|
||||
path.moveTo(x, y);
|
||||
started = true;
|
||||
} else {
|
||||
path.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
canvas.drawPath(path, playedPaint);
|
||||
|
||||
canvas.drawCircle(Offset(playedX, midY), 6, Paint()..color = playedColor);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _WavySeekPainter oldDelegate) {
|
||||
return oldDelegate.progress != progress ||
|
||||
oldDelegate.phase != phase ||
|
||||
oldDelegate.animate != animate ||
|
||||
oldDelegate.playedColor != playedColor ||
|
||||
oldDelegate.trackColor != trackColor;
|
||||
}
|
||||
}
|
||||
|
||||
class _PdfPreview extends StatefulWidget {
|
||||
final NextcloudItem item;
|
||||
final ServerProvider provider;
|
||||
|
||||
+103
-18
@@ -214,6 +214,12 @@ class _FilesViewState extends State<FilesView> {
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
onTap: () => _confirmDeleteSelected(context, provider, selected),
|
||||
),
|
||||
if (selected.length == 1)
|
||||
SelectionAction(
|
||||
icon: Icons.drive_file_rename_outline_rounded,
|
||||
label: 'Rename',
|
||||
onTap: () => _renameItem(provider, selected.single),
|
||||
),
|
||||
if (selected.length == 1)
|
||||
SelectionAction(
|
||||
icon: Icons.info_outline_rounded,
|
||||
@@ -223,6 +229,55 @@ class _FilesViewState extends State<FilesView> {
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _renameItem(ServerProvider provider, NextcloudItem item) async {
|
||||
final controller = TextEditingController(text: item.name);
|
||||
final newName = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('Rename'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
onSubmitted: (value) => Navigator.pop(dialogContext, value.trim()),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(dialogContext, controller.text.trim()),
|
||||
child: const Text('Rename'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
// Not disposed here: `showDialog`'s Future resolves as soon as the pop
|
||||
// is initiated, while the dialog (and this controller's TextField)
|
||||
// is still mounted and mid-exit-transition - disposing immediately
|
||||
// crashes with a "still has listeners" assertion. It's a plain,
|
||||
// short-lived controller with no ticker/stream to leak, so letting it
|
||||
// get garbage-collected once the transition finishes is the safe call.
|
||||
if (newName == null || newName.isEmpty || newName == item.name) return;
|
||||
if (!mounted) return;
|
||||
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final success = await provider.renameItem(item, newName);
|
||||
_clearSelection();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
success ? 'Renamed to $newName' : 'Failed to rename ${item.name}',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _shareSelected(
|
||||
BuildContext context,
|
||||
ServerProvider provider,
|
||||
@@ -282,7 +337,10 @@ class _FilesViewState extends State<FilesView> {
|
||||
.toList();
|
||||
|
||||
final controlsRow = Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
|
||||
// 16, not 20 - matches the SliverAppBar toolbar's own default
|
||||
// horizontal content inset when it's selecting, so the two rows'
|
||||
// content lines up instead of the controls row looking shifted in.
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -390,25 +448,51 @@ class _FilesViewState extends State<FilesView> {
|
||||
if (_isSelecting)
|
||||
SliverAppBar(
|
||||
pinned: true,
|
||||
// This isn't the scroll view's topmost sliver - the Synced
|
||||
// header above it already reserves status-bar space, so leaving
|
||||
// this at its default `primary: true` double-reserves it too,
|
||||
// showing up as a big empty gap above the toolbar.
|
||||
primary: false,
|
||||
automaticallyImplyLeading: false,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
tooltip: 'Cancel selection',
|
||||
onPressed: _clearSelection,
|
||||
toolbarHeight: 48,
|
||||
// No leading/actions slots - everything lives in `title`, wrapped
|
||||
// in the exact same 16px horizontal Padding as the controls row
|
||||
// above it, so the two rows' content lines up edge-to-edge
|
||||
// instead of relying on SliverAppBar's own leadingWidth/actions
|
||||
// insets (which don't match the controls row's).
|
||||
titleSpacing: 0,
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close_rounded, size: 20),
|
||||
tooltip: 'Cancel selection',
|
||||
onPressed: _clearSelection,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${selectedItems.length} selected',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final action in _buildSelectionActions(
|
||||
provider,
|
||||
selectedItems,
|
||||
))
|
||||
IconButton(
|
||||
icon: Icon(action.icon, size: 20, color: action.color),
|
||||
tooltip: action.label,
|
||||
onPressed: action.onTap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
title: Text('${selectedItems.length} selected'),
|
||||
actions: [
|
||||
for (final action in _buildSelectionActions(
|
||||
provider,
|
||||
selectedItems,
|
||||
))
|
||||
IconButton(
|
||||
icon: Icon(action.icon, color: action.color),
|
||||
tooltip: action.label,
|
||||
onPressed: action.onTap,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
|
||||
// Files List / Grid
|
||||
@@ -727,6 +811,7 @@ class _FilesViewState extends State<FilesView> {
|
||||
? card
|
||||
: SwipeableItem(
|
||||
itemKey: ValueKey('file-${item.id}'),
|
||||
itemName: item.name,
|
||||
provider: provider,
|
||||
onFavorite: () => provider.toggleItemFavorite(item),
|
||||
onShare: () => ShareSheet.show(context, item),
|
||||
|
||||
+43
-17
@@ -180,25 +180,51 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
if (_isSelecting)
|
||||
SliverAppBar(
|
||||
pinned: true,
|
||||
// This isn't the scroll view's topmost sliver - the Synced
|
||||
// header above it already reserves status-bar space, so leaving
|
||||
// this at its default `primary: true` double-reserves it too,
|
||||
// showing up as a big empty gap above the toolbar.
|
||||
primary: false,
|
||||
automaticallyImplyLeading: false,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
tooltip: 'Cancel selection',
|
||||
onPressed: _clearSelection,
|
||||
toolbarHeight: 48,
|
||||
// No leading/actions slots - everything lives in `title`, wrapped
|
||||
// in the exact same 16px horizontal Padding as the controls row
|
||||
// above it, so the two rows' content lines up edge-to-edge
|
||||
// instead of relying on SliverAppBar's own leadingWidth/actions
|
||||
// insets (which don't match the controls row's).
|
||||
titleSpacing: 0,
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close_rounded, size: 20),
|
||||
tooltip: 'Cancel selection',
|
||||
onPressed: _clearSelection,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${selectedItems.length} selected',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final action in _buildSelectionActions(
|
||||
provider,
|
||||
selectedItems,
|
||||
))
|
||||
IconButton(
|
||||
icon: Icon(action.icon, size: 20, color: action.color),
|
||||
tooltip: action.label,
|
||||
onPressed: action.onTap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
title: Text('${selectedItems.length} selected'),
|
||||
actions: [
|
||||
for (final action in _buildSelectionActions(
|
||||
provider,
|
||||
selectedItems,
|
||||
))
|
||||
IconButton(
|
||||
icon: Icon(action.icon, color: action.color),
|
||||
tooltip: action.label,
|
||||
onPressed: action.onTap,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 8)),
|
||||
|
||||
|
||||
+24
-16
@@ -1,7 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/app_tab.dart';
|
||||
import '../models/nextcloud_item.dart';
|
||||
import '../providers/server_provider.dart';
|
||||
import 'file_viewer_screen.dart';
|
||||
@@ -90,6 +92,25 @@ class _SearchViewState extends State<SearchView> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigates the Files tab to [item] (its own folder if it's a folder,
|
||||
/// its parent folder otherwise - rebuilding the whole breadcrumb trail
|
||||
/// from root rather than assuming it's under wherever the user was
|
||||
/// browsing before), switches the shell to the Files tab, and closes
|
||||
/// search. For a file, also opens it once back on Files.
|
||||
Future<void> _openResult(NextcloudItem item) async {
|
||||
final provider = context.read<ServerProvider>();
|
||||
final navigator = Navigator.of(context);
|
||||
|
||||
final folderPath = item.isFolder ? item.path : p.dirname(item.path);
|
||||
await provider.navigateToAbsoluteFolder(folderPath);
|
||||
provider.requestTab(AppTab.files);
|
||||
navigator.popUntil((route) => route.isFirst);
|
||||
|
||||
if (!item.isFolder) {
|
||||
navigator.push(FileViewerScreen.route(item: item));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -106,6 +127,7 @@ class _SearchViewState extends State<SearchView> {
|
||||
focusNode: _focusNode,
|
||||
autoFocus: true,
|
||||
hintText: 'Search your files...',
|
||||
elevation: const WidgetStatePropertyAll(0),
|
||||
onChanged: _onChanged,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
@@ -151,14 +173,7 @@ class _SearchViewState extends State<SearchView> {
|
||||
);
|
||||
}
|
||||
if (!_hasSearched) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Search across your whole Nextcloud',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
if (_results.isEmpty) {
|
||||
return Center(
|
||||
@@ -185,14 +200,7 @@ class _SearchViewState extends State<SearchView> {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: () {
|
||||
if (item.isFolder) {
|
||||
context.read<ServerProvider>().navigateToFolder(item.path);
|
||||
Navigator.pop(context);
|
||||
} else {
|
||||
Navigator.push(context, FileViewerScreen.route(item: item));
|
||||
}
|
||||
},
|
||||
onTap: () => _openResult(item),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user