diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index 77b4eee..05d61cf 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -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 diff --git a/lib/providers/shares_controller.dart b/lib/providers/shares_controller.dart index fe6c5aa..ab5b31b 100644 --- a/lib/providers/shares_controller.dart +++ b/lib/providers/shares_controller.dart @@ -8,7 +8,16 @@ import 'session_controller.dart'; class SharesController extends ChangeNotifier { final SessionController session; - List _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 _withMeShares = []; + List _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 get shares => _shares; + List 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 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 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; diff --git a/lib/views/account_view.dart b/lib/views/account_view.dart index 5861d5e..1058de8 100644 --- a/lib/views/account_view.dart +++ b/lib/views/account_view.dart @@ -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 = [ - 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 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 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 diff --git a/lib/views/file_viewer_screen.dart b/lib/views/file_viewer_screen.dart index 65f5f16..7da59c8 100644 --- a/lib/views/file_viewer_screen.dart +++ b/lib/views/file_viewer_screen.dart @@ -148,6 +148,12 @@ class _FileViewerScreenState extends State { /// `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 { 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 { // 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 { 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 { url: null, headers: const {}, localPath: localPath, + onZoomChanged: isActive + ? (zoomed) => setState(() => _isZoomed = zoomed) + : null, ); } return MediaVideoPreview( diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index a8cf266..4ef84e9 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -227,12 +227,9 @@ class _GridThumbnail extends StatelessWidget { } } -class _FilesViewState extends State - with SingleTickerProviderStateMixin { +class _FilesViewState extends State { final Set _selectedIds = {}; int _lastPathDepth = 1; - final ScrollController _controlsScrollController = ScrollController(); - final List _scrollHintControllers = []; bool get _isSelecting => _selectedIds.isNotEmpty; @@ -264,84 +261,6 @@ class _FilesViewState extends State // 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 _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([ - 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 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) ...[ diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index 40dcc67..eae28ef 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -381,51 +381,36 @@ class _PhotosViewState extends State { 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 { // 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, + ), + ), + ), + ], ), ], ), diff --git a/lib/views/shares_view.dart b/lib/views/shares_view.dart index 3d398a4..acb342a 100644 --- a/lib/views/shares_view.dart +++ b/lib/views/shares_view.dart @@ -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 { 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 { 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 { 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 { 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 { context.read().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 _confirmUnshare( BuildContext context, NextcloudShare share, diff --git a/lib/widgets/files_controls_row.dart b/lib/widgets/files_controls_row.dart index d47c5b3..1af9bec 100644 --- a/lib/widgets/files_controls_row.dart +++ b/lib/widgets/files_controls_row.dart @@ -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( - 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( + iconOnly: true, + value: files.isGridView, + onChanged: files.setGridView, + options: const [ + NooSegmentOption(value: false, icon: LucideIcons.list), + NooSegmentOption(value: true, icon: LucideIcons.grid), + ], + ), + ], ), ); } diff --git a/lib/widgets/noo/lists/noo_banner.dart b/lib/widgets/noo/lists/noo_banner.dart index 06365f1..aa778f9 100644 --- a/lib/widgets/noo/lists/noo_banner.dart +++ b/lib/widgets/noo/lists/noo_banner.dart @@ -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, + ), ), ), ), + ], ], ), ); diff --git a/lib/widgets/noo/lists/noo_summary_card.dart b/lib/widgets/noo/lists/noo_summary_card.dart index 27ab47e..62ce3f3 100644 --- a/lib/widgets/noo/lists/noo_summary_card.dart +++ b/lib/widgets/noo/lists/noo_summary_card.dart @@ -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!), diff --git a/lib/widgets/settings/settings_appearance.dart b/lib/widgets/settings/settings_appearance.dart index 26d333e..8ec884a 100644 --- a/lib/widgets/settings/settings_appearance.dart +++ b/lib/widgets/settings/settings_appearance.dart @@ -47,34 +47,7 @@ class SettingsAppearanceSection extends StatelessWidget { children: [ SettingsSection( title: 'Appearance', - children: [ - NooSettingsRow( - icon: LucideIcons.sunMoon, - label: const Text('Theme'), - trailing: NooSegmentedControl( - 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( + 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. diff --git a/lib/widgets/viewer/media_image_preview.dart b/lib/widgets/viewer/media_image_preview.dart index 79f2af1..0f268f7 100644 --- a/lib/widgets/viewer/media_image_preview.dart +++ b/lib/widgets/viewer/media_image_preview.dart @@ -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? onZoomChanged; + const MediaImagePreview({ super.key, required this.url, required this.headers, this.localPath, + this.onZoomChanged, }); @override @@ -35,6 +42,7 @@ class _MediaImagePreviewState extends State late final AnimationController _animController; Animation? _animation; Offset _doubleTapPosition = Offset.zero; + bool _isZoomed = false; @override void initState() { @@ -48,10 +56,19 @@ class _MediaImagePreviewState extends State _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(); diff --git a/lib/widgets/viewer/media_pdf_preview.dart b/lib/widgets/viewer/media_pdf_preview.dart index 16df16d..6a2b690 100644 --- a/lib/widgets/viewer/media_pdf_preview.dart +++ b/lib/widgets/viewer/media_pdf_preview.dart @@ -57,9 +57,7 @@ class _MediaPdfPreviewState extends State { } 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 { 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!); } }