Ten fixes: controls-row alignment, banners, shares, viewer bounds

- FilesControlsRow (and Photos' own controls row) wrapped its Row in a
  SingleChildScrollView, which gives a child unbounded width - silently
  breaking the trailing Spacer (a flex child needs bounded width) and
  leaving the List/Grid toggle sitting right after the filter chip
  instead of pinned to the row's right edge, "not enough controls to
  fill the row" or not. Removed the scroll wrapper (two chips and an
  icon toggle never need it) and the dead scroll-hint plumbing that
  went with it in files_view.dart.
- Photos' All/Photos/Videos chips moved into the filter sheet as a
  full-width segmented control (matching Settings' Theme control),
  instead of their own row of chips competing for space with the sort
  and filter chips.
- Settings' Theme row: the System/Light/Dark segmented control now
  sits on its own row below the label instead of squeezed into a
  trailing slot beside it, where three icon+label segments were too
  cramped to read comfortably - same shape as the sort sheet's
  Ascending/Descending control.
- NooBanner (Trash's "Empty trash") and NooSummaryCard (Offline's
  "Sync now") both move their action onto its own row below the
  description text instead of squeezed onto the same line, where a
  longer caption had no room to share with it.
- SharesController now caches each scope (with you / by you, which
  also covers links) after its first fetch instead of re-fetching on
  every single switch, including switching straight back to a scope
  already loaded seconds ago.
- Shares' per-row overflow menu jumped straight to the unshare
  confirmation with no menu at all - a link share had no way to copy
  its own URL short of opening the full Share sheet. It's a real menu
  now: "Copy link" (link shares only) plus remove/unshare.
- Settings gained a trailing jump rail on mobile (one small icon per
  section) so a specific section can be reached in one tap instead of
  scrolling the whole list by hand; desktop's 2-column grid doesn't
  need one.
- The media viewer's PageView now disables its own left/right swipe
  while the current photo is zoomed in, so panning around it doesn't
  also swipe to the next item.
- The PDF preview no longer sets minScale below 1.0: pdfx's
  PdfViewPinch hard-codes an *infinite* pan boundary whenever minScale
  < 1 (not an exposed parameter, so there's no way to override it),
  which let a page be panned arbitrarily far off-screen without ever
  zooming.
This commit is contained in:
2026-09-28 22:55:31 -04:00
parent 0abea46b1c
commit a127d72d56
13 changed files with 556 additions and 314 deletions
+35 -34
View File
@@ -32,16 +32,21 @@ String _typeFilterLabel(FilesTypeFilter filter) {
/// Hidden-files and storage-scope, which have no home in the mockup's
/// simple two-chip row, live inside the filter chip's sheet instead of as
/// their own row controls.
///
/// A plain `Row`, not a horizontally-scrolling one - it used to wrap in a
/// `SingleChildScrollView`, which gives its child unbounded width, silently
/// breaking the trailing `Spacer` (a flex child needs a bounded width to
/// size against) and leaving the List/Grid toggle sitting right after the
/// filter chip instead of pinned to the row's right edge. The two chips
/// plus an icon-only toggle never come close to needing to scroll anyway.
class FilesControlsRow extends StatelessWidget {
final String folderPath;
final bool showStorageScope;
final ScrollController? scrollController;
const FilesControlsRow({
super.key,
required this.folderPath,
this.showStorageScope = true,
this.scrollController,
});
@override
@@ -54,38 +59,34 @@ class FilesControlsRow extends StatelessWidget {
return SizedBox(
height: 44,
child: SingleChildScrollView(
controller: scrollController,
scrollDirection: Axis.horizontal,
child: Row(
children: [
NooChip(
icon: files.sortAscendingFor(folderPath)
? LucideIcons.arrowUp
: LucideIcons.arrowDown,
onTap: () => _showSortSheet(context, files),
child: Text(sortFieldLabel(files.sortFieldFor(folderPath))),
),
const SizedBox(width: 8),
NooChip(
icon: LucideIcons.filter,
trailing: NooChipTrailing.menu,
selected: filtersActive,
onTap: () => _showFilterSheet(context, files),
child: Text(_typeFilterLabel(files.filesTypeFilter)),
),
const Spacer(),
NooSegmentedControl<bool>(
iconOnly: true,
value: files.isGridView,
onChanged: files.setGridView,
options: const [
NooSegmentOption(value: false, icon: LucideIcons.list),
NooSegmentOption(value: true, icon: LucideIcons.grid),
],
),
],
),
child: Row(
children: [
NooChip(
icon: files.sortAscendingFor(folderPath)
? LucideIcons.arrowUp
: LucideIcons.arrowDown,
onTap: () => _showSortSheet(context, files),
child: Text(sortFieldLabel(files.sortFieldFor(folderPath))),
),
const SizedBox(width: 8),
NooChip(
icon: LucideIcons.filter,
trailing: NooChipTrailing.menu,
selected: filtersActive,
onTap: () => _showFilterSheet(context, files),
child: Text(_typeFilterLabel(files.filesTypeFilter)),
),
const Spacer(),
NooSegmentedControl<bool>(
iconOnly: true,
value: files.isGridView,
onChanged: files.setGridView,
options: const [
NooSegmentOption(value: false, icon: LucideIcons.list),
NooSegmentOption(value: true, icon: LucideIcons.grid),
],
),
],
),
);
}
+34 -18
View File
@@ -26,29 +26,45 @@ class NooBanner extends StatelessWidget {
final colors = context.nooColors;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(NooRadii.card)),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(NooRadii.card),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 20, color: colors.fg2),
const SizedBox(width: 12),
Expanded(
child: DefaultTextStyle(
style: NooText.body.copyWith(color: colors.fg2),
child: child,
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(icon, size: 20, color: colors.fg2),
const SizedBox(width: 12),
Expanded(
child: DefaultTextStyle(
style: NooText.body.copyWith(color: colors.fg2),
child: child,
),
),
],
),
if (actionLabel != null)
GestureDetector(
onTap: onAction,
child: Text(
actionLabel!,
style: NooText.buttonSm.copyWith(
fontSize: 14,
color: actionIsDanger ? colors.danger : colors.accentText,
// Its own row below the description text, not squeezed onto the
// same line - a long retention message and "Empty trash" used to
// fight for the same row's width.
if (actionLabel != null) ...[
const SizedBox(height: 10),
Align(
alignment: Alignment.centerRight,
child: GestureDetector(
onTap: onAction,
child: Text(
actionLabel!,
style: NooText.buttonSm.copyWith(
fontSize: 14,
color: actionIsDanger ? colors.danger : colors.accentText,
),
),
),
),
],
],
),
);
+27 -23
View File
@@ -10,6 +10,7 @@ enum NooSummaryCardTone { normal, danger, accent }
class NooSummaryCard extends StatelessWidget {
final String stat;
final String? caption;
/// 0-1; renders a [NooProgressBar].
final double? progress;
final String? meta;
@@ -29,41 +30,44 @@ class NooSummaryCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final bg = tone == NooSummaryCardTone.danger ? colors.dangerSoft : colors.surface;
final bg = tone == NooSummaryCardTone.danger
? colors.dangerSoft
: colors.surface;
final statColor = switch (tone) {
NooSummaryCardTone.danger => colors.danger,
NooSummaryCardTone.accent => colors.accentText,
NooSummaryCardTone.normal => colors.fg1,
};
final capColor = tone == NooSummaryCardTone.danger ? colors.danger : colors.fg2;
final capColor = tone == NooSummaryCardTone.danger
? colors.danger
: colors.fg2;
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(NooRadii.card)),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(NooRadii.card),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(stat, style: NooText.stat.copyWith(color: statColor)),
if (caption != null) ...[
const SizedBox(height: 6),
Text(
caption!,
style: NooText.body.copyWith(fontSize: 14, height: 1.2, color: capColor),
),
],
],
),
Text(stat, style: NooText.stat.copyWith(color: statColor)),
if (caption != null) ...[
const SizedBox(height: 6),
Text(
caption!,
style: NooText.body.copyWith(
fontSize: 14,
height: 1.2,
color: capColor,
),
?action,
],
),
),
],
// Its own row below the description, not squeezed beside the
// stat/caption - there wasn't room to grow the action (e.g. a
// labelled "Sync now" button) without it colliding with a long
// caption.
if (action != null) ...[const SizedBox(height: 14), action!],
if (progress != null) ...[
const SizedBox(height: 14),
NooProgressBar(value: progress!),
+64 -28
View File
@@ -47,34 +47,7 @@ class SettingsAppearanceSection extends StatelessWidget {
children: [
SettingsSection(
title: 'Appearance',
children: [
NooSettingsRow(
icon: LucideIcons.sunMoon,
label: const Text('Theme'),
trailing: NooSegmentedControl<ThemeMode>(
size: NooSegmentedSize.sm,
value: settings.themeMode,
onChanged: settings.setThemeMode,
options: const [
NooSegmentOption(
value: ThemeMode.system,
icon: LucideIcons.monitor,
label: 'System',
),
NooSegmentOption(
value: ThemeMode.light,
icon: LucideIcons.sun,
label: 'Light',
),
NooSegmentOption(
value: ThemeMode.dark,
icon: LucideIcons.moon,
label: 'Dark',
),
],
),
),
],
children: [_ThemeRow(settings: settings)],
),
const SizedBox(height: NooSpace.xl),
SettingsSection(
@@ -120,6 +93,69 @@ class SettingsAppearanceSection extends StatelessWidget {
}
}
/// The "Theme" row: label on its own line, then the System/Light/Dark
/// segmented control full-width on the line below - not the usual
/// label-plus-trailing-control `NooSettingsRow` layout, since three
/// icon+label segments crammed into a trailing slot next to the row's own
/// label left every segment too cramped to read comfortably. Matches the
/// same "label, then a full-width segmented control below it" shape
/// `_showSortSheet` (Ascending/Descending) and Photos' own filter sheet
/// (All/Photos/Videos) already use.
class _ThemeRow extends StatelessWidget {
final SettingsController settings;
const _ThemeRow({required this.settings});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Material(
color: colors.surface,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.md,
vertical: 12,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.sunMoon, size: 20, color: colors.fg2),
const SizedBox(width: 14),
Text('Theme', style: NooText.bodyL.copyWith(color: colors.fg1)),
],
),
const SizedBox(height: 12),
NooSegmentedControl<ThemeMode>(
fill: true,
value: settings.themeMode,
onChanged: settings.setThemeMode,
options: const [
NooSegmentOption(
value: ThemeMode.system,
icon: LucideIcons.monitor,
label: 'System',
),
NooSegmentOption(
value: ThemeMode.light,
icon: LucideIcons.sun,
label: 'Light',
),
NooSegmentOption(
value: ThemeMode.dark,
icon: LucideIcons.moon,
label: 'Dark',
),
],
),
],
),
),
);
}
}
/// The current accent-color choice, shown as a small filled circle in the
/// "Accent color" row - a wallpaper icon when following the OS's dynamic
/// color instead of a fixed swatch.
@@ -15,11 +15,18 @@ class MediaImagePreview extends StatefulWidget {
/// `FileViewerScreen.localPathResolver`'s doc comment).
final String? localPath;
/// Reports whenever this image crosses the zoomed-in/out threshold (not
/// on every micro-change in scale) - lets the parent `PageView` disable
/// its own left/right swipe while zoomed in, so panning around a zoomed
/// photo doesn't fight with swiping to the next one.
final ValueChanged<bool>? onZoomChanged;
const MediaImagePreview({
super.key,
required this.url,
required this.headers,
this.localPath,
this.onZoomChanged,
});
@override
@@ -35,6 +42,7 @@ class _MediaImagePreviewState extends State<MediaImagePreview>
late final AnimationController _animController;
Animation<Matrix4>? _animation;
Offset _doubleTapPosition = Offset.zero;
bool _isZoomed = false;
@override
void initState() {
@@ -48,10 +56,19 @@ class _MediaImagePreviewState extends State<MediaImagePreview>
_transformController.value = _animation!.value;
}
});
_transformController.addListener(_handleTransformChanged);
}
void _handleTransformChanged() {
final isZoomed = _transformController.value.getMaxScaleOnAxis() > 1.01;
if (isZoomed == _isZoomed) return;
_isZoomed = isZoomed;
widget.onZoomChanged?.call(isZoomed);
}
@override
void dispose() {
_transformController.removeListener(_handleTransformChanged);
_animController.dispose();
_transformController.dispose();
super.dispose();
+9 -11
View File
@@ -57,9 +57,7 @@ class _MediaPdfPreviewState extends State<MediaPdfPreview> {
}
if (!mounted) return;
setState(() {
_controller = PdfControllerPinch(
document: PdfDocument.openData(bytes),
);
_controller = PdfControllerPinch(document: PdfDocument.openData(bytes));
});
} catch (e) {
if (mounted) setState(() => _error = e.toString());
@@ -86,13 +84,13 @@ class _MediaPdfPreviewState extends State<MediaPdfPreview> {
if (_controller == null) {
return Center(child: CircularProgressIndicator(color: colors.accent));
}
// pdfx's own default minScale (1.0 = the page's true/100% size) is
// often *larger* than the fit-to-width size a wide page first renders
// at, since that initial render is just normal box layout, not the
// InteractiveViewer transform pinching engages on first touch - so the
// moment you touch the page it snaps up to 1.0 and, with the default
// floor, can never pinch back down past it. A low floor here lets you
// zoom back out past that to the fit-width view you started at.
return PdfViewPinch(controller: _controller!, minScale: 0.3);
// Keep minScale at (not below) 1.0: pdfx's PdfViewPinch hard-codes an
// *infinite* boundaryMargin whenever minScale < 1 (its own
// pdf_view_pinch.dart), meaning the page could be panned arbitrarily
// far off-screen with no way back short of reopening the viewer, even
// without ever pinching to zoom. There's no way to override that
// margin from here - it isn't an exposed parameter - so this avoids
// the branch that sets it instead of fighting it.
return PdfViewPinch(controller: _controller!);
}
}