improved unknown file preview handling with probe failure fallbacks and switched to an explicit TabController in the share view to prevent build-time layout issues

This commit is contained in:
2026-05-13 20:28:30 +02:00
parent d970cfbe0c
commit 0fd42439e2
2 changed files with 104 additions and 69 deletions
+57 -45
View File
@@ -17,7 +17,19 @@ class QrShareView extends StatefulWidget {
State<QrShareView> createState() => _QrShareViewState(); State<QrShareView> createState() => _QrShareViewState();
} }
class _QrShareViewState extends State<QrShareView> { class _QrShareViewState extends State<QrShareView>
with SingleTickerProviderStateMixin {
// Owning the TabController explicitly (instead of DefaultTabController) is
// a workaround for a Flutter framework bug where TabBarView's
// didChangeDependencies issues a jumpToPage mid-build, whose scroll
// notification then calls setState on _TabStyle while the frame is still
// being built — only reproduces reliably with a bottomNavigationBar in the
// Scaffold underneath.
late final TabController _tabController = TabController(
length: 2,
vsync: this,
);
@override @override
void initState() { void initState() {
ScreenBrightness.instance.setApplicationScreenBrightness(1.0); ScreenBrightness.instance.setApplicationScreenBrightness(1.0);
@@ -26,6 +38,7 @@ class _QrShareViewState extends State<QrShareView> {
@override @override
void dispose() { void dispose() {
_tabController.dispose();
ScreenBrightness.instance.resetApplicationScreenBrightness(); ScreenBrightness.instance.resetApplicationScreenBrightness();
super.dispose(); super.dispose();
} }
@@ -45,53 +58,52 @@ class _QrShareViewState extends State<QrShareView> {
} }
@override @override
Widget build(BuildContext context) => DefaultTabController( Widget build(BuildContext context) => Scaffold(
length: 2, appBar: AppBar(
child: Scaffold( title: const Text('Teile die App'),
appBar: AppBar( bottom: TabBar(
title: const Text('Teile die App'), controller: _tabController,
bottom: const TabBar( tabs: const [
tabs: [ Tab(icon: Icon(Icons.android_outlined), text: 'Android'),
Tab(icon: Icon(Icons.android_outlined), text: 'Android'), Tab(icon: Icon(Icons.apple_outlined), text: 'iOS & iPadOS'),
Tab(icon: Icon(Icons.apple_outlined), text: 'iOS & iPadOS'),
],
),
),
body: const TabBarView(
children: [
AppSharePlatformView('Für Android', _androidUrl),
AppSharePlatformView('Für iOS & iPad', _iosUrl),
], ],
), ),
bottomNavigationBar: SafeArea( ),
child: Material( body: TabBarView(
color: Theme.of(context).colorScheme.surface, controller: _tabController,
child: Column( children: const [
mainAxisSize: MainAxisSize.min, AppSharePlatformView('Für Android', _androidUrl),
children: [ AppSharePlatformView('Für iOS & iPad', _iosUrl),
const Divider(height: 1), ],
Builder( ),
builder: (innerCtx) => ListTile( bottomNavigationBar: SafeArea(
leading: const Icon(Icons.share_outlined), child: Material(
title: const Text('Per Link teilen'), color: Theme.of(context).colorScheme.surface,
trailing: const Icon(Icons.arrow_right), child: Column(
onTap: () => _shareLink(innerCtx), mainAxisSize: MainAxisSize.min,
), children: [
const Divider(height: 1),
Builder(
builder: (innerCtx) => ListTile(
leading: const Icon(Icons.share_outlined),
title: const Text('Per Link teilen'),
trailing: const Icon(Icons.arrow_right),
onTap: () => _shareLink(innerCtx),
), ),
ListTile( ),
leading: const Icon(Icons.android_outlined), ListTile(
title: const Text('Android-Link kopieren'), leading: const Icon(Icons.android_outlined),
trailing: const Icon(Icons.copy), title: const Text('Android-Link kopieren'),
onTap: () => copyToClipboard(context, _androidUrl), trailing: const Icon(Icons.copy),
), onTap: () => copyToClipboard(context, _androidUrl),
ListTile( ),
leading: const Icon(Icons.apple_outlined), ListTile(
title: const Text('iOS-Link kopieren'), leading: const Icon(Icons.apple_outlined),
trailing: const Icon(Icons.copy), title: const Text('iOS-Link kopieren'),
onTap: () => copyToClipboard(context, _iosUrl), trailing: const Icon(Icons.copy),
), onTap: () => copyToClipboard(context, _iosUrl),
], ),
), ],
), ),
), ),
), ),
+47 -24
View File
@@ -508,8 +508,6 @@ class _FileViewerState extends State<FileViewer> {
Widget _buildUnknownPlaceholder() { Widget _buildUnknownPlaceholder() {
final theme = Theme.of(context); final theme = Theme.of(context);
final descriptors = _availableActions(); final descriptors = _availableActions();
final remote = widget.remoteFile;
final hasPreview = remote?.hasPreview == true;
return ListView( return ListView(
padding: const EdgeInsets.symmetric(vertical: 24), padding: const EdgeInsets.symmetric(vertical: 24),
children: [ children: [
@@ -517,16 +515,8 @@ class _FileViewerState extends State<FileViewer> {
padding: const EdgeInsets.symmetric(horizontal: 24), padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column( child: Column(
children: [ children: [
_UnknownPreviewHeader(remoteFile: remote), _UnknownPreviewBlock(remoteFile: widget.remoteFile),
const SizedBox(height: 16), const SizedBox(height: 16),
if (!hasPreview) ...[
Text(
'Vorschau nicht verfügbar',
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
],
Text( Text(
widget.path.split('/').last, widget.path.split('/').last,
style: theme.textTheme.titleSmall?.copyWith( style: theme.textTheme.titleSmall?.copyWith(
@@ -607,24 +597,48 @@ class _TextPayload {
const _TextPayload({required this.content, required this.truncated}); const _TextPayload({required this.content, required this.truncated});
} }
/// Header for the "Vorschau nicht verfügbar" screen: tries to fetch the /// Header block for the "Vorschau nicht verfügbar" screen.
/// Nextcloud thumbnail and shows it mid-sized if available, otherwise ///
/// falls back to the generic file icon. /// Two visual modes — kept layout-equivalent so the screen looks identical
class _UnknownPreviewHeader extends StatelessWidget { /// whether the server already said "no preview" or the probe failed late:
/// * **No preview available** (server said no, no remoteFile, or probe
/// errored): compact "file icon + 'Vorschau nicht verfügbar' text".
/// * **Preview rendering / loaded**: mid-sized thumbnail without text.
class _UnknownPreviewBlock extends StatefulWidget {
final RemoteFileRef? remoteFile; final RemoteFileRef? remoteFile;
const _UnknownPreviewHeader({required this.remoteFile}); const _UnknownPreviewBlock({required this.remoteFile});
@override
State<_UnknownPreviewBlock> createState() => _UnknownPreviewBlockState();
}
class _UnknownPreviewBlockState extends State<_UnknownPreviewBlock> {
static const double _previewSize = 180; static const double _previewSize = 180;
bool _failed = false;
Widget _fallbackIcon() => Widget _compact(ThemeData theme) => Column(
const Icon(Icons.insert_drive_file_outlined, size: 60); mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.insert_drive_file_outlined, size: 60),
const SizedBox(height: 16),
Text(
'Vorschau nicht verfügbar',
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
],
);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final remote = remoteFile; final theme = Theme.of(context);
// Skip the probe outright when the server already told us there is no final remote = widget.remoteFile;
// preview — saves an HTTP request that would 404 anyway. final canProbe =
if (remote == null || remote.hasPreview == false) return _fallbackIcon(); remote != null &&
remote.hasPreview != false &&
remote.fileId != null &&
!_failed;
if (!canProbe) return _compact(theme);
return SizedBox( return SizedBox(
width: _previewSize, width: _previewSize,
height: _previewSize, height: _previewSize,
@@ -633,10 +647,19 @@ class _UnknownPreviewHeader extends StatelessWidget {
imageUrl: _ncPreviewUrl(remote, width: 360), imageUrl: _ncPreviewUrl(remote, width: 360),
fadeInDuration: Duration.zero, fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero, fadeOutDuration: Duration.zero,
errorListener: (_) {}, // Late probe failure: re-render into the compact layout so the
// screen doesn't keep a 180×180 box around a tiny icon. Deferred
// to the next frame because setState during build is illegal.
errorListener: (_) {
if (!mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _failed = true);
});
},
placeholder: (_, _) => placeholder: (_, _) =>
const Center(child: AppProgressIndicator.large()), const Center(child: AppProgressIndicator.large()),
errorWidget: (_, _, _) => Center(child: _fallbackIcon()), // Briefly empty while the post-frame setState swaps layouts.
errorWidget: (_, _, _) => const SizedBox.shrink(),
imageBuilder: (_, imageProvider) => ClipRRect( imageBuilder: (_, imageProvider) => ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Image( child: Image(