multiple runtime bugfixes for 1.5.5+65
This commit is contained in:
@@ -18,6 +18,9 @@ analyzer:
|
|||||||
- "**/*.freezed.dart"
|
- "**/*.freezed.dart"
|
||||||
- "lib/firebase_options.dart"
|
- "lib/firebase_options.dart"
|
||||||
- "build/**"
|
- "build/**"
|
||||||
|
- android/**
|
||||||
|
- ios/**
|
||||||
|
- web/**
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
rules:
|
rules:
|
||||||
|
|||||||
@@ -1,10 +1,35 @@
|
|||||||
|
import 'package:nextcloud/nextcloud.dart';
|
||||||
|
|
||||||
import '../files_sharing/file_sharing_api.dart';
|
import '../files_sharing/file_sharing_api.dart';
|
||||||
import '../files_sharing/file_sharing_api_params.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
|
/// WebDAV folder under which Talk-shared files are uploaded before being
|
||||||
/// linked into a chat.
|
/// linked into a chat.
|
||||||
const String talkShareFolder = 'MarianumMobile';
|
const String talkShareFolder = 'MarianumMobile';
|
||||||
|
|
||||||
|
Future<void>? _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<void> ensureTalkShareFolder() =>
|
||||||
|
_shareFolderReady ??= _createTalkShareFolder();
|
||||||
|
|
||||||
|
Future<void> _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
|
/// Posts each already-uploaded WebDAV path as a Talk share (ShareType 10) to
|
||||||
/// the given conversation token. Calls run concurrently — the server accepts
|
/// the given conversation token. Calls run concurrently — the server accepts
|
||||||
/// parallel posts and the picker UI is blocked anyway, so we shouldn't pay
|
/// parallel posts and the picker UI is blocked anyway, so we shouldn't pay
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.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/marianumcloud/talk/room/get_room_response.dart';
|
||||||
import '../api/marianumconnect/marianumconnect_endpoint.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/bloc/chat_bloc.dart';
|
||||||
import '../state/app/modules/chat_list/bloc/chat_list_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 '../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/login/nextcloud_login_flow_page.dart';
|
||||||
import '../view/pages/files/files.dart';
|
import '../view/pages/files/files.dart';
|
||||||
import '../view/pages/files/sharing/sharee_picker_page.dart';
|
import '../view/pages/files/sharing/sharee_picker_page.dart';
|
||||||
@@ -228,7 +228,7 @@ class AppRoutes {
|
|||||||
openTickerPage(context, slug: slug);
|
openTickerPage(context, slug: slug);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
unawaited(openExternalUrl(href));
|
unawaited(UrlOpener.openUrl(href));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extracts the ticker page slug from a link that points at another ticker
|
/// Extracts the ticker page slug from a link that points at another ticker
|
||||||
@@ -250,24 +250,13 @@ class AppRoutes {
|
|||||||
return Uri.decodeComponent(rest);
|
return Uri.decodeComponent(rest);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Launches an external URL, restricted to safe schemes (http/https/mailto/tel).
|
|
||||||
static Future<void> 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
|
/// Opens a possibly site-relative web URL (e.g. the ticker `webUrl`) by
|
||||||
/// prefixing the active Marianum-Connect base URL when needed.
|
/// prefixing the active Marianum-Connect base URL when needed.
|
||||||
static Future<void> openWebUrl(String relativeOrAbsolute) {
|
static Future<void> openWebUrl(String relativeOrAbsolute) {
|
||||||
if (relativeOrAbsolute.startsWith('http')) {
|
if (relativeOrAbsolute.startsWith('http')) {
|
||||||
return openExternalUrl(relativeOrAbsolute);
|
return UrlOpener.openUrl(relativeOrAbsolute);
|
||||||
}
|
}
|
||||||
return openExternalUrl(
|
return UrlOpener.openUrl(
|
||||||
'${MarianumConnectEndpoint.current()}$relativeOrAbsolute',
|
'${MarianumConnectEndpoint.current()}$relativeOrAbsolute',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,35 @@
|
|||||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
import 'dart:developer';
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
|
||||||
|
|
||||||
|
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 {
|
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<void> onOpen(LinkableElement link) => openUrl(link.url);
|
static Future<void> onOpen(LinkableElement link) => openUrl(link.url);
|
||||||
|
|
||||||
static Future<void> openUrl(String url) async {
|
static Future<void> openUrl(String url) async {
|
||||||
if (await canLaunchUrlString(url)) {
|
final uri = Uri.tryParse(url);
|
||||||
await launchUrlString(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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import '../../api/errors/error_mapper.dart';
|
|||||||
import '../../api/marianumcloud/app_password/delete_app_password.dart';
|
import '../../api/marianumcloud/app_password/delete_app_password.dart';
|
||||||
import '../../api/marianumcloud/login_flow/login_flow_api.dart';
|
import '../../api/marianumcloud/login_flow/login_flow_api.dart';
|
||||||
import '../../model/account_data.dart';
|
import '../../model/account_data.dart';
|
||||||
import '../../routing/app_routes.dart';
|
import '../../utils/url_opener.dart';
|
||||||
import '../../widget/app_progress_indicator.dart';
|
import '../../widget/app_progress_indicator.dart';
|
||||||
|
|
||||||
/// Die beiden Durchläufe des Login Flow v2: Der erste liefert das allgemeine
|
/// Die beiden Durchläufe des Login Flow v2: Der erste liefert das allgemeine
|
||||||
@@ -79,7 +79,7 @@ class _NextcloudLoginFlowPageState extends State<NextcloudLoginFlowPage>
|
|||||||
setState(() => _flow = flow);
|
setState(() => _flow = flow);
|
||||||
_startedAt = DateTime.now();
|
_startedAt = DateTime.now();
|
||||||
_timer = Timer.periodic(_pollInterval, (_) => _poll());
|
_timer = Timer.periodic(_pollInterval, (_) => _poll());
|
||||||
unawaited(AppRoutes.openExternalUrl(flow.loginUrl));
|
unawaited(UrlOpener.openUrl(flow.loginUrl));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _error = errorToUserMessage(e));
|
setState(() => _error = errorToUserMessage(e));
|
||||||
@@ -240,7 +240,7 @@ class _NextcloudLoginFlowPageState extends State<NextcloudLoginFlowPage>
|
|||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
icon: const Icon(Icons.open_in_browser),
|
icon: const Icon(Icons.open_in_browser),
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
unawaited(AppRoutes.openExternalUrl(flow.loginUrl)),
|
unawaited(UrlOpener.openUrl(flow.loginUrl)),
|
||||||
label: Text(
|
label: Text(
|
||||||
isTalkStep
|
isTalkStep
|
||||||
? 'Freigabe im Browser bestätigen'
|
? 'Freigabe im Browser bestätigen'
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import '../../../../api/errors/error_mapper.dart';
|
|||||||
import '../../../../api/marianumcloud/autocomplete/autocomplete_api.dart';
|
import '../../../../api/marianumcloud/autocomplete/autocomplete_api.dart';
|
||||||
import '../../../../api/marianumcloud/autocomplete/autocomplete_response.dart';
|
import '../../../../api/marianumcloud/autocomplete/autocomplete_response.dart';
|
||||||
import '../../../../api/marianumcloud/files_sharing/queries/share/share.dart';
|
import '../../../../api/marianumcloud/files_sharing/queries/share/share.dart';
|
||||||
import '../../../../model/endpoint_data.dart';
|
|
||||||
import '../../../../utils/debouncer.dart';
|
import '../../../../utils/debouncer.dart';
|
||||||
import '../../../../utils/haptics.dart';
|
import '../../../../utils/haptics.dart';
|
||||||
import '../../../../widget/app_progress_indicator.dart';
|
import '../../../../widget/app_progress_indicator.dart';
|
||||||
|
import '../../../../widget/user_avatar.dart';
|
||||||
|
|
||||||
/// Result of [ShareePickerPage]: a recipient to create a share for.
|
/// Result of [ShareePickerPage]: a recipient to create a share for.
|
||||||
class ShareeRef {
|
class ShareeRef {
|
||||||
@@ -209,16 +209,11 @@ class _ShareePickerPageState extends State<ShareePickerPage> {
|
|||||||
|
|
||||||
Widget _resultTile(AutocompleteResponseObject object) {
|
Widget _resultTile(AutocompleteResponseObject object) {
|
||||||
final isGroup = shareTypeFromSource(object.source) == kShareTypeGroup;
|
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
|
final leading = isGroup
|
||||||
? const CircleAvatar(child: Icon(Icons.groups_outlined))
|
? const CircleAvatar(child: Icon(Icons.groups_outlined))
|
||||||
: CircleAvatar(
|
: UserAvatar(id: object.id, semanticLabel: object.label);
|
||||||
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),
|
|
||||||
);
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: leading,
|
leading: leading,
|
||||||
title: Text(object.label),
|
title: Text(object.label),
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
||||||
|
|
||||||
import '../../../api/errors/error_mapper.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.dart';
|
||||||
import '../../../api/marianumcloud/talk/send_message/send_message_params.dart';
|
import '../../../api/marianumcloud/talk/send_message/send_message_params.dart';
|
||||||
import '../../../api/marianumcloud/talk/share_files_to_chat.dart';
|
import '../../../api/marianumcloud/talk/share_files_to_chat.dart';
|
||||||
import '../../../api/marianumcloud/webdav/webdav_api.dart';
|
|
||||||
import '../../../routing/app_routes.dart';
|
import '../../../routing/app_routes.dart';
|
||||||
import '../../../share_intent/pending_share.dart';
|
import '../../../share_intent/pending_share.dart';
|
||||||
import '../../../share_intent/remote_file_ref.dart';
|
import '../../../share_intent/remote_file_ref.dart';
|
||||||
@@ -137,12 +135,7 @@ Future<void> _externalShareFlow(
|
|||||||
PendingShare share,
|
PendingShare share,
|
||||||
) async {
|
) async {
|
||||||
if (share.hasFiles) {
|
if (share.hasFiles) {
|
||||||
try {
|
await ensureTalkShareFolder();
|
||||||
final webdav = await WebdavApi.webdav;
|
|
||||||
await webdav.mkcol(PathUri.parse('/$talkShareFolder'));
|
|
||||||
} catch (_) {
|
|
||||||
// mkcol throws when the folder already exists; ignore.
|
|
||||||
}
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
await pushScreen(
|
await pushScreen(
|
||||||
context,
|
context,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class _ChatInfoState extends State<ChatInfo> {
|
|||||||
GetParticipantsCache(
|
GetParticipantsCache(
|
||||||
chatToken: widget.room.token,
|
chatToken: widget.room.token,
|
||||||
onUpdate: (GetParticipantsResponse data) {
|
onUpdate: (GetParticipantsResponse data) {
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
participants = data;
|
participants = data;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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 '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/room/get_room_response.dart';
|
||||||
import '../../../../api/marianumcloud/talk/send_message/send_message.dart';
|
import '../../../../api/marianumcloud/talk/send_message/send_message.dart';
|
||||||
import '../../../../api/marianumcloud/talk/send_message/send_message_params.dart';
|
import '../../../../api/marianumcloud/talk/send_message/send_message_params.dart';
|
||||||
import '../../../../api/marianumcloud/talk/share_files_to_chat.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_bloc.dart';
|
||||||
import '../../../../state/app/modules/chat/bloc/chat_state.dart';
|
import '../../../../state/app/modules/chat/bloc/chat_state.dart';
|
||||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
@@ -68,11 +66,7 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
|||||||
Future<void> mediaUpload(List<String>? paths) async {
|
Future<void> mediaUpload(List<String>? paths) async {
|
||||||
if (paths == null) return;
|
if (paths == null) return;
|
||||||
|
|
||||||
unawaited(
|
unawaited(ensureTalkShareFolder());
|
||||||
WebdavApi.webdav.then(
|
|
||||||
(webdav) => webdav.mkcol(PathUri.parse('/$talkShareFolder')),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
unawaited(
|
unawaited(
|
||||||
@@ -113,6 +107,7 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
settings = context.read<SettingsCubit>();
|
settings = context.read<SettingsCubit>();
|
||||||
|
_loadDraft();
|
||||||
final draftReply = settings
|
final draftReply = settings
|
||||||
.val()
|
.val()
|
||||||
.talkSettings
|
.talkSettings
|
||||||
@@ -122,6 +117,24 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
// Defer to end-of-frame: resetting the shared notifier synchronously during
|
// Defer to end-of-frame: resetting the shared notifier synchronously during
|
||||||
@@ -263,8 +276,6 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
_textBoxController.text =
|
|
||||||
settings.val().talkSettings.drafts[widget.sendToToken] ?? '';
|
|
||||||
final chatBloc = context.watch<ChatBloc>();
|
final chatBloc = context.watch<ChatBloc>();
|
||||||
final chatState = chatBloc.state.data;
|
final chatState = chatBloc.state.data;
|
||||||
|
|
||||||
|
|||||||
@@ -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_bloc.dart';
|
||||||
import '../../../state/app/modules/ticker/bloc/ticker_state.dart';
|
import '../../../state/app/modules/ticker/bloc/ticker_state.dart';
|
||||||
import '../../../theming/app_theme.dart';
|
import '../../../theming/app_theme.dart';
|
||||||
|
import '../../../utils/url_opener.dart';
|
||||||
import '../../../widget/details_bottom_sheet.dart';
|
import '../../../widget/details_bottom_sheet.dart';
|
||||||
import '../../../widget/placeholder_view.dart';
|
import '../../../widget/placeholder_view.dart';
|
||||||
import '../../../widget/prosemirror/pm_json_view.dart';
|
import '../../../widget/prosemirror/pm_json_view.dart';
|
||||||
@@ -247,7 +248,7 @@ class _TickerScaffoldState extends State<TickerScaffold> {
|
|||||||
void _openRedirect(TickerNavPage page) {
|
void _openRedirect(TickerNavPage page) {
|
||||||
final url = page.externalUrl;
|
final url = page.externalUrl;
|
||||||
if (url != null && url.isNotEmpty) {
|
if (url != null && url.isNotEmpty) {
|
||||||
unawaited(AppRoutes.openExternalUrl(url));
|
unawaited(UrlOpener.openUrl(url));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +263,7 @@ class _TickerScaffoldState extends State<TickerScaffold> {
|
|||||||
_ensureBackEntry();
|
_ensureBackEntry();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
unawaited(AppRoutes.openExternalUrl(href));
|
unawaited(UrlOpener.openUrl(href));
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _titleForSlug(String slug) {
|
String? _titleForSlug(String slug) {
|
||||||
|
|||||||
@@ -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/bloc/ticker_page_state.dart';
|
||||||
import '../../../../state/app/modules/ticker/repository/ticker_page_repository.dart';
|
import '../../../../state/app/modules/ticker/repository/ticker_page_repository.dart';
|
||||||
import '../../../../theming/app_theme.dart';
|
import '../../../../theming/app_theme.dart';
|
||||||
|
import '../../../../utils/url_opener.dart';
|
||||||
import '../../../../widget/app_progress_indicator.dart';
|
import '../../../../widget/app_progress_indicator.dart';
|
||||||
import '../../../../widget/placeholder_view.dart';
|
import '../../../../widget/placeholder_view.dart';
|
||||||
import '../../../../widget/prosemirror/pm_json_view.dart';
|
import '../../../../widget/prosemirror/pm_json_view.dart';
|
||||||
@@ -87,7 +88,7 @@ class _TickerPageContentState extends State<_TickerPageContent> {
|
|||||||
_redirected = true;
|
_redirected = true;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (url != null && url.isNotEmpty) AppRoutes.openExternalUrl(url);
|
if (url != null && url.isNotEmpty) UrlOpener.openUrl(url);
|
||||||
widget.onRedirect?.call();
|
widget.onRedirect?.call();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
|
|
||||||
import '../utils/haptics.dart';
|
import '../utils/haptics.dart';
|
||||||
|
import '../utils/url_opener.dart';
|
||||||
import 'async_action_button.dart';
|
import 'async_action_button.dart';
|
||||||
|
|
||||||
class ConfirmDialog extends StatelessWidget {
|
class ConfirmDialog extends StatelessWidget {
|
||||||
@@ -74,8 +74,7 @@ class ConfirmDialog extends StatelessWidget {
|
|||||||
title: 'Link öffnen',
|
title: 'Link öffnen',
|
||||||
content: 'Möchtest du den folgenden Link öffnen?\n$url',
|
content: 'Möchtest du den folgenden Link öffnen?\n$url',
|
||||||
confirmButton: 'Öffnen',
|
confirmButton: 'Öffnen',
|
||||||
onConfirm: () =>
|
onConfirm: () => UrlOpener.openUrl(url),
|
||||||
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.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.dart';
|
||||||
import '../../api/emergency/emergency_notice_client.dart';
|
import '../../api/emergency/emergency_notice_client.dart';
|
||||||
import '../../state/app/modules/settings/bloc/settings_cubit.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
|
/// Wraps the app and surfaces a backend-independent emergency notice on cold
|
||||||
/// start and resume. Renders [child] unchanged and only overlays a dialog when
|
/// start and resume. Renders [child] unchanged and only overlays a dialog when
|
||||||
@@ -80,9 +80,7 @@ class _EmergencyNoticeDialog extends StatelessWidget {
|
|||||||
|
|
||||||
Future<void> _openLink(String? href) async {
|
Future<void> _openLink(String? href) async {
|
||||||
if (href == null) return;
|
if (href == null) return;
|
||||||
if (await canLaunchUrlString(href)) {
|
await UrlOpener.openUrl(href);
|
||||||
await launchUrlString(href);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:photo_view/photo_view.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 'a11y/a11y_labels.dart';
|
||||||
|
import 'app_progress_indicator.dart';
|
||||||
|
import 'placeholder_view.dart';
|
||||||
import 'user_avatar.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 String id;
|
||||||
final bool isGroup;
|
final bool isGroup;
|
||||||
|
|
||||||
@@ -15,24 +22,73 @@ class LargeProfilePictureView extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LargeProfilePictureView> createState() =>
|
||||||
|
_LargeProfilePictureViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LargeProfilePictureViewState extends State<LargeProfilePictureView> {
|
||||||
|
late Future<AvatarPayload?> _payload;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_payload = loadAvatarPayload(
|
||||||
|
id: widget.id,
|
||||||
|
isGroup: widget.isGroup,
|
||||||
|
size: 1024,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(label)),
|
appBar: AppBar(title: Text(label)),
|
||||||
body: Semantics(
|
body: Semantics(
|
||||||
image: true,
|
image: true,
|
||||||
label: label,
|
label: label,
|
||||||
child: PhotoView(
|
child: FutureBuilder<AvatarPayload?>(
|
||||||
|
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,
|
minScale: 0.5,
|
||||||
maxScale: 3.0,
|
maxScale: 3.0,
|
||||||
imageProvider: Image.network(
|
imageProvider: MemoryImage(payload.bytes),
|
||||||
avatarUrl(id: id, isGroup: isGroup, size: 1024),
|
backgroundDecoration: BoxDecoration(color: background),
|
||||||
headers: {'Authorization': AccountData().getBasicAuthHeader()},
|
);
|
||||||
).image,
|
},
|
||||||
backgroundDecoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.surface,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ import 'app_progress_indicator.dart';
|
|||||||
/// subtree for [placeholder] for the duration of any transition; the status
|
/// subtree for [placeholder] for the duration of any transition; the status
|
||||||
/// listener fires before that frame's layout, so the fragile subtree is gone
|
/// listener fires before that frame's layout, so the fragile subtree is gone
|
||||||
/// before the new transform lays out.
|
/// 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 {
|
class RouteTransitionGate extends StatefulWidget {
|
||||||
const RouteTransitionGate({super.key, required this.builder, this.placeholder});
|
const RouteTransitionGate({super.key, required this.builder, this.placeholder});
|
||||||
|
|
||||||
@@ -31,6 +40,11 @@ class _RouteTransitionGateState extends State<RouteTransitionGate> {
|
|||||||
Animation<double>? _animation;
|
Animation<double>? _animation;
|
||||||
Animation<double>? _secondaryAnimation;
|
Animation<double>? _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
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
@@ -68,15 +82,32 @@ class _RouteTransitionGateState extends State<RouteTransitionGate> {
|
|||||||
status == AnimationStatus.forward || status == AnimationStatus.reverse;
|
status == AnimationStatus.forward || status == AnimationStatus.reverse;
|
||||||
|
|
||||||
void _onAnimationStatus(AnimationStatus status) {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_transitioning) {
|
if (!_transitioning) {
|
||||||
|
if (_settled) return widget.builder(context);
|
||||||
|
_scheduleSettle();
|
||||||
|
}
|
||||||
return widget.placeholder ??
|
return widget.placeholder ??
|
||||||
const Center(child: AppProgressIndicator.large());
|
const Center(child: AppProgressIndicator.large());
|
||||||
}
|
}
|
||||||
return widget.builder(context);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+126
-82
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../api/http_errors.dart';
|
||||||
import '../model/account_data.dart';
|
import '../model/account_data.dart';
|
||||||
import '../model/endpoint_data.dart';
|
import '../model/endpoint_data.dart';
|
||||||
import '../push/push_avatar.dart';
|
import '../push/push_avatar.dart';
|
||||||
@@ -41,14 +42,17 @@ class UserAvatar extends StatefulWidget {
|
|||||||
State<UserAvatar> createState() => _UserAvatarState();
|
State<UserAvatar> 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 Uint8List bytes;
|
||||||
final bool isSvg;
|
final bool isSvg;
|
||||||
_AvatarPayload(this.bytes, this.isSvg);
|
AvatarPayload(this.bytes, this.isSvg);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AvatarCacheEntry {
|
class _AvatarCacheEntry {
|
||||||
final _AvatarPayload? payload;
|
final AvatarPayload? payload;
|
||||||
final DateTime fetchedAt;
|
final DateTime fetchedAt;
|
||||||
_AvatarCacheEntry(this.payload, this.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.
|
// Pending map dedups concurrent mounts onto a single HTTP call.
|
||||||
final LinkedHashMap<String, _AvatarCacheEntry> _resolvedAvatars =
|
final LinkedHashMap<String, _AvatarCacheEntry> _resolvedAvatars =
|
||||||
LinkedHashMap<String, _AvatarCacheEntry>();
|
LinkedHashMap<String, _AvatarCacheEntry>();
|
||||||
final Map<String, Future<_AvatarPayload?>> _pendingAvatars = {};
|
final Map<String, Future<AvatarPayload?>> _pendingAvatars = {};
|
||||||
|
|
||||||
// Bumped by invalidateAvatarCache so *already mounted* avatars re-resolve.
|
// Bumped by invalidateAvatarCache so *already mounted* avatars re-resolve.
|
||||||
// Clearing the cache map alone only affects future mounts — a UserAvatar
|
// 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';
|
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<AvatarPayload?> 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<AvatarPayload?> _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<void>((_) {}, 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<AvatarPayload?> _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('<?xml') || head.startsWith('<svg');
|
||||||
|
}
|
||||||
|
|
||||||
/// Drops cached avatar bytes so the next mount re-fetches from the server.
|
/// Drops cached avatar bytes so the next mount re-fetches from the server.
|
||||||
/// Call after the app uploaded or removed an avatar — without this the
|
/// Call after the app uploaded or removed an avatar — without this the
|
||||||
/// 30-min TTL would mask the change for the rest of the session.
|
/// 30-min TTL would mask the change for the rest of the session.
|
||||||
@@ -122,7 +238,7 @@ _AvatarCacheEntry? _readAvatarCache(String url) {
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _writeAvatarCache(String url, _AvatarPayload? payload) {
|
void _writeAvatarCache(String url, AvatarPayload? payload) {
|
||||||
_resolvedAvatars.remove(url);
|
_resolvedAvatars.remove(url);
|
||||||
_resolvedAvatars[url] = _AvatarCacheEntry(payload, DateTime.now());
|
_resolvedAvatars[url] = _AvatarCacheEntry(payload, DateTime.now());
|
||||||
while (_resolvedAvatars.length > _kAvatarCacheMax) {
|
while (_resolvedAvatars.length > _kAvatarCacheMax) {
|
||||||
@@ -131,7 +247,7 @@ void _writeAvatarCache(String url, _AvatarPayload? payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _UserAvatarState extends State<UserAvatar> {
|
class _UserAvatarState extends State<UserAvatar> {
|
||||||
_AvatarPayload? _payload;
|
AvatarPayload? _payload;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -228,30 +344,16 @@ class _UserAvatarState extends State<UserAvatar> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final pending = _pendingAvatars.putIfAbsent(url, () {
|
AvatarPayload? fresh;
|
||||||
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<void>((_) {}, onError: (_) {}).whenComplete(() {
|
|
||||||
if (identical(_pendingAvatars[url], future)) {
|
|
||||||
_pendingAvatars.remove(url);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return future;
|
|
||||||
});
|
|
||||||
|
|
||||||
_AvatarPayload? fresh;
|
|
||||||
try {
|
try {
|
||||||
fresh = await pending;
|
fresh = await _fetchDeduped(url);
|
||||||
} on Object {
|
} on Object {
|
||||||
// Transient failure (offline, 5xx). Keep showing the cached picture; the
|
// Transient failure (offline, 5xx). Keep showing the cached picture; the
|
||||||
// next mount retries. Deliberately no null-cache so we don't mask it.
|
// next mount retries. Deliberately no null-cache so we don't mask it.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_commit(url, id, isGroup, size, fresh);
|
_commitAvatar(url, id: id, isGroup: isGroup, size: size, payload: fresh);
|
||||||
if (!mounted || _url() != url) return;
|
if (!mounted || _url() != url) return;
|
||||||
if (fresh == null) {
|
if (fresh == null) {
|
||||||
// HTTP 404 — the avatar was removed server-side. Fall back to the icon.
|
// HTTP 404 — the avatar was removed server-side. Fall back to the icon.
|
||||||
@@ -261,69 +363,11 @@ class _UserAvatarState extends State<UserAvatar> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persists a resolved result to the in-memory and disk caches. Uses the
|
static bool _sameBytes(AvatarPayload? a, AvatarPayload? b) {
|
||||||
// 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) {
|
|
||||||
if (a == null || b == null) return a == b;
|
if (a == null || b == null) return a == b;
|
||||||
return listEquals(a.bytes, b.bytes);
|
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('<?xml') || head.startsWith('<svg');
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ description: Mobile client for Webuntis and Nextcloud with Talk integration
|
|||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.5.4+64
|
version: 1.5.5+65
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.8.0 <4.0.0"
|
sdk: ">=3.8.0 <4.0.0"
|
||||||
|
|
||||||
|
|||||||
@@ -83,4 +83,19 @@ void main() {
|
|||||||
expect(find.byKey(gated), findsOneWidget);
|
expect(find.byKey(gated), findsOneWidget);
|
||||||
expect(find.byKey(placeholder), findsNothing);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user