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
+34 -5
View File
@@ -207,8 +207,13 @@ multi-select)
**Banner / summary card**
- A surface card with radius 20 (surface-2 on desktop) and padding of 14–18.
- Trash: an info icon, the retention text, and "Empty trash" as a danger text button.
- Offline: the stat, a 6px progress bar, the last-sync meta, and a "Sync now" tonal button.
- The action sits on its own row below the description text, not squeezed
onto the same line - a long retention message/caption and the action
used to fight for the same row's width.
- Trash: an info icon, the retention text, then "Empty trash" as a
right-aligned danger text button on the row below.
- Offline: the stat and caption, then a "Sync now" tonal button on the row
below, then the 6px progress bar and the last-sync meta.
**Progress bar**
- 6px tall pill. Surface-3 track with an accent fill.
@@ -224,6 +229,13 @@ multi-select)
- Left to right: an optional 20px icon in fg-2, then the label (16) with an optional subtitle (13, fg-3), then a trailing control.
- Trailing control is one of: a toggle, a value in fg-3 with a chevron (desktop uses a 32px dropdown with radius 10), a segmented control, a pill button, or a status badge.
- A destructive row uses danger text.
- Exception: a segmented control with 3+ labelled segments (Theme's
System/Light/Dark) gets its own row below the label instead of a
trailing slot - three icon+label segments crammed in next to the label
left each one too cramped to read. Label row, then the full-width
segmented control on the row below, both on one continuous surface (no
divider between them) - same shape the sort sheet's Ascending/Descending
control and Photos' filter-sheet type control already use.
**Tab order row**
- A grip icon (`grip-vertical`, fg-3), the tab icon, the label, then a pin button: a 36px circle (30 on desktop) that is accent-soft with `pin` when pinned, or surface-2 with `pin-off` when not.
@@ -304,7 +316,13 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The
- Toolbar: sort chip (`Name ↑`), type filter chip, List/Grid segmented toggle.
- Desktop adds Modified and Shared filter chips, an active-filter chip, and an item count.
- Content: a list, or a grid when the toggle is set.
- **Photos:** type chips (All, Photos, Videos, Camera), then the photo grid grouped by month. The grid view is the only view.
- **Photos:** a sort chip and a filter chip (mirroring Files' own controls
row - no List/Grid toggle, the grid is the only view). The type filter
(All, Photos, Videos - Camera isn't backed by real data, see the view's
doc comment) lives inside the filter sheet as a full-width segmented
control, the same "label, then the control on its own row below" shape
Settings' Theme row uses, not its own row of chips. Then the photo grid
grouped by month.
- **Favorites:** a file list with a star as the trailing icon.
- **Offline:** a summary card (or 4 stat cards on desktop), then a list whose rows show sync status icons and status meta.
- **Shares:** a segmented control (With you, By you, Links), then a list. The meta reads "Owner · Permission".
@@ -326,7 +344,12 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The
slots, the rest sit behind "More". See §2 "Selection action bar".
9. Swipe on a file
Mobile uses one column of grouped lists. Desktop uses a 2-column grid of cards with a 1px line and radius 20.
Mobile uses one column of grouped lists, with a trailing jump rail (one
small icon per section, pinned where the scrollbar would sit) since the
full list runs long enough that scrolling to a specific section by hand
is slow - tapping an icon scrolls straight to that section. Desktop uses
a 2-column grid of cards with a 1px line and radius 20, wide enough to
see most sections without scrolling, so it doesn't get the rail.
- **Share sheet / dialog:** sections in this order:
1. Header: file tile, name, size · folder, and close.
2. **Share with people:** an input ("Name, email or group"), then the people with access. The owner comes first; the others each have a permission pill ("Can edit ▾").
@@ -358,7 +381,13 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The
externally, download, delete, details) on every platform; don't add a
top-bar overflow menu for the same actions. Video adds a transport row
above the action row: time · seek bar · time, then play/pause and mute
centered below it.
centered below it. A photo can be pinch-zoomed; while zoomed in, the
gallery's own left/right swipe between items is disabled so panning
around the zoomed photo doesn't also swipe to the next one - it comes
back the moment the photo returns to its un-zoomed scale. A PDF preview
stays at its fit-width scale as its floor rather than letting the user
zoom out past it, since going below 1.0 scale removes any limit on how
far the page can be panned, including off-screen entirely.
- **Search:** pushed from the shell's search entry point (`menu`/`search`
icon in the top bar, or the inline field below an iOS large title -
DESIGN_SYSTEM §2 "Search field" placement). The destination screen is one
+37 -8
View File
@@ -8,7 +8,16 @@ import 'session_controller.dart';
class SharesController extends ChangeNotifier {
final SessionController session;
List<NextcloudShare> _shares = [];
/// Each scope's own cache - switching `sharedWithMe` no longer refetches
/// on every switch, only the first time a scope is visited (or on a
/// pull-to-refresh/retry), since `By you`/`Links` (see shares_view.dart's
/// client-side split of this same list) and `With you` used to hit the
/// network again on every single toggle, even switching straight back to
/// a scope just fetched seconds ago.
List<NextcloudShare> _withMeShares = [];
List<NextcloudShare> _byMeShares = [];
bool _withMeLoaded = false;
bool _byMeLoaded = false;
bool _isLoading = false;
String? _errorMessage;
bool _sharedWithMe = false;
@@ -18,13 +27,17 @@ class SharesController extends ChangeNotifier {
session.addAccountActivatedListener(_onAccountActivated);
}
List<NextcloudShare> get shares => _shares;
List<NextcloudShare> get shares =>
_sharedWithMe ? _withMeShares : _byMeShares;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
bool get sharedWithMe => _sharedWithMe;
void _onAccountCleared() {
_shares = [];
_withMeShares = [];
_byMeShares = [];
_withMeLoaded = false;
_byMeLoaded = false;
_isLoading = false;
_errorMessage = null;
_sharedWithMe = false;
@@ -35,19 +48,29 @@ class SharesController extends ChangeNotifier {
unawaited(fetchAll());
}
/// Fetches the *current* scope fresh, ignoring its cache - the initial
/// load, pull-to-refresh, and the error-retry button all want a real
/// round-trip regardless of whether this scope was already loaded.
Future<void> fetchAll() async {
final service = session.service;
if (!session.isLoggedIn || service == null) return;
final gen = session.sessionGeneration;
final wantsWithMe = _sharedWithMe;
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
final shares = await service.fetchShares(sharedWithMe: _sharedWithMe);
final shares = await service.fetchShares(sharedWithMe: wantsWithMe);
if (gen != session.sessionGeneration) return;
_shares = shares;
if (wantsWithMe) {
_withMeShares = shares;
_withMeLoaded = true;
} else {
_byMeShares = shares;
_byMeLoaded = true;
}
} catch (e) {
if (gen != session.sessionGeneration) return;
debugPrint('[SharesController] Error fetching shares: $e');
@@ -60,12 +83,14 @@ class SharesController extends ChangeNotifier {
}
}
/// Switches between "shared by me" and "shared with me" and refetches.
/// Switches between "shared by me" and "shared with me". Only fetches
/// when the target scope hasn't been loaded yet - switching back to one
/// already cached just swaps which list [shares] reads from.
void setSharedWithMe(bool value) {
if (_sharedWithMe == value) return;
_sharedWithMe = value;
notifyListeners();
fetchAll();
if (!(value ? _withMeLoaded : _byMeLoaded)) fetchAll();
}
Future<bool> deleteShare(NextcloudShare share) async {
@@ -73,7 +98,11 @@ class SharesController extends ChangeNotifier {
if (service == null) return false;
final success = await service.deleteShare(share.id);
if (success) {
_shares = _shares.where((s) => s.id != share.id).toList();
if (_sharedWithMe) {
_withMeShares = _withMeShares.where((s) => s.id != share.id).toList();
} else {
_byMeShares = _byMeShares.where((s) => s.id != share.id).toList();
}
notifyListeners();
}
return success;
+121 -26
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../theme/design_tokens.dart';
import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/nav/noo_toolbar.dart';
@@ -13,27 +14,35 @@ import '../widgets/settings/settings_security.dart';
import '../widgets/settings/settings_swipe.dart';
import '../widgets/settings/settings_tabs.dart';
/// One entry in [AccountView._sections] - [icon] and [label] are only used
/// by [_SectionJumpRail]'s tap targets, not shown next to the section
/// itself (each section already titles itself).
typedef _Section = (String label, IconData icon, Widget child);
/// Settings, pushed on top of the shell (DESIGN_SYSTEM.md 4's 9-section
/// order: account card, accounts, security, file sync, files cache,
/// appearance, tabs, action bar, swipe on a file). One column of
/// [SettingsSection]s (and [SettingsActionBarSection], which has no option
/// rows of its own to put in one - just the reorder list) on
/// mobile; a 2-column grid of cards on desktop - see each
/// mobile, with a trailing jump rail (see [_SectionJumpRail]) since the
/// list runs long enough that finding a specific section by scrolling
/// alone is slow; a 2-column grid of cards on desktop, wide enough to see
/// most sections without scrolling, so it doesn't get one. See each
/// `widgets/settings/*.dart` file for a section's own content and any
/// setting that had to be slotted in or grouped under "Advanced appearance".
class AccountView extends StatelessWidget {
const AccountView({super.key});
static const _sections = <Widget>[
SettingsAccountCard(),
SettingsAccountsSection(),
SettingsSecuritySection(),
SettingsFileSyncSection(),
SettingsFilesCacheSection(),
SettingsAppearanceSection(),
SettingsTabsSection(),
SettingsActionBarSection(),
SettingsSwipeSection(),
static const _sections = <_Section>[
('Account', LucideIcons.circleUser, SettingsAccountCard()),
('Accounts', LucideIcons.users, SettingsAccountsSection()),
('Security', LucideIcons.shield, SettingsSecuritySection()),
('File sync', LucideIcons.folderSync, SettingsFileSyncSection()),
('Files cache', LucideIcons.hardDrive, SettingsFilesCacheSection()),
('Appearance', LucideIcons.sunMoon, SettingsAppearanceSection()),
('Tabs', LucideIcons.layoutGrid, SettingsTabsSection()),
('Action bar', LucideIcons.slidersHorizontal, SettingsActionBarSection()),
('Swipe', LucideIcons.moveHorizontal, SettingsSwipeSection()),
];
@override
@@ -53,37 +62,123 @@ class AccountView extends StatelessWidget {
body: SafeArea(
top: false,
child: desktop
? _DesktopGrid(sections: _sections)
? _DesktopGrid(sections: [for (final s in _sections) s.$3])
: _MobileList(sections: _sections),
),
);
}
}
class _MobileList extends StatelessWidget {
final List<Widget> sections;
/// A stable per-section identity, shared between the [KeyedSubtree] each
/// section is wrapped in and [_SectionJumpRail]'s lookup - [GlobalObjectKey]
/// compares by [String] equality, not instance identity, so building a new
/// one from the same label on each side still resolves to the same key.
GlobalKey _sectionKey(String label) => GlobalObjectKey(label);
class _MobileList extends StatefulWidget {
final List<_Section> sections;
const _MobileList({required this.sections});
@override
State<_MobileList> createState() => _MobileListState();
}
class _MobileListState extends State<_MobileList> {
final _scrollController = ScrollController();
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _jumpTo(String label) {
final target = _sectionKey(label).currentContext;
if (target == null) return;
Scrollable.ensureVisible(
target,
duration: NooMotion.slow,
curve: NooMotion.ease,
alignment: 0.05,
);
}
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.fromLTRB(
NooSpace.sm,
NooSpace.sm,
NooSpace.sm,
NooSpace.xxl,
),
physics: const BouncingScrollPhysics(),
return Stack(
children: [
for (final section in sections) ...[
section,
const SizedBox(height: NooSpace.xl),
],
ListView(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(
NooSpace.sm,
NooSpace.sm,
NooSpace.xxl,
NooSpace.xxl,
),
physics: const BouncingScrollPhysics(),
children: [
for (final (label, _, child) in widget.sections) ...[
KeyedSubtree(key: _sectionKey(label), child: child),
const SizedBox(height: NooSpace.xl),
],
],
),
Positioned(
top: 0,
bottom: 0,
right: 2,
child: _SectionJumpRail(sections: widget.sections, onTap: _jumpTo),
),
],
);
}
}
/// A vertical rail of small per-section icons pinned to the trailing edge,
/// sitting where the scrollbar would otherwise be - tapping one jumps
/// straight to that section instead of scrolling the whole (fairly long)
/// Settings list by hand. Each icon is a generous 32px tap target even
/// though the rail itself is slim, so this stays usable one-handed.
class _SectionJumpRail extends StatelessWidget {
final List<_Section> sections;
final ValueChanged<String> onTap;
const _SectionJumpRail({required this.sections, required this.onTap});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Center(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 2),
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(NooRadii.pill),
border: Border.all(color: colors.line),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final (label, icon, _) in sections)
Semantics(
button: true,
label: 'Jump to $label',
child: InkResponse(
onTap: () => onTap(label),
radius: 18,
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(icon, size: 16, color: colors.fg3),
),
),
),
],
),
),
);
}
}
/// Splits the sections between two columns rather than a strict grid, since
/// each card's content height varies a lot (the tab reorder list and the
/// accounts list can both run much taller than, say, Security) - a fixed
+20
View File
@@ -148,6 +148,12 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
/// `VideoTransportControls` inside the bottom `MediaActionBar` panel.
VideoPlayerController? _videoController;
/// Whether the *current* page's `MediaImagePreview` is zoomed in - see
/// `_buildBody`'s `onZoomChanged`. Disables the `PageView`'s own
/// left/right swipe while true, so panning around a zoomed photo doesn't
/// also drag the gallery to the next item.
bool _isZoomed = false;
NextcloudItem get _currentItem => _mediaItems[_currentIndex];
bool get _isOffline => widget.localPathResolver != null;
@@ -552,6 +558,10 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
return PageView.builder(
controller: _pageController,
// Zoomed into the current photo: let InteractiveViewer's own pan
// claim horizontal drags instead of the PageView swiping to the next
// item out from under it.
physics: _isZoomed ? const NeverScrollableScrollPhysics() : null,
itemCount: _mediaItems.length,
onPageChanged: (index) => setState(() {
_currentIndex = index;
@@ -560,6 +570,10 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
// but that can lag a frame or two behind this rebuild - clear
// eagerly so the transport row never briefly shows the old video.
_videoController = null;
// The new page always starts unzoomed - and the old page's own
// listener is now scoped away from `_isZoomed` (see `isActive`
// below), so nothing else would reset this.
_isZoomed = false;
}),
itemBuilder: (context, index) {
final mediaItem = _mediaItems[index];
@@ -570,6 +584,9 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
key: ValueKey(mediaItem.id),
url: session.service!.fileUrl(mediaItem.path),
headers: session.service!.authHeaders,
onZoomChanged: isActive
? (zoomed) => setState(() => _isZoomed = zoomed)
: null,
);
}
return MediaVideoPreview(
@@ -613,6 +630,9 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
url: null,
headers: const {},
localPath: localPath,
onZoomChanged: isActive
? (zoomed) => setState(() => _isZoomed = zoomed)
: null,
);
}
return MediaVideoPreview(
+1 -85
View File
@@ -227,12 +227,9 @@ class _GridThumbnail extends StatelessWidget {
}
}
class _FilesViewState extends State<FilesView>
with SingleTickerProviderStateMixin {
class _FilesViewState extends State<FilesView> {
final Set<String> _selectedIds = {};
int _lastPathDepth = 1;
final ScrollController _controlsScrollController = ScrollController();
final List<AnimationController> _scrollHintControllers = [];
bool get _isSelecting => _selectedIds.isNotEmpty;
@@ -264,84 +261,6 @@ class _FilesViewState extends State<FilesView>
// that just never got its initial fetch.
bool _requestedInitialLoad = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback(
(_) => _playScrollHint(_controlsScrollController),
);
}
/// A one-shot hint that a horizontally-scrollable row actually scrolls:
/// nudges it a little to the right and back, once - a motion cue instead
/// of a persistent widget (a chevron badge, an edge fade) sitting on top
/// of the actual controls the whole time. Shared by the controls row
/// (played once it first appears) and the selection actions row (played
/// the first time a selection starts, see `_toggleSelection`). No-ops if
/// there's nothing to scroll (row already fits).
Future<void> _playScrollHint(ScrollController scrollController) async {
// The delay lets the row's first frame (and its actual layout/max
// scroll extent) settle before nudging it, and reads more like a
// deliberate hint than something that happens to fire on load.
await Future.delayed(const Duration(milliseconds: 500));
// `hasClients` only means a position is attached, not that it's
// finished its first layout - reading maxScrollExtent before that
// throws (min/maxScrollExtent are still null internally).
if (!mounted ||
!scrollController.hasClients ||
!scrollController.position.hasContentDimensions) {
return;
}
final maxExtent = scrollController.position.maxScrollExtent;
if (maxExtent <= 0) return;
final double peak = maxExtent < 36 ? maxExtent : 36;
// Driven as a single controller (rather than two chained animateTo
// calls) with mirrored ease-in-out halves, so the motion decelerates
// smoothly into the peak and back out instead of visibly changing
// pace where the two legs meet.
final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
_scrollHintControllers.add(controller);
final hint = TweenSequence<double>([
TweenSequenceItem(
tween: Tween(
begin: 0.0,
end: peak,
).chain(CurveTween(curve: Curves.easeInOutSine)),
weight: 50,
),
TweenSequenceItem(
tween: Tween(
begin: peak,
end: 0.0,
).chain(CurveTween(curve: Curves.easeInOutSine)),
weight: 50,
),
]).animate(controller);
void onTick() {
if (scrollController.hasClients) {
scrollController.jumpTo(hint.value);
}
}
hint.addListener(onTick);
await controller.forward();
hint.removeListener(onTick);
_scrollHintControllers.remove(controller);
controller.dispose();
}
@override
void dispose() {
for (final controller in _scrollHintControllers) {
controller.dispose();
}
_controlsScrollController.dispose();
super.dispose();
}
void _toggleSelection(NextcloudItem item) {
HapticFeedback.selectionClick();
setState(() {
@@ -703,13 +622,10 @@ class _FilesViewState extends State<FilesView>
children: [
Row(
children: [
// The scroll-hint nudge (see _playScrollHint) plays on
// this row once, right after it first appears.
Expanded(
child: FilesControlsRow(
folderPath: browser.currentFolderPath,
showStorageScope: !_offline,
scrollController: _controlsScrollController,
),
),
if (_offline) ...[
+75 -64
View File
@@ -381,51 +381,36 @@ class _PhotosViewState extends State<PhotosView> {
FilesController files,
) {
final filtersActive =
_typeFilter != _PhotoTypeFilter.all ||
photos.showFavoritesOnly ||
photos.showHidden ||
files.storageScope != StorageScope.cloud;
// A plain Row, not a horizontally-scrolling one - see
// `FilesControlsRow`'s identical fix/doc comment: a `SingleChildScrollView`
// gives its child unbounded width for no benefit here (two chips never
// need to scroll), and it's actively harmful for a row with a trailing
// `Spacer`/flex child.
return SizedBox(
height: 44,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
NooChip(
icon: photos.sortAscending
? LucideIcons.arrowUp
: LucideIcons.arrowDown,
onTap: () => _showSortSheet(context, photos),
child: Text(sortFieldLabel(photos.sortField)),
),
const SizedBox(width: 8),
NooChip(
selected: _typeFilter == _PhotoTypeFilter.all,
onTap: () => setState(() => _typeFilter = _PhotoTypeFilter.all),
child: const Text('All'),
),
const SizedBox(width: 8),
NooChip(
selected: _typeFilter == _PhotoTypeFilter.image,
onTap: () => setState(() => _typeFilter = _PhotoTypeFilter.image),
child: const Text('Photos'),
),
const SizedBox(width: 8),
NooChip(
selected: _typeFilter == _PhotoTypeFilter.video,
onTap: () => setState(() => _typeFilter = _PhotoTypeFilter.video),
child: const Text('Videos'),
),
const SizedBox(width: 8),
NooChip(
icon: LucideIcons.filter,
trailing: NooChipTrailing.menu,
selected: filtersActive,
onTap: () => _showFilterSheet(context, photos, files),
child: const Text('Filter'),
),
],
),
child: Row(
children: [
NooChip(
icon: photos.sortAscending
? LucideIcons.arrowUp
: LucideIcons.arrowDown,
onTap: () => _showSortSheet(context, photos),
child: Text(sortFieldLabel(photos.sortField)),
),
const SizedBox(width: 8),
NooChip(
icon: LucideIcons.filter,
trailing: NooChipTrailing.menu,
selected: filtersActive,
onTap: () => _showFilterSheet(context, photos, files),
child: const Text('Filters'),
),
],
),
);
}
@@ -503,33 +488,59 @@ class _PhotosViewState extends State<PhotosView> {
// toggles span two controllers.
ListenableBuilder(
listenable: Listenable.merge([photos, files]),
builder: (context, _) => NooGroupedList(
builder: (context, _) => Column(
mainAxisSize: MainAxisSize.min,
children: [
NooSettingsRow(
icon: LucideIcons.heart,
label: const Text('Favorites only'),
trailing: NooToggle(
checked: photos.showFavoritesOnly,
onChanged: (_) => photos.toggleFavoritesFilter(),
),
),
NooSettingsRow(
icon: LucideIcons.eye,
label: const Text('Show hidden files'),
trailing: NooToggle(
checked: photos.showHidden,
onChanged: (_) => photos.toggleShowHidden(),
),
),
NooSettingsRow(
icon: LucideIcons.hardDrive,
label: const Text('External storage'),
trailing: NooToggle(
checked: files.storageScope == StorageScope.external,
onChanged: (external) => files.setStorageScope(
external ? StorageScope.external : StorageScope.cloud,
// Same segmented-control design as Settings' Theme row - a
// full-width pill track, not chips, and its own row rather
// than a settings row's trailing slot (matches the
// Ascending/Descending control above, in `_showSortSheet`).
NooSegmentedControl<_PhotoTypeFilter>(
fill: true,
value: _typeFilter,
onChanged: (filter) => setState(() => _typeFilter = filter),
options: const [
NooSegmentOption(value: _PhotoTypeFilter.all, label: 'All'),
NooSegmentOption(
value: _PhotoTypeFilter.image,
label: 'Photos',
),
),
NooSegmentOption(
value: _PhotoTypeFilter.video,
label: 'Videos',
),
],
),
const SizedBox(height: 22),
NooGroupedList(
children: [
NooSettingsRow(
icon: LucideIcons.heart,
label: const Text('Favorites only'),
trailing: NooToggle(
checked: photos.showFavoritesOnly,
onChanged: (_) => photos.toggleFavoritesFilter(),
),
),
NooSettingsRow(
icon: LucideIcons.eye,
label: const Text('Show hidden files'),
trailing: NooToggle(
checked: photos.showHidden,
onChanged: (_) => photos.toggleShowHidden(),
),
),
NooSettingsRow(
icon: LucideIcons.hardDrive,
label: const Text('External storage'),
trailing: NooToggle(
checked: files.storageScope == StorageScope.external,
onChanged: (external) => files.setStorageScope(
external ? StorageScope.external : StorageScope.cloud,
),
),
),
],
),
],
),
+82 -12
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/app_tab.dart';
@@ -11,16 +12,21 @@ import '../widgets/noo/core/noo_segmented_control.dart';
import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/lists/noo_settings_row.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/tabs/tab_state_slivers.dart';
/// Which slice of [SharesController.shares] is on screen - `With you` maps
/// straight onto the controller's own `sharedWithMe: true` fetch, but the
/// controller only ever exposes one boolean scope, so `By you` and `Links`
/// both use its `sharedWithMe: false` fetch and are told apart client-side
/// by [NextcloudShare.shareType] (see `_visibleShares`) - `Links` and
/// `By you` never trigger a re-fetch when switching between each other,
/// only when crossing to/from `With you`.
/// by [NextcloudShare.shareType] (see `_visibleShares`). `SharesController`
/// caches each of its two scopes after its first fetch, so switching
/// between any of the three - including back to `With you` after leaving
/// it - only re-fetches a scope that's never been loaded yet, not on every
/// single switch.
enum _ShareScope { withYou, byYou, links }
IconData _shareTypeIcon(ShareType type) {
@@ -110,7 +116,11 @@ class _SharesViewState extends State<SharesView> {
onRetry: sharesController.fetchAll,
)
else if (shares.isEmpty)
tabEmptySliver(context, icon: LucideIcons.share2, message: _emptyMessage())
tabEmptySliver(
context,
icon: LucideIcons.share2,
message: _emptyMessage(),
)
else if (isDesktop)
_buildDesktopTable(context, shares)
else
@@ -137,9 +147,13 @@ class _SharesViewState extends State<SharesView> {
case _ShareScope.withYou:
return c.shares;
case _ShareScope.byYou:
return c.shares.where((s) => s.shareType != ShareType.publicLink).toList();
return c.shares
.where((s) => s.shareType != ShareType.publicLink)
.toList();
case _ShareScope.links:
return c.shares.where((s) => s.shareType == ShareType.publicLink).toList();
return c.shares
.where((s) => s.shareType == ShareType.publicLink)
.toList();
}
}
@@ -175,13 +189,17 @@ class _SharesViewState extends State<SharesView> {
delegate: SliverChildBuilderDelegate((context, index) {
final share = shares[index];
final row = NooFileRow(
kind: NooFileKind.from(name: share.name, isDirectory: share.isFolder),
kind: NooFileKind.from(
name: share.name,
isDirectory: share.isFolder,
),
name: share.name,
meta: '${share.ownerDisplayName} · ${_permissionLabel(share.permissions)}',
meta:
'${share.ownerDisplayName} · ${_permissionLabel(share.permissions)}',
iosStyle: NooLayout.iosStyle(context),
onTap: share.isFolder ? () => _openFolder(context, share) : null,
trailing: Icon(_shareTypeIcon(share.shareType), size: 16),
onMore: () => _confirmUnshare(context, share),
onMore: () => _showShareActions(context, share),
);
final isFirst = index == 0;
final isLast = index == shares.length - 1;
@@ -215,16 +233,22 @@ class _SharesViewState extends State<SharesView> {
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (index == 0) {
return const NooFileTableHeader(col2Label: 'Owner', col3Label: 'Permission');
return const NooFileTableHeader(
col2Label: 'Owner',
col3Label: 'Permission',
);
}
final share = shares[index - 1];
return NooFileTableRow(
kind: NooFileKind.from(name: share.name, isDirectory: share.isFolder),
kind: NooFileKind.from(
name: share.name,
isDirectory: share.isFolder,
),
name: share.name,
col2: share.ownerDisplayName,
col3: _permissionLabel(share.permissions),
onTap: share.isFolder ? () => _openFolder(context, share) : null,
onMore: () => _confirmUnshare(context, share),
onMore: () => _showShareActions(context, share),
);
}, childCount: shares.length + 1),
),
@@ -240,6 +264,52 @@ class _SharesViewState extends State<SharesView> {
context.read<SettingsController>().requestTab(AppTab.files);
}
/// The row/table overflow menu - used to jump straight to the unshare
/// confirmation with no menu at all, so a link share had no way to copy
/// its own URL short of opening it and finding "Copy link" inside the
/// full Share sheet. A link share gets that shortcut here too now;
/// everything else just gets the one remove/unshare action.
void _showShareActions(BuildContext context, NextcloudShare share) {
final isLink = share.shareType == ShareType.publicLink && share.url != null;
showNooSheet(
context,
children: [
NooGroupedList(
children: [
if (isLink)
NooSettingsRow(
icon: LucideIcons.copy,
label: const Text('Copy link'),
onTap: () {
Navigator.pop(context);
_copyShareLink(context, share);
},
),
NooSettingsRow(
icon: LucideIcons.trash2,
label: Text(share.sharedWithMe ? 'Remove share' : 'Unshare'),
danger: true,
onTap: () {
Navigator.pop(context);
_confirmUnshare(context, share);
},
),
],
),
],
);
}
void _copyShareLink(BuildContext context, NextcloudShare share) {
Clipboard.setData(ClipboardData(text: share.url!));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Link copied'),
behavior: SnackBarBehavior.floating,
),
);
}
Future<void> _confirmUnshare(
BuildContext context,
NextcloudShare share,
+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!);
}
}