Ten fixes: controls-row alignment, banners, shares, viewer bounds
- FilesControlsRow (and Photos' own controls row) wrapped its Row in a SingleChildScrollView, which gives a child unbounded width - silently breaking the trailing Spacer (a flex child needs bounded width) and leaving the List/Grid toggle sitting right after the filter chip instead of pinned to the row's right edge, "not enough controls to fill the row" or not. Removed the scroll wrapper (two chips and an icon toggle never need it) and the dead scroll-hint plumbing that went with it in files_view.dart. - Photos' All/Photos/Videos chips moved into the filter sheet as a full-width segmented control (matching Settings' Theme control), instead of their own row of chips competing for space with the sort and filter chips. - Settings' Theme row: the System/Light/Dark segmented control now sits on its own row below the label instead of squeezed into a trailing slot beside it, where three icon+label segments were too cramped to read comfortably - same shape as the sort sheet's Ascending/Descending control. - NooBanner (Trash's "Empty trash") and NooSummaryCard (Offline's "Sync now") both move their action onto its own row below the description text instead of squeezed onto the same line, where a longer caption had no room to share with it. - SharesController now caches each scope (with you / by you, which also covers links) after its first fetch instead of re-fetching on every single switch, including switching straight back to a scope already loaded seconds ago. - Shares' per-row overflow menu jumped straight to the unshare confirmation with no menu at all - a link share had no way to copy its own URL short of opening the full Share sheet. It's a real menu now: "Copy link" (link shares only) plus remove/unshare. - Settings gained a trailing jump rail on mobile (one small icon per section) so a specific section can be reached in one tap instead of scrolling the whole list by hand; desktop's 2-column grid doesn't need one. - The media viewer's PageView now disables its own left/right swipe while the current photo is zoomed in, so panning around it doesn't also swipe to the next item. - The PDF preview no longer sets minScale below 1.0: pdfx's PdfViewPinch hard-codes an *infinite* pan boundary whenever minScale < 1 (not an exposed parameter, so there's no way to override it), which let a page be panned arbitrarily far off-screen without ever zooming.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -8,7 +8,16 @@ import 'session_controller.dart';
|
||||
class SharesController extends ChangeNotifier {
|
||||
final SessionController session;
|
||||
|
||||
List<NextcloudShare> _shares = [];
|
||||
/// Each scope's own cache - switching `sharedWithMe` no longer refetches
|
||||
/// on every switch, only the first time a scope is visited (or on a
|
||||
/// pull-to-refresh/retry), since `By you`/`Links` (see shares_view.dart's
|
||||
/// client-side split of this same list) and `With you` used to hit the
|
||||
/// network again on every single toggle, even switching straight back to
|
||||
/// a scope just fetched seconds ago.
|
||||
List<NextcloudShare> _withMeShares = [];
|
||||
List<NextcloudShare> _byMeShares = [];
|
||||
bool _withMeLoaded = false;
|
||||
bool _byMeLoaded = false;
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
bool _sharedWithMe = false;
|
||||
@@ -18,13 +27,17 @@ class SharesController extends ChangeNotifier {
|
||||
session.addAccountActivatedListener(_onAccountActivated);
|
||||
}
|
||||
|
||||
List<NextcloudShare> get shares => _shares;
|
||||
List<NextcloudShare> get shares =>
|
||||
_sharedWithMe ? _withMeShares : _byMeShares;
|
||||
bool get isLoading => _isLoading;
|
||||
String? get errorMessage => _errorMessage;
|
||||
bool get sharedWithMe => _sharedWithMe;
|
||||
|
||||
void _onAccountCleared() {
|
||||
_shares = [];
|
||||
_withMeShares = [];
|
||||
_byMeShares = [];
|
||||
_withMeLoaded = false;
|
||||
_byMeLoaded = false;
|
||||
_isLoading = false;
|
||||
_errorMessage = null;
|
||||
_sharedWithMe = false;
|
||||
@@ -35,19 +48,29 @@ class SharesController extends ChangeNotifier {
|
||||
unawaited(fetchAll());
|
||||
}
|
||||
|
||||
/// Fetches the *current* scope fresh, ignoring its cache - the initial
|
||||
/// load, pull-to-refresh, and the error-retry button all want a real
|
||||
/// round-trip regardless of whether this scope was already loaded.
|
||||
Future<void> fetchAll() async {
|
||||
final service = session.service;
|
||||
if (!session.isLoggedIn || service == null) return;
|
||||
final gen = session.sessionGeneration;
|
||||
final wantsWithMe = _sharedWithMe;
|
||||
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final shares = await service.fetchShares(sharedWithMe: _sharedWithMe);
|
||||
final shares = await service.fetchShares(sharedWithMe: wantsWithMe);
|
||||
if (gen != session.sessionGeneration) return;
|
||||
_shares = shares;
|
||||
if (wantsWithMe) {
|
||||
_withMeShares = shares;
|
||||
_withMeLoaded = true;
|
||||
} else {
|
||||
_byMeShares = shares;
|
||||
_byMeLoaded = true;
|
||||
}
|
||||
} catch (e) {
|
||||
if (gen != session.sessionGeneration) return;
|
||||
debugPrint('[SharesController] Error fetching shares: $e');
|
||||
@@ -60,12 +83,14 @@ class SharesController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Switches between "shared by me" and "shared with me" and refetches.
|
||||
/// Switches between "shared by me" and "shared with me". Only fetches
|
||||
/// when the target scope hasn't been loaded yet - switching back to one
|
||||
/// already cached just swaps which list [shares] reads from.
|
||||
void setSharedWithMe(bool value) {
|
||||
if (_sharedWithMe == value) return;
|
||||
_sharedWithMe = value;
|
||||
notifyListeners();
|
||||
fetchAll();
|
||||
if (!(value ? _withMeLoaded : _byMeLoaded)) fetchAll();
|
||||
}
|
||||
|
||||
Future<bool> deleteShare(NextcloudShare share) async {
|
||||
@@ -73,7 +98,11 @@ class SharesController extends ChangeNotifier {
|
||||
if (service == null) return false;
|
||||
final success = await service.deleteShare(share.id);
|
||||
if (success) {
|
||||
_shares = _shares.where((s) => s.id != share.id).toList();
|
||||
if (_sharedWithMe) {
|
||||
_withMeShares = _withMeShares.where((s) => s.id != share.id).toList();
|
||||
} else {
|
||||
_byMeShares = _byMeShares.where((s) => s.id != share.id).toList();
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
return success;
|
||||
|
||||
+113
-18
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import '../widgets/noo/nav/noo_top_bar.dart';
|
||||
import '../widgets/noo/nav/noo_toolbar.dart';
|
||||
@@ -13,27 +14,35 @@ import '../widgets/settings/settings_security.dart';
|
||||
import '../widgets/settings/settings_swipe.dart';
|
||||
import '../widgets/settings/settings_tabs.dart';
|
||||
|
||||
/// One entry in [AccountView._sections] - [icon] and [label] are only used
|
||||
/// by [_SectionJumpRail]'s tap targets, not shown next to the section
|
||||
/// itself (each section already titles itself).
|
||||
typedef _Section = (String label, IconData icon, Widget child);
|
||||
|
||||
/// Settings, pushed on top of the shell (DESIGN_SYSTEM.md 4's 9-section
|
||||
/// order: account card, accounts, security, file sync, files cache,
|
||||
/// appearance, tabs, action bar, swipe on a file). One column of
|
||||
/// [SettingsSection]s (and [SettingsActionBarSection], which has no option
|
||||
/// rows of its own to put in one - just the reorder list) on
|
||||
/// mobile; a 2-column grid of cards on desktop - see each
|
||||
/// mobile, with a trailing jump rail (see [_SectionJumpRail]) since the
|
||||
/// list runs long enough that finding a specific section by scrolling
|
||||
/// alone is slow; a 2-column grid of cards on desktop, wide enough to see
|
||||
/// most sections without scrolling, so it doesn't get one. See each
|
||||
/// `widgets/settings/*.dart` file for a section's own content and any
|
||||
/// setting that had to be slotted in or grouped under "Advanced appearance".
|
||||
class AccountView extends StatelessWidget {
|
||||
const AccountView({super.key});
|
||||
|
||||
static const _sections = <Widget>[
|
||||
SettingsAccountCard(),
|
||||
SettingsAccountsSection(),
|
||||
SettingsSecuritySection(),
|
||||
SettingsFileSyncSection(),
|
||||
SettingsFilesCacheSection(),
|
||||
SettingsAppearanceSection(),
|
||||
SettingsTabsSection(),
|
||||
SettingsActionBarSection(),
|
||||
SettingsSwipeSection(),
|
||||
static const _sections = <_Section>[
|
||||
('Account', LucideIcons.circleUser, SettingsAccountCard()),
|
||||
('Accounts', LucideIcons.users, SettingsAccountsSection()),
|
||||
('Security', LucideIcons.shield, SettingsSecuritySection()),
|
||||
('File sync', LucideIcons.folderSync, SettingsFileSyncSection()),
|
||||
('Files cache', LucideIcons.hardDrive, SettingsFilesCacheSection()),
|
||||
('Appearance', LucideIcons.sunMoon, SettingsAppearanceSection()),
|
||||
('Tabs', LucideIcons.layoutGrid, SettingsTabsSection()),
|
||||
('Action bar', LucideIcons.slidersHorizontal, SettingsActionBarSection()),
|
||||
('Swipe', LucideIcons.moveHorizontal, SettingsSwipeSection()),
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -53,33 +62,119 @@ class AccountView extends StatelessWidget {
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: desktop
|
||||
? _DesktopGrid(sections: _sections)
|
||||
? _DesktopGrid(sections: [for (final s in _sections) s.$3])
|
||||
: _MobileList(sections: _sections),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MobileList extends StatelessWidget {
|
||||
final List<Widget> sections;
|
||||
/// A stable per-section identity, shared between the [KeyedSubtree] each
|
||||
/// section is wrapped in and [_SectionJumpRail]'s lookup - [GlobalObjectKey]
|
||||
/// compares by [String] equality, not instance identity, so building a new
|
||||
/// one from the same label on each side still resolves to the same key.
|
||||
GlobalKey _sectionKey(String label) => GlobalObjectKey(label);
|
||||
|
||||
class _MobileList extends StatefulWidget {
|
||||
final List<_Section> sections;
|
||||
const _MobileList({required this.sections});
|
||||
|
||||
@override
|
||||
State<_MobileList> createState() => _MobileListState();
|
||||
}
|
||||
|
||||
class _MobileListState extends State<_MobileList> {
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _jumpTo(String label) {
|
||||
final target = _sectionKey(label).currentContext;
|
||||
if (target == null) return;
|
||||
Scrollable.ensureVisible(
|
||||
target,
|
||||
duration: NooMotion.slow,
|
||||
curve: NooMotion.ease,
|
||||
alignment: 0.05,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
return Stack(
|
||||
children: [
|
||||
ListView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
NooSpace.sm,
|
||||
NooSpace.sm,
|
||||
NooSpace.sm,
|
||||
NooSpace.xxl,
|
||||
NooSpace.xxl,
|
||||
),
|
||||
physics: const BouncingScrollPhysics(),
|
||||
children: [
|
||||
for (final section in sections) ...[
|
||||
section,
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,12 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
||||
/// `VideoTransportControls` inside the bottom `MediaActionBar` panel.
|
||||
VideoPlayerController? _videoController;
|
||||
|
||||
/// Whether the *current* page's `MediaImagePreview` is zoomed in - see
|
||||
/// `_buildBody`'s `onZoomChanged`. Disables the `PageView`'s own
|
||||
/// left/right swipe while true, so panning around a zoomed photo doesn't
|
||||
/// also drag the gallery to the next item.
|
||||
bool _isZoomed = false;
|
||||
|
||||
NextcloudItem get _currentItem => _mediaItems[_currentIndex];
|
||||
|
||||
bool get _isOffline => widget.localPathResolver != null;
|
||||
@@ -552,6 +558,10 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
||||
|
||||
return PageView.builder(
|
||||
controller: _pageController,
|
||||
// Zoomed into the current photo: let InteractiveViewer's own pan
|
||||
// claim horizontal drags instead of the PageView swiping to the next
|
||||
// item out from under it.
|
||||
physics: _isZoomed ? const NeverScrollableScrollPhysics() : null,
|
||||
itemCount: _mediaItems.length,
|
||||
onPageChanged: (index) => setState(() {
|
||||
_currentIndex = index;
|
||||
@@ -560,6 +570,10 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
||||
// but that can lag a frame or two behind this rebuild - clear
|
||||
// eagerly so the transport row never briefly shows the old video.
|
||||
_videoController = null;
|
||||
// The new page always starts unzoomed - and the old page's own
|
||||
// listener is now scoped away from `_isZoomed` (see `isActive`
|
||||
// below), so nothing else would reset this.
|
||||
_isZoomed = false;
|
||||
}),
|
||||
itemBuilder: (context, index) {
|
||||
final mediaItem = _mediaItems[index];
|
||||
@@ -570,6 +584,9 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
||||
key: ValueKey(mediaItem.id),
|
||||
url: session.service!.fileUrl(mediaItem.path),
|
||||
headers: session.service!.authHeaders,
|
||||
onZoomChanged: isActive
|
||||
? (zoomed) => setState(() => _isZoomed = zoomed)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
return MediaVideoPreview(
|
||||
@@ -613,6 +630,9 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
||||
url: null,
|
||||
headers: const {},
|
||||
localPath: localPath,
|
||||
onZoomChanged: isActive
|
||||
? (zoomed) => setState(() => _isZoomed = zoomed)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
return MediaVideoPreview(
|
||||
|
||||
@@ -227,12 +227,9 @@ class _GridThumbnail extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _FilesViewState extends State<FilesView>
|
||||
with SingleTickerProviderStateMixin {
|
||||
class _FilesViewState extends State<FilesView> {
|
||||
final Set<String> _selectedIds = {};
|
||||
int _lastPathDepth = 1;
|
||||
final ScrollController _controlsScrollController = ScrollController();
|
||||
final List<AnimationController> _scrollHintControllers = [];
|
||||
|
||||
bool get _isSelecting => _selectedIds.isNotEmpty;
|
||||
|
||||
@@ -264,84 +261,6 @@ class _FilesViewState extends State<FilesView>
|
||||
// that just never got its initial fetch.
|
||||
bool _requestedInitialLoad = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _playScrollHint(_controlsScrollController),
|
||||
);
|
||||
}
|
||||
|
||||
/// A one-shot hint that a horizontally-scrollable row actually scrolls:
|
||||
/// nudges it a little to the right and back, once - a motion cue instead
|
||||
/// of a persistent widget (a chevron badge, an edge fade) sitting on top
|
||||
/// of the actual controls the whole time. Shared by the controls row
|
||||
/// (played once it first appears) and the selection actions row (played
|
||||
/// the first time a selection starts, see `_toggleSelection`). No-ops if
|
||||
/// there's nothing to scroll (row already fits).
|
||||
Future<void> _playScrollHint(ScrollController scrollController) async {
|
||||
// The delay lets the row's first frame (and its actual layout/max
|
||||
// scroll extent) settle before nudging it, and reads more like a
|
||||
// deliberate hint than something that happens to fire on load.
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
// `hasClients` only means a position is attached, not that it's
|
||||
// finished its first layout - reading maxScrollExtent before that
|
||||
// throws (min/maxScrollExtent are still null internally).
|
||||
if (!mounted ||
|
||||
!scrollController.hasClients ||
|
||||
!scrollController.position.hasContentDimensions) {
|
||||
return;
|
||||
}
|
||||
final maxExtent = scrollController.position.maxScrollExtent;
|
||||
if (maxExtent <= 0) return;
|
||||
final double peak = maxExtent < 36 ? maxExtent : 36;
|
||||
// Driven as a single controller (rather than two chained animateTo
|
||||
// calls) with mirrored ease-in-out halves, so the motion decelerates
|
||||
// smoothly into the peak and back out instead of visibly changing
|
||||
// pace where the two legs meet.
|
||||
final controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
);
|
||||
_scrollHintControllers.add(controller);
|
||||
final hint = TweenSequence<double>([
|
||||
TweenSequenceItem(
|
||||
tween: Tween(
|
||||
begin: 0.0,
|
||||
end: peak,
|
||||
).chain(CurveTween(curve: Curves.easeInOutSine)),
|
||||
weight: 50,
|
||||
),
|
||||
TweenSequenceItem(
|
||||
tween: Tween(
|
||||
begin: peak,
|
||||
end: 0.0,
|
||||
).chain(CurveTween(curve: Curves.easeInOutSine)),
|
||||
weight: 50,
|
||||
),
|
||||
]).animate(controller);
|
||||
void onTick() {
|
||||
if (scrollController.hasClients) {
|
||||
scrollController.jumpTo(hint.value);
|
||||
}
|
||||
}
|
||||
|
||||
hint.addListener(onTick);
|
||||
await controller.forward();
|
||||
hint.removeListener(onTick);
|
||||
_scrollHintControllers.remove(controller);
|
||||
controller.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in _scrollHintControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
_controlsScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggleSelection(NextcloudItem item) {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() {
|
||||
@@ -703,13 +622,10 @@ class _FilesViewState extends State<FilesView>
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
// The scroll-hint nudge (see _playScrollHint) plays on
|
||||
// this row once, right after it first appears.
|
||||
Expanded(
|
||||
child: FilesControlsRow(
|
||||
folderPath: browser.currentFolderPath,
|
||||
showStorageScope: !_offline,
|
||||
scrollController: _controlsScrollController,
|
||||
),
|
||||
),
|
||||
if (_offline) ...[
|
||||
|
||||
+34
-23
@@ -381,14 +381,18 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
FilesController files,
|
||||
) {
|
||||
final filtersActive =
|
||||
_typeFilter != _PhotoTypeFilter.all ||
|
||||
photos.showFavoritesOnly ||
|
||||
photos.showHidden ||
|
||||
files.storageScope != StorageScope.cloud;
|
||||
|
||||
// A plain Row, not a horizontally-scrolling one - see
|
||||
// `FilesControlsRow`'s identical fix/doc comment: a `SingleChildScrollView`
|
||||
// gives its child unbounded width for no benefit here (two chips never
|
||||
// need to scroll), and it's actively harmful for a row with a trailing
|
||||
// `Spacer`/flex child.
|
||||
return SizedBox(
|
||||
height: 44,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
NooChip(
|
||||
@@ -399,34 +403,15 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
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: const Text('Filters'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -503,7 +488,31 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
// toggles span two controllers.
|
||||
ListenableBuilder(
|
||||
listenable: Listenable.merge([photos, files]),
|
||||
builder: (context, _) => NooGroupedList(
|
||||
builder: (context, _) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 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,
|
||||
@@ -533,6 +542,8 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
+82
-12
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../models/app_tab.dart';
|
||||
@@ -11,16 +12,21 @@ import '../widgets/noo/core/noo_segmented_control.dart';
|
||||
import '../widgets/noo/files/noo_file_kind.dart';
|
||||
import '../widgets/noo/files/noo_file_row.dart';
|
||||
import '../widgets/noo/files/noo_file_table.dart';
|
||||
import '../widgets/noo/lists/noo_grouped_list.dart';
|
||||
import '../widgets/noo/lists/noo_settings_row.dart';
|
||||
import '../widgets/noo/noo_layout.dart';
|
||||
import '../widgets/noo/overlays/noo_sheet.dart';
|
||||
import '../widgets/tabs/tab_state_slivers.dart';
|
||||
|
||||
/// Which slice of [SharesController.shares] is on screen - `With you` maps
|
||||
/// straight onto the controller's own `sharedWithMe: true` fetch, but the
|
||||
/// controller only ever exposes one boolean scope, so `By you` and `Links`
|
||||
/// both use its `sharedWithMe: false` fetch and are told apart client-side
|
||||
/// by [NextcloudShare.shareType] (see `_visibleShares`) - `Links` and
|
||||
/// `By you` never trigger a re-fetch when switching between each other,
|
||||
/// only when crossing to/from `With you`.
|
||||
/// by [NextcloudShare.shareType] (see `_visibleShares`). `SharesController`
|
||||
/// caches each of its two scopes after its first fetch, so switching
|
||||
/// between any of the three - including back to `With you` after leaving
|
||||
/// it - only re-fetches a scope that's never been loaded yet, not on every
|
||||
/// single switch.
|
||||
enum _ShareScope { withYou, byYou, links }
|
||||
|
||||
IconData _shareTypeIcon(ShareType type) {
|
||||
@@ -110,7 +116,11 @@ class _SharesViewState extends State<SharesView> {
|
||||
onRetry: sharesController.fetchAll,
|
||||
)
|
||||
else if (shares.isEmpty)
|
||||
tabEmptySliver(context, icon: LucideIcons.share2, message: _emptyMessage())
|
||||
tabEmptySliver(
|
||||
context,
|
||||
icon: LucideIcons.share2,
|
||||
message: _emptyMessage(),
|
||||
)
|
||||
else if (isDesktop)
|
||||
_buildDesktopTable(context, shares)
|
||||
else
|
||||
@@ -137,9 +147,13 @@ class _SharesViewState extends State<SharesView> {
|
||||
case _ShareScope.withYou:
|
||||
return c.shares;
|
||||
case _ShareScope.byYou:
|
||||
return c.shares.where((s) => s.shareType != ShareType.publicLink).toList();
|
||||
return c.shares
|
||||
.where((s) => s.shareType != ShareType.publicLink)
|
||||
.toList();
|
||||
case _ShareScope.links:
|
||||
return c.shares.where((s) => s.shareType == ShareType.publicLink).toList();
|
||||
return c.shares
|
||||
.where((s) => s.shareType == ShareType.publicLink)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,13 +189,17 @@ class _SharesViewState extends State<SharesView> {
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final share = shares[index];
|
||||
final row = NooFileRow(
|
||||
kind: NooFileKind.from(name: share.name, isDirectory: share.isFolder),
|
||||
kind: NooFileKind.from(
|
||||
name: share.name,
|
||||
meta: '${share.ownerDisplayName} · ${_permissionLabel(share.permissions)}',
|
||||
isDirectory: share.isFolder,
|
||||
),
|
||||
name: share.name,
|
||||
meta:
|
||||
'${share.ownerDisplayName} · ${_permissionLabel(share.permissions)}',
|
||||
iosStyle: NooLayout.iosStyle(context),
|
||||
onTap: share.isFolder ? () => _openFolder(context, share) : null,
|
||||
trailing: Icon(_shareTypeIcon(share.shareType), size: 16),
|
||||
onMore: () => _confirmUnshare(context, share),
|
||||
onMore: () => _showShareActions(context, share),
|
||||
);
|
||||
final isFirst = index == 0;
|
||||
final isLast = index == shares.length - 1;
|
||||
@@ -215,16 +233,22 @@ class _SharesViewState extends State<SharesView> {
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
if (index == 0) {
|
||||
return const NooFileTableHeader(col2Label: 'Owner', col3Label: 'Permission');
|
||||
return const NooFileTableHeader(
|
||||
col2Label: 'Owner',
|
||||
col3Label: 'Permission',
|
||||
);
|
||||
}
|
||||
final share = shares[index - 1];
|
||||
return NooFileTableRow(
|
||||
kind: NooFileKind.from(name: share.name, isDirectory: share.isFolder),
|
||||
kind: NooFileKind.from(
|
||||
name: share.name,
|
||||
isDirectory: share.isFolder,
|
||||
),
|
||||
name: share.name,
|
||||
col2: share.ownerDisplayName,
|
||||
col3: _permissionLabel(share.permissions),
|
||||
onTap: share.isFolder ? () => _openFolder(context, share) : null,
|
||||
onMore: () => _confirmUnshare(context, share),
|
||||
onMore: () => _showShareActions(context, share),
|
||||
);
|
||||
}, childCount: shares.length + 1),
|
||||
),
|
||||
@@ -240,6 +264,52 @@ class _SharesViewState extends State<SharesView> {
|
||||
context.read<SettingsController>().requestTab(AppTab.files);
|
||||
}
|
||||
|
||||
/// The row/table overflow menu - used to jump straight to the unshare
|
||||
/// confirmation with no menu at all, so a link share had no way to copy
|
||||
/// its own URL short of opening it and finding "Copy link" inside the
|
||||
/// full Share sheet. A link share gets that shortcut here too now;
|
||||
/// everything else just gets the one remove/unshare action.
|
||||
void _showShareActions(BuildContext context, NextcloudShare share) {
|
||||
final isLink = share.shareType == ShareType.publicLink && share.url != null;
|
||||
showNooSheet(
|
||||
context,
|
||||
children: [
|
||||
NooGroupedList(
|
||||
children: [
|
||||
if (isLink)
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.copy,
|
||||
label: const Text('Copy link'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_copyShareLink(context, share);
|
||||
},
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.trash2,
|
||||
label: Text(share.sharedWithMe ? 'Remove share' : 'Unshare'),
|
||||
danger: true,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_confirmUnshare(context, share);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _copyShareLink(BuildContext context, NextcloudShare share) {
|
||||
Clipboard.setData(ClipboardData(text: share.url!));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Link copied'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmUnshare(
|
||||
BuildContext context,
|
||||
NextcloudShare share,
|
||||
|
||||
@@ -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,9 +59,6 @@ class FilesControlsRow extends StatelessWidget {
|
||||
|
||||
return SizedBox(
|
||||
height: 44,
|
||||
child: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
NooChip(
|
||||
@@ -86,7 +88,6 @@ class FilesControlsRow extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,14 @@ 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(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 20, color: colors.fg2),
|
||||
@@ -38,8 +44,16 @@ class NooBanner extends StatelessWidget {
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
if (actionLabel != null)
|
||||
GestureDetector(
|
||||
],
|
||||
),
|
||||
// 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!,
|
||||
@@ -49,6 +63,8 @@ class NooBanner extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,24 +30,24 @@ 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)),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -55,15 +56,18 @@ class NooSummaryCard extends StatelessWidget {
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
caption!,
|
||||
style: NooText.body.copyWith(fontSize: 14, height: 1.2, color: capColor),
|
||||
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!),
|
||||
|
||||
@@ -47,34 +47,7 @@ class SettingsAppearanceSection extends StatelessWidget {
|
||||
children: [
|
||||
SettingsSection(
|
||||
title: 'Appearance',
|
||||
children: [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.sunMoon,
|
||||
label: const Text('Theme'),
|
||||
trailing: NooSegmentedControl<ThemeMode>(
|
||||
size: NooSegmentedSize.sm,
|
||||
value: settings.themeMode,
|
||||
onChanged: settings.setThemeMode,
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: ThemeMode.system,
|
||||
icon: LucideIcons.monitor,
|
||||
label: 'System',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: ThemeMode.light,
|
||||
icon: LucideIcons.sun,
|
||||
label: 'Light',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: ThemeMode.dark,
|
||||
icon: LucideIcons.moon,
|
||||
label: 'Dark',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
children: [_ThemeRow(settings: settings)],
|
||||
),
|
||||
const SizedBox(height: NooSpace.xl),
|
||||
SettingsSection(
|
||||
@@ -120,6 +93,69 @@ class SettingsAppearanceSection extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The "Theme" row: label on its own line, then the System/Light/Dark
|
||||
/// segmented control full-width on the line below - not the usual
|
||||
/// label-plus-trailing-control `NooSettingsRow` layout, since three
|
||||
/// icon+label segments crammed into a trailing slot next to the row's own
|
||||
/// label left every segment too cramped to read comfortably. Matches the
|
||||
/// same "label, then a full-width segmented control below it" shape
|
||||
/// `_showSortSheet` (Ascending/Descending) and Photos' own filter sheet
|
||||
/// (All/Photos/Videos) already use.
|
||||
class _ThemeRow extends StatelessWidget {
|
||||
final SettingsController settings;
|
||||
|
||||
const _ThemeRow({required this.settings});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Material(
|
||||
color: colors.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: NooSpace.md,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(LucideIcons.sunMoon, size: 20, color: colors.fg2),
|
||||
const SizedBox(width: 14),
|
||||
Text('Theme', style: NooText.bodyL.copyWith(color: colors.fg1)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
NooSegmentedControl<ThemeMode>(
|
||||
fill: true,
|
||||
value: settings.themeMode,
|
||||
onChanged: settings.setThemeMode,
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: ThemeMode.system,
|
||||
icon: LucideIcons.monitor,
|
||||
label: 'System',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: ThemeMode.light,
|
||||
icon: LucideIcons.sun,
|
||||
label: 'Light',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: ThemeMode.dark,
|
||||
icon: LucideIcons.moon,
|
||||
label: 'Dark',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The current accent-color choice, shown as a small filled circle in the
|
||||
/// "Accent color" row - a wallpaper icon when following the OS's dynamic
|
||||
/// color instead of a fixed swatch.
|
||||
|
||||
@@ -15,11 +15,18 @@ class MediaImagePreview extends StatefulWidget {
|
||||
/// `FileViewerScreen.localPathResolver`'s doc comment).
|
||||
final String? localPath;
|
||||
|
||||
/// Reports whenever this image crosses the zoomed-in/out threshold (not
|
||||
/// on every micro-change in scale) - lets the parent `PageView` disable
|
||||
/// its own left/right swipe while zoomed in, so panning around a zoomed
|
||||
/// photo doesn't fight with swiping to the next one.
|
||||
final ValueChanged<bool>? onZoomChanged;
|
||||
|
||||
const MediaImagePreview({
|
||||
super.key,
|
||||
required this.url,
|
||||
required this.headers,
|
||||
this.localPath,
|
||||
this.onZoomChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -35,6 +42,7 @@ class _MediaImagePreviewState extends State<MediaImagePreview>
|
||||
late final AnimationController _animController;
|
||||
Animation<Matrix4>? _animation;
|
||||
Offset _doubleTapPosition = Offset.zero;
|
||||
bool _isZoomed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -48,10 +56,19 @@ class _MediaImagePreviewState extends State<MediaImagePreview>
|
||||
_transformController.value = _animation!.value;
|
||||
}
|
||||
});
|
||||
_transformController.addListener(_handleTransformChanged);
|
||||
}
|
||||
|
||||
void _handleTransformChanged() {
|
||||
final isZoomed = _transformController.value.getMaxScaleOnAxis() > 1.01;
|
||||
if (isZoomed == _isZoomed) return;
|
||||
_isZoomed = isZoomed;
|
||||
widget.onZoomChanged?.call(isZoomed);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_transformController.removeListener(_handleTransformChanged);
|
||||
_animController.dispose();
|
||||
_transformController.dispose();
|
||||
super.dispose();
|
||||
|
||||
@@ -57,9 +57,7 @@ class _MediaPdfPreviewState extends State<MediaPdfPreview> {
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_controller = PdfControllerPinch(
|
||||
document: PdfDocument.openData(bytes),
|
||||
);
|
||||
_controller = PdfControllerPinch(document: PdfDocument.openData(bytes));
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = e.toString());
|
||||
@@ -86,13 +84,13 @@ class _MediaPdfPreviewState extends State<MediaPdfPreview> {
|
||||
if (_controller == null) {
|
||||
return Center(child: CircularProgressIndicator(color: colors.accent));
|
||||
}
|
||||
// pdfx's own default minScale (1.0 = the page's true/100% size) is
|
||||
// often *larger* than the fit-to-width size a wide page first renders
|
||||
// at, since that initial render is just normal box layout, not the
|
||||
// InteractiveViewer transform pinching engages on first touch - so the
|
||||
// moment you touch the page it snaps up to 1.0 and, with the default
|
||||
// floor, can never pinch back down past it. A low floor here lets you
|
||||
// zoom back out past that to the fit-width view you started at.
|
||||
return PdfViewPinch(controller: _controller!, minScale: 0.3);
|
||||
// Keep minScale at (not below) 1.0: pdfx's PdfViewPinch hard-codes an
|
||||
// *infinite* boundaryMargin whenever minScale < 1 (its own
|
||||
// pdf_view_pinch.dart), meaning the page could be panned arbitrarily
|
||||
// far off-screen with no way back short of reopening the viewer, even
|
||||
// without ever pinching to zoom. There's no way to override that
|
||||
// margin from here - it isn't an exposed parameter - so this avoids
|
||||
// the branch that sets it instead of fighting it.
|
||||
return PdfViewPinch(controller: _controller!);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user