mirror of
https://github.com/block/buzz.git
synced 2026-11-05 08:51:10 +00:00
Returning to an already-open channel after a few minutes in the background could replace its messages with the loading skeleton. The relay session drops its socket five seconds into the background, so nearly every return to the app reconnects. When that reconnect took more than two seconds, the channel page covered the loaded timeline with the connection skeleton until the relay came back. The messages provider already keeps the cached timeline through a reconnect, so the overlay was the only thing hiding it. Now a loaded timeline stays visible while the relay reconnects. The skeleton still shows when nothing has loaded yet, and forums keep their existing behavior (skeleton only before any posts load). To keep a reconnect indicator, the iOS title capsule now shows a thin bar in the chat color (the Appearance accent) along its bottom edge while a page with loaded content is not current. For a channel timeline that covers both the reconnect and the catch-up after it (resubscribing and fetching the newest window). The wait has no known length, so the bar sweeps in, pulses until the channel is current, then fades. Reduce Motion shows a static bar. Forums show it only while reconnecting, because their posts provider doesn't refetch after a reconnect. The native navigation view owns the bar, so it survives title rebuilds. Android has no title capsule and is unchanged. ### Demo iPhone 18 Pro simulator on a local relay: background the app for more than 5s with the relay down, return, then let the relay come back. About 8s of relay startup is cut from the middle. https://github.com/user-attachments/assets/e88d0191-766b-4054-bc00-99ed306cd0cc ### Before merge - [x] Kenny Lopez reviews the visible changes ([approved in Buzz #buzz-mobile](buzz://message?channel=9f18f3c6-b5e5-453d-b36d-65487913be0b&id=c8b01eaa3760e44c3b6583f2e02bec576d78679adfb364e5fe718cc0926bdc2d)). ### Validation - Updated tests: the reconnect cases in `channel_detail_page_test.dart` and `loading_review_tests.dart` now assert that loaded messages and cached media stay visible and in place during a reconnect. - Added test: the iOS navigation payload sets `titleRefreshing` while a loaded timeline reconnects and clears it on connect. - Added test: `isCatchingUp` holds from reconnect until the newest window merges, and is already false when that state publishes. - Added test: a failed catch-up after reconnect still notifies listeners once, with `isCatchingUp` false, so the bar clears. - `flutter analyze` clean, iOS simulator build succeeds, and the full mobile suite passes on the final head. - Checked visually on an iOS 27 simulator (demo above): loaded messages stay visible on resume, and the bar pulses from foreground until the reconnect's catch-up finishes, then fades. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
504 lines
17 KiB
Dart
504 lines
17 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../relay/relay.dart';
|
|
import '../theme/mobile_appearance.dart';
|
|
import '../theme/theme_extensions.dart';
|
|
import '../animated_avatar.dart';
|
|
import 'avatar_image.dart';
|
|
import 'app_route_observer.dart';
|
|
import 'ios_navigation_metrics.dart';
|
|
export 'ios_navigation_metrics.dart';
|
|
|
|
/// A UIKit bar item or menu item, with its action owned by the Flutter route.
|
|
class IosNavigationAction {
|
|
const IosNavigationAction({
|
|
required this.label,
|
|
this.symbol,
|
|
this.icon,
|
|
this.imageUrl,
|
|
this.avatarInitial,
|
|
this.avatarIdentity,
|
|
this.avatarIsAgent = false,
|
|
this.activityColor,
|
|
this.activityLabel,
|
|
this.onPressed,
|
|
this.children = const [],
|
|
this.selected = false,
|
|
this.onAvatarBoundsChanged,
|
|
this.avatarHidden = false,
|
|
|
|
this.plain = false,
|
|
this.sharesBackground = true,
|
|
});
|
|
|
|
final String label;
|
|
final String? symbol;
|
|
|
|
/// Custom font artwork rendered as a native template image.
|
|
final IconData? icon;
|
|
final String? imageUrl;
|
|
final String? avatarInitial;
|
|
final String? avatarIdentity;
|
|
final bool avatarIsAgent;
|
|
final Color? activityColor;
|
|
final String? activityLabel;
|
|
final VoidCallback? onPressed;
|
|
final List<IosNavigationAction> children;
|
|
final bool selected;
|
|
|
|
/// Reports the avatar's global bounds for a transition into this bar item.
|
|
final ValueChanged<Rect>? onAvatarBoundsChanged;
|
|
|
|
/// Hides a transition's destination avatar while retaining its layout slot.
|
|
final bool avatarHidden;
|
|
|
|
/// Omits the shared Liquid Glass background for a text-only action.
|
|
final bool plain;
|
|
|
|
/// Whether this action can share its glass background with adjacent actions.
|
|
final bool sharesBackground;
|
|
|
|
Map<String, Object?> _encode(String id) => {
|
|
'id': id,
|
|
'label': label,
|
|
'symbol': avatarInitial == null ? symbol : null,
|
|
'avatarInitial': avatarInitial,
|
|
'avatarIsAgent': avatarIsAgent,
|
|
'avatarArtwork': avatarIsAgent && usesInsetAgentArtwork(imageUrl),
|
|
'activityColor': activityColor?.toARGB32(),
|
|
'activityLabel': activityLabel,
|
|
'imageUrl': imageUrl,
|
|
'enabled': onPressed != null || children.isNotEmpty,
|
|
'selected': selected,
|
|
'tracksAvatarBounds': onAvatarBoundsChanged != null,
|
|
'avatarHidden': avatarHidden,
|
|
|
|
'plain': plain,
|
|
'sharesBackground': sharesBackground,
|
|
'children': [
|
|
for (var i = 0; i < children.length; i++) children[i]._encode('$id.$i'),
|
|
],
|
|
};
|
|
|
|
void _dispatch(List<String> path) {
|
|
if (path.isEmpty) {
|
|
onPressed?.call();
|
|
return;
|
|
}
|
|
final index = int.tryParse(path.first);
|
|
if (index != null && index >= 0 && index < children.length) {
|
|
children[index]._dispatch(path.sublist(1));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Scroll position of the route whose UIKit navigation bar is visible.
|
|
class IosNavigationScrollScope extends InheritedWidget {
|
|
const IosNavigationScrollScope({
|
|
super.key,
|
|
required this.offset,
|
|
required super.child,
|
|
});
|
|
|
|
final ValueListenable<double> offset;
|
|
|
|
static ValueListenable<double>? maybeOf(BuildContext context) => context
|
|
.dependOnInheritedWidgetOfExactType<IosNavigationScrollScope>()
|
|
?.offset;
|
|
|
|
@override
|
|
bool updateShouldNotify(IosNavigationScrollScope oldWidget) =>
|
|
offset != oldWidget.offset;
|
|
}
|
|
|
|
/// A real UINavigationController bar embedded in the current Flutter route.
|
|
/// UIKit owns title layout, SF Symbols, menus, back items, and large-title motion.
|
|
class IosNavigationBar extends HookConsumerWidget {
|
|
const IosNavigationBar({
|
|
super.key,
|
|
required this.title,
|
|
this.subtitle,
|
|
this.ephemeralLabel,
|
|
this.titleAvatar,
|
|
this.titlePresenceColor,
|
|
this.titleRefreshing = false,
|
|
this.onTitlePressed,
|
|
this.largeTitle = false,
|
|
this.alwaysFrosted = false,
|
|
this.leading,
|
|
this.actions = const [],
|
|
this.onBack,
|
|
this.foregroundColor,
|
|
this.onReadyChanged,
|
|
});
|
|
|
|
static const viewType = 'buzz/ios_navigation_bar';
|
|
|
|
final String title;
|
|
final String? subtitle;
|
|
final String? ephemeralLabel;
|
|
final IosNavigationAction? titleAvatar;
|
|
final Color? titlePresenceColor;
|
|
|
|
/// Shows a refresh bar along the bottom of the subtitled title capsule.
|
|
final bool titleRefreshing;
|
|
final VoidCallback? onTitlePressed;
|
|
final bool largeTitle;
|
|
|
|
/// Keeps the navigation backdrop visible before the first scroll update.
|
|
final bool alwaysFrosted;
|
|
final IosNavigationAction? leading;
|
|
final List<IosNavigationAction> actions;
|
|
final VoidCallback? onBack;
|
|
final Color? foregroundColor;
|
|
|
|
/// Reports when the latest native configuration and layout are applied.
|
|
/// Cosmetic avatar downloads do not delay readiness.
|
|
final ValueChanged<bool>? onReadyChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final channel = useState<MethodChannel?>(null);
|
|
final viewKey = useMemoized(GlobalKey.new);
|
|
final latest = useRef(this)..value = this;
|
|
final offset = IosNavigationScrollScope.maybeOf(context);
|
|
final collapseRange = IosNavigationMetrics.of(context).largeTitleHeight;
|
|
final avatarActions = <String, IosNavigationAction>{
|
|
'titleAvatar': ?titleAvatar,
|
|
if (leading?.avatarInitial != null) 'leading': leading!,
|
|
for (var i = 0; i < actions.length; i++)
|
|
if (actions[i].avatarInitial != null) '$i': actions[i],
|
|
};
|
|
final auth = ref.watch(mediaGetAuthServiceProvider);
|
|
final client = ref.watch(mediaHttpClientProvider);
|
|
final colors = Theme.of(context).colorScheme;
|
|
String imageKey(IosNavigationAction action) => jsonEncode([
|
|
action.avatarIdentity,
|
|
action.imageUrl,
|
|
action.avatarInitial,
|
|
action.avatarIsAgent,
|
|
]);
|
|
final avatarImages = useState(<String, ({String key, String data})>{});
|
|
final avatarKey = jsonEncode([
|
|
for (final entry in avatarActions.entries)
|
|
[entry.key, imageKey(entry.value)],
|
|
]);
|
|
useEffect(
|
|
() {
|
|
var active = true;
|
|
for (final entry in avatarActions.entries) {
|
|
unawaited(
|
|
nativeAvatarImage(
|
|
url: entry.value.imageUrl,
|
|
initial: entry.value.avatarInitial!,
|
|
isAgent: entry.value.avatarIsAgent,
|
|
circular: true,
|
|
background:
|
|
entry.value.avatarIsAgent &&
|
|
usesInsetAgentArtwork(entry.value.imageUrl)
|
|
? Colors.transparent
|
|
: colors.primaryContainer,
|
|
foreground: colors.onPrimaryContainer,
|
|
networkImage: (url) =>
|
|
MediaImageProvider(url: url, auth: auth, client: client),
|
|
).then(
|
|
(bytes) {
|
|
if (!active || !context.mounted || bytes == null) return;
|
|
avatarImages.value = {
|
|
...avatarImages.value,
|
|
entry.key: (
|
|
key: imageKey(entry.value),
|
|
data: base64Encode(bytes),
|
|
),
|
|
};
|
|
},
|
|
onError: (Object _, StackTrace _) {
|
|
// Keep UIKit's synchronous initial fallback on image failure.
|
|
},
|
|
),
|
|
);
|
|
}
|
|
return () => active = false;
|
|
},
|
|
[
|
|
avatarKey,
|
|
auth,
|
|
client,
|
|
colors.primaryContainer,
|
|
colors.onPrimaryContainer,
|
|
],
|
|
);
|
|
final iconImages = useState(<String, String>{});
|
|
final icons = <String, IconData>{
|
|
if (leading?.icon != null) 'leading': leading!.icon!,
|
|
for (var i = 0; i < actions.length; i++)
|
|
if (actions[i].icon != null) '$i': actions[i].icon!,
|
|
};
|
|
final iconKey = jsonEncode([
|
|
for (final entry in icons.entries)
|
|
[
|
|
entry.key,
|
|
entry.value.codePoint,
|
|
entry.value.fontFamily,
|
|
entry.value.fontPackage,
|
|
],
|
|
]);
|
|
useEffect(() {
|
|
if (icons.isEmpty) return null;
|
|
var active = true;
|
|
unawaited(
|
|
Future.microtask(() async {
|
|
final rendered = <String, String>{};
|
|
for (final entry in icons.entries) {
|
|
final recorder = ui.PictureRecorder();
|
|
final painter = TextPainter(
|
|
text: TextSpan(
|
|
text: String.fromCharCode(entry.value.codePoint),
|
|
style: TextStyle(
|
|
fontFamily: entry.value.fontFamily,
|
|
package: entry.value.fontPackage,
|
|
fontSize: 22,
|
|
color: const Color(0xff000000),
|
|
),
|
|
),
|
|
textDirection: TextDirection.ltr,
|
|
)..layout();
|
|
final canvas = Canvas(recorder)..scale(3);
|
|
painter.paint(
|
|
canvas,
|
|
Offset((24 - painter.width) / 2, (24 - painter.height) / 2),
|
|
);
|
|
final picture = recorder.endRecording();
|
|
final image = await picture.toImage(72, 72);
|
|
final bytes = await image.toByteData(
|
|
format: ui.ImageByteFormat.png,
|
|
);
|
|
if (bytes != null) {
|
|
rendered[entry.key] = base64Encode(bytes.buffer.asUint8List());
|
|
}
|
|
image.dispose();
|
|
picture.dispose();
|
|
painter.dispose();
|
|
}
|
|
if (active && context.mounted) iconImages.value = rendered;
|
|
}),
|
|
);
|
|
return () => active = false;
|
|
}, [iconKey]);
|
|
Map<String, Object?> encodeAction(IosNavigationAction action, String id) {
|
|
final key = imageKey(action);
|
|
final retained = avatarImages.value[id];
|
|
return {
|
|
...action._encode(id),
|
|
if (iconImages.value[id] != null) 'iconData': iconImages.value[id],
|
|
'avatarBackground': colors.primaryContainer.toARGB32(),
|
|
'avatarForeground': colors.onPrimaryContainer.toARGB32(),
|
|
if (retained?.key == key) 'imageData': retained!.data,
|
|
};
|
|
}
|
|
|
|
final payload = <String, Object?>{
|
|
'title': title,
|
|
'safeTop': MediaQuery.viewPaddingOf(context).top,
|
|
'subtitle': subtitle,
|
|
'ephemeralLabel': ephemeralLabel,
|
|
'titleAvatar': titleAvatar == null
|
|
? null
|
|
: encodeAction(titleAvatar!, 'titleAvatar'),
|
|
'titlePresenceColor': titlePresenceColor?.toARGB32(),
|
|
'titleRefreshing': titleRefreshing,
|
|
'titleRefreshColor': ChatColors.of(context).outgoing.toARGB32(),
|
|
'titleEnabled': onTitlePressed != null,
|
|
'largeTitle': largeTitle,
|
|
'alwaysFrosted': alwaysFrosted,
|
|
'back': onBack != null,
|
|
'leading': leading == null ? null : encodeAction(leading!, 'leading'),
|
|
'actions': [
|
|
for (var i = 0; i < actions.length; i++) encodeAction(actions[i], '$i'),
|
|
],
|
|
'background': colors.surface.toARGB32(),
|
|
'controlBackground': context.controlSurface.toARGB32(),
|
|
'dark': Theme.of(context).brightness == Brightness.dark,
|
|
'foreground': (foregroundColor ?? Theme.of(context).colorScheme.onSurface)
|
|
.toARGB32(),
|
|
};
|
|
final signature = jsonEncode(payload);
|
|
|
|
useEffect(() {
|
|
final current = channel.value;
|
|
if (current == null) return null;
|
|
current.setMethodCallHandler((call) async {
|
|
if (call.method == 'avatarBounds') {
|
|
final values = call.arguments as Map<Object?, Object?>;
|
|
final box = viewKey.currentContext?.findRenderObject();
|
|
if (box is RenderBox && values['id'] == 'leading') {
|
|
final x = (values['x'] as num).toDouble();
|
|
final y = (values['y'] as num).toDouble();
|
|
final width = (values['width'] as num).toDouble();
|
|
final height = (values['height'] as num).toDouble();
|
|
latest.value.leading?.onAvatarBoundsChanged?.call(
|
|
box.localToGlobal(Offset(x, y)) & Size(width, height),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (call.method == 'metrics') {
|
|
if (context.mounted) {
|
|
IosNavigationMetrics.update(
|
|
context,
|
|
call.arguments as Map<Object?, Object?>,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (call.method != 'action') return;
|
|
final id = call.arguments as String;
|
|
final config = latest.value;
|
|
if (id == 'title') {
|
|
config.onTitlePressed?.call();
|
|
} else if (id == 'back') {
|
|
config.onBack?.call();
|
|
} else {
|
|
final path = id.split('.');
|
|
if (path.first == 'leading') {
|
|
config.leading?._dispatch(path.sublist(1));
|
|
} else {
|
|
final index = int.tryParse(path.first);
|
|
if (index != null && index >= 0 && index < config.actions.length) {
|
|
config.actions[index]._dispatch(path.sublist(1));
|
|
}
|
|
}
|
|
}
|
|
});
|
|
return () => current.setMethodCallHandler(null);
|
|
}, [channel.value]);
|
|
|
|
useEffect(() {
|
|
final current = channel.value;
|
|
var active = true;
|
|
void report(bool ready) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (active && context.mounted) {
|
|
latest.value.onReadyChanged?.call(ready);
|
|
}
|
|
});
|
|
WidgetsBinding.instance.ensureVisualUpdate();
|
|
}
|
|
|
|
report(false);
|
|
if (current != null) {
|
|
unawaited(() async {
|
|
try {
|
|
await current.invokeMethod<void>('configure', payload);
|
|
if (!active) return;
|
|
if (latest.value.onReadyChanged != null) {
|
|
await current.invokeMethod<void>('prepareForReveal');
|
|
}
|
|
report(true);
|
|
} on PlatformException catch (error) {
|
|
debugPrint('Native navigation configuration failed: $error');
|
|
}
|
|
}());
|
|
}
|
|
return () => active = false;
|
|
}, [channel.value, signature]);
|
|
|
|
useEffect(() {
|
|
// UIKit owns its collapse range. Send real page depth so a changed native
|
|
// inset cannot leave a deeply scrolled page short of the collapse point.
|
|
void sync() {
|
|
final current = channel.value;
|
|
if (current != null) {
|
|
unawaited(
|
|
current.invokeMethod<void>(
|
|
'scroll',
|
|
(offset?.value ?? 0).clamp(0.0, double.infinity),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
sync();
|
|
offset?.addListener(sync);
|
|
return () => offset?.removeListener(sync);
|
|
}, [channel.value, offset, collapseRange]);
|
|
|
|
final route = ModalRoute.of(context);
|
|
useEffect(() {
|
|
var active = true;
|
|
Future<void> restoreOnReveal() async {
|
|
final current = channel.value;
|
|
if (!active ||
|
|
!context.mounted ||
|
|
route?.isCurrent != true ||
|
|
current == null) {
|
|
return;
|
|
}
|
|
try {
|
|
// Home stays mounted while another route covers its platform view.
|
|
// Returning does not emit a scroll notification, so replay the live
|
|
// depth before asking UIKit to lay out the revealed navigation bar.
|
|
await current.invokeMethod<void>(
|
|
'scroll',
|
|
(offset?.value ?? 0).clamp(0.0, double.infinity),
|
|
);
|
|
if (!active || route?.isCurrent != true) return;
|
|
await current.invokeMethod<void>('prepareForReveal');
|
|
} on PlatformException catch (error) {
|
|
debugPrint('Native navigation reveal failed: $error');
|
|
}
|
|
}
|
|
|
|
void scheduleRestore() {
|
|
// Restore at reveal and again after the covering route exits. Route
|
|
// transforms can change UIKit's safe-area layout during transitions.
|
|
// The initial didPopNext alone runs before that transform has settled.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
unawaited(restoreOnReveal());
|
|
});
|
|
WidgetsBinding.instance.ensureVisualUpdate();
|
|
}
|
|
|
|
final observer = _NavigationRevealObserver(scheduleRestore);
|
|
if (route != null) {
|
|
appRouteObserver.subscribe(observer, route);
|
|
appRouteObserver.addRevealListener(route, scheduleRestore);
|
|
}
|
|
return () {
|
|
active = false;
|
|
appRouteObserver.unsubscribe(observer);
|
|
if (route != null) {
|
|
appRouteObserver.removeRevealListener(route, scheduleRestore);
|
|
}
|
|
};
|
|
}, [channel.value, offset, collapseRange, route]);
|
|
|
|
return UiKitView(
|
|
key: viewKey,
|
|
viewType: viewType,
|
|
creationParams: payload,
|
|
creationParamsCodec: const StandardMessageCodec(),
|
|
onPlatformViewCreated: (id) {
|
|
channel.value = MethodChannel('$viewType/$id');
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _NavigationRevealObserver extends RouteAware {
|
|
_NavigationRevealObserver(this.onReveal);
|
|
|
|
final VoidCallback onReveal;
|
|
|
|
@override
|
|
void didPopNext() => onReveal();
|
|
}
|