Files
noo/lib/views/file_viewer_screen.dart
T
ayushyaandClaude Sonnet 5 f2bba2914e
Build APK / build (push) Successful in 5m27s
Offline tab as Files view, automatic background sync, sync notifications
- Offline tab is now FilesView(offline: true) over a FolderBrowser interface
  (FilesController / OfflineController) sharing controls, tiles, thumbnails
- Synced files follow the Files Cache rule (default: refresh every 15 min):
  per-account WorkManager jobs, in-app timer, resume and pull triggers;
  root-etag shortcut skips full walks when nothing changed
- Single sync at a time (shared lock); logging out stops that account's sync
- Sync safety: PROPFIND failures skip the path instead of deleting local files
- Mirror empty folders and remove deleted ones; drop synced paths deleted on
  the server; missing local files are re-downloaded
- Notifications: silent per-account sync notifications, "Background sync
  notifications" setting, audible upload/download completion
- Files refresh in place (no spinner flash); retry after network returns
- Fix sync badges not updating (shared native status stream), "Sync off" on
  launch (eager providers), Files Cache section moved under Device Sync

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:06:55 -04:00

1337 lines
42 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:open_file/open_file.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:pdfx/pdfx.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 '../providers/settings_controller.dart';
import '../services/download_service.dart';
import '../widgets/details/details_sheet.dart';
import '../widgets/frosted_glass_container.dart';
import '../widgets/marquee_title.dart';
import '../widgets/seek_bar_painter.dart';
import '../widgets/share_sheet.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;
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);
});
}
Future<void> _deleteCurrentItem(ItemOperations ops) async {
final item = _currentItem;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Delete file'),
content: Text(
'Delete "${item.name}" from the server? This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Delete'),
),
],
);
},
);
if (confirmed != true || !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 settings = context.read<SettingsController>();
final ops = context.read<ItemOperations>();
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colorScheme.surface,
body: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _controlsVisible = !_controlsVisible),
child: Stack(
children: [
Positioned.fill(
child: Container(
color: colorScheme.surface,
child: _buildBody(context, session),
),
),
if (_downloadProgress != null)
Positioned(
left: 0,
right: 0,
top: 0,
child: LinearProgressIndicator(
value: _downloadProgress,
color: colorScheme.primary,
backgroundColor: colorScheme.onSurface.withValues(
alpha: 0.16,
),
),
),
AnimatedSlide(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOutCubic,
offset: _controlsVisible ? Offset.zero : const Offset(0, -1.4),
child: IgnorePointer(
ignoring: !_controlsVisible,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: FrostedGlassContainer(
opacity: settings.bottomBarOpacity,
blurSigma: settings.bottomBarBlur,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 6,
),
child: Row(
children: [
_ActionIconButton(
icon: Icons.arrow_back_rounded,
tooltip: 'Back',
onTap: () => Navigator.pop(context),
),
const SizedBox(width: 4),
Expanded(
child: MarqueeTitle(
text: _currentItem.name,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 10),
],
),
),
),
),
),
),
),
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,
opacity: settings.bottomBarOpacity,
blurSigma: settings.bottomBarBlur,
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);
}
final settings = context.read<SettingsController>();
return PageView.builder(
controller: _pageController,
itemCount: _mediaItems.length,
onPageChanged: (index) => setState(() => _currentIndex = index),
itemBuilder: (context, index) {
final mediaItem = _mediaItems[index];
if (!_isOffline) {
if (mediaItem.type == NextcloudItemType.image) {
return _ImagePreview(
key: ValueKey(mediaItem.id),
url: session.service!.fileUrl(mediaItem.path),
headers: session.service!.authHeaders,
);
}
return _VideoPreview(
key: ValueKey(mediaItem.id),
url: session.service!.fileUrl(mediaItem.path),
headers: session.service!.authHeaders,
isActive: index == _currentIndex,
controlsBottomOffset: _controlsVisible ? 108 : 24,
opacity: settings.bottomBarOpacity,
blurSigma: settings.bottomBarBlur,
);
}
// 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 const Center(child: CircularProgressIndicator());
}
final localPath = snapshot.data;
if (localPath == null) {
return Center(
child: Icon(
Icons.cloud_off_rounded,
color: Theme.of(context).colorScheme.onSurfaceVariant,
size: 64,
),
);
}
if (mediaItem.type == NextcloudItemType.image) {
return _ImagePreview(url: null, headers: const {}, localPath: localPath);
}
return _VideoPreview(
url: null,
headers: const {},
localPath: localPath,
isActive: index == _currentIndex,
controlsBottomOffset: _controlsVisible ? 108 : 24,
opacity: settings.bottomBarOpacity,
blurSigma: settings.bottomBarBlur,
);
},
);
},
);
}
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 _PdfPreview(
item: widget.item,
session: session,
localPathResolver: widget.localPathResolver,
);
}
if (_isText) {
return _TextPreview(
item: widget.item,
session: session,
localPathResolver: widget.localPathResolver,
);
}
return _UnsupportedPreview(
item: widget.item,
isBusy: _isBusy,
onOpenExternally: () => _openExternally(session),
onOpenDetails: () => DetailsSheet.show(context, widget.item),
);
}
}
}
/// The floating action bar (share/favorite/open/download/delete), sized and
/// positioned to match the app-wide frosted-glass bottom navigation bar.
class _MediaActionBar extends StatelessWidget {
final bool isFavorite;
final bool isBusy;
final bool showServerActions;
final double opacity;
final double blurSigma;
final VoidCallback onShare;
final VoidCallback onFavorite;
final VoidCallback onDelete;
final VoidCallback onOpenExternally;
final VoidCallback onDownload;
final VoidCallback onDetails;
const _MediaActionBar({
required this.isFavorite,
required this.isBusy,
required this.showServerActions,
required this.opacity,
required this.blurSigma,
required this.onShare,
required this.onFavorite,
required this.onDelete,
required this.onOpenExternally,
required this.onDownload,
required this.onDetails,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return SafeArea(
child: Padding(
padding: const EdgeInsets.only(bottom: 20, left: 20, right: 20),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: FrostedGlassContainer(
opacity: opacity,
blurSigma: blurSigma,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_ActionIconButton(
icon: Icons.share_rounded,
tooltip: 'Share',
onTap: isBusy ? null : onShare,
),
if (showServerActions)
_ActionIconButton(
icon: isFavorite
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
tooltip: 'Favorite',
color: isFavorite ? Colors.red : null,
onTap: onFavorite,
),
_ActionIconButton(
icon: Icons.open_in_new_rounded,
tooltip: 'Open externally',
onTap: isBusy ? null : onOpenExternally,
),
if (showServerActions)
_ActionIconButton(
icon: Icons.download_rounded,
tooltip: 'Download',
onTap: isBusy ? null : onDownload,
),
if (showServerActions)
_ActionIconButton(
icon: Icons.delete_outline_rounded,
tooltip: 'Delete',
color: colorScheme.error,
onTap: isBusy ? null : onDelete,
),
_ActionIconButton(
icon: Icons.info_outline_rounded,
tooltip: 'Details',
onTap: onDetails,
),
],
),
),
),
),
),
);
}
}
class _ActionIconButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback? onTap;
final Color? color;
const _ActionIconButton({
required this.icon,
required this.tooltip,
required this.onTap,
this.color,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final fg = onTap == null
? colorScheme.onSurface.withValues(alpha: 0.4)
: (color ?? colorScheme.onSurface);
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
child: Icon(icon, color: fg, size: 22),
),
),
);
}
}
class _ImagePreview extends StatefulWidget {
final String? url;
final Map<String, String> headers;
/// When set, read the image from this on-disk path instead of [url] -
/// already-resolved by the caller (see
/// `FileViewerScreen.localPathResolver`'s doc comment).
final String? localPath;
const _ImagePreview({
super.key,
required this.url,
required this.headers,
this.localPath,
});
@override
State<_ImagePreview> createState() => _ImagePreviewState();
}
class _ImagePreviewState extends State<_ImagePreview>
with SingleTickerProviderStateMixin {
static const _doubleTapScale = 3.0;
final TransformationController _transformController =
TransformationController();
late final AnimationController _animController;
Animation<Matrix4>? _animation;
Offset _doubleTapPosition = Offset.zero;
@override
void initState() {
super.initState();
_animController =
AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
)..addListener(() {
if (_animation != null) {
_transformController.value = _animation!.value;
}
});
}
@override
void dispose() {
_animController.dispose();
_transformController.dispose();
super.dispose();
}
void _onDoubleTapDown(TapDownDetails details) {
_doubleTapPosition = details.localPosition;
}
void _onDoubleTap() {
final isZoomedIn = _transformController.value.getMaxScaleOnAxis() > 1.01;
final Matrix4 endMatrix;
if (isZoomedIn) {
endMatrix = Matrix4.identity();
} else {
final p = _doubleTapPosition;
endMatrix = Matrix4.identity()
..translateByDouble(
-p.dx * (_doubleTapScale - 1),
-p.dy * (_doubleTapScale - 1),
0,
1,
)
..scaleByDouble(_doubleTapScale, _doubleTapScale, _doubleTapScale, 1);
}
_animation = Matrix4Tween(
begin: _transformController.value,
end: endMatrix,
).animate(CurveTween(curve: Curves.easeOut).animate(_animController));
_animController.forward(from: 0);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return SizedBox.expand(
child: GestureDetector(
onDoubleTapDown: _onDoubleTapDown,
onDoubleTap: _onDoubleTap,
child: InteractiveViewer(
transformationController: _transformController,
minScale: 0.8,
maxScale: 5.0,
child: Center(
child: widget.localPath != null
? Image.file(
File(widget.localPath!),
fit: BoxFit.contain,
errorBuilder: (context, error, stack) => Center(
child: Icon(
Icons.broken_image_outlined,
color: colorScheme.onSurfaceVariant,
size: 64,
),
),
)
: Image.network(
widget.url!,
headers: widget.headers,
fit: BoxFit.contain,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return Center(
child: CircularProgressIndicator(
color: colorScheme.primary,
),
);
},
errorBuilder: (context, error, stack) => Center(
child: Icon(
Icons.broken_image_outlined,
color: colorScheme.onSurfaceVariant,
size: 64,
),
),
),
),
),
),
);
}
}
class _VideoPreview extends StatefulWidget {
final String? url;
final Map<String, String> headers;
final bool isActive;
/// When set, read the video from this on-disk path instead of [url] -
/// already-resolved by the caller (see
/// `FileViewerScreen.localPathResolver`'s doc comment).
final String? localPath;
/// Distance from the bottom of the screen the transport controls should
/// sit at. Passed in by the parent so the controls can track the floating
/// action bar: resting just above it when visible, sliding down to hug
/// the screen edge when the bar (and rest of the chrome) is hidden.
final double controlsBottomOffset;
/// Opacity/blur for the transport controls' frosted-glass background,
/// matching the app-wide bottom bar and media action bar styling.
final double opacity;
final double blurSigma;
const _VideoPreview({
super.key,
required this.url,
required this.headers,
this.localPath,
this.isActive = true,
this.controlsBottomOffset = 24,
this.opacity = 0.55,
this.blurSigma = 28,
});
@override
State<_VideoPreview> createState() => _VideoPreviewState();
}
class _VideoPreviewState extends State<_VideoPreview> {
VideoPlayerController? _controller;
String? _error;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
try {
final controller = widget.localPath != null
? VideoPlayerController.file(File(widget.localPath!))
: VideoPlayerController.networkUrl(
Uri.parse(widget.url!),
httpHeaders: widget.headers,
);
await controller.initialize();
if (!mounted) {
controller.dispose();
return;
}
setState(() => _controller = controller);
if (widget.isActive) controller.play();
} catch (e) {
if (mounted) setState(() => _error = e.toString());
}
}
@override
void didUpdateWidget(covariant _VideoPreview oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isActive != widget.isActive) {
if (widget.isActive) {
_controller?.play();
} else {
_controller?.pause();
}
}
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
String _formatDuration(Duration d) {
final minutes = d.inMinutes.remainder(60);
final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final progressBarStyle = context
.watch<SettingsController>()
.mediaProgressBarStyle;
if (_error != null) {
return Center(
child: Text(
'Could not play video: $_error',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
);
}
final controller = _controller;
if (controller == null || !controller.value.isInitialized) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
}
return SizedBox.expand(
child: Stack(
alignment: Alignment.center,
children: [
Center(
child: AspectRatio(
aspectRatio: controller.value.aspectRatio,
child: VideoPlayer(controller),
),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOutCubic,
left: 16,
right: 16,
bottom: widget.controlsBottomOffset,
child: AnimatedBuilder(
animation: controller,
builder: (context, _) {
final colorScheme = Theme.of(context).colorScheme;
final fg = colorScheme.onSurface;
// Same frosted-glass treatment (and user opacity/blur
// settings) as the back button and media action bar, so the
// transport controls match the rest of the app's chrome.
return FrostedGlassContainer(
borderRadius: 24,
opacity: widget.opacity,
blurSigma: widget.blurSigma,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 4,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
_ActionIconButton(
icon: controller.value.isPlaying
? Icons.pause_rounded
: Icons.play_arrow_rounded,
tooltip: controller.value.isPlaying
? 'Pause'
: 'Play',
onTap: () => controller.value.isPlaying
? controller.pause()
: controller.play(),
),
Expanded(
child: Text(
'${_formatDuration(controller.value.position)} / ${_formatDuration(controller.value.duration)}',
textAlign: TextAlign.center,
style: TextStyle(color: fg, fontSize: 13),
),
),
_ActionIconButton(
icon: controller.value.volume == 0
? Icons.volume_off_rounded
: Icons.volume_up_rounded,
tooltip: controller.value.volume == 0
? 'Unmute'
: 'Mute',
onTap: () => controller.setVolume(
controller.value.volume == 0 ? 1 : 0,
),
),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: _WavySeekBar(
controller: controller,
playedColor: fg,
trackColor: fg.withValues(alpha: 0.3),
style: progressBarStyle,
),
),
],
),
),
);
},
),
),
],
),
);
}
}
/// A Material You "expressive" wavy seek bar: the played portion of the
/// track animates as a travelling sine wave while playing and settles flat
/// when paused, matching the Android 16+ media player style. Drag or tap
/// anywhere on it to seek.
class _WavySeekBar extends StatefulWidget {
final VideoPlayerController controller;
final Color playedColor;
final Color trackColor;
final MediaProgressBarStyle style;
const _WavySeekBar({
required this.controller,
required this.playedColor,
required this.trackColor,
required this.style,
});
@override
State<_WavySeekBar> createState() => _WavySeekBarState();
}
class _WavySeekBarState extends State<_WavySeekBar>
with SingleTickerProviderStateMixin {
late final AnimationController _waveController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
double? _dragRatio;
@override
void dispose() {
_waveController.dispose();
super.dispose();
}
void _seekToRatio(double ratio) {
final duration = widget.controller.value.duration;
widget.controller.seekTo(duration * ratio.clamp(0.0, 1.0));
}
@override
Widget build(BuildContext context) {
final value = widget.controller.value;
final animatesWave =
widget.style == MediaProgressBarStyle.wavy ||
widget.style == MediaProgressBarStyle.squiggly;
final isPlaying = animatesWave && value.isPlaying && _dragRatio == null;
if (isPlaying && !_waveController.isAnimating) {
_waveController.repeat();
} else if (!isPlaying && _waveController.isAnimating) {
_waveController.stop();
}
final durationMs = value.duration.inMilliseconds;
final baseRatio = durationMs > 0
? value.position.inMilliseconds / durationMs
: 0.0;
final ratio = (_dragRatio ?? baseRatio).clamp(0.0, 1.0);
return SizedBox(
height: 28,
child: LayoutBuilder(
builder: (context, constraints) {
void updateFromDx(double dx) {
setState(
() => _dragRatio = (dx / constraints.maxWidth).clamp(0.0, 1.0),
);
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (details) =>
updateFromDx(details.localPosition.dx),
onHorizontalDragUpdate: (details) =>
updateFromDx(details.localPosition.dx),
onHorizontalDragEnd: (_) {
if (_dragRatio != null) _seekToRatio(_dragRatio!);
setState(() => _dragRatio = null);
},
onTapUp: (details) =>
_seekToRatio(details.localPosition.dx / constraints.maxWidth),
child: AnimatedBuilder(
animation: _waveController,
builder: (context, _) {
return CustomPaint(
size: Size(constraints.maxWidth, 28),
painter: SeekBarPainter(
style: widget.style,
progress: ratio,
phase: _waveController.value * 2 * math.pi,
animate: isPlaying,
playedColor: widget.playedColor,
trackColor: widget.trackColor,
),
);
},
),
);
},
),
);
}
}
class _PdfPreview extends StatefulWidget {
final NextcloudItem item;
final SessionController session;
/// When set, read bytes from the local path it resolves to instead of
/// fetching from the server - see
/// `FileViewerScreen.localPathResolver`'s doc comment.
final Future<String?> Function(NextcloudItem item)? localPathResolver;
const _PdfPreview({
required this.item,
required this.session,
this.localPathResolver,
});
@override
State<_PdfPreview> createState() => _PdfPreviewState();
}
class _PdfPreviewState extends State<_PdfPreview> {
PdfControllerPinch? _controller;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final Uint8List bytes;
if (widget.localPathResolver != null) {
final localPath = await widget.localPathResolver!(widget.item);
if (localPath == null) {
if (mounted) {
setState(() => _error = 'No longer available offline');
}
return;
}
bytes = await File(localPath).readAsBytes();
} else {
bytes = Uint8List.fromList(
await widget.session.service!.fetchBytes(widget.item.path),
);
}
if (!mounted) return;
setState(() {
_controller = PdfControllerPinch(
document: PdfDocument.openData(bytes),
);
});
} catch (e) {
if (mounted) setState(() => _error = e.toString());
}
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
if (_error != null) {
return Center(
child: Text(
'Could not load PDF: $_error',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
);
}
if (_controller == null) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
}
// pdfx's own default minScale (1.0 = the page's true/100% size) is
// often *larger* than the fit-to-width size a wide page first renders
// at, since that initial render is just normal box layout, not the
// InteractiveViewer transform pinching engages on first touch - so the
// moment you touch the page it snaps up to 1.0 and, with the default
// floor, can never pinch back down past it. A low floor here lets you
// zoom back out past that to the fit-width view you started at.
return PdfViewPinch(controller: _controller!, minScale: 0.3);
}
}
class _TextPreview extends StatefulWidget {
final NextcloudItem item;
final SessionController session;
/// When set, read bytes from the local path it resolves to instead of
/// fetching from the server - see
/// `FileViewerScreen.localPathResolver`'s doc comment.
final Future<String?> Function(NextcloudItem item)? localPathResolver;
const _TextPreview({
required this.item,
required this.session,
this.localPathResolver,
});
@override
State<_TextPreview> createState() => _TextPreviewState();
}
class _TextPreviewState extends State<_TextPreview> {
String? _content;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final List<int> bytes;
if (widget.localPathResolver != null) {
final localPath = await widget.localPathResolver!(widget.item);
if (localPath == null) {
if (mounted) {
setState(() => _error = 'No longer available offline');
}
return;
}
bytes = await File(localPath).readAsBytes();
} else {
bytes = await widget.session.service!.fetchBytes(widget.item.path);
}
if (mounted) {
setState(() => _content = utf8.decode(bytes, allowMalformed: true));
}
} catch (e) {
if (mounted) setState(() => _error = e.toString());
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
if (_error != null) {
return Center(
child: Text(
'Could not load file: $_error',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
);
}
if (_content == null) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
}
return Container(
color: colorScheme.surface,
padding: const EdgeInsets.all(20),
child: SingleChildScrollView(
child: SelectableText(
_content!,
style: TextStyle(
color: colorScheme.onSurface,
fontFamily: 'monospace',
fontSize: 13,
height: 1.5,
),
),
),
);
}
}
class _UnsupportedPreview extends StatelessWidget {
final NextcloudItem item;
final bool isBusy;
final VoidCallback onOpenExternally;
final VoidCallback onOpenDetails;
const _UnsupportedPreview({
required this.item,
required this.isBusy,
required this.onOpenExternally,
required this.onOpenDetails,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.insert_drive_file_rounded,
color: colorScheme.onSurfaceVariant,
size: 72,
),
const SizedBox(height: 16),
Text(
item.name,
textAlign: TextAlign.center,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
const SizedBox(height: 8),
Text(
'No inline preview for this file type.',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 13,
),
),
const SizedBox(height: 28),
FilledButton.icon(
onPressed: isBusy ? null : onOpenExternally,
icon: const Icon(Icons.open_in_new_rounded),
label: const Text('Open with...'),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: onOpenDetails,
icon: const Icon(Icons.info_outline_rounded),
label: const Text('Details'),
),
],
),
),
);
}
}