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();
}
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
void initState() {
ScreenBrightness.instance.setApplicationScreenBrightness(1.0);
@@ -26,6 +38,7 @@ class _QrShareViewState extends State<QrShareView> {
@override
void dispose() {
_tabController.dispose();
ScreenBrightness.instance.resetApplicationScreenBrightness();
super.dispose();
}
@@ -45,53 +58,52 @@ class _QrShareViewState extends State<QrShareView> {
}
@override
Widget build(BuildContext context) => DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('Teile die App'),
bottom: const TabBar(
tabs: [
Tab(icon: Icon(Icons.android_outlined), text: 'Android'),
Tab(icon: Icon(Icons.apple_outlined), text: 'iOS & iPadOS'),
],
),
),
body: const TabBarView(
children: [
AppSharePlatformView('Für Android', _androidUrl),
AppSharePlatformView('Für iOS & iPad', _iosUrl),
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Teile die App'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(icon: Icon(Icons.android_outlined), text: 'Android'),
Tab(icon: Icon(Icons.apple_outlined), text: 'iOS & iPadOS'),
],
),
bottomNavigationBar: SafeArea(
child: Material(
color: Theme.of(context).colorScheme.surface,
child: Column(
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),
),
),
body: TabBarView(
controller: _tabController,
children: const [
AppSharePlatformView('Für Android', _androidUrl),
AppSharePlatformView('Für iOS & iPad', _iosUrl),
],
),
bottomNavigationBar: SafeArea(
child: Material(
color: Theme.of(context).colorScheme.surface,
child: Column(
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),
title: const Text('Android-Link kopieren'),
trailing: const Icon(Icons.copy),
onTap: () => copyToClipboard(context, _androidUrl),
),
ListTile(
leading: const Icon(Icons.apple_outlined),
title: const Text('iOS-Link kopieren'),
trailing: const Icon(Icons.copy),
onTap: () => copyToClipboard(context, _iosUrl),
),
],
),
),
ListTile(
leading: const Icon(Icons.android_outlined),
title: const Text('Android-Link kopieren'),
trailing: const Icon(Icons.copy),
onTap: () => copyToClipboard(context, _androidUrl),
),
ListTile(
leading: const Icon(Icons.apple_outlined),
title: const Text('iOS-Link kopieren'),
trailing: const Icon(Icons.copy),
onTap: () => copyToClipboard(context, _iosUrl),
),
],
),
),
),
+47 -24
View File
@@ -508,8 +508,6 @@ class _FileViewerState extends State<FileViewer> {
Widget _buildUnknownPlaceholder() {
final theme = Theme.of(context);
final descriptors = _availableActions();
final remote = widget.remoteFile;
final hasPreview = remote?.hasPreview == true;
return ListView(
padding: const EdgeInsets.symmetric(vertical: 24),
children: [
@@ -517,16 +515,8 @@ class _FileViewerState extends State<FileViewer> {
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
_UnknownPreviewHeader(remoteFile: remote),
_UnknownPreviewBlock(remoteFile: widget.remoteFile),
const SizedBox(height: 16),
if (!hasPreview) ...[
Text(
'Vorschau nicht verfügbar',
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
],
Text(
widget.path.split('/').last,
style: theme.textTheme.titleSmall?.copyWith(
@@ -607,24 +597,48 @@ class _TextPayload {
const _TextPayload({required this.content, required this.truncated});
}
/// Header for the "Vorschau nicht verfügbar" screen: tries to fetch the
/// Nextcloud thumbnail and shows it mid-sized if available, otherwise
/// falls back to the generic file icon.
class _UnknownPreviewHeader extends StatelessWidget {
/// Header block for the "Vorschau nicht verfügbar" screen.
///
/// Two visual modes — kept layout-equivalent so the screen looks identical
/// 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;
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;
bool _failed = false;
Widget _fallbackIcon() =>
const Icon(Icons.insert_drive_file_outlined, size: 60);
Widget _compact(ThemeData theme) => Column(
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
Widget build(BuildContext context) {
final remote = remoteFile;
// Skip the probe outright when the server already told us there is no
// preview — saves an HTTP request that would 404 anyway.
if (remote == null || remote.hasPreview == false) return _fallbackIcon();
final theme = Theme.of(context);
final remote = widget.remoteFile;
final canProbe =
remote != null &&
remote.hasPreview != false &&
remote.fileId != null &&
!_failed;
if (!canProbe) return _compact(theme);
return SizedBox(
width: _previewSize,
height: _previewSize,
@@ -633,10 +647,19 @@ class _UnknownPreviewHeader extends StatelessWidget {
imageUrl: _ncPreviewUrl(remote, width: 360),
fadeInDuration: 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: (_, _) =>
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(
borderRadius: BorderRadius.circular(8),
child: Image(