diff --git a/analysis_options.yaml b/analysis_options.yaml index 5dd5f4c..4169792 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -18,6 +18,9 @@ analyzer: - "**/*.freezed.dart" - "lib/firebase_options.dart" - "build/**" + - android/** + - ios/** + - web/** linter: rules: diff --git a/lib/api/marianumcloud/talk/share_files_to_chat.dart b/lib/api/marianumcloud/talk/share_files_to_chat.dart index 9409e39..9be0faa 100644 --- a/lib/api/marianumcloud/talk/share_files_to_chat.dart +++ b/lib/api/marianumcloud/talk/share_files_to_chat.dart @@ -1,10 +1,35 @@ +import 'package:nextcloud/nextcloud.dart'; + import '../files_sharing/file_sharing_api.dart'; import '../files_sharing/file_sharing_api_params.dart'; +import '../webdav/webdav_api.dart'; /// WebDAV folder under which Talk-shared files are uploaded before being /// linked into a chat. const String talkShareFolder = 'MarianumMobile'; +Future? _shareFolderReady; + +/// Creates [talkShareFolder] if it is missing, at most once per session — the +/// folder is permanent, so every later upload would just pay a round trip to +/// be told it already exists (WebDAV answers MKCOL on an existing collection +/// with 405, which is the normal case here and must not surface as an error). +Future ensureTalkShareFolder() => + _shareFolderReady ??= _createTalkShareFolder(); + +Future _createTalkShareFolder() async { + try { + final webdav = await WebdavApi.webdav; + await webdav.mkcol(PathUri.parse('/$talkShareFolder')); + } on DynamiteApiException catch (e) { + // Anything but "already exists" leaves the folder unconfirmed, so the next + // upload has to try again. + if (e.statusCode != 405) _shareFolderReady = null; + } catch (_) { + _shareFolderReady = null; + } +} + /// Posts each already-uploaded WebDAV path as a Talk share (ShareType 10) to /// the given conversation token. Calls run concurrently — the server accepts /// parallel posts and the picker UI is blocked anyway, so we shouldn't pay diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index fb26356..63232c6 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'package:url_launcher/url_launcher_string.dart'; import '../api/marianumcloud/talk/room/get_room_response.dart'; import '../api/marianumconnect/marianumconnect_endpoint.dart'; @@ -18,6 +17,7 @@ import '../state/app/modules/app_modules.dart'; import '../state/app/modules/chat/bloc/chat_bloc.dart'; import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../state/app/modules/marianum_message/bloc/marianum_message_state.dart'; +import '../utils/url_opener.dart'; import '../view/login/nextcloud_login_flow_page.dart'; import '../view/pages/files/files.dart'; import '../view/pages/files/sharing/sharee_picker_page.dart'; @@ -228,7 +228,7 @@ class AppRoutes { openTickerPage(context, slug: slug); return; } - unawaited(openExternalUrl(href)); + unawaited(UrlOpener.openUrl(href)); } /// Extracts the ticker page slug from a link that points at another ticker @@ -250,24 +250,13 @@ class AppRoutes { return Uri.decodeComponent(rest); } - /// Launches an external URL, restricted to safe schemes (http/https/mailto/tel). - static Future openExternalUrl(String url) async { - final uri = Uri.tryParse(url); - if (uri == null) return; - const allowed = {'http', 'https', 'mailto', 'tel'}; - if (!allowed.contains(uri.scheme.toLowerCase())) return; - if (await canLaunchUrlString(url)) { - await launchUrlString(url, mode: LaunchMode.externalApplication); - } - } - /// Opens a possibly site-relative web URL (e.g. the ticker `webUrl`) by /// prefixing the active Marianum-Connect base URL when needed. static Future openWebUrl(String relativeOrAbsolute) { if (relativeOrAbsolute.startsWith('http')) { - return openExternalUrl(relativeOrAbsolute); + return UrlOpener.openUrl(relativeOrAbsolute); } - return openExternalUrl( + return UrlOpener.openUrl( '${MarianumConnectEndpoint.current()}$relativeOrAbsolute', ); } diff --git a/lib/utils/url_opener.dart b/lib/utils/url_opener.dart index 24164cd..5091fbd 100644 --- a/lib/utils/url_opener.dart +++ b/lib/utils/url_opener.dart @@ -1,12 +1,35 @@ -import 'package:flutter_linkify/flutter_linkify.dart'; -import 'package:url_launcher/url_launcher_string.dart'; +import 'dart:developer'; +import 'package:flutter_linkify/flutter_linkify.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Single entry point for opening links that come from outside the app (chat +/// messages, ticker content, emergency notices). class UrlOpener { + /// Schemes we hand to the platform. Message content is user supplied, so + /// anything that could address another app directly stays out. + static const _allowedSchemes = {'http', 'https', 'mailto', 'tel'}; + static Future onOpen(LinkableElement link) => openUrl(link.url); static Future openUrl(String url) async { - if (await canLaunchUrlString(url)) { - await launchUrlString(url); + final uri = Uri.tryParse(url); + if (uri == null || !_allowedSchemes.contains(uri.scheme.toLowerCase())) { + return; + } + + // externalApplication first: it hands the link to the app that owns it + // (YouTube, Maps, the mail client) instead of iOS' in-app Safari sheet, + // which throws a PlatformException whenever its load fails. + for (final mode in const [ + LaunchMode.externalApplication, + LaunchMode.platformDefault, + ]) { + try { + if (await launchUrl(uri, mode: mode)) return; + } catch (e) { + log('launching $uri as $mode failed: $e'); + } } } } diff --git a/lib/view/login/nextcloud_login_flow_page.dart b/lib/view/login/nextcloud_login_flow_page.dart index bcd784f..57d1aed 100644 --- a/lib/view/login/nextcloud_login_flow_page.dart +++ b/lib/view/login/nextcloud_login_flow_page.dart @@ -8,7 +8,7 @@ import '../../api/errors/error_mapper.dart'; import '../../api/marianumcloud/app_password/delete_app_password.dart'; import '../../api/marianumcloud/login_flow/login_flow_api.dart'; import '../../model/account_data.dart'; -import '../../routing/app_routes.dart'; +import '../../utils/url_opener.dart'; import '../../widget/app_progress_indicator.dart'; /// Die beiden Durchläufe des Login Flow v2: Der erste liefert das allgemeine @@ -79,7 +79,7 @@ class _NextcloudLoginFlowPageState extends State setState(() => _flow = flow); _startedAt = DateTime.now(); _timer = Timer.periodic(_pollInterval, (_) => _poll()); - unawaited(AppRoutes.openExternalUrl(flow.loginUrl)); + unawaited(UrlOpener.openUrl(flow.loginUrl)); } catch (e) { if (!mounted) return; setState(() => _error = errorToUserMessage(e)); @@ -240,7 +240,7 @@ class _NextcloudLoginFlowPageState extends State FilledButton.icon( icon: const Icon(Icons.open_in_browser), onPressed: () => - unawaited(AppRoutes.openExternalUrl(flow.loginUrl)), + unawaited(UrlOpener.openUrl(flow.loginUrl)), label: Text( isTalkStep ? 'Freigabe im Browser bestätigen' diff --git a/lib/view/pages/files/sharing/sharee_picker_page.dart b/lib/view/pages/files/sharing/sharee_picker_page.dart index e2d347d..803f541 100644 --- a/lib/view/pages/files/sharing/sharee_picker_page.dart +++ b/lib/view/pages/files/sharing/sharee_picker_page.dart @@ -4,10 +4,10 @@ import '../../../../api/errors/error_mapper.dart'; import '../../../../api/marianumcloud/autocomplete/autocomplete_api.dart'; import '../../../../api/marianumcloud/autocomplete/autocomplete_response.dart'; import '../../../../api/marianumcloud/files_sharing/queries/share/share.dart'; -import '../../../../model/endpoint_data.dart'; import '../../../../utils/debouncer.dart'; import '../../../../utils/haptics.dart'; import '../../../../widget/app_progress_indicator.dart'; +import '../../../../widget/user_avatar.dart'; /// Result of [ShareePickerPage]: a recipient to create a share for. class ShareeRef { @@ -209,16 +209,11 @@ class _ShareePickerPageState extends State { Widget _resultTile(AutocompleteResponseObject object) { final isGroup = shareTypeFromSource(object.source) == kShareTypeGroup; + // Nextcloud groups are not Talk rooms, so UserAvatar's group URL does not + // apply to them — they keep a plain icon. final leading = isGroup ? const CircleAvatar(child: Icon(Icons.groups_outlined)) - : CircleAvatar( - foregroundImage: Image.network( - 'https://${EndpointData().nextcloud().full()}/avatar/${object.id}/128', - ).image, - backgroundColor: Theme.of(context).primaryColor, - foregroundColor: Colors.white, - child: const Icon(Icons.person), - ); + : UserAvatar(id: object.id, semanticLabel: object.label); return ListTile( leading: leading, title: Text(object.label), diff --git a/lib/view/pages/share_intent/share_chat_picker.dart b/lib/view/pages/share_intent/share_chat_picker.dart index f1199fb..905ebd9 100644 --- a/lib/view/pages/share_intent/share_chat_picker.dart +++ b/lib/view/pages/share_intent/share_chat_picker.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:nextcloud/nextcloud.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import '../../../api/errors/error_mapper.dart'; @@ -10,7 +9,6 @@ import '../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../api/marianumcloud/talk/send_message/send_message.dart'; import '../../../api/marianumcloud/talk/send_message/send_message_params.dart'; import '../../../api/marianumcloud/talk/share_files_to_chat.dart'; -import '../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../routing/app_routes.dart'; import '../../../share_intent/pending_share.dart'; import '../../../share_intent/remote_file_ref.dart'; @@ -137,12 +135,7 @@ Future _externalShareFlow( PendingShare share, ) async { if (share.hasFiles) { - try { - final webdav = await WebdavApi.webdav; - await webdav.mkcol(PathUri.parse('/$talkShareFolder')); - } catch (_) { - // mkcol throws when the folder already exists; ignore. - } + await ensureTalkShareFolder(); if (!context.mounted) return; await pushScreen( context, diff --git a/lib/view/pages/talk/details/chat_info.dart b/lib/view/pages/talk/details/chat_info.dart index 776b2ea..58a2880 100644 --- a/lib/view/pages/talk/details/chat_info.dart +++ b/lib/view/pages/talk/details/chat_info.dart @@ -39,6 +39,7 @@ class _ChatInfoState extends State { GetParticipantsCache( chatToken: widget.room.token, onUpdate: (GetParticipantsResponse data) { + if (!mounted) return; setState(() { participants = data; }); diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart index 0473bcf..f1b05f4 100644 --- a/lib/view/pages/talk/widgets/chat_textfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -2,14 +2,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:nextcloud/nextcloud.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../api/marianumcloud/talk/send_message/send_message.dart'; import '../../../../api/marianumcloud/talk/send_message/send_message_params.dart'; import '../../../../api/marianumcloud/talk/share_files_to_chat.dart'; -import '../../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/chat/bloc/chat_state.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; @@ -68,11 +66,7 @@ class _ChatTextfieldState extends State { Future mediaUpload(List? paths) async { if (paths == null) return; - unawaited( - WebdavApi.webdav.then( - (webdav) => webdav.mkcol(PathUri.parse('/$talkShareFolder')), - ), - ); + unawaited(ensureTalkShareFolder()); if (!mounted) return; unawaited( @@ -113,6 +107,7 @@ class _ChatTextfieldState extends State { void initState() { super.initState(); settings = context.read(); + _loadDraft(); final draftReply = settings .val() .talkSettings @@ -122,6 +117,24 @@ class _ChatTextfieldState extends State { } } + @override + void didUpdateWidget(covariant ChatTextfield oldWidget) { + super.didUpdateWidget(oldWidget); + // The state survives a room switch in the split view, so the draft has to + // follow the new token. Every edit persists through _setDraft, so the + // outgoing room's draft is already stored at this point. + if (oldWidget.sendToToken != widget.sendToToken) _loadDraft(); + } + + /// Seeds the field from the stored draft. Only ever called on mount and on a + /// room switch: `TextEditingController.text` resets selection and composing, + /// so doing this per build would drop the cursor position, any active text + /// selection and a running IME composition on every chat update. + void _loadDraft() { + _textBoxController.text = + settings.val().talkSettings.drafts[widget.sendToToken] ?? ''; + } + @override void dispose() { // Defer to end-of-frame: resetting the shared notifier synchronously during @@ -263,8 +276,6 @@ class _ChatTextfieldState extends State { @override Widget build(BuildContext context) { - _textBoxController.text = - settings.val().talkSettings.drafts[widget.sendToToken] ?? ''; final chatBloc = context.watch(); final chatState = chatBloc.state.data; diff --git a/lib/view/pages/ticker/ticker_view.dart b/lib/view/pages/ticker/ticker_view.dart index 1fec050..5b1ce6d 100644 --- a/lib/view/pages/ticker/ticker_view.dart +++ b/lib/view/pages/ticker/ticker_view.dart @@ -14,6 +14,7 @@ import '../../../state/app/infrastructure/utility_widgets/loadable_hydrated_bloc import '../../../state/app/modules/ticker/bloc/ticker_bloc.dart'; import '../../../state/app/modules/ticker/bloc/ticker_state.dart'; import '../../../theming/app_theme.dart'; +import '../../../utils/url_opener.dart'; import '../../../widget/details_bottom_sheet.dart'; import '../../../widget/placeholder_view.dart'; import '../../../widget/prosemirror/pm_json_view.dart'; @@ -247,7 +248,7 @@ class _TickerScaffoldState extends State { void _openRedirect(TickerNavPage page) { final url = page.externalUrl; if (url != null && url.isNotEmpty) { - unawaited(AppRoutes.openExternalUrl(url)); + unawaited(UrlOpener.openUrl(url)); } } @@ -262,7 +263,7 @@ class _TickerScaffoldState extends State { _ensureBackEntry(); return; } - unawaited(AppRoutes.openExternalUrl(href)); + unawaited(UrlOpener.openUrl(href)); } String? _titleForSlug(String slug) { diff --git a/lib/view/pages/ticker/widgets/ticker_page_body.dart b/lib/view/pages/ticker/widgets/ticker_page_body.dart index 86b9f13..ba64c1a 100644 --- a/lib/view/pages/ticker/widgets/ticker_page_body.dart +++ b/lib/view/pages/ticker/widgets/ticker_page_body.dart @@ -14,6 +14,7 @@ import '../../../../state/app/modules/ticker/bloc/ticker_page_bloc.dart'; import '../../../../state/app/modules/ticker/bloc/ticker_page_state.dart'; import '../../../../state/app/modules/ticker/repository/ticker_page_repository.dart'; import '../../../../theming/app_theme.dart'; +import '../../../../utils/url_opener.dart'; import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/placeholder_view.dart'; import '../../../../widget/prosemirror/pm_json_view.dart'; @@ -87,7 +88,7 @@ class _TickerPageContentState extends State<_TickerPageContent> { _redirected = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - if (url != null && url.isNotEmpty) AppRoutes.openExternalUrl(url); + if (url != null && url.isNotEmpty) UrlOpener.openUrl(url); widget.onRedirect?.call(); }); } diff --git a/lib/widget/confirm_dialog.dart b/lib/widget/confirm_dialog.dart index 256146f..3bf3105 100644 --- a/lib/widget/confirm_dialog.dart +++ b/lib/widget/confirm_dialog.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:url_launcher/url_launcher.dart'; import '../utils/haptics.dart'; +import '../utils/url_opener.dart'; import 'async_action_button.dart'; class ConfirmDialog extends StatelessWidget { @@ -74,8 +74,7 @@ class ConfirmDialog extends StatelessWidget { title: 'Link öffnen', content: 'Möchtest du den folgenden Link öffnen?\n$url', confirmButton: 'Öffnen', - onConfirm: () => - launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication), + onConfirm: () => UrlOpener.openUrl(url), ), ); } diff --git a/lib/widget/emergency/emergency_notice_gate.dart b/lib/widget/emergency/emergency_notice_gate.dart index 527fc7c..b1c7430 100644 --- a/lib/widget/emergency/emergency_notice_gate.dart +++ b/lib/widget/emergency/emergency_notice_gate.dart @@ -3,11 +3,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; -import 'package:url_launcher/url_launcher_string.dart'; import '../../api/emergency/emergency_notice.dart'; import '../../api/emergency/emergency_notice_client.dart'; import '../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../utils/url_opener.dart'; /// Wraps the app and surfaces a backend-independent emergency notice on cold /// start and resume. Renders [child] unchanged and only overlays a dialog when @@ -80,9 +80,7 @@ class _EmergencyNoticeDialog extends StatelessWidget { Future _openLink(String? href) async { if (href == null) return; - if (await canLaunchUrlString(href)) { - await launchUrlString(href); - } + await UrlOpener.openUrl(href); } @override diff --git a/lib/widget/large_profile_picture_view.dart b/lib/widget/large_profile_picture_view.dart index 40e9be1..42f0c84 100644 --- a/lib/widget/large_profile_picture_view.dart +++ b/lib/widget/large_profile_picture_view.dart @@ -1,11 +1,18 @@ import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:photo_view/photo_view.dart'; -import '../model/account_data.dart'; +import '../api/errors/error_mapper.dart'; import 'a11y/a11y_labels.dart'; +import 'app_progress_indicator.dart'; +import 'placeholder_view.dart'; import 'user_avatar.dart'; -class LargeProfilePictureView extends StatelessWidget { +/// Full-screen avatar. Loads the bytes itself instead of handing the URL to an +/// [ImageProvider]: Nextcloud and Spreed serve generated avatars as SVG, which +/// no codec can decode ("Invalid image data") — those get an [SvgPicture] +/// instead of the zoomable [PhotoView]. +class LargeProfilePictureView extends StatefulWidget { final String id; final bool isGroup; @@ -15,24 +22,73 @@ class LargeProfilePictureView extends StatelessWidget { super.key, }); + @override + State createState() => + _LargeProfilePictureViewState(); +} + +class _LargeProfilePictureViewState extends State { + late Future _payload; + + @override + void initState() { + super.initState(); + _payload = loadAvatarPayload( + id: widget.id, + isGroup: widget.isGroup, + size: 1024, + ); + } + @override Widget build(BuildContext context) { - final label = isGroup ? A11yLabels.groupPicture : A11yLabels.profilePicture; + final label = widget.isGroup + ? A11yLabels.groupPicture + : A11yLabels.profilePicture; + final background = Theme.of(context).colorScheme.surface; + return Scaffold( appBar: AppBar(title: Text(label)), body: Semantics( image: true, label: label, - child: PhotoView( - minScale: 0.5, - maxScale: 3.0, - imageProvider: Image.network( - avatarUrl(id: id, isGroup: isGroup, size: 1024), - headers: {'Authorization': AccountData().getBasicAuthHeader()}, - ).image, - backgroundDecoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - ), + child: FutureBuilder( + future: _payload, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: AppProgressIndicator.large()); + } + final payload = snapshot.data; + if (snapshot.hasError) { + return PlaceholderView( + icon: Icons.broken_image_outlined, + text: errorToUserMessage(snapshot.error), + ); + } + // No avatar on the server — the same icon the small avatar shows. + if (payload == null) { + return PlaceholderView( + icon: widget.isGroup ? Icons.group : Icons.person, + text: 'Kein Bild hinterlegt.', + ); + } + if (payload.isSvg) { + return ColoredBox( + color: background, + child: InteractiveViewer( + minScale: 0.5, + maxScale: 3, + child: SvgPicture.memory(payload.bytes, fit: BoxFit.contain), + ), + ); + } + return PhotoView( + minScale: 0.5, + maxScale: 3.0, + imageProvider: MemoryImage(payload.bytes), + backgroundDecoration: BoxDecoration(color: background), + ); + }, ), ), ); diff --git a/lib/widget/route_transition_gate.dart b/lib/widget/route_transition_gate.dart index b606893..6b2c42d 100644 --- a/lib/widget/route_transition_gate.dart +++ b/lib/widget/route_transition_gate.dart @@ -14,6 +14,15 @@ import 'app_progress_indicator.dart'; /// subtree for [placeholder] for the duration of any transition; the status /// listener fires before that frame's layout, so the fragile subtree is gone /// before the new transform lays out. +/// +/// On top of that the gate always yields one idle post-frame (`_settled`) +/// before mounting [builder], whether or not a route animation ran. A +/// `RenderTransform` is a `RenderProxyBox` and only takes its size *after* +/// laying out its child, so during that first pass `size` throws — and route +/// transitions are not the only source of a fresh transform: `PersistentTabView` +/// wraps every tab screen in an animated `Transform.translate` and builds a tab +/// lazily on first activation, which is how the ticker's in-place PDF pages hit +/// the same window with no route animation in sight. class RouteTransitionGate extends StatefulWidget { const RouteTransitionGate({super.key, required this.builder, this.placeholder}); @@ -31,6 +40,11 @@ class _RouteTransitionGateState extends State { Animation? _animation; Animation? _secondaryAnimation; + /// Becomes true one idle post-frame after the last transition ended; only + /// then is [RouteTransitionGate.builder] safe to mount (see class doc). + bool _settled = false; + bool _settleScheduled = false; + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -68,15 +82,32 @@ class _RouteTransitionGateState extends State { status == AnimationStatus.forward || status == AnimationStatus.reverse; void _onAnimationStatus(AnimationStatus status) { - if (mounted) setState(() {}); + if (!mounted) return; + // A (re)starting transition invalidates the settled state; it has to be + // re-earned once the transition finishes. + setState(() { + if (_transitioning) _settled = false; + }); + } + + void _scheduleSettle() { + if (_settleScheduled) return; + _settleScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _settleScheduled = false; + if (mounted && !_transitioning && !_settled) { + setState(() => _settled = true); + } + }); } @override Widget build(BuildContext context) { - if (_transitioning) { - return widget.placeholder ?? - const Center(child: AppProgressIndicator.large()); + if (!_transitioning) { + if (_settled) return widget.builder(context); + _scheduleSettle(); } - return widget.builder(context); + return widget.placeholder ?? + const Center(child: AppProgressIndicator.large()); } } diff --git a/lib/widget/user_avatar.dart b/lib/widget/user_avatar.dart index 4a43e09..3db7fb8 100644 --- a/lib/widget/user_avatar.dart +++ b/lib/widget/user_avatar.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:http/http.dart' as http; +import '../api/http_errors.dart'; import '../model/account_data.dart'; import '../model/endpoint_data.dart'; import '../push/push_avatar.dart'; @@ -41,14 +42,17 @@ class UserAvatar extends StatefulWidget { State createState() => _UserAvatarState(); } -class _AvatarPayload { +/// Raw avatar bytes plus the one bit callers need to render them: Nextcloud +/// and Spreed answer with SVG for generated (never uploaded) avatars, which no +/// `ImageProvider` can decode. +class AvatarPayload { final Uint8List bytes; final bool isSvg; - _AvatarPayload(this.bytes, this.isSvg); + AvatarPayload(this.bytes, this.isSvg); } class _AvatarCacheEntry { - final _AvatarPayload? payload; + final AvatarPayload? payload; final DateTime fetchedAt; _AvatarCacheEntry(this.payload, this.fetchedAt); } @@ -61,7 +65,7 @@ const Duration _kAvatarCacheTtl = Duration(minutes: 30); // Pending map dedups concurrent mounts onto a single HTTP call. final LinkedHashMap _resolvedAvatars = LinkedHashMap(); -final Map> _pendingAvatars = {}; +final Map> _pendingAvatars = {}; // Bumped by invalidateAvatarCache so *already mounted* avatars re-resolve. // Clearing the cache map alone only affects future mounts — a UserAvatar @@ -79,6 +83,118 @@ String avatarUrl({required String id, required bool isGroup, int size = 512}) { return 'https://$host/avatar/$id/$size'; } +/// Resolves an avatar through the shared caches: in-memory first, then disk, +/// then the network — the fetch being deduped across every concurrent caller +/// and written back to both caches. +/// +/// Returns `null` when the server has no avatar (HTTP 404) and throws on a +/// transient failure, so callers can keep whatever they already show. +Future loadAvatarPayload({ + required String id, + required bool isGroup, + int size = 512, +}) async { + final url = avatarUrl(id: id, isGroup: isGroup, size: size); + final cached = _readAvatarCache(url); + if (cached != null) return cached.payload; + + final diskBytes = await AvatarDiskCache.instance.read( + id: id, + isGroup: isGroup, + size: size, + ); + if (diskBytes != null) { + final payload = _payloadFromBytes(diskBytes); + _writeAvatarCache(url, payload); + return payload; + } + + final payload = await _fetchDeduped(url); + _commitAvatar(url, id: id, isGroup: isGroup, size: size, payload: payload); + return payload; +} + +/// Shares one in-flight request per URL across all callers. +Future _fetchDeduped(String url) => + _pendingAvatars.putIfAbsent(url, () { + final future = _fetchAvatarPayload(url); + // Cleanup hangs off an error-neutralised copy: whenComplete on `future` + // itself returns a second future that forwards the error unawaited. + unawaited( + future.then((_) {}, onError: (_) {}).whenComplete(() { + if (identical(_pendingAvatars[url], future)) { + _pendingAvatars.remove(url); + } + }), + ); + return future; + }); + +/// Persists a resolved result to the in-memory and disk caches. +void _commitAvatar( + String url, { + required String id, + required bool isGroup, + required int size, + required AvatarPayload? payload, +}) { + _writeAvatarCache(url, payload); + if (payload != null) { + unawaited( + AvatarDiskCache.instance.write( + id: id, + isGroup: isGroup, + size: size, + bytes: payload.bytes, + ), + ); + } else { + unawaited(AvatarDiskCache.instance.evict(id: id, isGroup: isGroup)); + } +} + +/// Returns the avatar bytes, `null` for a definitive miss (HTTP 404 — no +/// avatar exists), or throws on a transient error (offline, non-200) so the +/// caller keeps the cached picture instead of blanking it. +Future _fetchAvatarPayload(String url) async { + final response = await sendGuarded( + 'Avatar $url', + () => http.get( + Uri.parse(url), + headers: { + ...AccountData().authHeaders(), + 'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml', + }, + ), + ); + if (response == null) return null; + if (response.statusCode == 404) return null; + if (response.statusCode != 200 || response.bodyBytes.isEmpty) { + throwForStatus( + response.statusCode, + httpErrorDetail('Avatar $url', response.body, response.statusCode), + ); + } + + final contentType = response.headers['content-type']?.toLowerCase() ?? ''; + final bytes = response.bodyBytes; + final isSvg = contentType.contains('svg') || _looksLikeSvg(bytes); + return AvatarPayload(bytes, isSvg); +} + +AvatarPayload _payloadFromBytes(Uint8List bytes) => + AvatarPayload(bytes, _looksLikeSvg(bytes)); + +bool _looksLikeSvg(Uint8List bytes) { + final head = utf8 + .decode( + bytes.sublist(0, bytes.length < 256 ? bytes.length : 256), + allowMalformed: true, + ) + .trimLeft(); + return head.startsWith(' _kAvatarCacheMax) { @@ -131,7 +247,7 @@ void _writeAvatarCache(String url, _AvatarPayload? payload) { } class _UserAvatarState extends State { - _AvatarPayload? _payload; + AvatarPayload? _payload; @override void initState() { @@ -228,30 +344,16 @@ class _UserAvatarState extends State { } } - final pending = _pendingAvatars.putIfAbsent(url, () { - final future = _fetch(url); - // Cleanup hangs off an error-neutralised copy: whenComplete on `future` - // itself returns a second future that forwards the error unawaited. - unawaited( - future.then((_) {}, onError: (_) {}).whenComplete(() { - if (identical(_pendingAvatars[url], future)) { - _pendingAvatars.remove(url); - } - }), - ); - return future; - }); - - _AvatarPayload? fresh; + AvatarPayload? fresh; try { - fresh = await pending; + fresh = await _fetchDeduped(url); } on Object { // Transient failure (offline, 5xx). Keep showing the cached picture; the // next mount retries. Deliberately no null-cache so we don't mask it. return; } - _commit(url, id, isGroup, size, fresh); + _commitAvatar(url, id: id, isGroup: isGroup, size: size, payload: fresh); if (!mounted || _url() != url) return; if (fresh == null) { // HTTP 404 — the avatar was removed server-side. Fall back to the icon. @@ -261,69 +363,11 @@ class _UserAvatarState extends State { } } - // Persists a resolved result to the in-memory and disk caches. Uses the - // captured subject (not widget.*) so a recycled widget can't misfile bytes. - void _commit( - String url, - String id, - bool isGroup, - int size, - _AvatarPayload? payload, - ) { - _writeAvatarCache(url, payload); - if (payload != null) { - unawaited( - AvatarDiskCache.instance.write( - id: id, - isGroup: isGroup, - size: size, - bytes: payload.bytes, - ), - ); - } else { - unawaited(AvatarDiskCache.instance.evict(id: id, isGroup: isGroup)); - } - } - - static _AvatarPayload _payloadFromBytes(Uint8List bytes) => - _AvatarPayload(bytes, _looksLikeSvg(bytes)); - - static bool _sameBytes(_AvatarPayload? a, _AvatarPayload? b) { + static bool _sameBytes(AvatarPayload? a, AvatarPayload? b) { if (a == null || b == null) return a == b; return listEquals(a.bytes, b.bytes); } - /// Returns the avatar bytes, `null` for a definitive miss (HTTP 404 — no - /// avatar exists), or throws on a transient error (offline, non-200) so the - /// caller keeps the cached picture instead of blanking it. - Future<_AvatarPayload?> _fetch(String url) async { - final response = await http.get( - Uri.parse(url), - headers: { - 'Authorization': AccountData().getBasicAuthHeader(), - 'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml', - }, - ); - if (response.statusCode == 404) return null; - if (response.statusCode != 200 || response.bodyBytes.isEmpty) { - throw Exception('avatar fetch failed: HTTP ${response.statusCode}'); - } - - final contentType = response.headers['content-type']?.toLowerCase() ?? ''; - final bytes = response.bodyBytes; - final isSvg = contentType.contains('svg') || _looksLikeSvg(bytes); - return _AvatarPayload(bytes, isSvg); - } - - static bool _looksLikeSvg(Uint8List bytes) { - final head = utf8 - .decode( - bytes.sublist(0, bytes.length < 256 ? bytes.length : 256), - allowMalformed: true, - ) - .trimLeft(); - return head.startsWith('=3.8.0 <4.0.0" diff --git a/test/widget/route_transition_gate_test.dart b/test/widget/route_transition_gate_test.dart index 1799723..1fc216b 100644 --- a/test/widget/route_transition_gate_test.dart +++ b/test/widget/route_transition_gate_test.dart @@ -83,4 +83,19 @@ void main() { expect(find.byKey(gated), findsOneWidget); expect(find.byKey(placeholder), findsNothing); }); + + testWidgets('holds the child back for one frame without a route animation', ( + tester, + ) async { + // The tab case: no route animation ever runs, but the ancestors (and any + // freshly inserted tab-transition Transform) do their first layout in this + // very frame, so the fragile subtree must not be part of it. + await tester.pumpWidget(MaterialApp(home: gatedPage())); + expect(find.byKey(placeholder), findsOneWidget); + expect(find.byKey(gated), findsNothing); + + await tester.pump(); + expect(find.byKey(gated), findsOneWidget); + expect(find.byKey(placeholder), findsNothing); + }); }