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