Files
noo/lib/views/file_viewer_screen.dart
T
ayushya c0f702b00e Sheet peek/expand, theme-aware media stage, and edge-to-edge viewer bars
Long-content bottom sheets (Details' Versions/Activity tabs with many
rows) opened locked to nearly full screen height with no room left to
adjust. showNooSheet now measures its content once against the
available height: anything that fits comfortably still sizes to
content as before, but content tall enough to need scrolling opens at
a native-style draggable peek instead, which the user can drag up to
reveal more of (and drag back down to dismiss).

The media viewer's stage was pinned to black regardless of theme,
which read as jarringly out of place in light mode - it now follows
the app's own `bg`. The floating top/bottom bars stay a fixed dark
panel either way (they need to read over arbitrary photo/video
brightness, not over the stage), but both had their SafeArea nested
the wrong way around: it wrapped the whole panel instead of just its
content, so the panel's background stopped short of the actual screen
edge - most visibly the bottom bar ending above the gesture area
instead of extending behind it. The background now wraps the SafeArea
instead.
2026-09-28 18:16:44 -04:00

657 lines
23 KiB
Dart

import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:open_file/open_file.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart';
import '../models/nextcloud_item.dart';
import '../providers/item_operations.dart';
import '../providers/session_controller.dart';
import '../services/download_service.dart';
import '../theme/design_tokens.dart';
import '../widgets/details/details_sheet.dart';
import '../widgets/frosted_glass_container.dart';
import '../widgets/marquee_title.dart';
import '../widgets/noo/core/noo_button.dart';
import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/noo/overlays/noo_dialog.dart';
import '../widgets/noo/overlays/noo_overlay_header.dart';
import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/share_sheet.dart';
import '../widgets/viewer/media_action_bar.dart';
import '../widgets/viewer/media_image_preview.dart';
import '../widgets/viewer/media_pdf_preview.dart';
import '../widgets/viewer/media_text_preview.dart';
import '../widgets/viewer/media_unsupported_preview.dart';
import '../widgets/viewer/media_video_preview.dart';
const _textPreviewExtensions = {
'.txt',
'.md',
'.json',
'.yaml',
'.yml',
'.xml',
'.log',
'.csv',
'.dart',
'.js',
'.ts',
'.py',
'.java',
'.kt',
'.swift',
'.c',
'.cpp',
'.h',
'.sh',
'.bat',
'.ini',
'.conf',
'.gradle',
'.html',
'.css',
};
class FileViewerScreen extends StatefulWidget {
final NextcloudItem item;
/// The other items in the same folder/album, so images and videos can be
/// swiped through without leaving the viewer. Non-media items in this list
/// are ignored. If omitted (or the item isn't an image/video), the viewer
/// just shows [item] on its own. Works the same way whether or not
/// [localPathResolver] is set - swiping through offline media resolves
/// each sibling's local path on demand, exactly like Files/Photos.
final List<NextcloudItem>? siblings;
/// When set, every preview (and "open externally") resolves its file
/// from local disk through this instead of fetching from the server -
/// used by the Offline tab, whose whole point is browsing already-
/// downloaded files with no connection. Called once per item actually
/// shown (the initial item and each swiped-to sibling), not just up
/// front. Actions that need a live server (favorite/delete/download-to-
/// device) hide themselves in this mode; Share/Open-externally/Details
/// still work (Share already prefers the local copy when one exists -
/// see `ShareSheet._shareFileDirectly` - and Details' shares/activity/
/// versions tabs degrade the same way any other offline server call
/// does).
final Future<String?> Function(NextcloudItem item)? localPathResolver;
const FileViewerScreen({
super.key,
required this.item,
this.siblings,
this.localPathResolver,
});
/// Opens the viewer with no page transition — media should appear
/// instantly, not fade/zoom in the way MaterialPageRoute normally would.
/// Going *back* still uses the theme's normal (predictive-back-capable)
/// transition, via [_InstantOpenPageRoute] below.
static Route<void> route({
required NextcloudItem item,
List<NextcloudItem>? siblings,
Future<String?> Function(NextcloudItem item)? localPathResolver,
}) {
return _InstantOpenPageRoute<void>(
pageBuilder: (context) => FileViewerScreen(
item: item,
siblings: siblings,
localPathResolver: localPathResolver,
),
);
}
@override
State<FileViewerScreen> createState() => _FileViewerScreenState();
}
/// A [PageRoute] that opens instantly (zero-duration forward transition)
/// but still delegates to the app's [PageTransitionsTheme] for the reverse
/// transition — unlike a plain [PageRouteBuilder] with no transitionsBuilder,
/// which never consults the theme and so can't participate in Android's
/// predictive-back gesture at all.
class _InstantOpenPageRoute<T> extends PageRoute<T>
with MaterialRouteTransitionMixin<T> {
_InstantOpenPageRoute({required this.pageBuilder, super.settings});
final WidgetBuilder pageBuilder;
@override
Widget buildContent(BuildContext context) => pageBuilder(context);
@override
bool get maintainState => true;
@override
Duration get transitionDuration => Duration.zero;
@override
Duration get reverseTransitionDuration => const Duration(milliseconds: 300);
}
class _FileViewerScreenState extends State<FileViewerScreen> {
double? _downloadProgress;
bool _isBusy = false;
bool _controlsVisible = true;
late final bool _isSwipeable;
late final List<NextcloudItem> _mediaItems;
late int _currentIndex;
late final PageController _pageController;
/// The active page's video controller, once `MediaVideoPreview` reports
/// it - only ever set by the *current* page (see `_buildBody`), so a
/// neighboring `PageView` page can't steal the transport row. Drives
/// `VideoTransportControls` inside the bottom `MediaActionBar` panel.
VideoPlayerController? _videoController;
NextcloudItem get _currentItem => _mediaItems[_currentIndex];
bool get _isOffline => widget.localPathResolver != null;
bool get _isPdf => _currentItem.name.toLowerCase().endsWith('.pdf');
bool get _isText => _textPreviewExtensions.contains(
p.extension(_currentItem.name).toLowerCase(),
);
@override
void initState() {
super.initState();
_isSwipeable =
widget.item.type == NextcloudItemType.image ||
widget.item.type == NextcloudItemType.video;
if (_isSwipeable) {
final source = widget.siblings ?? [widget.item];
_mediaItems = source
.where(
(i) =>
i.type == NextcloudItemType.image ||
i.type == NextcloudItemType.video,
)
.toList();
if (_mediaItems.isEmpty) _mediaItems = [widget.item];
final idx = _mediaItems.indexWhere((i) => i.path == widget.item.path);
_currentIndex = idx >= 0 ? idx : 0;
} else {
_mediaItems = [widget.item];
_currentIndex = 0;
}
_pageController = PageController(initialPage: _currentIndex);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
Future<String> _downloadToTemp(
SessionController session,
NextcloudItem item,
) async {
final tempDir = await getTemporaryDirectory();
final savePath = p.join(tempDir.path, item.name);
setState(() => _downloadProgress = 0);
await session.service!.downloadToFile(
item.path,
savePath,
onProgress: (received, total) {
if (total > 0 && mounted) {
setState(() => _downloadProgress = received / total);
}
},
);
if (mounted) setState(() => _downloadProgress = null);
return savePath;
}
Future<void> _openExternally(SessionController session) async {
final item = _currentItem;
setState(() => _isBusy = true);
try {
final path = _isOffline
? await widget.localPathResolver!(item)
: await _downloadToTemp(session, item);
if (path == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('This file is no longer available offline'),
),
);
}
return;
}
final result = await OpenFile.open(path);
if (mounted && result.type != ResultType.done) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open file: ${result.message}')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to open ${item.name}: $e')),
);
}
} finally {
if (mounted) setState(() => _isBusy = false);
}
}
/// Hands off to `DownloadService.kt` (see its doc comment) rather than
/// downloading in Dart then prompting `file_saver` - same reasoning as
/// `UploadService`/`ShareUploadService.kt` on the upload side: a real
/// Android Service survives the app being closed mid-download, and one
/// notification covers progress/cancel instead of blocking this screen.
Future<void> _downloadToDevice(SessionController session) async {
final item = _currentItem;
try {
await DownloadService.startDownload(session, [item]);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Downloading ${item.name} - see the notification for progress',
),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Download failed: $e')));
}
}
}
Future<void> _toggleFavorite(ItemOperations ops) async {
final item = _currentItem;
await ops.toggleItemFavorite(item);
if (!mounted) return;
setState(() {
_mediaItems[_currentIndex] = item.copyWith(isFavorite: !item.isFavorite);
});
}
/// Delete confirmation, restyled as [showNooDialog] (desktop) /
/// [showNooSheet] (mobile) instead of an [AlertDialog] - same pattern as
/// `confirmRemoveAccount` in `settings_dialogs.dart`.
Future<bool> _confirmDelete(String name) async {
var confirmed = false;
void cancel() => Navigator.pop(context);
void confirm() {
confirmed = true;
Navigator.pop(context);
}
Widget buttons() => Row(
children: [
Expanded(
child: NooButton(
variant: NooButtonVariant.secondary,
size: NooButtonSize.card,
fullWidth: true,
onTap: cancel,
child: const Text('Cancel'),
),
),
const SizedBox(width: NooSpace.sm),
Expanded(
child: NooButton(
variant: NooButtonVariant.danger,
size: NooButtonSize.card,
fullWidth: true,
onTap: confirm,
child: const Text('Delete'),
),
),
],
);
final message = 'Delete "$name" from the server? This cannot be undone.';
final colors = context.nooColors;
if (NooLayout.isDesktop(context)) {
await showNooDialog(
context,
title: 'Delete file',
children: [
Text(message, style: NooText.body.copyWith(color: colors.fg2)),
buttons(),
],
);
} else {
await showNooSheet(
context,
children: [
NooOverlayHeader(title: 'Delete file', onClose: cancel),
Text(message, style: NooText.body.copyWith(color: colors.fg2)),
buttons(),
],
);
}
return confirmed;
}
Future<void> _deleteCurrentItem(ItemOperations ops) async {
final item = _currentItem;
final confirmed = await _confirmDelete(item.name);
if (!confirmed || !mounted) return;
setState(() => _isBusy = true);
final success = await ops.deleteItem(item.path);
if (!mounted) return;
setState(() => _isBusy = false);
if (!success) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed to delete ${item.name}')));
return;
}
setState(() {
_mediaItems.removeAt(_currentIndex);
if (_mediaItems.isNotEmpty && _currentIndex >= _mediaItems.length) {
_currentIndex = _mediaItems.length - 1;
}
});
if (_mediaItems.isEmpty) {
if (mounted) Navigator.pop(context);
return;
}
if (_isSwipeable && _pageController.hasClients) {
_pageController.jumpToPage(_currentIndex);
}
}
@override
Widget build(BuildContext context) {
final session = context.read<SessionController>();
final ops = context.read<ItemOperations>();
final colors = context.nooColors;
// The stage follows the app's own theme (`bg`) rather than being pinned
// to black - a black stage in light mode read as jarringly out of place
// next to the rest of the light-themed app. The floating top/action
// bars stay a fixed dark frosted panel regardless (see
// FrostedGlassContainer's doc comment) since they need to read over
// arbitrary photo/video brightness, not over this background.
final stageColor = colors.bg;
return Scaffold(
backgroundColor: stageColor,
body: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _controlsVisible = !_controlsVisible),
child: Stack(
children: [
Positioned.fill(
child: Container(
color: stageColor,
child: _buildBody(context, session),
),
),
if (_downloadProgress != null)
Positioned(
left: 0,
right: 0,
top: 0,
child: LinearProgressIndicator(
value: _downloadProgress,
color: colors.accent,
backgroundColor: colors.fg1.withValues(alpha: 0.16),
),
),
// Scrim behind the top bar, per the approved canvas
// (https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4): a plain
// gradient fade, not `FrostedGlassContainer`'s blur - just
// enough to keep the back button/title legible over bright
// media without another blurred layer stacked on the bar itself.
if (_isSwipeable)
IgnorePointer(
child: AnimatedOpacity(
duration: const Duration(milliseconds: 220),
opacity: _controlsVisible ? 1 : 0,
child: Container(
height: 150,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x8C000000), Colors.transparent],
),
),
),
),
),
AnimatedSlide(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOutCubic,
offset: _controlsVisible ? Offset.zero : const Offset(0, -1.4),
child: IgnorePointer(
ignoring: !_controlsVisible,
// The blurred/tinted background lives outside the SafeArea
// (not inside it) so it extends up behind the status bar
// instead of stopping short beneath it - see the matching
// fix/comment on MediaActionBar's own SafeArea.
child: FrostedGlassContainer(
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 6,
),
child: IconTheme.merge(
// Fixed white, not `colors.fg1`: this bar always
// sits on the dark translucent panel over the media
// stage (see `FrostedGlassContainer`'s doc comment),
// regardless of the app's light/dark theme.
data: const IconThemeData(
color: Colors.white,
size: 24,
),
child: Row(
children: [
NooTopBarButton(
icon: LucideIcons.arrowLeft,
tooltip: 'Back',
onTap: () => Navigator.pop(context),
),
const SizedBox(width: 4),
Expanded(
child: MarqueeTitle(
text: _currentItem.name,
style: NooText.label.copyWith(
color: Colors.white,
),
),
),
const SizedBox(width: 10),
],
),
),
),
),
),
),
),
if (_isSwipeable)
Align(
alignment: Alignment.bottomCenter,
child: IgnorePointer(
child: AnimatedOpacity(
duration: const Duration(milliseconds: 220),
opacity: _controlsVisible ? 1 : 0,
child: Container(
height: 260,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Color(0x99000000), Colors.transparent],
),
),
),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: AnimatedSlide(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOutCubic,
offset: _controlsVisible ? Offset.zero : const Offset(0, 1.4),
child: IgnorePointer(
ignoring: !_controlsVisible,
child: MediaActionBar(
isFavorite: _currentItem.isFavorite,
isBusy: _isBusy,
// Favorite/delete/download-to-device all need a live
// server - hidden rather than shown-and-failing while
// browsing an already-local file from the Offline tab.
showServerActions: !_isOffline,
transportControls:
_isSwipeable &&
_currentItem.type == NextcloudItemType.video &&
_videoController != null
? VideoTransportControls(controller: _videoController!)
: null,
onShare: () => ShareSheet.show(context, _currentItem),
onFavorite: () => _toggleFavorite(ops),
onDelete: () => _deleteCurrentItem(ops),
onOpenExternally: () => _openExternally(session),
onDownload: () => _downloadToDevice(session),
onDetails: () => DetailsSheet.show(context, _currentItem),
),
),
),
),
],
),
),
);
}
Widget _buildBody(BuildContext context, SessionController session) {
if (!_isSwipeable) {
return _buildStaticPreview(context, session);
}
return PageView.builder(
controller: _pageController,
itemCount: _mediaItems.length,
onPageChanged: (index) => setState(() {
_currentIndex = index;
// The outgoing page's `onController` stops being passed below (it's
// no longer the active index) and reports null on its own dispose,
// but that can lag a frame or two behind this rebuild - clear
// eagerly so the transport row never briefly shows the old video.
_videoController = null;
}),
itemBuilder: (context, index) {
final mediaItem = _mediaItems[index];
final isActive = index == _currentIndex;
if (!_isOffline) {
if (mediaItem.type == NextcloudItemType.image) {
return MediaImagePreview(
key: ValueKey(mediaItem.id),
url: session.service!.fileUrl(mediaItem.path),
headers: session.service!.authHeaders,
);
}
return MediaVideoPreview(
key: ValueKey(mediaItem.id),
url: session.service!.fileUrl(mediaItem.path),
headers: session.service!.authHeaders,
isActive: isActive,
onController: isActive
? (c) => setState(() => _videoController = c)
: null,
);
}
// Offline: each swiped-to item resolves its own local path on
// demand (see FileViewerScreen.localPathResolver's doc comment) -
// this is what makes swiping through offline media behave exactly
// like Files/Photos instead of only ever showing the one item the
// viewer was opened on.
return FutureBuilder<String?>(
key: ValueKey(mediaItem.id),
future: widget.localPathResolver!(mediaItem),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return Center(
child: CircularProgressIndicator(
color: context.nooColors.accent,
),
);
}
final localPath = snapshot.data;
if (localPath == null) {
return Center(
child: Icon(
LucideIcons.cloudOff,
color: context.nooColors.fg3,
size: 64,
),
);
}
if (mediaItem.type == NextcloudItemType.image) {
return MediaImagePreview(
url: null,
headers: const {},
localPath: localPath,
);
}
return MediaVideoPreview(
url: null,
headers: const {},
localPath: localPath,
isActive: isActive,
onController: isActive
? (c) => setState(() => _videoController = c)
: null,
);
},
);
},
);
}
Widget _buildStaticPreview(BuildContext context, SessionController session) {
switch (widget.item.type) {
case NextcloudItemType.image:
case NextcloudItemType.video:
// Unreachable: these types are always handled by the swipeable path.
return const SizedBox.shrink();
default:
if (_isPdf) {
return MediaPdfPreview(
item: widget.item,
session: session,
localPathResolver: widget.localPathResolver,
);
}
if (_isText) {
return MediaTextPreview(
item: widget.item,
session: session,
localPathResolver: widget.localPathResolver,
);
}
return MediaUnsupportedPreview(
item: widget.item,
isBusy: _isBusy,
onOpenExternally: () => _openExternally(session),
onOpenDetails: () => DetailsSheet.show(context, widget.item),
);
}
}
}