Noo design system rebuild #1

Merged
ayushya merged 31 commits from noo-design-system-rebuild into main 2026-10-01 09:23:58 -04:00
13 changed files with 556 additions and 314 deletions
Showing only changes of commit a127d72d56 - Show all commits
+34 -5
View File
@@ -207,8 +207,13 @@ multi-select)
**Banner / summary card** **Banner / summary card**
- A surface card with radius 20 (surface-2 on desktop) and padding of 14–18. - 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. - The action sits on its own row below the description text, not squeezed
- Offline: the stat, a 6px progress bar, the last-sync meta, and a "Sync now" tonal button. 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** **Progress bar**
- 6px tall pill. Surface-3 track with an accent fill. - 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. - 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. - 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. - 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** **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. - 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. - 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. - 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. - 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. - **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. - **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". - **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". slots, the rest sit behind "More". See §2 "Selection action bar".
9. Swipe on a file 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: - **Share sheet / dialog:** sections in this order:
1. Header: file tile, name, size · folder, and close. 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 ▾"). 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 externally, download, delete, details) on every platform; don't add a
top-bar overflow menu for the same actions. Video adds a transport row 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 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` - **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 - 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 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 { class SharesController extends ChangeNotifier {
final SessionController session; 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; bool _isLoading = false;
String? _errorMessage; String? _errorMessage;
bool _sharedWithMe = false; bool _sharedWithMe = false;
@@ -18,13 +27,17 @@ class SharesController extends ChangeNotifier {
session.addAccountActivatedListener(_onAccountActivated); session.addAccountActivatedListener(_onAccountActivated);
} }
List<NextcloudShare> get shares => _shares; List<NextcloudShare> get shares =>
_sharedWithMe ? _withMeShares : _byMeShares;
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
bool get sharedWithMe => _sharedWithMe; bool get sharedWithMe => _sharedWithMe;
void _onAccountCleared() { void _onAccountCleared() {
_shares = []; _withMeShares = [];
_byMeShares = [];
_withMeLoaded = false;
_byMeLoaded = false;
_isLoading = false; _isLoading = false;
_errorMessage = null; _errorMessage = null;
_sharedWithMe = false; _sharedWithMe = false;
@@ -35,19 +48,29 @@ class SharesController extends ChangeNotifier {
unawaited(fetchAll()); 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 { Future<void> fetchAll() async {
final service = session.service; final service = session.service;
if (!session.isLoggedIn || service == null) return; if (!session.isLoggedIn || service == null) return;
final gen = session.sessionGeneration; final gen = session.sessionGeneration;
final wantsWithMe = _sharedWithMe;
_isLoading = true; _isLoading = true;
_errorMessage = null; _errorMessage = null;
notifyListeners(); notifyListeners();
try { try {
final shares = await service.fetchShares(sharedWithMe: _sharedWithMe); final shares = await service.fetchShares(sharedWithMe: wantsWithMe);
if (gen != session.sessionGeneration) return; if (gen != session.sessionGeneration) return;
_shares = shares; if (wantsWithMe) {
_withMeShares = shares;
_withMeLoaded = true;
} else {
_byMeShares = shares;
_byMeLoaded = true;
}
} catch (e) { } catch (e) {
if (gen != session.sessionGeneration) return; if (gen != session.sessionGeneration) return;
debugPrint('[SharesController] Error fetching shares: $e'); 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) { void setSharedWithMe(bool value) {
if (_sharedWithMe == value) return; if (_sharedWithMe == value) return;
_sharedWithMe = value; _sharedWithMe = value;
notifyListeners(); notifyListeners();
fetchAll(); if (!(value ? _withMeLoaded : _byMeLoaded)) fetchAll();
} }
Future<bool> deleteShare(NextcloudShare share) async { Future<bool> deleteShare(NextcloudShare share) async {
@@ -73,7 +98,11 @@ class SharesController extends ChangeNotifier {
if (service == null) return false; if (service == null) return false;
final success = await service.deleteShare(share.id); final success = await service.deleteShare(share.id);
if (success) { 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(); notifyListeners();
} }
return success; return success;
+121 -26
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../theme/design_tokens.dart'; import '../theme/design_tokens.dart';
import '../widgets/noo/nav/noo_top_bar.dart'; import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/nav/noo_toolbar.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_swipe.dart';
import '../widgets/settings/settings_tabs.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 /// Settings, pushed on top of the shell (DESIGN_SYSTEM.md 4's 9-section
/// order: account card, accounts, security, file sync, files cache, /// order: account card, accounts, security, file sync, files cache,
/// appearance, tabs, action bar, swipe on a file). One column of /// appearance, tabs, action bar, swipe on a file). One column of
/// [SettingsSection]s (and [SettingsActionBarSection], which has no option /// [SettingsSection]s (and [SettingsActionBarSection], which has no option
/// rows of its own to put in one - just the reorder list) on /// 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 /// `widgets/settings/*.dart` file for a section's own content and any
/// setting that had to be slotted in or grouped under "Advanced appearance". /// setting that had to be slotted in or grouped under "Advanced appearance".
class AccountView extends StatelessWidget { class AccountView extends StatelessWidget {
const AccountView({super.key}); const AccountView({super.key});
static const _sections = <Widget>[ static const _sections = <_Section>[
SettingsAccountCard(), ('Account', LucideIcons.circleUser, SettingsAccountCard()),
SettingsAccountsSection(), ('Accounts', LucideIcons.users, SettingsAccountsSection()),
SettingsSecuritySection(), ('Security', LucideIcons.shield, SettingsSecuritySection()),
SettingsFileSyncSection(), ('File sync', LucideIcons.folderSync, SettingsFileSyncSection()),
SettingsFilesCacheSection(), ('Files cache', LucideIcons.hardDrive, SettingsFilesCacheSection()),
SettingsAppearanceSection(), ('Appearance', LucideIcons.sunMoon, SettingsAppearanceSection()),
SettingsTabsSection(), ('Tabs', LucideIcons.layoutGrid, SettingsTabsSection()),
SettingsActionBarSection(), ('Action bar', LucideIcons.slidersHorizontal, SettingsActionBarSection()),
SettingsSwipeSection(), ('Swipe', LucideIcons.moveHorizontal, SettingsSwipeSection()),
]; ];
@override @override
@@ -53,37 +62,123 @@ class AccountView extends StatelessWidget {
body: SafeArea( body: SafeArea(
top: false, top: false,
child: desktop child: desktop
? _DesktopGrid(sections: _sections) ? _DesktopGrid(sections: [for (final s in _sections) s.$3])
: _MobileList(sections: _sections), : _MobileList(sections: _sections),
), ),
); );
} }
} }
class _MobileList extends StatelessWidget { /// A stable per-section identity, shared between the [KeyedSubtree] each
final List<Widget> sections; /// 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}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView( return Stack(
padding: const EdgeInsets.fromLTRB(
NooSpace.sm,
NooSpace.sm,
NooSpace.sm,
NooSpace.xxl,
),
physics: const BouncingScrollPhysics(),
children: [ children: [
for (final section in sections) ...[ ListView(
section, controller: _scrollController,
const SizedBox(height: NooSpace.xl), 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 /// 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 /// 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 /// 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. /// `VideoTransportControls` inside the bottom `MediaActionBar` panel.
VideoPlayerController? _videoController; 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]; NextcloudItem get _currentItem => _mediaItems[_currentIndex];
bool get _isOffline => widget.localPathResolver != null; bool get _isOffline => widget.localPathResolver != null;
@@ -552,6 +558,10 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
return PageView.builder( return PageView.builder(
controller: _pageController, 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, itemCount: _mediaItems.length,
onPageChanged: (index) => setState(() { onPageChanged: (index) => setState(() {
_currentIndex = index; _currentIndex = index;
@@ -560,6 +570,10 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
// but that can lag a frame or two behind this rebuild - clear // but that can lag a frame or two behind this rebuild - clear
// eagerly so the transport row never briefly shows the old video. // eagerly so the transport row never briefly shows the old video.
_videoController = null; _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) { itemBuilder: (context, index) {
final mediaItem = _mediaItems[index]; final mediaItem = _mediaItems[index];
@@ -570,6 +584,9 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
key: ValueKey(mediaItem.id), key: ValueKey(mediaItem.id),
url: session.service!.fileUrl(mediaItem.path), url: session.service!.fileUrl(mediaItem.path),
headers: session.service!.authHeaders, headers: session.service!.authHeaders,
onZoomChanged: isActive
? (zoomed) => setState(() => _isZoomed = zoomed)
: null,
); );
} }
return MediaVideoPreview( return MediaVideoPreview(
@@ -613,6 +630,9 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
url: null, url: null,
headers: const {}, headers: const {},
localPath: localPath, localPath: localPath,
onZoomChanged: isActive
? (zoomed) => setState(() => _isZoomed = zoomed)
: null,
); );
} }
return MediaVideoPreview( return MediaVideoPreview(
+1 -85
View File
@@ -227,12 +227,9 @@ class _GridThumbnail extends StatelessWidget {
} }
} }
class _FilesViewState extends State<FilesView> class _FilesViewState extends State<FilesView> {
with SingleTickerProviderStateMixin {
final Set<String> _selectedIds = {}; final Set<String> _selectedIds = {};
int _lastPathDepth = 1; int _lastPathDepth = 1;
final ScrollController _controlsScrollController = ScrollController();
final List<AnimationController> _scrollHintControllers = [];
bool get _isSelecting => _selectedIds.isNotEmpty; bool get _isSelecting => _selectedIds.isNotEmpty;
@@ -264,84 +261,6 @@ class _FilesViewState extends State<FilesView>
// that just never got its initial fetch. // that just never got its initial fetch.
bool _requestedInitialLoad = false; 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) { void _toggleSelection(NextcloudItem item) {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
setState(() { setState(() {
@@ -703,13 +622,10 @@ class _FilesViewState extends State<FilesView>
children: [ children: [
Row( Row(
children: [ children: [
// The scroll-hint nudge (see _playScrollHint) plays on
// this row once, right after it first appears.
Expanded( Expanded(
child: FilesControlsRow( child: FilesControlsRow(
folderPath: browser.currentFolderPath, folderPath: browser.currentFolderPath,
showStorageScope: !_offline, showStorageScope: !_offline,
scrollController: _controlsScrollController,
), ),
), ),
if (_offline) ...[ if (_offline) ...[
+75 -64
View File
@@ -381,51 +381,36 @@ class _PhotosViewState extends State<PhotosView> {
FilesController files, FilesController files,
) { ) {
final filtersActive = final filtersActive =
_typeFilter != _PhotoTypeFilter.all ||
photos.showFavoritesOnly || photos.showFavoritesOnly ||
photos.showHidden || photos.showHidden ||
files.storageScope != StorageScope.cloud; 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( return SizedBox(
height: 44, height: 44,
child: SingleChildScrollView( child: Row(
scrollDirection: Axis.horizontal, children: [
child: Row( NooChip(
children: [ icon: photos.sortAscending
NooChip( ? LucideIcons.arrowUp
icon: photos.sortAscending : LucideIcons.arrowDown,
? LucideIcons.arrowUp onTap: () => _showSortSheet(context, photos),
: LucideIcons.arrowDown, child: Text(sortFieldLabel(photos.sortField)),
onTap: () => _showSortSheet(context, photos), ),
child: Text(sortFieldLabel(photos.sortField)), const SizedBox(width: 8),
), NooChip(
const SizedBox(width: 8), icon: LucideIcons.filter,
NooChip( trailing: NooChipTrailing.menu,
selected: _typeFilter == _PhotoTypeFilter.all, selected: filtersActive,
onTap: () => setState(() => _typeFilter = _PhotoTypeFilter.all), onTap: () => _showFilterSheet(context, photos, files),
child: const Text('All'), child: const Text('Filters'),
), ),
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'),
),
],
),
), ),
); );
} }
@@ -503,33 +488,59 @@ class _PhotosViewState extends State<PhotosView> {
// toggles span two controllers. // toggles span two controllers.
ListenableBuilder( ListenableBuilder(
listenable: Listenable.merge([photos, files]), listenable: Listenable.merge([photos, files]),
builder: (context, _) => NooGroupedList( builder: (context, _) => Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
NooSettingsRow( // Same segmented-control design as Settings' Theme row - a
icon: LucideIcons.heart, // full-width pill track, not chips, and its own row rather
label: const Text('Favorites only'), // than a settings row's trailing slot (matches the
trailing: NooToggle( // Ascending/Descending control above, in `_showSortSheet`).
checked: photos.showFavoritesOnly, NooSegmentedControl<_PhotoTypeFilter>(
onChanged: (_) => photos.toggleFavoritesFilter(), fill: true,
), value: _typeFilter,
), onChanged: (filter) => setState(() => _typeFilter = filter),
NooSettingsRow( options: const [
icon: LucideIcons.eye, NooSegmentOption(value: _PhotoTypeFilter.all, label: 'All'),
label: const Text('Show hidden files'), NooSegmentOption(
trailing: NooToggle( value: _PhotoTypeFilter.image,
checked: photos.showHidden, label: 'Photos',
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,
), ),
), 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/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/app_tab.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_kind.dart';
import '../widgets/noo/files/noo_file_row.dart'; import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.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/noo_layout.dart';
import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/tabs/tab_state_slivers.dart'; import '../widgets/tabs/tab_state_slivers.dart';
/// Which slice of [SharesController.shares] is on screen - `With you` maps /// Which slice of [SharesController.shares] is on screen - `With you` maps
/// straight onto the controller's own `sharedWithMe: true` fetch, but the /// straight onto the controller's own `sharedWithMe: true` fetch, but the
/// controller only ever exposes one boolean scope, so `By you` and `Links` /// controller only ever exposes one boolean scope, so `By you` and `Links`
/// both use its `sharedWithMe: false` fetch and are told apart client-side /// both use its `sharedWithMe: false` fetch and are told apart client-side
/// by [NextcloudShare.shareType] (see `_visibleShares`) - `Links` and /// by [NextcloudShare.shareType] (see `_visibleShares`). `SharesController`
/// `By you` never trigger a re-fetch when switching between each other, /// caches each of its two scopes after its first fetch, so switching
/// only when crossing to/from `With you`. /// 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 } enum _ShareScope { withYou, byYou, links }
IconData _shareTypeIcon(ShareType type) { IconData _shareTypeIcon(ShareType type) {
@@ -110,7 +116,11 @@ class _SharesViewState extends State<SharesView> {
onRetry: sharesController.fetchAll, onRetry: sharesController.fetchAll,
) )
else if (shares.isEmpty) else if (shares.isEmpty)
tabEmptySliver(context, icon: LucideIcons.share2, message: _emptyMessage()) tabEmptySliver(
context,
icon: LucideIcons.share2,
message: _emptyMessage(),
)
else if (isDesktop) else if (isDesktop)
_buildDesktopTable(context, shares) _buildDesktopTable(context, shares)
else else
@@ -137,9 +147,13 @@ class _SharesViewState extends State<SharesView> {
case _ShareScope.withYou: case _ShareScope.withYou:
return c.shares; return c.shares;
case _ShareScope.byYou: 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: 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) { delegate: SliverChildBuilderDelegate((context, index) {
final share = shares[index]; final share = shares[index];
final row = NooFileRow( final row = NooFileRow(
kind: NooFileKind.from(name: share.name, isDirectory: share.isFolder), kind: NooFileKind.from(
name: share.name,
isDirectory: share.isFolder,
),
name: share.name, name: share.name,
meta: '${share.ownerDisplayName} · ${_permissionLabel(share.permissions)}', meta:
'${share.ownerDisplayName} · ${_permissionLabel(share.permissions)}',
iosStyle: NooLayout.iosStyle(context), iosStyle: NooLayout.iosStyle(context),
onTap: share.isFolder ? () => _openFolder(context, share) : null, onTap: share.isFolder ? () => _openFolder(context, share) : null,
trailing: Icon(_shareTypeIcon(share.shareType), size: 16), trailing: Icon(_shareTypeIcon(share.shareType), size: 16),
onMore: () => _confirmUnshare(context, share), onMore: () => _showShareActions(context, share),
); );
final isFirst = index == 0; final isFirst = index == 0;
final isLast = index == shares.length - 1; final isLast = index == shares.length - 1;
@@ -215,16 +233,22 @@ class _SharesViewState extends State<SharesView> {
sliver: SliverList( sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate((context, index) {
if (index == 0) { if (index == 0) {
return const NooFileTableHeader(col2Label: 'Owner', col3Label: 'Permission'); return const NooFileTableHeader(
col2Label: 'Owner',
col3Label: 'Permission',
);
} }
final share = shares[index - 1]; final share = shares[index - 1];
return NooFileTableRow( return NooFileTableRow(
kind: NooFileKind.from(name: share.name, isDirectory: share.isFolder), kind: NooFileKind.from(
name: share.name,
isDirectory: share.isFolder,
),
name: share.name, name: share.name,
col2: share.ownerDisplayName, col2: share.ownerDisplayName,
col3: _permissionLabel(share.permissions), col3: _permissionLabel(share.permissions),
onTap: share.isFolder ? () => _openFolder(context, share) : null, onTap: share.isFolder ? () => _openFolder(context, share) : null,
onMore: () => _confirmUnshare(context, share), onMore: () => _showShareActions(context, share),
); );
}, childCount: shares.length + 1), }, childCount: shares.length + 1),
), ),
@@ -240,6 +264,52 @@ class _SharesViewState extends State<SharesView> {
context.read<SettingsController>().requestTab(AppTab.files); 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( Future<void> _confirmUnshare(
BuildContext context, BuildContext context,
NextcloudShare share, 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 /// 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 /// simple two-chip row, live inside the filter chip's sheet instead of as
/// their own row controls. /// 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 { class FilesControlsRow extends StatelessWidget {
final String folderPath; final String folderPath;
final bool showStorageScope; final bool showStorageScope;
final ScrollController? scrollController;
const FilesControlsRow({ const FilesControlsRow({
super.key, super.key,
required this.folderPath, required this.folderPath,
this.showStorageScope = true, this.showStorageScope = true,
this.scrollController,
}); });
@override @override
@@ -54,38 +59,34 @@ class FilesControlsRow extends StatelessWidget {
return SizedBox( return SizedBox(
height: 44, height: 44,
child: SingleChildScrollView( child: Row(
controller: scrollController, children: [
scrollDirection: Axis.horizontal, NooChip(
child: Row( icon: files.sortAscendingFor(folderPath)
children: [ ? LucideIcons.arrowUp
NooChip( : LucideIcons.arrowDown,
icon: files.sortAscendingFor(folderPath) onTap: () => _showSortSheet(context, files),
? LucideIcons.arrowUp child: Text(sortFieldLabel(files.sortFieldFor(folderPath))),
: LucideIcons.arrowDown, ),
onTap: () => _showSortSheet(context, files), const SizedBox(width: 8),
child: Text(sortFieldLabel(files.sortFieldFor(folderPath))), NooChip(
), icon: LucideIcons.filter,
const SizedBox(width: 8), trailing: NooChipTrailing.menu,
NooChip( selected: filtersActive,
icon: LucideIcons.filter, onTap: () => _showFilterSheet(context, files),
trailing: NooChipTrailing.menu, child: Text(_typeFilterLabel(files.filesTypeFilter)),
selected: filtersActive, ),
onTap: () => _showFilterSheet(context, files), const Spacer(),
child: Text(_typeFilterLabel(files.filesTypeFilter)), NooSegmentedControl<bool>(
), iconOnly: true,
const Spacer(), value: files.isGridView,
NooSegmentedControl<bool>( onChanged: files.setGridView,
iconOnly: true, options: const [
value: files.isGridView, NooSegmentOption(value: false, icon: LucideIcons.list),
onChanged: files.setGridView, NooSegmentOption(value: true, icon: LucideIcons.grid),
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; final colors = context.nooColors;
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(NooRadii.card)), decoration: BoxDecoration(
child: Row( color: colors.surface,
crossAxisAlignment: CrossAxisAlignment.center, borderRadius: BorderRadius.circular(NooRadii.card),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon(icon, size: 20, color: colors.fg2), Row(
const SizedBox(width: 12), crossAxisAlignment: CrossAxisAlignment.center,
Expanded( children: [
child: DefaultTextStyle( Icon(icon, size: 20, color: colors.fg2),
style: NooText.body.copyWith(color: colors.fg2), const SizedBox(width: 12),
child: child, Expanded(
), child: DefaultTextStyle(
style: NooText.body.copyWith(color: colors.fg2),
child: child,
),
),
],
), ),
if (actionLabel != null) // Its own row below the description text, not squeezed onto the
GestureDetector( // same line - a long retention message and "Empty trash" used to
onTap: onAction, // fight for the same row's width.
child: Text( if (actionLabel != null) ...[
actionLabel!, const SizedBox(height: 10),
style: NooText.buttonSm.copyWith( Align(
fontSize: 14, alignment: Alignment.centerRight,
color: actionIsDanger ? colors.danger : colors.accentText, 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 { class NooSummaryCard extends StatelessWidget {
final String stat; final String stat;
final String? caption; final String? caption;
/// 0-1; renders a [NooProgressBar]. /// 0-1; renders a [NooProgressBar].
final double? progress; final double? progress;
final String? meta; final String? meta;
@@ -29,41 +30,44 @@ class NooSummaryCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; 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) { final statColor = switch (tone) {
NooSummaryCardTone.danger => colors.danger, NooSummaryCardTone.danger => colors.danger,
NooSummaryCardTone.accent => colors.accentText, NooSummaryCardTone.accent => colors.accentText,
NooSummaryCardTone.normal => colors.fg1, NooSummaryCardTone.normal => colors.fg1,
}; };
final capColor = tone == NooSummaryCardTone.danger ? colors.danger : colors.fg2; final capColor = tone == NooSummaryCardTone.danger
? colors.danger
: colors.fg2;
return Container( return Container(
padding: const EdgeInsets.all(18), 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Text(stat, style: NooText.stat.copyWith(color: statColor)),
crossAxisAlignment: CrossAxisAlignment.end, if (caption != null) ...[
children: [ const SizedBox(height: 6),
Expanded( Text(
child: Column( caption!,
crossAxisAlignment: CrossAxisAlignment.start, style: NooText.body.copyWith(
children: [ fontSize: 14,
Text(stat, style: NooText.stat.copyWith(color: statColor)), height: 1.2,
if (caption != null) ...[ color: capColor,
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) ...[ if (progress != null) ...[
const SizedBox(height: 14), const SizedBox(height: 14),
NooProgressBar(value: progress!), NooProgressBar(value: progress!),
+64 -28
View File
@@ -47,34 +47,7 @@ class SettingsAppearanceSection extends StatelessWidget {
children: [ children: [
SettingsSection( SettingsSection(
title: 'Appearance', title: 'Appearance',
children: [ children: [_ThemeRow(settings: settings)],
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',
),
],
),
),
],
), ),
const SizedBox(height: NooSpace.xl), const SizedBox(height: NooSpace.xl),
SettingsSection( 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 /// 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 /// "Accent color" row - a wallpaper icon when following the OS's dynamic
/// color instead of a fixed swatch. /// color instead of a fixed swatch.
@@ -15,11 +15,18 @@ class MediaImagePreview extends StatefulWidget {
/// `FileViewerScreen.localPathResolver`'s doc comment). /// `FileViewerScreen.localPathResolver`'s doc comment).
final String? localPath; 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({ const MediaImagePreview({
super.key, super.key,
required this.url, required this.url,
required this.headers, required this.headers,
this.localPath, this.localPath,
this.onZoomChanged,
}); });
@override @override
@@ -35,6 +42,7 @@ class _MediaImagePreviewState extends State<MediaImagePreview>
late final AnimationController _animController; late final AnimationController _animController;
Animation<Matrix4>? _animation; Animation<Matrix4>? _animation;
Offset _doubleTapPosition = Offset.zero; Offset _doubleTapPosition = Offset.zero;
bool _isZoomed = false;
@override @override
void initState() { void initState() {
@@ -48,10 +56,19 @@ class _MediaImagePreviewState extends State<MediaImagePreview>
_transformController.value = _animation!.value; _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 @override
void dispose() { void dispose() {
_transformController.removeListener(_handleTransformChanged);
_animController.dispose(); _animController.dispose();
_transformController.dispose(); _transformController.dispose();
super.dispose(); super.dispose();
+9 -11
View File
@@ -57,9 +57,7 @@ class _MediaPdfPreviewState extends State<MediaPdfPreview> {
} }
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_controller = PdfControllerPinch( _controller = PdfControllerPinch(document: PdfDocument.openData(bytes));
document: PdfDocument.openData(bytes),
);
}); });
} catch (e) { } catch (e) {
if (mounted) setState(() => _error = e.toString()); if (mounted) setState(() => _error = e.toString());
@@ -86,13 +84,13 @@ class _MediaPdfPreviewState extends State<MediaPdfPreview> {
if (_controller == null) { if (_controller == null) {
return Center(child: CircularProgressIndicator(color: colors.accent)); return Center(child: CircularProgressIndicator(color: colors.accent));
} }
// pdfx's own default minScale (1.0 = the page's true/100% size) is // Keep minScale at (not below) 1.0: pdfx's PdfViewPinch hard-codes an
// often *larger* than the fit-to-width size a wide page first renders // *infinite* boundaryMargin whenever minScale < 1 (its own
// at, since that initial render is just normal box layout, not the // pdf_view_pinch.dart), meaning the page could be panned arbitrarily
// InteractiveViewer transform pinching engages on first touch - so the // far off-screen with no way back short of reopening the viewer, even
// moment you touch the page it snaps up to 1.0 and, with the default // without ever pinching to zoom. There's no way to override that
// floor, can never pinch back down past it. A low floor here lets you // margin from here - it isn't an exposed parameter - so this avoids
// zoom back out past that to the fit-width view you started at. // the branch that sets it instead of fighting it.
return PdfViewPinch(controller: _controller!, minScale: 0.3); return PdfViewPinch(controller: _controller!);
} }
} }