multiple runtime bugfixes for 1.5.5+65
This commit is contained in:
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<void> _openLink(String? href) async {
|
||||
if (href == null) return;
|
||||
if (await canLaunchUrlString(href)) {
|
||||
await launchUrlString(href);
|
||||
}
|
||||
await UrlOpener.openUrl(href);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -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<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
|
||||
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<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,
|
||||
maxScale: 3.0,
|
||||
imageProvider: MemoryImage(payload.bytes),
|
||||
backgroundDecoration: BoxDecoration(color: background),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<RouteTransitionGate> {
|
||||
Animation<double>? _animation;
|
||||
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
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
@@ -68,15 +82,32 @@ class _RouteTransitionGateState extends State<RouteTransitionGate> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
+126
-82
@@ -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<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 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<String, _AvatarCacheEntry> _resolvedAvatars =
|
||||
LinkedHashMap<String, _AvatarCacheEntry>();
|
||||
final Map<String, Future<_AvatarPayload?>> _pendingAvatars = {};
|
||||
final Map<String, Future<AvatarPayload?>> _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<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.
|
||||
/// 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.
|
||||
@@ -122,7 +238,7 @@ _AvatarCacheEntry? _readAvatarCache(String url) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
void _writeAvatarCache(String url, _AvatarPayload? payload) {
|
||||
void _writeAvatarCache(String url, AvatarPayload? payload) {
|
||||
_resolvedAvatars.remove(url);
|
||||
_resolvedAvatars[url] = _AvatarCacheEntry(payload, DateTime.now());
|
||||
while (_resolvedAvatars.length > _kAvatarCacheMax) {
|
||||
@@ -131,7 +247,7 @@ void _writeAvatarCache(String url, _AvatarPayload? payload) {
|
||||
}
|
||||
|
||||
class _UserAvatarState extends State<UserAvatar> {
|
||||
_AvatarPayload? _payload;
|
||||
AvatarPayload? _payload;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -228,30 +344,16 @@ class _UserAvatarState extends State<UserAvatar> {
|
||||
}
|
||||
}
|
||||
|
||||
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<void>((_) {}, 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<UserAvatar> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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('<?xml') || head.startsWith('<svg');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
Reference in New Issue
Block a user