Noo design system rebuild #1
@@ -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