Media viewer details panel, Upload FAB size setting, PDF zoom/viewport fixes, avatar menu account switcher; bump to 1.0.2+5
Build App Bundle / build (push) Successful in 6m2s

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-01 18:41:51 -04:00
co-authored by Claude Code
parent 0b7df833f6
commit 4611bc2142
22 changed files with 846 additions and 94 deletions
+184 -2
View File
@@ -7,6 +7,7 @@ import '../providers/settings_controller.dart';
import '../providers/trash_controller.dart';
import '../theme/design_tokens.dart';
import 'noo/core/noo_avatar.dart';
import '../views/login_view.dart';
import 'noo/core/noo_badge.dart';
import 'noo/lists/noo_settings_row.dart';
import 'shell/shell_common.dart';
@@ -43,9 +44,16 @@ Future<void> showAvatarMenu(BuildContext context) {
);
}
class _AvatarMenuContent extends StatelessWidget {
class _AvatarMenuContent extends StatefulWidget {
const _AvatarMenuContent();
@override
State<_AvatarMenuContent> createState() => _AvatarMenuContentState();
}
class _AvatarMenuContentState extends State<_AvatarMenuContent> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
@@ -124,7 +132,7 @@ class _AvatarMenuContent extends StatelessWidget {
// header reads as a continuation of the button that
// opened it rather than a disconnected card.
InkWell(
onTap: closeAndOpenSettings,
onTap: () => setState(() => _expanded = !_expanded),
child: Padding(
padding: const EdgeInsets.all(NooSpace.md),
child: Row(
@@ -161,10 +169,87 @@ class _AvatarMenuContent extends StatelessWidget {
current: true,
size: 40,
),
const SizedBox(width: 8),
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colors.fg1.withValues(alpha: 0.08),
),
child: Icon(
_expanded
? LucideIcons.chevronUp
: LucideIcons.chevronDown,
size: 20,
color: colors.fg2,
),
),
],
),
),
),
AnimatedSize(
duration: NooMotion.fast,
curve: NooMotion.ease,
alignment: Alignment.topCenter,
child: _expanded
? Column(
children: [
for (final account in session.accounts)
if (account.id != session.activeAccountId)
_OtherAccountRow(
name: account.username,
host: serverHost(account.serverUrl),
onTap: () {
Navigator.pop(context);
session.switchAccount(account.id);
},
),
Padding(
padding: const EdgeInsets.fromLTRB(
NooSpace.md,
0,
NooSpace.md,
NooSpace.md,
),
child: Row(
spacing: 8,
children: [
Expanded(
child: _AccountButton(
icon: LucideIcons.userPlus,
label: 'Add Account',
onTap: () {
final nav = Navigator.of(
context,
);
nav.pop();
nav.push(
MaterialPageRoute(
builder: (_) =>
const LoginView(
isAddingAccount: true,
),
),
);
},
),
),
Expanded(
child: _AccountButton(
icon: LucideIcons.users,
label: 'Manage Accounts',
onTap: closeAndOpenSettings,
),
),
],
),
),
],
)
: const SizedBox(width: double.infinity),
),
if (hiddenTabs.isNotEmpty) ...[
Divider(height: 1, color: colors.line),
for (final tab in hiddenTabs)
@@ -201,3 +286,100 @@ class _AvatarMenuContent extends StatelessWidget {
);
}
}
class _OtherAccountRow extends StatelessWidget {
final String name;
final String host;
final VoidCallback onTap;
const _OtherAccountRow({
required this.name,
required this.host,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(NooSpace.md),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.bodyL.copyWith(
fontWeight: FontWeight.w600,
color: colors.fg1,
),
),
const SizedBox(height: 2),
Text(
host,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(color: colors.fg3),
),
],
),
),
const SizedBox(width: 12),
NooAvatar(initials: accountInitial(name), current: false, size: 40),
const SizedBox(width: 44),
],
),
),
);
}
}
class _AccountButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _AccountButton({
required this.icon,
required this.label,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final radius = BorderRadius.circular(NooRadii.input);
return Material(
color: colors.bg,
borderRadius: radius,
child: InkWell(
borderRadius: radius,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 6,
children: [
Icon(icon, size: 18, color: colors.fg2),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.body.copyWith(color: colors.fg1),
),
),
],
),
),
),
);
}
}
+7 -7
View File
@@ -54,7 +54,7 @@ enum _DetailsTab { info, versions, activity }
/// and the "open externally" flow for unsupported file types.
///
/// Only used directly on mobile (bundles its own [NooOverlayHeader]); the
/// desktop path in [show] passes [_DetailsBody] straight to [showNooDialog],
/// desktop path in [show] passes [DetailsBody] straight to [showNooDialog],
/// which renders the header itself via `leading`/`title`/`subtitle`.
class DetailsSheet extends StatelessWidget {
final NextcloudItem item;
@@ -68,7 +68,7 @@ class DetailsSheet extends StatelessWidget {
leading: detailsFileTile(item),
title: item.name,
subtitle: detailsMetaLine(item),
children: [_DetailsBody(item: item)],
children: [DetailsBody(item: item)],
);
}
return showNooSheet(context, children: [DetailsSheet(item: item)]);
@@ -86,7 +86,7 @@ class DetailsSheet extends StatelessWidget {
onClose: () => Navigator.pop(context),
),
const SizedBox(height: NooSpace.lg),
_DetailsBody(item: item),
DetailsBody(item: item),
],
);
}
@@ -102,16 +102,16 @@ class DetailsSheet extends StatelessWidget {
/// guard), so switching tabs via a bare `switch` on the selected type would
/// tear down and rebuild whichever tab isn't showing, discarding its
/// fetched data and re-requesting it every time the user switched back.
class _DetailsBody extends StatefulWidget {
class DetailsBody extends StatefulWidget {
final NextcloudItem item;
const _DetailsBody({required this.item});
const DetailsBody({super.key, required this.item});
@override
State<_DetailsBody> createState() => _DetailsBodyState();
State<DetailsBody> createState() => _DetailsBodyState();
}
class _DetailsBodyState extends State<_DetailsBody> {
class _DetailsBodyState extends State<DetailsBody> {
_DetailsTab _tab = _DetailsTab.info;
@override
+7 -2
View File
@@ -17,6 +17,9 @@ class FrostedGlassContainer extends StatelessWidget {
final Widget child;
final double borderRadius;
/// Overrides [borderRadius] for non-uniform corners (e.g. top-only).
final BorderRadius? radius;
/// Blur sigma for the backdrop filter. Defaults to a fixed value; pass an
/// explicit value (e.g. from user settings) to make it adjustable.
final double blurSigma;
@@ -35,6 +38,7 @@ class FrostedGlassContainer extends StatelessWidget {
super.key,
required this.child,
this.borderRadius = 0,
this.radius,
this.blurSigma = 20,
this.opacity = 0.8,
this.color,
@@ -42,15 +46,16 @@ class FrostedGlassContainer extends StatelessWidget {
@override
Widget build(BuildContext context) {
final shape = radius ?? BorderRadius.circular(borderRadius);
final tint = color ?? context.nooColors.surface;
return ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
borderRadius: shape,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
child: Container(
decoration: BoxDecoration(
color: tint.withValues(alpha: opacity),
borderRadius: BorderRadius.circular(borderRadius),
borderRadius: shape,
),
child: child,
),
@@ -22,6 +22,9 @@ class NooGroupedList extends StatefulWidget {
final String? aside;
final List<Widget> children;
final Widget? footer;
/// Shown between [label] and the card (e.g. a `NooInfoNote`).
final Widget? notice;
final bool collapsible;
final bool initiallyExpanded;
@@ -31,6 +34,7 @@ class NooGroupedList extends StatefulWidget {
this.aside,
required this.children,
this.footer,
this.notice,
this.collapsible = false,
this.initiallyExpanded = true,
});
@@ -58,6 +62,10 @@ class _NooGroupedListState extends State<NooGroupedList> {
),
const SizedBox(height: 8),
],
if (widget.notice != null) ...[
widget.notice!,
const SizedBox(height: NooSpace.md),
],
AnimatedCrossFade(
duration: NooMotion.fast,
sizeCurve: NooMotion.ease,
+34 -5
View File
@@ -34,7 +34,10 @@ class NooShareSection extends StatelessWidget {
Row(
children: [
Expanded(
child: Text(title, style: NooText.sectionTitle.copyWith(color: colors.fg1)),
child: Text(
title,
style: NooText.sectionTitle.copyWith(color: colors.fg1),
),
),
?trailing,
],
@@ -60,10 +63,13 @@ class NooPersonAccessRow extends StatelessWidget {
final String name;
final String? subtitle;
final bool owner;
/// Pill label for non-owners, e.g. "Can edit" / "Can view".
final String permission;
final VoidCallback? onPermissionTap;
final bool permissionLoading;
final String ownerLabel;
/// Replaces the owner label / permission pill (e.g. a plain search result).
final Widget? trailing;
@@ -75,6 +81,7 @@ class NooPersonAccessRow extends StatelessWidget {
this.owner = false,
this.permission = 'Can edit',
this.onPermissionTap,
this.permissionLoading = false,
this.ownerLabel = 'Owner',
this.trailing,
});
@@ -121,7 +128,11 @@ class NooPersonAccessRow extends StatelessWidget {
else if (owner)
Text(ownerLabel, style: NooText.label.copyWith(color: colors.fg3))
else
NooPermissionPill(label: permission, onTap: onPermissionTap),
NooPermissionPill(
label: permission,
onTap: onPermissionTap,
loading: permissionLoading,
),
],
),
);
@@ -133,8 +144,14 @@ class NooPersonAccessRow extends StatelessWidget {
class NooPermissionPill extends StatelessWidget {
final String label;
final VoidCallback? onTap;
final bool loading;
const NooPermissionPill({super.key, required this.label, this.onTap});
const NooPermissionPill({
super.key,
required this.label,
this.onTap,
this.loading = false,
});
@override
Widget build(BuildContext context) {
@@ -152,9 +169,21 @@ class NooPermissionPill extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: NooText.label.copyWith(height: 1, color: colors.fg1)),
Text(
label,
style: NooText.label.copyWith(height: 1, color: colors.fg1),
),
const SizedBox(width: NooSpace.xxs),
Icon(LucideIcons.chevronDown, size: 14, color: colors.fg2),
loading
? SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colors.fg2,
),
)
: Icon(LucideIcons.chevronDown, size: 14, color: colors.fg2),
],
),
),
+5
View File
@@ -64,6 +64,10 @@ class _NooSheetBody extends StatefulWidget {
class _NooSheetBodyState extends State<_NooSheetBody> {
bool _needsPeek = false;
// Keeps the sheet's content (and its State) alive when the body swaps
// between the plain and draggable layouts, instead of rebuilding it.
final _contentKey = GlobalKey();
void _handleOverflow(ScrollMetrics metrics) {
if (_needsPeek || metrics.maxScrollExtent <= 0) return;
// `ScrollMetricsNotification` is dispatched mid-layout; deferring avoids
@@ -84,6 +88,7 @@ class _NooSheetBodyState extends State<_NooSheetBody> {
controller: scrollController,
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
child: Column(
key: _contentKey,
mainAxisSize: MainAxisSize.min,
children: [
Container(
@@ -69,6 +69,13 @@ class SettingsAppearanceSection extends StatelessWidget {
],
),
const SizedBox(height: NooSpace.xl),
SettingsSection(
title: 'Upload button',
notice:
'Auto shows the label on Files and Photos and shrinks to an icon on other tabs. Mini and Expanded stay the same on every tab. Has no effect on wide tablets.',
children: [_FabStyleRow(settings: settings)],
),
const SizedBox(height: NooSpace.xl),
SettingsSection(
title: 'Advanced appearance',
children: [
@@ -293,6 +300,64 @@ class _NavMenuStyleRow extends StatelessWidget {
}
}
class _FabStyleRow extends StatelessWidget {
final SettingsController settings;
const _FabStyleRow({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.circlePlus, size: 20, color: colors.fg2),
const SizedBox(width: 14),
Text(
'Upload button size',
style: NooText.bodyL.copyWith(color: colors.fg1),
),
],
),
const SizedBox(height: 12),
NooSegmentedControl<FabStyle>(
fill: true,
value: settings.fabStyle,
onChanged: settings.setFabStyle,
options: const [
NooSegmentOption(
value: FabStyle.auto,
icon: LucideIcons.wandSparkles,
label: 'Auto',
),
NooSegmentOption(
value: FabStyle.mini,
icon: LucideIcons.minimize2,
label: 'Mini',
),
NooSegmentOption(
value: FabStyle.expanded,
icon: LucideIcons.maximize2,
label: 'Expanded',
),
],
),
],
),
),
);
}
}
/// 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.
+2 -17
View File
@@ -33,27 +33,12 @@ class SettingsSection extends StatelessWidget {
Widget build(BuildContext context) {
final colors = context.nooColors;
if (!NooLayout.isDesktop(context)) {
final list = NooGroupedList(
return NooGroupedList(
label: title,
notice: notice != null ? NooInfoNote(message: notice!) : null,
footer: subtitle != null ? Text(subtitle!) : null,
// Not collapsible: each mobile Settings section now renders on its
// own pushed screen (see `account_view.dart`'s doc comment for the
// menu-then-pushed-screen pattern), so there's no long single-scroll
// page left to collapse sections *within* - an earlier design had
// every section inline in one column and made them individually
// collapsible for exactly that reason; that's gone now that each
// one is already isolated on its own screen.
children: children,
);
if (notice == null) return list;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
NooInfoNote(message: notice!),
const SizedBox(height: NooSpace.md),
list,
],
);
}
return Container(
decoration: BoxDecoration(
+104 -17
View File
@@ -19,6 +19,7 @@ import 'noo/core/noo_avatar.dart';
import 'noo/core/noo_button.dart';
import 'noo/core/noo_chip.dart';
import 'noo/core/noo_toggle.dart';
import 'noo/lists/noo_grouped_list.dart';
import 'noo/lists/noo_settings_row.dart';
import 'noo/media/noo_photo_tile.dart';
import 'noo/noo_layout.dart';
@@ -98,6 +99,8 @@ class _ShareSheetState extends State<ShareSheet> {
bool _isSearching = false;
bool _isAddingPerson = false;
bool _isTogglingLink = false;
String? _pendingShareeKey;
final Set<String> _updatingShareIds = {};
bool _isSharingFile = false;
bool _showInherited = false;
final _peopleController = TextEditingController();
@@ -183,15 +186,20 @@ class _ShareSheetState extends State<ShareSheet> {
}
}
String _shareeKey(NextcloudSharee s) => '${s.shareTypeValue}:${s.shareWith}';
Future<void> _addSharee(NextcloudSharee sharee) async {
if (_pendingShareeKey != null) return;
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
setState(() => _pendingShareeKey = _shareeKey(sharee));
final share = await ops.createShare(
path: widget.item.path,
shareType: sharee.shareTypeValue,
shareWith: sharee.shareWith,
);
if (!mounted) return;
setState(() => _pendingShareeKey = null);
if (share == null) {
messenger.showSnackBar(
const SnackBar(
@@ -353,36 +361,93 @@ class _ShareSheetState extends State<ShareSheet> {
}
}
/// A small popup with the one action the compact [NooPersonAccessRow]
/// permission pill has no room for - removing that person's access. There's
/// no permission-update endpoint on [ItemOperations] yet, so the pill
/// itself is read-only (see the rebuild report).
/// Permission menu for a person row: Can view / Can edit, then removing
/// access. Keeps the reshare bit (16) as it was.
void _openPersonMenu(NextcloudShare share) {
final label = share.sharedWithDisplayName ?? 'Shared';
final canEdit = (share.permissions & 2) != 0;
void remove() {
Navigator.pop(context);
_removeShare(share);
}
final row = NooSettingsRow(
icon: LucideIcons.userMinus,
label: const Text('Remove access'),
danger: true,
onTap: remove,
void choose(bool edit) {
Navigator.pop(context);
_setPermission(share, edit);
}
Widget check(bool on) => Icon(
LucideIcons.check,
size: 18,
color: on ? context.nooColors.accentText : Colors.transparent,
);
final rows = <Widget>[
NooSettingsRow(
icon: LucideIcons.eye,
label: const Text('Can view'),
trailing: check(!canEdit),
onTap: () => choose(false),
),
NooSettingsRow(
icon: LucideIcons.pencil,
label: const Text('Can edit'),
trailing: check(canEdit),
onTap: () => choose(true),
),
NooSettingsRow(
icon: LucideIcons.userMinus,
label: const Text('Remove access'),
danger: true,
onTap: remove,
),
];
if (NooLayout.isDesktop(context)) {
showNooDialog(context, title: label, children: [row]);
showNooDialog(
context,
title: label,
children: [Column(children: rows)],
);
} else {
showNooSheet(
context,
children: [
NooOverlayHeader(title: label, onClose: () => Navigator.pop(context)),
row,
NooGroupedList(children: rows),
],
);
}
}
Future<void> _setPermission(NextcloudShare share, bool edit) async {
if (((share.permissions & 2) != 0) == edit) return;
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
final reshare = share.permissions & 16;
final base = edit ? (share.isFolder ? 15 : 3) : 1;
final permissions = base | reshare;
setState(() => _updatingShareIds.add(share.id));
final ok = await ops.updateSharePermissions(share, permissions);
if (!mounted) return;
setState(() => _updatingShareIds.remove(share.id));
if (ok) {
setState(() {
_shares = [
for (final s in _shares)
s.id == share.id ? s.withPermissions(permissions) : s,
];
});
} else {
messenger.showSnackBar(
const SnackBar(
content: Text('Could not change permission'),
behavior: SnackBarBehavior.floating,
),
);
}
}
Widget _peopleSection(
NooColors colors,
SessionController session,
@@ -416,7 +481,7 @@ class _ShareSheetState extends State<ShareSheet> {
for (final sharee in _searchResults)
InkWell(
borderRadius: BorderRadius.circular(NooRadii.input),
onTap: () => _addSharee(sharee),
onTap: _pendingShareeKey == null ? () => _addSharee(sharee) : null,
child: NooPersonAccessRow(
avatar: NooAvatar(
initials: _initial(sharee.label),
@@ -427,7 +492,13 @@ class _ShareSheetState extends State<ShareSheet> {
),
name: sharee.label,
subtitle: sharee.subtitle,
trailing: const SizedBox.shrink(),
trailing: _pendingShareeKey == _shareeKey(sharee)
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const SizedBox.shrink(),
),
),
if (_searchResults.isNotEmpty)
@@ -463,7 +534,10 @@ class _ShareSheetState extends State<ShareSheet> {
? 'Invited by email'
: null),
permission: (share.permissions & 2) != 0 ? 'Can edit' : 'Can view',
onPermissionTap: () => _openPersonMenu(share),
permissionLoading: _updatingShareIds.contains(share.id),
onPermissionTap: _updatingShareIds.contains(share.id)
? null
: () => _openPersonMenu(share),
),
if (_inherited.isNotEmpty) ...[
const SizedBox(height: NooSpace.xs),
@@ -625,9 +699,22 @@ class _ShareSheetState extends State<ShareSheet> {
const SizedBox(height: 22),
NooShareSection(
title: 'Share link',
trailing: NooToggle(
checked: hasLink,
onChanged: _isTogglingLink ? null : _toggleLink,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isTogglingLink) ...[
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: NooSpace.sm),
],
NooToggle(
checked: hasLink,
onChanged: _isTogglingLink ? null : _toggleLink,
),
],
),
caption: hasLink
? null
+17 -8
View File
@@ -22,9 +22,14 @@ class MediaActionBar extends StatelessWidget {
final VoidCallback onDelete;
final VoidCallback onOpenExternally;
final VoidCallback onDownload;
final VoidCallback onDetails;
final VoidCallback? onDetails;
final Widget? handle;
final Widget? below;
const MediaActionBar({
this.onDetails,
this.handle,
this.below,
super.key,
required this.isFavorite,
required this.isBusy,
@@ -35,7 +40,6 @@ class MediaActionBar extends StatelessWidget {
required this.onDelete,
required this.onOpenExternally,
required this.onDownload,
required this.onDetails,
});
@override
@@ -48,11 +52,15 @@ class MediaActionBar extends StatelessWidget {
// short and leaving that strip unstyled - only the actual row content
// needs padding up and away from the gesture area.
return FrostedGlassContainer(
radius: const BorderRadius.vertical(
top: Radius.circular(NooRadii.sheetTop),
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
?handle,
if (transportControls != null) ...[
transportControls!,
Divider(height: 1, color: colors.fg1.withValues(alpha: 0.14)),
@@ -94,17 +102,18 @@ class MediaActionBar extends StatelessWidget {
ViewerIconButton(
icon: LucideIcons.trash2,
tooltip: 'Delete',
color: colors.danger,
onTap: isBusy ? null : onDelete,
),
ViewerIconButton(
icon: LucideIcons.info,
tooltip: 'Details',
onTap: onDetails,
),
if (onDetails != null)
ViewerIconButton(
icon: LucideIcons.info,
tooltip: 'Details',
onTap: onDetails,
),
],
),
),
?below,
],
),
),
+133
View File
@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import '../../models/nextcloud_item.dart';
import '../../theme/design_tokens.dart';
import '../details/details_sheet.dart';
import '../noo/overlays/noo_overlay_header.dart';
/// Builds the viewer's bottom bar with a drag [handle] on top and the
/// expandable [below] details region underneath its action row.
typedef MediaPanelBarBuilder =
Widget Function(BuildContext context, Widget handle, Widget below);
/// The media viewer's bottom bar as a draggable panel: dragging it (or
/// swiping up on the media, via [MediaDetailsPanelState.expand]) grows the
/// panel upward so the action row rides on top of the file's details instead
/// of the details covering it in a separate modal sheet.
class MediaDetailsPanel extends StatefulWidget {
final NextcloudItem item;
final MediaPanelBarBuilder builder;
const MediaDetailsPanel({
super.key,
required this.item,
required this.builder,
});
@override
State<MediaDetailsPanel> createState() => MediaDetailsPanelState();
}
class MediaDetailsPanelState extends State<MediaDetailsPanel>
with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 260),
);
double _maxHeight = 1;
bool get isExpanded => _c.value > 0.5;
void expand() => _c.animateTo(1, curve: Curves.easeOutCubic);
void collapse() => _c.animateTo(0, curve: Curves.easeOutCubic);
void toggle() => isExpanded ? collapse() : expand();
@override
void dispose() {
_c.dispose();
super.dispose();
}
void _onDragUpdate(DragUpdateDetails d) {
_c.value -= (d.primaryDelta ?? 0) / _maxHeight;
}
void _onDragEnd(DragEndDetails d) {
final v = d.primaryVelocity ?? 0;
if (v < -400) {
expand();
} else if (v > 400) {
collapse();
} else {
toggleToNearest();
}
}
void toggleToNearest() => _c.value > 0.5 ? expand() : collapse();
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
_maxHeight = MediaQuery.sizeOf(context).height * 0.55;
final handle = Padding(
padding: const EdgeInsets.only(top: 8, bottom: 2),
child: Center(
child: Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: colors.fg3.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(2),
),
),
),
);
final below = AnimatedBuilder(
animation: _c,
builder: (context, _) {
if (_c.value == 0) return const SizedBox.shrink();
return SizedBox(
height: _c.value * _maxHeight,
child: ClipRect(
child: OverflowBox(
alignment: Alignment.topCenter,
minHeight: 0,
maxHeight: _maxHeight,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
NooSpace.lg,
NooSpace.md,
NooSpace.lg,
NooSpace.lg,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
NooOverlayHeader(
leading: detailsFileTile(widget.item),
title: widget.item.name,
subtitle: detailsMetaLine(widget.item),
onClose: collapse,
),
const SizedBox(height: NooSpace.lg),
DetailsBody(
key: ValueKey(widget.item.id),
item: widget.item,
),
],
),
),
),
),
);
},
);
return GestureDetector(
onVerticalDragUpdate: _onDragUpdate,
onVerticalDragEnd: _onDragEnd,
child: widget.builder(context, handle, below),
);
}
}
+63 -3
View File
@@ -17,8 +17,12 @@ class MediaPdfPreview extends StatefulWidget {
/// `FileViewerScreen.localPathResolver`'s doc comment.
final Future<String?> Function(NextcloudItem item)? localPathResolver;
/// Space reserved above the page for an overlaid top bar (0 when hidden).
final double topInset;
const MediaPdfPreview({
super.key,
this.topInset = 0,
required this.item,
required this.session,
this.localPathResolver,
@@ -29,8 +33,14 @@ class MediaPdfPreview extends StatefulWidget {
}
class _MediaPdfPreviewState extends State<MediaPdfPreview> {
// Only the first few pages are measured; longer documents are assumed
// taller than any screen.
static const _sizedPages = 3;
PdfControllerPinch? _controller;
String? _error;
List<Size> _pageSizes = const [];
int _pageCount = 0;
@override
void initState() {
@@ -55,9 +65,21 @@ class _MediaPdfPreviewState extends State<MediaPdfPreview> {
await widget.session.service!.fetchBytes(widget.item.path),
);
}
if (!mounted) return;
final document = await PdfDocument.openData(bytes);
final sizes = <Size>[];
for (var i = 1; i <= document.pagesCount && i <= _sizedPages; i++) {
// autoCloseAndroid closes the previous page itself; closing here too throws.
final page = await document.getPage(i, autoCloseAndroid: true);
sizes.add(Size(page.width, page.height));
}
if (!mounted) {
await document.close();
return;
}
setState(() {
_controller = PdfControllerPinch(document: PdfDocument.openData(bytes));
_pageSizes = sizes;
_pageCount = document.pagesCount;
_controller = PdfControllerPinch(document: Future.value(document));
});
} catch (e) {
if (mounted) setState(() => _error = e.toString());
@@ -91,6 +113,44 @@ class _MediaPdfPreviewState extends State<MediaPdfPreview> {
// 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!);
//
// pdfx's InteractiveViewer also refuses to zoom out past
// `viewport.height / document.height`. When the whole document is
// shorter than the screen (e.g. a one-page PDF) that ratio is above 1, so
// after pinching in, fit-width (scale 1) becomes unreachable. PdfViewPinch
// wraps its content in a SafeArea, which counts MediaQuery padding toward
// that document height, so padding the bottom up to the viewport height
// keeps the ratio at or below 1 without shrinking the gesture area.
return AnimatedPadding(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOutCubic,
padding: EdgeInsets.only(top: widget.topInset),
child: LayoutBuilder(
builder: (context, constraints) {
final media = MediaQuery.of(context);
var padding = media.padding.copyWith(top: 0);
if (_pageSizes.isNotEmpty && _pageSizes.length == _pageCount) {
const pagePadding = 10.0; // PdfViewPinch's default `padding`
final maxWidth = _pageSizes.fold<double>(
0,
(m, s) => s.width > m ? s.width : m,
);
final ratio = (constraints.maxWidth - pagePadding * 2) / maxWidth;
final docHeight = _pageSizes.fold<double>(
pagePadding,
(h, s) => h + s.height * ratio + pagePadding,
);
final needed = constraints.maxHeight - docHeight;
if (needed > padding.bottom) {
padding = padding.copyWith(bottom: needed);
}
}
return MediaQuery(
data: media.copyWith(padding: padding),
child: PdfViewPinch(controller: _controller!),
);
},
),
);
}
}