diff --git a/lib/view/pages/more/share/qr_share_view.dart b/lib/view/pages/more/share/qr_share_view.dart index fbbe2f7..05d9ff6 100644 --- a/lib/view/pages/more/share/qr_share_view.dart +++ b/lib/view/pages/more/share/qr_share_view.dart @@ -17,7 +17,19 @@ class QrShareView extends StatefulWidget { State createState() => _QrShareViewState(); } -class _QrShareViewState extends State { +class _QrShareViewState extends State + 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 { @override void dispose() { + _tabController.dispose(); ScreenBrightness.instance.resetApplicationScreenBrightness(); super.dispose(); } @@ -45,53 +58,52 @@ class _QrShareViewState extends State { } @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), + ), + ], ), ), ), diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index 27f4e7e..12ed37b 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -508,8 +508,6 @@ class _FileViewerState extends State { 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 { 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(