feat(mobile): add animated community switcher (#8076)

## Summary

Replace the community switch sheet with a two-column picker. Selection
enlarges the avatar, waits with the voice-note loader for destination
channel content and layout to settle, then follows a curved path into
the header.

Cache raster icons at a maximum 512-pixel size and accept only
resource-free emoji SVGs. Cancel timed-out or disposed downloads without
delaying content readiness. Animate Edit badges in place, and open Add
Community on the pairing welcome page. Community removal lives only in
the switcher’s Edit mode. Keep the flow shared across Android/iOS, with
native iOS controls and removal confirmation, and clear navigation
backgrounds at rest.

### Related issue

Related to #8070 (community icon refresh/caching).

### Testing

- 122 focused Flutter regression tests and seven native iOS tests passed
after merging the latest navigation changes from `main`. Earlier feature
build installed and launched on a physical iPhone.
- Full mobile pre-push checks passed; regression coverage includes
gradient rendering, rejected/oversized icons, bounded persisted raster
decoding, SVG resource rejection and cache migration, timeout/disposal
cancellation, disconnected destination channels, degraded unread-history
settlement, and switching while icons refresh. Full `just ci` stopped
during Rust checks for low disk space. Hosted CI remains required.
- [Grid and Edit
previews](https://github.com/block/buzz/pull/8076#issuecomment-5977838957).

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-10-04 14:11:39 +01:00
committed by GitHub
parent 05d1714912
commit d2da104d94
39 changed files with 4134 additions and 772 deletions

View File

@@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
C0AF00000000000000000001 /* NativeConfirmationDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AF00000000000000000002 /* NativeConfirmationDialog.swift */; };
BZZ00000000000000000001 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000006 /* NotificationService.swift */; };
BZZ00000000000000000002 /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000009 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
BZZ00000000000000000020 /* BuzzPushKit in Frameworks */ = {isa = PBXBuildFile; productRef = BZZ00000000000000000022 /* BuzzPushKit */; };
@@ -88,6 +89,7 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
C0AF00000000000000000002 /* NativeConfirmationDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeConfirmationDialog.swift; sourceTree = "<group>"; };
BZZ00000000000000000006 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
BZZ00000000000000000007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
BZZ00000000000000000008 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = NotificationService.entitlements; sourceTree = "<group>"; };
@@ -277,6 +279,7 @@
4A71C0142F40A00100A17E01 /* NativeMessageActionSurface.swift */,
4A71C0162F40B00100A17E01 /* HuddleMediaPlugin.swift */,
4A71C0182F40C00100A17E01 /* HuddleAudioEngine.swift */,
C0AF00000000000000000002 /* NativeConfirmationDialog.swift */,
4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */,
4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */,
4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */,
@@ -609,6 +612,7 @@
4A71C0132F40A00100A17E01 /* NativeMessageActionSurface.swift in Sources */,
4A71C0152F40B00100A17E01 /* HuddleMediaPlugin.swift in Sources */,
4A71C0172F40C00100A17E01 /* HuddleAudioEngine.swift in Sources */,
C0AF00000000000000000001 /* NativeConfirmationDialog.swift in Sources */,
4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */,
4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */,
4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */,

View File

@@ -41,6 +41,7 @@ import os.log
private var concentricSheetSurfaceChannel: FlutterMethodChannel?
private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator?
private var nativeEmojiPickerCoordinator: NativeEmojiPickerCoordinator?
private var nativeConfirmationDialogCoordinator: NativeConfirmationDialogCoordinator?
private var nativeProfileTextEditorCoordinator: NativeProfileTextEditorCoordinator?
private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel?
private var huddleMediaPlugin: HuddleMediaPlugin?
@@ -243,6 +244,13 @@ import os.log
parentViewController: nativeEmojiPickerRegistrar?.viewController
)
nativeConfirmationDialogCoordinator = NativeConfirmationDialogCoordinator(
messenger: messenger,
parentViewController: engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativeConfirmationDialog"
)?.viewController
)
let nativeProfileTextEditorRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativeProfileTextEditor"
)

View File

@@ -208,6 +208,8 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
private var measuredCategory: UIContentSizeCategory?
private var measuring = false
private var metrics: [String: Any]?
private weak var trackedAvatar: UIButton?
private var reportedAvatarFrame: CGRect?
init(frame: CGRect, id: Int64, args: Any?, messenger: FlutterBinaryMessenger, parent: UIViewController?) {
container = NavigationClipView(frame: frame)
@@ -234,6 +236,11 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
channel.setMethodCallHandler { [weak self] call, result in
switch call.method {
case "configure": self?.configure(call.arguments as? [String: Any] ?? [:])
case "prepareForReveal":
UIView.performWithoutAnimation {
self?.container.setNeedsLayout()
self?.container.layoutIfNeeded()
}
case "scroll":
self?.setScrollOffset((call.arguments as? NSNumber)?.doubleValue ?? 0)
default: result(FlutterMethodNotImplemented); return
@@ -259,6 +266,22 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
navigation.view.layoutIfNeeded()
measureIfNeeded()
applyScroll()
reportAvatarBounds()
}
private func reportAvatarBounds() {
guard let avatar = trackedAvatar, avatar.window != nil, avatar.bounds.width > 0 else { return }
avatar.layoutIfNeeded()
guard let image = avatar.imageView, image.bounds.width > 0 else { return }
let frame = image.convert(image.bounds, to: container)
guard frame != reportedAvatarFrame else { return }
reportedAvatarFrame = frame
DispatchQueue.main.async { [weak self] in
self?.channel.invokeMethod("avatarBounds", arguments: [
"id": "leading", "x": frame.minX, "y": frame.minY,
"width": frame.width, "height": frame.height
])
}
}
private func configure(_ args: [String: Any]) {
@@ -326,6 +349,8 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
item.titleView = nil
}
item.largeTitleDisplayMode = bar.prefersLargeTitles ? .always : .never
trackedAvatar = nil
reportedAvatarFrame = nil
if let leading = args["leading"] as? [String: Any] {
item.leftBarButtonItems = [makeItem(leading)]
} else if args["back"] as? Bool == true {
@@ -378,6 +403,7 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
offset = max(0, value)
material.alpha = min(1, offset / 12)
applyScroll()
reportAvatarBounds()
}
private func applyScroll() {
@@ -463,6 +489,27 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
}.withRenderingMode(.alwaysOriginal)
item.accessibilityValue = data["activityLabel"] as? String
}
if data["tracksAvatarBounds"] as? Bool == true, data["id"] as? String == "leading",
data["avatarInitial"] is String {
// An explicit native button gives Flutter a public, measured destination
// without depending on UINavigationBar's private view hierarchy.
let button = UIButton(type: .custom)
// UIKit adds the glass button's own padding around this custom view.
// Match the 36-point image on both axes so that glass stays circular.
button.frame = CGRect(x: 0, y: 0, width: 36, height: 36)
button.widthAnchor.constraint(equalToConstant: 36).isActive = true
button.heightAnchor.constraint(equalToConstant: 36).isActive = true
button.setImage(item.image?.withAlignmentRectInsets(.zero), for: .normal)
button.addAction(action, for: .touchUpInside)
button.isEnabled = item.isEnabled
button.accessibilityLabel = item.accessibilityLabel
button.accessibilityIdentifier = "community-navigation-avatar"
let hidden = data["avatarHidden"] as? Bool == true
button.alpha = hidden ? 0 : 1
button.accessibilityElementsHidden = hidden
trackedAvatar = button
return UIBarButtonItem(customView: button)
}
return item
}

View File

@@ -0,0 +1,77 @@
import Flutter
import UIKit
/// Owns a destructive UIKit alert until dismissal and replies exactly once.
final class NativeConfirmationDialogCoordinator: NSObject {
private let channel: FlutterMethodChannel
private weak var parent: UIViewController?
private weak var alert: UIAlertController?
private var pendingResult: FlutterResult?
private var isFinishing = false
init(messenger: FlutterBinaryMessenger, parentViewController: UIViewController?) {
channel = FlutterMethodChannel(name: "buzz/confirmation_dialog", binaryMessenger: messenger)
parent = parentViewController
super.init()
channel.setMethodCallHandler { [weak self] call, result in
guard let self else { result(false); return }
self.handle(call, result: result)
}
}
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard call.method == "present" else { result(FlutterMethodNotImplemented); return }
guard let data = call.arguments as? [String: Any],
let title = data["title"] as? String,
let message = data["message"] as? String,
let confirmLabel = data["confirmLabel"] as? String,
let cancelLabel = data["cancelLabel"] as? String else {
result(FlutterError(code: "invalid_arguments", message: "Expected confirmation labels.", details: nil))
return
}
// Repeated taps must not create stacked alerts or multiple removals.
guard pendingResult == nil else { result(false); return }
var presenter = parent ?? UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.filter { $0.activationState == .foregroundActive }
.flatMap(\.windows).first(where: \.isKeyWindow)?.rootViewController
while let presented = presenter?.presentedViewController { presenter = presented }
guard let presenter, presenter.view.window != nil,
!presenter.isBeingDismissed, !(presenter is UIAlertController) else {
result(FlutterError(code: "presentation_failed", message: "Cannot present confirmation right now.", details: nil))
return
}
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.overrideUserInterfaceStyle = data["dark"] as? Bool == true ? .dark : .light
alert.addAction(UIAlertAction(title: cancelLabel, style: .cancel) { [weak self] _ in
self?.cancel()
})
alert.addAction(UIAlertAction(title: confirmLabel, style: .destructive) { [weak self] _ in
self?.finish(true)
})
pendingResult = result
self.alert = alert
presenter.present(alert, animated: !UIAccessibility.isReduceMotionEnabled)
}
/// Dismisses an outstanding confirmation without authorizing the action.
func cancel() { finish(false) }
private func finish(_ confirmed: Bool) {
guard pendingResult != nil, !isFinishing else { return }
isFinishing = true
guard let alert else { resolve(confirmed); return }
// Finish native dismissal before Flutter removes the underlying route.
alert.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [weak self] in
self?.resolve(confirmed)
}
}
private func resolve(_ confirmed: Bool) {
let result = pendingResult
pendingResult = nil
isFinishing = false
alert = nil
result?(confirmed)
}
}

View File

@@ -8,6 +8,106 @@ import XCTest
class RunnerTests: XCTestCase {
@MainActor
func testNativeRemovalConfirmationUsesDestructiveAlertAndCancelsOnce() async throws {
let messenger = NavigationTestMessenger()
let parent = UIViewController()
let window = UIWindow(frame: UIScreen.main.bounds)
window.windowScene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
window.rootViewController = parent
parent.view.backgroundColor = .systemBackground
window.makeKeyAndVisible()
defer { window.isHidden = true }
let coordinator = NativeConfirmationDialogCoordinator(messenger: messenger, parentViewController: parent)
let arguments: [String: Any] = ["title": "Remove community?",
"message": "Are you sure you want to remove “Alpha”? You can pair with it again later.",
"confirmLabel": "Remove", "cancelLabel": "Cancel", "dark": false]
var replies: [Bool] = []
let completed = expectation(description: "Native confirmation dismissed")
messenger.invoke("present", arguments: arguments) {
if let value = $0 as? Bool { replies.append(value); completed.fulfill() }
}
let alert = try XCTUnwrap(parent.presentedViewController as? UIAlertController)
if let transition = alert.transitionCoordinator {
await withCheckedContinuation { continuation in
transition.animate(alongsideTransition: nil) { _ in continuation.resume() }
}
}
XCTAssertEqual(alert.preferredStyle, .alert)
XCTAssertEqual(alert.title, "Remove community?")
XCTAssertEqual(alert.message, arguments["message"] as? String)
XCTAssertEqual(alert.actions.map(\.title), ["Cancel", "Remove"])
XCTAssertEqual(alert.actions.map(\.style), [.cancel, .destructive])
XCTAssertEqual(alert.overrideUserInterfaceStyle, .light)
XCTAssertTrue(replies.isEmpty)
var duplicate: Bool?
messenger.invoke("present", arguments: arguments) { duplicate = $0 as? Bool }
XCTAssertEqual(duplicate, false)
XCTAssertTrue(parent.presentedViewController === alert)
let capture = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
}
let attachment = XCTAttachment(image: capture)
attachment.name = "Native remove community alert"
attachment.lifetime = .keepAlways
add(attachment)
coordinator.cancel()
coordinator.cancel()
await fulfillment(of: [completed], timeout: 3)
XCTAssertNil(parent.presentedViewController)
XCTAssertEqual(replies, [false])
}
@MainActor
func testCommunityAvatarReportsItsVisibleFrameAndHidesWithoutMoving() async throws {
let messenger = NavigationTestMessenger()
let parent = UIViewController()
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 393, height: 852))
window.windowScene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
parent.view.backgroundColor = .systemBackground
window.rootViewController = parent
window.makeKeyAndVisible()
defer { window.isHidden = true }
let factory = IosNavigationBarFactory(messenger: messenger, parent: parent)
var leading: [String: Any] = ["id": "leading", "label": "Community settings",
"avatarInitial": "A", "enabled": true, "tracksAvatarBounds": true]
let bar = factory.create(withFrame: CGRect(x: 0, y: 0, width: 393, height: 160),
viewIdentifier: 999991, arguments: ["title": "Alpha", "largeTitle": true, "leading": leading,
"actions": [["id": "profile", "label": "Profile", "avatarInitial": "P", "enabled": true]]])
parent.view.addSubview(bar.view())
parent.view.layoutIfNeeded()
try await Task.sleep(nanoseconds: 200_000_000)
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
func avatar() throws -> UIButton {
try XCTUnwrap(navigation.topViewController?.navigationItem.leftBarButtonItems?.first?.customView as? UIButton)
}
// A rectangular custom view makes UIKit's surrounding glass a pill.
let buttonBounds = try avatar().bounds
XCTAssertEqual(buttonBounds.width, buttonBounds.height)
XCTAssertEqual(buttonBounds.width, 36)
let visibleFrame = try XCTUnwrap(messenger.avatarBounds)
XCTAssertEqual((visibleFrame["width"] as? NSNumber)?.doubleValue, 36)
XCTAssertEqual((visibleFrame["height"] as? NSNumber)?.doubleValue, 36)
let image = try XCTUnwrap(avatar().imageView)
let imageFrame = image.convert(image.bounds, to: bar.view())
XCTAssertEqual((visibleFrame["x"] as? NSNumber)?.doubleValue, imageFrame.minX)
XCTAssertEqual((visibleFrame["y"] as? NSNumber)?.doubleValue, imageFrame.minY)
try avatar().sendActions(for: .touchUpInside)
XCTAssertEqual(messenger.actions, ["leading"])
leading["avatarHidden"] = true
messenger.configure(["title": "Bravo", "largeTitle": true, "leading": leading])
var landingPrepared = false
messenger.invoke("prepareForReveal", arguments: [:]) { _ in landingPrepared = true }
XCTAssertTrue(landingPrepared)
XCTAssertFalse(bar.view().layer.needsLayout())
XCTAssertEqual(try avatar().alpha, 0)
XCTAssertEqual((messenger.avatarBounds?["x"] as? NSNumber)?.doubleValue, imageFrame.minX)
XCTAssertEqual((messenger.avatarBounds?["y"] as? NSNumber)?.doubleValue, imageFrame.minY)
leading["avatarHidden"] = false
messenger.configure(["title": "Bravo", "largeTitle": true, "leading": leading])
XCTAssertEqual(try avatar().alpha, 1)
}
@MainActor
func testConversationTitleHasWidthBeforePlatformViewLayout() throws {
let parent = UIViewController()
@@ -244,7 +344,7 @@ class RunnerTests: XCTestCase {
}
@MainActor
func testCompactMaterialOnlyAppearsAfterScrolling() async throws {
func testCompactNavigationMaterialFollowsScrollDepth() async throws {
let messenger = NavigationTestMessenger()
let parent = UIViewController()
let window = UIWindow(frame: UIScreen.main.bounds)
@@ -260,8 +360,9 @@ class RunnerTests: XCTestCase {
parent.view.addSubview(bar.view())
parent.view.layoutIfNeeded()
let material = try XCTUnwrap(bar.view().subviews.first as? UIVisualEffectView)
// At rest and during top-edge overscroll the bar is clear; positive
// scroll gradually introduces the backdrop.
XCTAssertEqual(material.alpha, 0, "Compact titles must not frost an unscrolled page")
// Scrolling, returning to rest, and pull-to-refresh all share the same
// treatment as large titles; reconfiguration must preserve that depth.
for offset in [0.0, 6.0, 52.0, 12.0, 0.0, -20.0, 0.0] {
messenger.scroll(to: offset)
bar.view().setNeedsLayout()
@@ -273,7 +374,10 @@ class RunnerTests: XCTestCase {
XCTAssertEqual(locations.first?.doubleValue, 0)
XCTAssertEqual(locations.last?.doubleValue, 1)
XCTAssertLessThan(locations[1].doubleValue, 1, "Compact material needs a soft lower edge")
XCTAssertEqual(material.alpha, min(1, max(0, offset) / 12), accuracy: 0.01)
XCTAssertEqual(material.alpha, min(1, max(0, offset) / 12), accuracy: 0.001)
messenger.configure(["title": "general", "subtitle": "37 members"])
XCTAssertEqual(material.alpha, min(1, max(0, offset) / 12), accuracy: 0.001,
"Updating a compact title must not restore blur at rest")
XCTAssertEqual(material.frame, bar.view().bounds)
if #available(iOS 26.0, *) {
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
@@ -1637,11 +1741,13 @@ private actor NativeEmojiDownloadProbe {
private final class NavigationTestMessenger: NSObject, FlutterBinaryMessenger {
var metrics: [String: Any]?
var avatarBounds: [String: Any]?
var actions: [String] = []
func send(onChannel channel: String, message: Data?) {
guard let message else { return }
let call = FlutterStandardMethodCodec.sharedInstance().decodeMethodCall(message)
if call.method == "metrics" { metrics = call.arguments as? [String: Any] }
if call.method == "avatarBounds" { avatarBounds = call.arguments as? [String: Any] }
if call.method == "action", let action = call.arguments as? String { actions.append(action) }
}
func send(onChannel channel: String, message: Data?, binaryReply callback: FlutterBinaryReply?) {
@@ -1662,6 +1768,12 @@ private final class NavigationTestMessenger: NSObject, FlutterBinaryMessenger {
handler = nil
}
func invoke(_ method: String, arguments: [String: Any], reply: @escaping (Any?) -> Void) {
let codec = FlutterStandardMethodCodec.sharedInstance()
let message = codec.encode(FlutterMethodCall(methodName: method, arguments: arguments))
handler?(message) { data in reply(data.flatMap { codec.decodeEnvelope($0) }) }
}
func configure(_ arguments: [String: Any]) {
let message = FlutterStandardMethodCodec.sharedInstance().encode(
FlutterMethodCall(methodName: "configure", arguments: arguments)

View File

@@ -24,11 +24,13 @@ class ChannelMutesState {
class ChannelMutesNotifier extends Notifier<ChannelMutesState> {
ChannelMutesManager? _manager;
bool _isInitialized = false;
@override
ChannelMutesState build() {
_manager?.dispose(flushPending: false);
_manager = null;
_isInitialized = false;
final relayConfig = ref.watch(relayConfigProvider);
final sessionState = ref.watch(relaySessionProvider);
@@ -85,6 +87,7 @@ class ChannelMutesNotifier extends Notifier<ChannelMutesState> {
Future.microtask(() async {
await manager.initialize();
if (_manager != manager) return;
_isInitialized = true;
_emitManagerState(manager);
});
@@ -98,7 +101,7 @@ class ChannelMutesNotifier extends Notifier<ChannelMutesState> {
void _emitManagerState(ChannelMutesManager manager) {
if (_manager != manager) return;
state = ChannelMutesState(
isReady: true,
isReady: _isInitialized,
store: manager.store,
version: state.version + 1,
);

View File

@@ -24,11 +24,13 @@ class ChannelSectionsState {
class ChannelSectionsNotifier extends Notifier<ChannelSectionsState> {
ChannelSectionsManager? _manager;
bool _isInitialized = false;
@override
ChannelSectionsState build() {
_manager?.dispose(flushPending: false);
_manager = null;
_isInitialized = false;
final relayConfig = ref.watch(relayConfigProvider);
final sessionState = ref.watch(relaySessionProvider);
@@ -85,6 +87,7 @@ class ChannelSectionsNotifier extends Notifier<ChannelSectionsState> {
Future.microtask(() async {
await manager.initialize();
if (_manager != manager) return;
_isInitialized = true;
_emitManagerState(manager);
});
@@ -116,7 +119,7 @@ class ChannelSectionsNotifier extends Notifier<ChannelSectionsState> {
void _emitManagerState(ChannelSectionsManager manager) {
if (_manager != manager) return;
state = ChannelSectionsState(
isReady: true,
isReady: _isInitialized,
store: manager.store,
version: state.version + 1,
);

View File

@@ -27,11 +27,13 @@ class ChannelSortState {
class ChannelSortNotifier extends Notifier<ChannelSortState> {
ChannelSortManager? _manager;
bool _isInitialized = false;
@override
ChannelSortState build() {
_manager?.dispose();
_manager = null;
_isInitialized = false;
final relayConfig = ref.watch(relayConfigProvider);
final sessionState = ref.watch(relaySessionProvider);
@@ -93,6 +95,7 @@ class ChannelSortNotifier extends Notifier<ChannelSortState> {
Future.microtask(() async {
await manager.initialize();
if (_manager != manager) return;
_isInitialized = true;
_emitManagerState(manager);
});
@@ -109,7 +112,7 @@ class ChannelSortNotifier extends Notifier<ChannelSortState> {
void _emitManagerState(ChannelSortManager manager) {
if (_manager != manager) return;
state = ChannelSortState(
isReady: true,
isReady: _isInitialized,
store: manager.store,
version: state.version + 1,
);

View File

@@ -24,11 +24,13 @@ class ChannelStarsState {
class ChannelStarsNotifier extends Notifier<ChannelStarsState> {
ChannelStarsManager? _manager;
bool _isInitialized = false;
@override
ChannelStarsState build() {
_manager?.dispose(flushPending: false);
_manager = null;
_isInitialized = false;
final relayConfig = ref.watch(relayConfigProvider);
final sessionState = ref.watch(relaySessionProvider);
@@ -85,6 +87,7 @@ class ChannelStarsNotifier extends Notifier<ChannelStarsState> {
Future.microtask(() async {
await manager.initialize();
if (_manager != manager) return;
_isInitialized = true;
_emitManagerState(manager);
});
@@ -98,7 +101,7 @@ class ChannelStarsNotifier extends Notifier<ChannelStarsState> {
void _emitManagerState(ChannelStarsManager manager) {
if (_manager != manager) return;
state = ChannelStarsState(
isReady: true,
isReady: _isInitialized,
store: manager.store,
version: state.version + 1,
);

View File

@@ -23,8 +23,10 @@ import '../../shared/widgets/anchored_popover_menu.dart';
import '../../shared/widgets/bee_refresh_indicator.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/ios_navigation_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../../shared/widgets/confirmation_dialog.dart';
import '../../shared/widgets/skeleton.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
@@ -63,6 +65,8 @@ part 'channels_page/sheets.dart';
part 'channels_page/badges.dart';
part 'channels_page/skeleton.dart';
part 'channels_page/community.dart';
part 'channels_page/community_switcher.dart';
part 'channels_page/community_switcher_action.dart';
part 'channels_page/quick_actions.dart';
part 'channels_page/quick_actions_launcher.dart';
@@ -188,6 +192,10 @@ class ChannelsPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// Resolve permissions before the community menu is opened.
if (communityInvitePageBuilder != null) {
ref.watch(currentCommunityRoleProvider);
}
final channelsAsync = ref.watch(channelsProvider);
final sessionState = ref.watch(relaySessionProvider);
final currentPubkey = ref
@@ -205,6 +213,12 @@ class ChannelsPage extends HookConsumerWidget {
bottomHeight: _kTopSectionBottomPadding,
nativeLargeTitle: true,
);
final communityAvatarKey = useMemoized(GlobalKey.new);
final nativeCommunityAvatarBounds = useRef<Rect?>(null);
final headerKey = useMemoized(GlobalKey.new);
final nativeHeaderReady = useRef(false);
final bodyReady = useRef(false);
final communityFlightActive = useState(false);
final channelsScrollController = useScrollController();
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final headerFrostProgress = useState(0.0);
@@ -314,14 +328,98 @@ class ChannelsPage extends HookConsumerWidget {
return timer.cancel;
}, [isReconnectingWithContent]);
void openCommunitySwitcher() {
unawaited(HapticFeedback.selectionClick());
Rect? measureCommunityAvatar() {
if (defaultTargetPlatform == TargetPlatform.iOS) {
final header = headerKey.currentContext?.findRenderObject();
final bounds = nativeCommunityAvatarBounds.value;
return header is RenderBox && bounds != null
? MatrixUtils.transformRect(header.getTransformTo(null), bounds)
: null;
}
final avatar = communityAvatarKey.currentContext?.findRenderObject();
return avatar is RenderBox
? MatrixUtils.transformRect(
avatar.getTransformTo(null),
Offset.zero & avatar.size,
)
: null;
}
Future<Rect?> prepareCommunityLanding() async {
// The list snapshot precedes unread history and DM profile hydration.
// Both can still change weight, labels, and sorting behind the picker.
final channels = ref.read(channelsProvider).requireValue;
final dmPubkeys = {
for (final channel in channels)
if (channel.isMember && !channel.isArchived && channel.isDm)
...channel.participantPubkeys,
};
await Future.wait([
ref.read(channelsProvider.notifier).waitForUnreadCatchUp(),
if (dmPubkeys.isNotEmpty)
ref.read(userCacheProvider.notifier).preload(dmPubkeys.toList()),
]);
if (!context.mounted || !communityFlightActive.value) return null;
// Settle Home's scale and scroll while the loading backdrop is opaque.
onSettingsTransitionProgress(0);
if (channelsScrollController.hasClients) {
channelsScrollController.jumpTo(
channelsScrollController.position.minScrollExtent,
);
}
Rect? previous;
var stableFrames = 0;
while (context.mounted && communityFlightActive.value) {
await WidgetsBinding.instance.endOfFrame;
if (!context.mounted) return null;
final ready =
ref.read(_communityContentReadyProvider).value == true &&
bodyReady.value &&
(defaultTargetPlatform != TargetPlatform.iOS ||
nativeHeaderReady.value);
final bounds = ready ? measureCommunityAvatar() : null;
stableFrames = ready && bounds == previous ? stableFrames + 1 : 0;
previous = bounds;
if (ready && stableFrames >= 2) return bounds;
}
return null;
}
void openCommunityGrid() {
if (!context.mounted) return;
ref.invalidate(communityIconProvider);
final destination = measureCommunityAvatar();
late final _CommunitySwitcherRoute route;
route = _CommunitySwitcherRoute(
onTransitionProgress: onSettingsTransitionProgress,
builder: (_) => _CommunitySwitcherPage(
destination: destination,
prepareLanding: prepareCommunityLanding,
onFlightChanged: (flying) {
route.flying = flying;
communityFlightActive.value = flying;
},
onTransitionProgress: onSettingsTransitionProgress,
),
);
Navigator.of(context).push(route).whenComplete(() {
if (context.mounted) communityFlightActive.value = false;
});
}
void openCommunitySwitcher() {
// Freeze the menu shape for this presentation. If a cold permission
// lookup is still pending, the next opening uses its resolved result.
final role = ref.read(currentCommunityRoleProvider).unwrapPrevious();
final canInvite = role.hasError || canManageCommunityInvites(role.value);
unawaited(HapticFeedback.selectionClick());
showBuzzModalBottomSheet<void>(
context: context,
showCloseButton: false,
showDragHandle: true,
builder: (_) => _CommunitySwitcherSheet(
builder: (_) => _CommunityMenuSheet(
canInvite: canInvite,
onSwitchCommunity: openCommunityGrid,
invitePageBuilder: communityInvitePageBuilder,
appearancePageBuilder: communityAppearancePageBuilder,
),
@@ -335,10 +433,7 @@ class ChannelsPage extends HookConsumerWidget {
final communityRelay = activeCommunity?.relayUrl;
final communityAvatar = communityRelay == null
? null
: ref
.watch(communityIconProvider(communityRelay))
.unwrapPrevious()
.value;
: ref.watch(communityIconPresentationProvider(communityRelay));
final profile = ref.watch(profileProvider).unwrapPrevious().value;
final communityName = activeCommunity?.name.trim() ?? '';
final topSectionGradient = context.appColors.topSectionGradient;
@@ -350,10 +445,22 @@ class ChannelsPage extends HookConsumerWidget {
: context.colors.surface,
backgroundGradient: topSectionGradient,
appBar: FrostedAppBar(
key: headerKey,
onNativeReadyChanged: (ready) => nativeHeaderReady.value = ready,
nativeTitle: communityName.isEmpty ? 'Community' : communityName,
nativeLargeTitle: true,
nativeLeading: IosNavigationAction(
label: 'Switch community',
label: 'Community settings',
onAvatarBoundsChanged: (bounds) {
final header = headerKey.currentContext?.findRenderObject();
if (header is RenderBox) {
// Store native coordinates, then apply the current Home transform
// when measuring the destination immediately before departure.
nativeCommunityAvatarBounds.value =
header.globalToLocal(bounds.topLeft) & bounds.size;
}
},
avatarHidden: communityFlightActive.value,
avatarIdentity: activeCommunity?.id,
symbol: 'building.2.crop.circle',
imageUrl: communityAvatar,
@@ -389,7 +496,11 @@ class ChannelsPage extends HookConsumerWidget {
? _kHeaderFrostMaxBlurSigma * headerFrostProgress.value
: 20,
showBottomDivider: false,
leading: _CommunityIndicator(onTap: openCommunitySwitcher),
leading: _CommunityIndicator(
onTap: openCommunitySwitcher,
avatarKey: communityAvatarKey,
hidden: communityFlightActive.value,
),
centerTitle: false,
titleStyle: headerTitleStyle,
title: _CommunityHeaderTitle(
@@ -420,6 +531,7 @@ class ChannelsPage extends HookConsumerWidget {
bottom: const SizedBox.expand(),
),
body: _ChannelsBody(
onReadyChanged: (ready) => bodyReady.value = ready,
channels: channels,
channelsAsync: channelsAsync,
showError: showError.value,

View File

@@ -6,6 +6,7 @@ class _ChannelsBody extends StatelessWidget {
final bool showError;
final SessionStatus sessionStatus;
final bool showConnectionSkeleton;
final ValueChanged<bool> onReadyChanged;
final String? currentPubkey;
final double topSectionHeight;
final bool usesPinnedGradient;
@@ -19,6 +20,7 @@ class _ChannelsBody extends StatelessWidget {
required this.showError,
required this.sessionStatus,
required this.showConnectionSkeleton,
required this.onReadyChanged,
required this.currentPubkey,
required this.topSectionHeight,
required this.usesPinnedGradient,
@@ -76,6 +78,7 @@ class _ChannelsBody extends StatelessWidget {
);
return SkeletonReveal(
onReadyChanged: onReadyChanged,
loading: loading,
shimmerEnabled: sessionStatus != SessionStatus.disconnected,
skeleton: _ChannelsSkeleton(

View File

@@ -1,465 +1,72 @@
part of '../channels_page.dart';
class _CommunitySwitcherSheet extends HookConsumerWidget {
const _CommunitySwitcherSheet({
class _CommunityMenuSheet extends StatelessWidget {
const _CommunityMenuSheet({
required this.canInvite,
required this.onSwitchCommunity,
this.invitePageBuilder,
this.appearancePageBuilder,
});
final bool canInvite;
final VoidCallback onSwitchCommunity;
final WidgetBuilder? invitePageBuilder;
final WidgetBuilder? appearancePageBuilder;
@override
Widget build(BuildContext context, WidgetRef ref) {
final communitiesAsync = ref.watch(communityListProvider);
final activeAsync = ref.watch(activeCommunityProvider);
final isEditing = useState(false);
final role = ref.watch(currentCommunityRoleProvider);
final canInvite = role.hasError || canManageCommunityInvites(role.value);
Widget build(BuildContext context) {
void openPage(WidgetBuilder builder) {
final navigator = Navigator.of(context, rootNavigator: true);
Navigator.of(context).pop();
navigator.push(MaterialPageRoute<void>(builder: builder));
}
Future<void> openSwitcher() async {
final route = ModalRoute.of(context);
Navigator.of(context).pop();
// Let the menu finish dismissing before the grid starts its entrance.
if (route != null) await route.completed;
onSwitchCommunity();
}
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
child: SingleChildScrollView(
key: const Key('community-switcher-sheet'),
children: [
const SizedBox(height: Grid.xs),
Flexible(
child: communitiesAsync.when(
loading: () => const SizedBox(
height: 120,
child: Center(
child: BuzzLoadingIndicator(
size: 40,
semanticLabel: 'Loading communities',
),
),
),
error: (e, _) => Padding(
padding: const EdgeInsets.all(Grid.xs),
child: Text('Error loading communities: $e'),
),
data: (communities) {
final activeId = activeAsync.value?.id;
final activeCommunity = communities
.where((community) => community.id == activeId)
.firstOrNull;
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: Grid.xs),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (activeCommunity != null ||
appearancePageBuilder != null ||
(canInvite && invitePageBuilder != null))
AppListCard(
label: 'Community settings',
children: [
if (canInvite && invitePageBuilder != null)
AppListRow(
icon: LucideIcons.userPlus,
title: 'Invite',
trailing: const Icon(
LucideIcons.chevronRight,
size: 18,
),
onTap: () => openPage(invitePageBuilder!),
),
if (appearancePageBuilder != null)
AppListRow(
icon: LucideIcons.sunMoon,
title: 'Appearance',
trailing: const Icon(
LucideIcons.chevronRight,
size: 18,
),
onTap: () => openPage(appearancePageBuilder!),
),
if (activeCommunity != null)
AppListRow(
icon: LucideIcons.circleMinus,
title: 'Remove community',
titleColor: context.colors.error,
onTap: () => _confirmRemoveCommunity(
context,
ref,
activeCommunity,
closeSheetAfterRemoval: true,
),
),
],
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
),
child: Row(
children: [
Expanded(
child: Text(
'Switch communities',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
SizedBox(
key: const Key('community-switcher-edit'),
width: 56,
height: 44,
child: TextButton(
onPressed: () =>
isEditing.value = !isEditing.value,
style: TextButton.styleFrom(
minimumSize: const Size(56, 44),
padding: EdgeInsets.zero,
tapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
child: Text(isEditing.value ? 'Done' : 'Edit'),
),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
),
child: Material(
key: const Key('community-switcher-options'),
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.card),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
for (
var index = 0;
index < communities.length;
index++
) ...[
if (index > 0)
const _CommunitySwitcherDivider(),
_CommunitySwitcherTile(
community: communities[index],
isActive: communities[index].id == activeId,
isEditing: isEditing.value,
onTap: isEditing.value
? null
: () async {
final community = communities[index];
if (community.id != activeId) {
await ref
.read(
communityListProvider
.notifier,
)
.switchCommunity(community.id);
}
if (context.mounted) {
Navigator.of(context).pop();
}
},
onRemove: () => _confirmRemoveCommunity(
context,
ref,
communities[index],
closeSheetAfterRemoval:
communities[index].id == activeId,
),
),
],
if (communities.isNotEmpty)
const _CommunitySwitcherDivider(),
_AddCommunityTile(
onTap: () {
final nav = Navigator.of(
context,
rootNavigator: true,
);
ref.read(pairingProvider.notifier).reset();
Navigator.of(context).pop();
nav.push(
MaterialPageRoute<void>(
builder: (_) => const PairingPage(
addingCommunity: true,
),
),
);
},
),
],
),
),
),
],
),
);
},
),
),
],
),
);
}
}
const double _communitySwitcherAvatarSize = 36;
class _CommunitySwitcherDivider extends StatelessWidget {
const _CommunitySwitcherDivider();
@override
Widget build(BuildContext context) {
return Divider(
height: 1,
thickness: 1,
indent: Grid.xs + _communitySwitcherAvatarSize + Grid.xs,
endIndent: 0,
color: context.colors.onSurface.withValues(alpha: 0.12),
);
}
}
class _CommunitySwitcherTile extends StatelessWidget {
final Community community;
final bool isActive;
final bool isEditing;
final VoidCallback? onTap;
final VoidCallback onRemove;
const _CommunitySwitcherTile({
required this.community,
required this.isActive,
required this.isEditing,
required this.onTap,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
final host = Uri.tryParse(community.relayUrl)?.host ?? community.relayUrl;
return InkWell(
key: Key('community-switcher-row-${community.id}'),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.xs,
Grid.twelve,
Grid.xxs,
Grid.twelve,
),
child: Row(
padding: const EdgeInsets.symmetric(vertical: Grid.xs),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_CommunityAvatar(
key: Key('community-switcher-avatar-${community.id}'),
name: community.name,
relayUrl: community.relayUrl,
size: _communitySwitcherAvatarSize,
),
const SizedBox(width: Grid.xs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
if (appearancePageBuilder != null ||
(canInvite && invitePageBuilder != null))
AppListCard(
label: 'Community settings',
children: [
Text(
community.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: isActive
? FontWeight.w600
: FontWeight.normal,
if (canInvite && invitePageBuilder != null)
AppListRow(
icon: LucideIcons.userPlus,
title: 'Invite',
trailing: const Icon(LucideIcons.chevronRight, size: 18),
onTap: () => openPage(invitePageBuilder!),
),
),
const SizedBox(height: Grid.quarter),
Text(
host,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
if (appearancePageBuilder != null)
AppListRow(
icon: LucideIcons.sunMoon,
title: 'Appearance',
trailing: const Icon(LucideIcons.chevronRight, size: 18),
onTap: () => openPage(appearancePageBuilder!),
),
),
],
),
),
const SizedBox(width: Grid.xxs),
_CommunityActionSwap(
key: Key('community-switcher-action-${community.id}'),
communityId: community.id,
communityName: community.name,
isActive: isActive,
isEditing: isEditing,
onRemove: onRemove,
),
],
),
),
);
}
}
const _communityActionSwapDuration = Duration(milliseconds: 250);
const double _communityActionSwapBlur = 2;
const double _communityActionSwapStartScale = 0.25;
class _CommunityActionSwap extends StatelessWidget {
const _CommunityActionSwap({
super.key,
required this.communityId,
required this.communityName,
required this.isActive,
required this.isEditing,
required this.onRemove,
});
final String communityId;
final String communityName;
final bool isActive;
final bool isEditing;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
final reduceMotion =
MediaQuery.maybeOf(context)?.disableAnimations ?? false;
return SizedBox.square(
dimension: 40,
child: AnimatedSwitcher(
duration: reduceMotion ? Duration.zero : _communityActionSwapDuration,
reverseDuration: reduceMotion
? Duration.zero
: _communityActionSwapDuration,
transitionBuilder: (child, animation) {
final eased = CurvedAnimation(
parent: animation,
curve: Curves.easeInOut,
);
return AnimatedBuilder(
animation: eased,
child: child,
builder: (context, child) {
final progress = eased.value;
final blur = _communityActionSwapBlur * (1 - progress);
final scale =
_communityActionSwapStartScale +
((1 - _communityActionSwapStartScale) * progress);
return Opacity(
opacity: progress,
child: ImageFiltered(
enabled: blur > 0.01,
imageFilter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
child: Transform.scale(scale: scale, child: child),
AppListCard(
children: [
AppListRow(
key: const Key('community-menu-switch'),
icon: LucideIcons.arrowLeftRight,
title: 'Switch Community',
trailing: const Icon(LucideIcons.chevronRight, size: 18),
onTap: openSwitcher,
),
);
},
);
},
child: isEditing
? IconButton(
key: ValueKey('community-switcher-remove-$communityId'),
tooltip: 'Remove $communityName',
visualDensity: VisualDensity.compact,
onPressed: onRemove,
icon: Icon(
LucideIcons.trash2,
size: 18,
color: context.colors.error,
),
)
: Center(
key: ValueKey('community-switcher-selection-$communityId'),
child: _CommunitySelectionIndicator(
key: ValueKey('community-switcher-circle-$communityId'),
communityName: communityName,
isActive: isActive,
),
),
),
);
}
}
class _CommunitySelectionIndicator extends StatelessWidget {
const _CommunitySelectionIndicator({
super.key,
required this.communityName,
required this.isActive,
});
final String communityName;
final bool isActive;
@override
Widget build(BuildContext context) {
return Semantics(
label: isActive
? '$communityName is the current community'
: 'Switch to $communityName',
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isActive ? context.appColors.success : Colors.transparent,
border: isActive
? null
: Border.all(color: context.colors.outlineVariant, width: 2),
shape: BoxShape.circle,
),
child: isActive
? const Icon(LucideIcons.check, color: Colors.white, size: 16)
: null,
),
);
}
}
class _AddCommunityTile extends StatelessWidget {
const _AddCommunityTile({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
key: const Key('community-switcher-add'),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.twelve,
),
child: Row(
children: [
Container(
width: _communitySwitcherAvatarSize,
height: _communitySwitcherAvatarSize,
decoration: BoxDecoration(
color: context.colors.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.plus,
size: 18,
color: context.colors.onPrimaryContainer,
),
),
const SizedBox(width: Grid.xs),
Text(
'Add Community',
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
],
),
],
),
@@ -474,32 +81,17 @@ Future<void> _confirmRemoveCommunity(
Community community, {
required bool closeSheetAfterRemoval,
}) async {
final confirmed = await showBuzzDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog.adaptive(
title: const Text('Remove community?'),
content: Text(
'Are you sure you want to remove “${community.name}”? '
'You can pair with it again later.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(backgroundColor: context.colors.error),
child: const Text('Remove'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
try {
final confirmed = await showDestructiveConfirmation(
context: context,
title: 'Remove community?',
message:
'Are you sure you want to remove “${community.name}”? '
'You can pair with it again later.',
confirmLabel: 'Remove',
);
if (!confirmed || !context.mounted) return;
await ref
.read(communityListProvider.notifier)
.removeCommunity(community.id);
@@ -516,7 +108,14 @@ Future<void> _confirmRemoveCommunity(
class _CommunityIndicator extends ConsumerWidget {
final VoidCallback onTap;
const _CommunityIndicator({required this.onTap});
const _CommunityIndicator({
required this.onTap,
required this.avatarKey,
required this.hidden,
});
final GlobalKey avatarKey;
final bool hidden;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -527,9 +126,17 @@ class _CommunityIndicator extends ConsumerWidget {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: _CommunityAvatar(
name: activeCommunity?.name,
relayUrl: activeCommunity?.relayUrl,
child: Semantics(
button: true,
label: 'Community settings',
child: Opacity(
opacity: hidden ? 0 : 1,
child: _CommunityAvatar(
key: avatarKey,
name: activeCommunity?.name,
relayUrl: activeCommunity?.relayUrl,
),
),
),
);
}
@@ -582,22 +189,31 @@ class _CommunityAvatar extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final trimmedName = name?.trim();
final initial = trimmedName != null && trimmedName.isNotEmpty
? trimmedName.substring(0, 1).toUpperCase()
? trimmedName.characters.first.toUpperCase()
: '?';
final relay = relayUrl;
final iconUrl = relay == null
? null
: ref.watch(communityIconProvider(relay)).value;
: ref.watch(communityIconPresentationProvider(relay));
return AvatarImage(
imageUrl: iconUrl,
radius: size / 2,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
initial,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
return ClipRSuperellipse(
borderRadius: BorderRadius.circular(Radii.card * size / 40),
child: ColoredBox(
color: context.colors.primaryContainer,
child: SizedBox.square(
dimension: size,
child: AvatarImageContent(
imageUrl: iconUrl,
fallback: Text(
initial,
textScaler: TextScaler.noScaling,
style: context.textTheme.labelMedium?.copyWith(
fontSize: size * 0.38,
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
),
),
);

View File

@@ -0,0 +1,650 @@
part of '../channels_page.dart';
const _communityGridAvatarSize = 88.0;
const _communityCenterDuration = Duration(milliseconds: 420);
const _communityCenterCurve = Cubic(0.22, 1, 0.36, 1);
const _communityDepartureDuration = Duration(milliseconds: 520);
const _communityDepartureCurve = Cubic(0.2, 0, 0.2, 1);
const _communityRevealDuration = Duration(milliseconds: 160);
const _communityCenteredAvatarSize = _communityGridAvatarSize * 1.5;
// Readiness follows the data that determines the landing page's layout. Live
// messages continue to sync normally after this first usable snapshot.
final _communityContentReadyProvider = Provider.autoDispose<AsyncValue<bool>>((
ref,
) {
final channels = ref.watch(channelsProvider);
final profile = ref.watch(profileProvider);
final community = ref.watch(activeCommunityProvider).unwrapPrevious().value;
final sections = ref.watch(channelSectionsProvider).isReady;
final stars = ref.watch(channelStarsProvider).isReady;
final sort = ref.watch(channelSortProvider).isReady;
final mutes = ref.watch(channelMutesProvider).isReady;
final readState = ref.watch(readStateProvider).isReady;
final hasIdentity = ref.watch(myPubkeyProvider) != null;
if (!channels.isLoading && channels.hasError) {
return AsyncError(channels.error!, channels.stackTrace!);
}
return AsyncData(
community != null &&
channels.hasValue &&
!channels.isLoading &&
!profile.isLoading &&
(!hasIdentity || (sections && stars && sort && mutes && readState)),
);
});
/// Reuses Settings' entrance/dismissal, while letting a selected avatar leave
/// independently of the surface. The flight already performs the exit fade.
class _CommunitySwitcherRoute extends _SettingsPageRoute {
_CommunitySwitcherRoute({
required super.builder,
required super.onTransitionProgress,
});
bool flying = false;
@override
bool didPop(void result) {
if (flying) controller?.reverseDuration = Duration.zero;
return super.didPop(result);
}
// Keep the entrance wrappers mounted during selection. Replacing them with
// the bare page would reparent the native header as the center motion starts.
}
class _CommunityFlight {
const _CommunityFlight(this.community, this.origin, this.previousCommunityId);
final Community community;
final Rect origin;
final String? previousCommunityId;
}
class _CommunitySwitcherPage extends HookConsumerWidget {
const _CommunitySwitcherPage({
required this.destination,
required this.prepareLanding,
required this.onFlightChanged,
required this.onTransitionProgress,
});
final Rect? destination;
final Future<Rect?> Function() prepareLanding;
final ValueChanged<bool> onFlightChanged;
final ValueChanged<double> onTransitionProgress;
@override
Widget build(BuildContext context, WidgetRef ref) {
final communitiesAsync = ref.watch(communityListProvider);
final activeId = ref.watch(activeCommunityProvider).value?.id;
final isEditing = useState(false);
final flight = useState<_CommunityFlight?>(null);
final landingBounds = useState(destination);
if (flight.value != null) ref.watch(_communityContentReadyProvider);
final error = useState<String?>(null);
final failedCommunityId = useState<String?>(null);
final centering = useAnimationController(
duration: _communityCenterDuration,
);
final controller = useAnimationController(
duration: _communityDepartureDuration,
);
final reveal = useAnimationController(duration: _communityRevealDuration);
final flightAvatar = useMemoized(() {
final selected = flight.value;
return selected == null
? const SizedBox.shrink()
: RepaintBoundary(
// Keep avatar decoding/painting out of the per-frame motion build.
child: Material(
type: MaterialType.transparency,
child: _CommunityAvatar(
name: selected.community.name,
relayUrl: selected.community.relayUrl,
size: _communityGridAvatarSize,
),
),
);
}, [flight.value]);
final reducedMotion =
MediaQuery.disableAnimationsOf(context) ||
FocusManager.instance.highlightMode == FocusHighlightMode.traditional;
Future<void> waitForContent() async {
final ready = Completer<void>();
final subscription = ref.listenManual(_communityContentReadyProvider, (
_,
next,
) {
if (ready.isCompleted) return;
if (next.hasError) {
ready.completeError(next.error!, next.stackTrace);
} else if (next.value == true) {
ready.complete();
}
}, fireImmediately: true);
try {
// A stalled relay must not trap someone behind an endless spinner.
await ready.future.timeout(const Duration(seconds: 20));
} finally {
subscription.close();
}
}
Future<void> selectCommunity(Community community, Rect origin) async {
if (flight.value != null) return;
if (community.id == activeId && community.id != failedCommunityId.value) {
Navigator.of(context).pop();
return;
}
error.value = null;
flight.value = _CommunityFlight(community, origin, activeId);
onFlightChanged(true);
unawaited(HapticFeedback.selectionClick());
final animate = !reducedMotion && destination != null;
try {
// Relay teardown, credential changes and provider hydration must not
// compete with the grid-to-center motion on the UI isolate.
if (animate) {
await centering.forward(from: 0).orCancel;
} else {
centering.value = 1;
}
if (!context.mounted) return;
await WidgetsBinding.instance.endOfFrame;
if (!context.mounted) return;
await ref
.read(communityListProvider.notifier)
.switchCommunity(community.id);
if (!context.mounted) return;
await ref.read(activeCommunityProvider.future);
if (!context.mounted) return;
// Refresh this scope before awaiting its content. ChannelsNotifier
// fences cached snapshots to the destination relay and identity.
ref.invalidate(channelsProvider);
await (() async {
await waitForContent();
if (!context.mounted) return;
final bounds = await prepareLanding();
if (context.mounted) landingBounds.value = bounds;
})().timeout(const Duration(seconds: 20));
if (!context.mounted) return;
if (animate) {
await controller.forward(from: 0).orCancel;
if (!context.mounted) return;
// Reveal the settled page only after the avatar stops moving.
await reveal.forward(from: 0).orCancel;
} else {
controller.value = 1;
reveal.value = 1;
}
if (!context.mounted) return;
Navigator.of(context).pop();
} catch (e) {
if (!context.mounted) return;
failedCommunityId.value = community.id;
flight.value = null;
centering.reset();
controller.reset();
reveal.reset();
onFlightChanged(false);
onTransitionProgress(1);
error.value = e is TimeoutException
? 'This community is taking longer to load. Select it to try again, or choose another.'
: 'Could not switch communities. Please try again.';
}
}
Future<void> addCommunity() async {
final navigator = Navigator.of(context, rootNavigator: true);
final route = ModalRoute.of(context);
final pairing = ref.read(pairingProvider.notifier);
Navigator.of(context).pop();
// Finish the picker exit before showing the pairing page.
if (route != null) await route.completed;
if (!navigator.mounted) return;
pairing.reset();
unawaited(
navigator.push(
MaterialPageRoute<void>(
builder: (_) => const PairingPage(addingCommunity: true),
),
),
);
}
final surface = Material(
key: const Key('community-switcher-page'),
color: context.colors.surface,
child: SafeArea(
top: defaultTargetPlatform != TargetPlatform.iOS,
child: Column(
children: [
if (defaultTargetPlatform == TargetPlatform.iOS)
SizedBox(
height: frostedAppBarHeight(context),
child: IosNavigationBar(
title: '',
leading: IosNavigationAction(
label: 'Close community switcher',
symbol: 'xmark',
onPressed: () => Navigator.of(context).pop(),
),
actions: [
IosNavigationAction(
label: isEditing.value ? 'Done' : 'Edit',
onPressed: () => isEditing.value = !isEditing.value,
),
],
),
)
else
Padding(
padding: const EdgeInsets.all(Grid.xxs),
child: Row(
children: [
IconButton(
tooltip: 'Close community switcher',
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(LucideIcons.x),
),
const Spacer(),
TextButton(
key: const Key('community-switcher-edit'),
onPressed: () => isEditing.value = !isEditing.value,
child: Text(isEditing.value ? 'Done' : 'Edit'),
),
],
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xs,
Grid.gutter,
Grid.md,
),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
children: [
Text(
'Switch Community',
key: const Key('community-switcher-title'),
textAlign: TextAlign.center,
style: context.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: Grid.md),
if (error.value != null)
Padding(
padding: const EdgeInsets.only(bottom: Grid.xs),
child: Semantics(
liveRegion: true,
child: Text(
error.value!,
style: TextStyle(color: context.colors.error),
),
),
),
communitiesAsync.when(
loading: () => const Center(
child: BuzzLoadingIndicator(
size: 40,
semanticLabel: 'Loading communities',
),
),
error: (e, _) =>
Text('Error loading communities: $e'),
data: (communities) => _CommunityGrid(
children: [
for (final community in communities)
_CommunityGridTile(
community: community,
// Keep the current-community marker stable
// while the selected avatar leaves the grid.
isActive:
community.id ==
(flight.value == null
? activeId
: flight.value!.previousCommunityId),
isEditing: isEditing.value,
hidden:
flight.value?.community.id ==
community.id,
onSelect: selectCommunity,
onRemove: () => _confirmRemoveCommunity(
context,
ref,
community,
closeSheetAfterRemoval:
community.id == activeId,
),
),
_CommunityGridAdd(onTap: addCommunity),
],
),
),
],
),
),
),
),
),
],
),
),
);
return PopScope(
canPop: flight.value == null,
child: IgnorePointer(
ignoring: flight.value != null,
child: AnimatedBuilder(
animation: Listenable.merge([centering, controller, reveal]),
child: RepaintBoundary(child: surface),
builder: (context, child) {
final selected = flight.value;
final departure = _communityDepartureCurve.transform(
controller.value,
);
final surfaceExit = Curves.easeOutCubic.transform(centering.value);
final centerProgress = _communityCenterCurve.transform(
centering.value,
);
final screen = MediaQuery.sizeOf(context);
final center = Offset(screen.width / 2, screen.height / 2);
final destinationRect = landingBounds.value;
return Stack(
fit: StackFit.expand,
children: [
if (selected != null)
Opacity(
key: const Key('community-loading-backdrop'),
opacity: 1 - Curves.easeOutCubic.transform(reveal.value),
child: ColoredBox(color: context.colors.surface),
),
Opacity(
key: const Key('community-picker-opacity'),
opacity: 1 - surfaceExit,
child: Transform.scale(
scale: 1 + 0.04 * surfaceExit,
child: child,
),
),
if (selected != null && controller.value == 0)
Positioned(
top: center.dy + _communityCenteredAvatarSize / 2 + Grid.md,
left: 0,
right: 0,
child: Center(
child: RepaintBoundary(
child: BuzzLoadingIndicator(
key: const Key('community-switch-loading'),
size: 18,
color: context.colors.onSecondaryContainer,
semanticLabel: 'Loading ${selected.community.name}',
),
),
),
),
if (selected != null)
_CommunityFlyingAvatar(
avatar: flightAvatar,
origin: selected.origin.center,
destination:
destinationRect ??
Rect.fromCenter(
center: center,
width: _communityCenteredAvatarSize,
height: _communityCenteredAvatarSize,
),
centerProgress: centerProgress,
departure: departure,
),
],
);
},
),
),
);
}
}
class _CommunityFlyingAvatar extends StatelessWidget {
const _CommunityFlyingAvatar({
required this.avatar,
required this.origin,
required this.destination,
required this.departure,
required this.centerProgress,
});
final Widget avatar;
final Offset origin;
final Rect destination;
final double departure;
final double centerProgress;
@override
Widget build(BuildContext context) {
final screen = MediaQuery.sizeOf(context);
final center = screen.center(Offset.zero);
final toCenter = center - origin;
final centerControl =
(origin + center) / 2 + Offset(-toCenter.dy, toCenter.dx) * 0.18;
final c = centerProgress;
final start =
origin * ((1 - c) * (1 - c)) +
centerControl * (2 * (1 - c) * c) +
center * (c * c);
final end = destination.center;
final delta = end - start;
// A quadratic Bezier bends perpendicular to travel, like Motion's arc.
// Bow outward first, then settle into the header. Clamp the control point
// so the larger avatar stays inside narrow screens throughout the swoop.
final bend = (start + end) / 2 + Offset(-delta.dy, delta.dx) * 0.75;
final margin = _communityCenteredAvatarSize / 2 + Grid.xs;
final width = screen.width;
final control = Offset(
bend.dx.clamp(margin, max(margin, width - margin)),
bend.dy,
);
final t = departure;
final position =
start * ((1 - t) * (1 - t)) +
control * (2 * (1 - t) * t) +
end * (t * t);
final centeredSize = lerpDouble(
_communityGridAvatarSize,
_communityCenteredAvatarSize,
centerProgress,
)!;
final size = lerpDouble(centeredSize, destination.width, t)!;
return Positioned(
left: 0,
top: 0,
width: _communityGridAvatarSize,
height: _communityGridAvatarSize,
child: Transform.translate(
offset:
position -
const Offset(
_communityGridAvatarSize / 2,
_communityGridAvatarSize / 2,
),
child: ExcludeSemantics(
child: Transform.scale(
key: const Key('community-flying-avatar'),
scale: size / _communityGridAvatarSize,
child: avatar,
),
),
),
);
}
}
/// Intrinsic-height rows keep two columns without clipping scaled labels.
class _CommunityGrid extends StatelessWidget {
const _CommunityGrid({required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) => Column(
key: const Key('community-switcher-options'),
mainAxisSize: MainAxisSize.min,
children: [
for (var index = 0; index < children.length; index += 2)
Padding(
padding: const EdgeInsets.only(bottom: Grid.md),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: children[index]),
const SizedBox(width: Grid.xs),
Expanded(
child: index + 1 < children.length
? children[index + 1]
: const SizedBox.shrink(),
),
],
),
),
],
);
}
class _CommunityGridTile extends HookWidget {
const _CommunityGridTile({
required this.community,
required this.isActive,
required this.isEditing,
required this.hidden,
required this.onSelect,
required this.onRemove,
});
final Community community;
final bool isActive;
final bool isEditing;
final bool hidden;
final Future<void> Function(Community, Rect) onSelect;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
final avatarKey = useMemoized(GlobalKey.new);
return Semantics(
selected: isActive,
child: InkWell(
key: Key('community-switcher-row-${community.id}'),
borderRadius: BorderRadius.circular(Radii.card),
onTap: isEditing
? null
: () {
final box = avatarKey.currentContext?.findRenderObject();
if (box is RenderBox) {
unawaited(
onSelect(
community,
box.localToGlobal(Offset.zero) & box.size,
),
);
}
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Column(
children: [
Stack(
clipBehavior: Clip.none,
children: [
Opacity(
opacity: hidden ? 0 : 1,
child: SizedBox(
key: avatarKey,
child: _CommunityAvatar(
key: Key('community-switcher-avatar-${community.id}'),
name: community.name,
relayUrl: community.relayUrl,
size: _communityGridAvatarSize,
),
),
),
Positioned(
right: -Grid.twelve,
bottom: -Grid.twelve,
child: _CommunityGridAction(
community: community,
isActive: isActive,
isEditing: isEditing,
onRemove: onRemove,
),
),
],
),
const SizedBox(height: Grid.xs),
Text(
community.name,
textAlign: TextAlign.center,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
),
);
}
}
class _CommunityGridAdd extends StatelessWidget {
const _CommunityGridAdd({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) => InkWell(
key: const Key('community-switcher-add'),
borderRadius: BorderRadius.circular(Radii.card),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Column(
children: [
ClipRSuperellipse(
borderRadius: BorderRadius.circular(
Radii.card * _communityGridAvatarSize / 40,
),
child: Container(
width: _communityGridAvatarSize,
height: _communityGridAvatarSize,
color: context.colors.primaryContainer.withValues(alpha: 0.5),
child: Icon(
LucideIcons.plus,
size: 32,
color: context.colors.onSurfaceVariant,
),
),
),
const SizedBox(height: Grid.xs),
Text(
'Add Community',
textAlign: TextAlign.center,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}

View File

@@ -0,0 +1,166 @@
part of '../channels_page.dart';
class _CommunityGridAction extends HookWidget {
const _CommunityGridAction({
required this.community,
required this.isActive,
required this.isEditing,
required this.onRemove,
});
final Community community;
final bool isActive;
final bool isEditing;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final controller = useAnimationController(
duration: const Duration(milliseconds: 220),
initialValue: isEditing ? 1 : 0,
);
useEffect(() {
if (reducedMotion) {
controller.value = isEditing ? 1 : 0;
} else if (isEditing) {
unawaited(controller.forward());
} else {
unawaited(controller.reverse());
}
return null;
}, [isEditing, reducedMotion]);
final progress = useAnimation(controller);
final t = Curves.easeInOutCubic.transform(progress);
final size = isActive ? lerpDouble(24, 36, t)! : 36.0;
final foreground = isActive
? Color.lerp(Colors.white, context.colors.onErrorContainer, t)!
: context.colors.onErrorContainer;
// A fixed touch target and center keep Edit from nudging the avatar/check.
return SizedBox.square(
key: Key('community-switcher-action-${community.id}'),
dimension: Grid.xl,
child: IconButton(
key: Key(
'community-switcher-${isEditing ? 'remove' : 'selection'}-${community.id}',
),
tooltip: isEditing ? 'Remove ${community.name}' : null,
padding: EdgeInsets.zero,
onPressed: isEditing ? onRemove : null,
icon: Opacity(
key: Key('community-switcher-action-opacity-${community.id}'),
opacity: isActive ? 1 : t,
child: Transform.scale(
key: Key('community-switcher-action-scale-${community.id}'),
scale: isActive ? 1 : lerpDouble(.9, 1, t)!,
child: Container(
key: Key('community-switcher-badge-${community.id}'),
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isActive
? Color.lerp(
context.appColors.success,
context.colors.errorContainer,
t,
)
: context.colors.errorContainer,
border: Border.all(color: context.colors.surface, width: 2),
),
child: Center(
child: isActive && t > 0 && t < 1
? CustomPaint(
size: Size.square(lerpDouble(14, 18, t)!),
painter: _CommunityActionGlyphPainter(t, foreground),
)
: isActive && t == 0
? Icon(LucideIcons.check, color: foreground, size: 14)
: t > 0
? Icon(LucideIcons.trash2, color: foreground, size: 18)
: const SizedBox.shrink(),
),
),
),
),
),
);
}
}
/// Interpolates the check stroke into the bin outline; its lid and ribs appear
/// as the outline opens. Reversing Edit follows the same geometry backwards.
class _CommunityActionGlyphPainter extends CustomPainter {
const _CommunityActionGlyphPainter(this.progress, this.color);
final double progress;
final Color color;
@override
void paint(Canvas canvas, Size size) {
canvas.scale(size.width / 24, size.height / 24);
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 2
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
Offset point(Offset check, Offset trash) =>
Offset.lerp(check, trash, progress)!;
final start = point(const Offset(4, 12), const Offset(5, 6));
final path = Path()..moveTo(start.dx, start.dy);
void line(Offset check, Offset trash) {
final p = point(check, trash);
path.lineTo(p.dx, p.dy);
}
void curve(
Offset checkControl,
Offset checkEnd,
Offset trashControl,
Offset trashEnd,
) {
final c = point(checkControl, trashControl);
final p = point(checkEnd, trashEnd);
path.quadraticBezierTo(c.dx, c.dy, p.dx, p.dy);
}
line(const Offset(7, 15), const Offset(5, 20));
curve(
const Offset(8, 16),
const Offset(9, 17),
const Offset(5, 22),
const Offset(7, 22),
);
line(const Offset(14.5, 11.5), const Offset(17, 22));
curve(
const Offset(17.25, 8.75),
const Offset(20, 6),
const Offset(19, 22),
const Offset(19, 20),
);
line(const Offset(20, 6), const Offset(19, 6));
canvas.drawPath(path, paint);
paint.color = color.withValues(alpha: progress);
canvas.drawPath(
Path()
..moveTo(3, 6)
..lineTo(21, 6)
..moveTo(9, 6)
..lineTo(9, 4)
..quadraticBezierTo(9, 2, 11, 2)
..lineTo(13, 2)
..quadraticBezierTo(15, 2, 15, 4)
..lineTo(15, 6)
..moveTo(10, 10)
..lineTo(10, 16)
..moveTo(14, 10)
..lineTo(14, 16),
paint,
);
}
@override
bool shouldRepaint(_CommunityActionGlyphPainter oldDelegate) =>
oldDelegate.progress != progress || oldDelegate.color != color;
}

View File

@@ -52,6 +52,9 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
final Map<String, _LiveChunkSubscription> _liveSubscriptionsByChunk = {};
Future<void> _liveSubscriptionQueue = Future.value();
Future<void> _unreadCatchUp = Future.value();
int? _settledUnreadGeneration;
Completer<void> _unreadReadinessChanged = Completer<void>();
Set<String> _desiredLiveChannelIds = const {};
int _subscriptionVersion = 0;
int _nextLiveChunkGeneration = 0;
@@ -93,6 +96,36 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
bool get hasLoaded => _hasLoaded;
/// Waits for the current list's initial unread history to settle.
///
/// Presentation transitions can await this without blocking ordinary cached
/// channel rendering. Failure keeps observed unread state and permits landing;
/// follow a newer refresh if it replaces work we awaited.
Future<void> waitForUnreadCatchUp() async {
while (ref.mounted) {
final changed = _unreadReadinessChanged.future;
final subscriptions = _liveSubscriptionQueue;
await subscriptions;
if (!ref.mounted) return;
final unread = _unreadCatchUp;
await unread;
if (identical(subscriptions, _liveSubscriptionQueue) &&
identical(unread, _unreadCatchUp) &&
_settledUnreadGeneration == _subscriptionVersion) {
return;
}
// A disconnected generation has no settled destination history.
// Wake on reconciliation rather than accepting an older completed future.
await changed;
}
}
void _notifyUnreadReadinessChanged() {
final previous = _unreadReadinessChanged;
_unreadReadinessChanged = Completer<void>();
previous.complete();
}
Map<String, Map<String, ObservedUnreadEvent>>
get observedUnreadEventsByChannel =>
Map<String, Map<String, ObservedUnreadEvent>>.unmodifiable({
@@ -106,6 +139,9 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
final pubkey = ref.watch(myPubkeyProvider)?.toLowerCase();
if (_memberSnapshotRelayBaseUrl != relayBaseUrl ||
_memberSnapshotPubkey != pubkey) {
// A cached snapshot is usable only within its relay and identity.
// A new scope must await its own connection and first channel fetch.
_hasLoaded = false;
_memberSnapshotRelayBaseUrl = relayBaseUrl;
_memberSnapshotPubkey = pubkey;
_memberSnapshotsByChannelId = const {};
@@ -150,7 +186,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
});
if (sessionState.status != SessionStatus.connected) {
// Keep the prior community's cache visible until the new relay connects.
// Preserve cached channels only for a reconnect within the same scope.
if (_hasLoaded) return state.value ?? const [];
await connected.future;
}
@@ -574,42 +610,36 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
int subscriptionGeneration,
) async {
if (!ref.mounted) return;
final myPk = ref.read(myPubkeyProvider);
if (myPk == null) return;
final session = ref.read(relaySessionProvider.notifier);
final mutedChannelIds = _mutedChannelIds();
final ReadStateState readState;
try {
readState = ref.read(readStateProvider);
} catch (error) {
debugPrint('[ChannelsNotifier] unread catch-up skipped: $error');
return;
}
final activeChannels = [
for (final channel in channels)
if (channel.isMember && !channel.isArchived) channel,
];
final channelById = {
for (final channel in activeChannels) channel.id: channel,
};
final readAtByChannel = {
for (final channel in activeChannels)
channel.id: readState.effectiveTimestamp(channel.id),
};
final filters = [
for (final channel in activeChannels)
NostrFilter(
kinds: EventKind.channelMessageEventKinds,
tags: {
'#h': [channel.id],
},
since: (readAtByChannel[channel.id] ?? -1) + 1,
limit: _unreadCatchUpLimit,
),
];
final myPk = ref.read(myPubkeyProvider);
if (myPk == null) return;
final session = ref.read(relaySessionProvider.notifier);
final mutedChannelIds = _mutedChannelIds();
final readState = ref.read(readStateProvider);
final activeChannels = [
for (final channel in channels)
if (channel.isMember && !channel.isArchived) channel,
];
final channelById = {
for (final channel in activeChannels) channel.id: channel,
};
final readAtByChannel = {
for (final channel in activeChannels)
channel.id: readState.effectiveTimestamp(channel.id),
};
final filters = [
for (final channel in activeChannels)
NostrFilter(
kinds: EventKind.channelMessageEventKinds,
tags: {
'#h': [channel.id],
},
since: (readAtByChannel[channel.id] ?? -1) + 1,
limit: _unreadCatchUpLimit,
),
];
try {
final events = await _fetchChannelHistoryBatch(
session,
filters,
@@ -674,6 +704,13 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
} catch (error) {
if (!ref.mounted) return;
debugPrint('[ChannelsNotifier] unread catch-up failed: $error');
} finally {
// Optional history can terminate without data. Release this destination
// with its retained unread state, but never settle a retired request.
if (ref.mounted && !_isCatchUpRetired(fence, subscriptionGeneration)) {
_settledUnreadGeneration = subscriptionGeneration;
_notifyUnreadReadinessChanged();
}
}
}

View File

@@ -53,6 +53,7 @@ extension _ChannelsNotifierLiveSubscriptions on ChannelsNotifier {
);
}
});
_notifyUnreadReadinessChanged();
return _liveSubscriptionQueue;
}
@@ -160,7 +161,9 @@ extension _ChannelsNotifierLiveSubscriptions on ChannelsNotifier {
}
fence.ensureCurrent();
unawaited(_catchUpUnreadEvents(channels, fence, subscriptionVersion));
_unreadCatchUp = _catchUpUnreadEvents(channels, fence, subscriptionVersion);
_notifyUnreadReadinessChanged();
unawaited(_unreadCatchUp);
_backstopTimer?.cancel();
_backstopTimer = Timer.periodic(
@@ -237,6 +240,7 @@ extension _ChannelsNotifierLiveSubscriptions on ChannelsNotifier {
void _clearLiveSubscriptions() {
_subscriptionVersion++;
_notifyUnreadReadinessChanged();
_desiredLiveChannelIds = const {};
_terminallyClosedLiveChunks.clear();
_clearRetainedLiveChunks();

View File

@@ -101,6 +101,10 @@ class PairingPage extends HookConsumerWidget {
nativeTitle: identityRecoveryOnly
? 'Send to Desktop'
: '',
frosted: false,
showBottomDivider: false,
iconColor: context._onboardingInk,
title: identityRecoveryOnly
? const Text('Send to Desktop')
: null,
@@ -109,6 +113,10 @@ class PairingPage extends HookConsumerWidget {
),
)
: AppBar(
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
foregroundColor: context._onboardingInk,
systemOverlayStyle: onboardingSystemOverlayStyle,
leadingWidth: Theme.of(context).platform == TargetPlatform.iOS
@@ -132,12 +140,14 @@ class PairingPage extends HookConsumerWidget {
tooltip: 'Back',
onPressed: () => Navigator.of(context).pop(),
),
title: Text(
identityRecoveryOnly ? 'Send to Desktop' : 'Add Community',
style: context.textTheme.titleMedium?.copyWith(
color: context._onboardingInk,
),
),
title: identityRecoveryOnly
? Text(
'Send to Desktop',
style: context.textTheme.titleMedium?.copyWith(
color: context._onboardingInk,
),
)
: null,
)
: null;

View File

@@ -142,7 +142,7 @@ class _FigmaHomeScreen extends ConsumerWidget {
final relayUrl = community?.relayUrl;
final communityIcon = relayUrl == null
? null
: ref.watch(communityIconProvider(relayUrl)).value;
: ref.watch(communityIconPresentationProvider(relayUrl));
final suppliedGradient = context.appColors.topSectionGradient;
final backgroundGradient =
suppliedGradient ??

View File

@@ -0,0 +1,81 @@
import 'dart:convert';
import 'dart:ui' as ui;
import '../emoji/emoji_avatar.dart';
const _maximumSourceEdge = 4096;
const _maximumSourcePixels = 4 * 1024 * 1024;
const _maximumCachedEdge = 512;
const _maximumEncodedBytes = 256 * 1024;
// Only the product's fixed emoji template is safe to retain as SVG. Arbitrary
// SVG can reference external or embedded rasters that bypass the limits below.
// Accept current desktop artwork and its two legacy rounded backgrounds.
final _emojiSvg = RegExp(
r'^<svg xmlns="http://www\.w3\.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><rect width="512" height="512"(?: rx="(?:112|256)")? fill="#[0-9a-fA-F]{6}"/><text x="50%" y="56%" dominant-baseline="middle" text-anchor="middle" font-size="258">[^<>]{1,64}</text></svg>$',
);
/// Rejects pathological raster dimensions before decoding and persists only a
/// small first-frame PNG or a regenerated, resource-free emoji SVG.
Future<String?> prepareCommunityIconArtwork(String artwork) async {
try {
final data = UriData.parse(artwork);
final bytes = data.contentAsBytes();
if (bytes.length > _maximumEncodedBytes) return null;
if (data.mimeType == 'image/svg+xml') {
final svg = utf8.decode(bytes);
if (!_emojiSvg.hasMatch(svg)) return null;
final emoji = parseEmojiAvatarSvg(svg);
if (emoji == null) return null;
return emojiAvatarDataUrl(emoji.emoji, emoji.colorValue);
}
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
try {
final descriptor = await ui.ImageDescriptor.encoded(buffer);
try {
final width = descriptor.width;
final height = descriptor.height;
if (width <= 0 ||
height <= 0 ||
width > _maximumSourceEdge ||
height > _maximumSourceEdge ||
width * height > _maximumSourcePixels) {
return null;
}
final codec = await descriptor.instantiateCodec(
targetWidth: width >= height
? width.clamp(1, _maximumCachedEdge)
: null,
targetHeight: height > width
? height.clamp(1, _maximumCachedEdge)
: null,
);
try {
final frame = await codec.getNextFrame();
try {
final png = await frame.image.toByteData(
format: ui.ImageByteFormat.png,
);
if (png == null || png.lengthInBytes > _maximumEncodedBytes) {
return null;
}
return Uri.dataFromBytes(
png.buffer.asUint8List(),
mimeType: 'image/png',
).toString();
} finally {
frame.image.dispose();
}
} finally {
codec.dispose();
}
} finally {
descriptor.dispose();
}
} finally {
buffer.dispose();
}
} catch (_) {
return null;
}
}

View File

@@ -0,0 +1,72 @@
import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../theme/theme_provider.dart';
// v1 allowed unbounded rasters and v2 allowed arbitrary SVG resources.
// Rebuild this derived cache through the validated artwork pipeline.
const _storageKey = 'buzz.community-icons.v3';
const _maximumEntries = 32;
const _maximumStoredCharacters = 3 * 1024 * 1024;
/// Last downloaded public icon artwork, keyed by the relay information URL.
/// It intentionally survives active-community changes and app restarts.
final communityIconCacheProvider =
NotifierProvider<CommunityIconCache, Map<String, String>>(
CommunityIconCache.new,
);
/// A bounded, best-effort local cache; it never stores community credentials.
class CommunityIconCache extends Notifier<Map<String, String>> {
SharedPreferences? _prefs;
Future<void> _writes = Future.value();
@override
Map<String, String> build() {
try {
_prefs = ref.read(savedPrefsProvider);
final raw = _prefs?.getString(_storageKey);
if (raw == null || raw.length > _maximumStoredCharacters) return {};
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) return {};
return _bounded({
for (final entry in decoded.entries)
if (entry.value is String &&
(entry.value as String).startsWith('data:image/'))
entry.key: entry.value as String,
});
} catch (_) {
// Artwork caching is optional when preferences are unavailable/corrupt.
return {};
}
}
/// Stores complete artwork, or clears an explicitly removed relay icon.
Future<void> remember(String relay, String? artwork) {
if (state[relay] == artwork) return _writes;
final next = {...state}..remove(relay);
if (artwork != null) next[relay] = artwork;
state = _bounded(next);
final encoded = jsonEncode(state);
_writes = _writes.then((_) async {
try {
await _prefs?.setString(_storageKey, encoded);
} catch (_) {
// Retain the in-memory copy even if persistence is unavailable.
}
});
return _writes;
}
Map<String, String> _bounded(Map<String, String> entries) {
var size = jsonEncode(entries).length;
while (entries.length > _maximumEntries ||
size > _maximumStoredCharacters) {
entries.remove(entries.keys.first);
size = jsonEncode(entries).length;
}
return Map.unmodifiable(entries);
}
}

View File

@@ -1,48 +1,143 @@
import 'dart:async';
import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
/// Supplies the HTTP client used for NIP-11 community icon lookups.
///
/// Tests can override this provider to return deterministic relay responses.
import 'community_icon_cache.dart';
import 'community_icon_artwork.dart';
const _maximumIconBytes = 256 * 1024;
const _lookupTimeout = Duration(seconds: 5);
/// Supplies the unauthenticated client for public NIP-11 documents and icons.
final communityIconHttpClientProvider = Provider<http.Client>((ref) {
final client = http.Client();
ref.onDispose(client.close);
return client;
});
/// Reads a community's icon from its public NIP-11 relay information document.
///
/// The lookup does not depend on the active relay session, so icons can render
/// for every paired community in the switcher. Callers explicitly invalidate
/// this family when opening the switcher so transient failures and relay
/// metadata updates can be retried without background polling.
/// Immediately presents saved artwork while a public metadata refresh runs.
final communityIconPresentationProvider = Provider.autoDispose
.family<String?, String>((ref, relayUrl) {
final key = _relayInfoUri(relayUrl)?.toString();
final saved = ref.watch(communityIconCacheProvider)[key];
final refreshed = ref.watch(communityIconProvider(relayUrl));
return saved ?? refreshed.value;
});
/// Refreshes public relay artwork without discarding its last usable copy.
/// Downloaded bytes (including remote images) are persisted, so reopening the
/// picker and offline launches do not depend on the active relay's image cache.
final communityIconProvider = FutureProvider.autoDispose
.family<String?, String>((ref, relayUrl) async {
final uri = _relayInfoUri(relayUrl);
if (uri == null) return null;
final key = uri.toString();
final cache = ref.read(communityIconCacheProvider.notifier);
final saved = ref.read(communityIconCacheProvider)[key];
final client = ref.read(communityIconHttpClientProvider);
var disposed = false;
final cancelled = Completer<void>();
ref.onDispose(() {
disposed = true;
cancelled.complete();
});
try {
final response = await ref
.read(communityIconHttpClientProvider)
.get(uri, headers: const {'Accept': 'application/nostr+json'})
.timeout(const Duration(seconds: 5));
if (response.statusCode < 200 || response.statusCode >= 300) {
final response = await _download(
client,
uri,
cancelled.future,
metadata: true,
);
if (response == null || disposed) return saved;
final document = jsonDecode(utf8.decode(response.bytes));
if (document is! Map<String, dynamic>) return saved;
final value = document['icon'];
if (value == null || (value is String && value.trim().isEmpty)) {
await cache.remember(key, null);
return null;
}
final document = jsonDecode(response.body);
if (document is! Map<String, dynamic>) return null;
final icon = document['icon'];
if (icon is! String || icon.trim().isEmpty) return null;
return icon.trim();
if (value is! String) return saved;
final icon = value.trim();
String? artwork;
if (icon.startsWith('data:image/')) {
final data = UriData.parse(icon);
if (data.contentAsBytes().length <= _maximumIconBytes) artwork = icon;
} else {
final imageUri = Uri.tryParse(icon);
if (imageUri != null &&
(imageUri.scheme == 'https' || imageUri.scheme == 'http')) {
final image = await _download(client, imageUri, cancelled.future);
if (image != null && image.mimeType.startsWith('image/')) {
artwork = Uri.dataFromBytes(
image.bytes,
mimeType: image.mimeType,
).toString();
}
}
}
if (disposed) return saved;
// Never expose rejected remote URLs to the avatar renderer: doing so
// would bypass the bounded download above.
if (artwork == null) return saved;
final prepared = await prepareCommunityIconArtwork(artwork);
if (prepared == null || disposed) return saved;
await cache.remember(key, prepared);
return prepared;
} catch (_) {
return null;
return saved;
}
});
Future<({List<int> bytes, String mimeType})?> _download(
http.Client client,
Uri uri,
Future<void> cancelled, {
bool metadata = false,
}) async {
final abort = Completer<void>();
void cancel() {
if (!abort.isCompleted) abort.complete();
}
unawaited(cancelled.then((_) => cancel()));
final request = http.AbortableRequest('GET', uri, abortTrigger: abort.future);
if (metadata) request.headers['Accept'] = 'application/nostr+json';
StreamIterator<List<int>>? iterator;
try {
final response = await client.send(request).timeout(_lookupTimeout);
iterator = StreamIterator(response.stream);
final limit = metadata ? _maximumIconBytes * 2 : _maximumIconBytes;
if (response.statusCode < 200 ||
response.statusCode >= 300 ||
(response.contentLength ?? 0) > limit) {
return null;
}
final deadline = DateTime.now().add(_lookupTimeout);
final bytes = <int>[];
while (await iterator.moveNext().timeout(
deadline.difference(DateTime.now()),
)) {
if (bytes.length + iterator.current.length > limit) return null;
bytes.addAll(iterator.current);
}
return (
bytes: bytes,
mimeType: (response.headers['content-type'] ?? '')
.split(';')
.first
.trim()
.toLowerCase(),
);
} finally {
// Future.timeout does not stop the underlying socket, including while
// waiting for response headers. Abort before releasing the stream.
cancel();
await iterator?.cancel();
}
}
Uri? _relayInfoUri(String relayUrl) {
try {
final uri = Uri.parse(relayUrl.trim());
@@ -52,7 +147,11 @@ Uri? _relayInfoUri(String relayUrl) {
'https' || 'http' => uri.scheme,
_ => null,
};
return scheme == null ? null : uri.replace(scheme: scheme);
return scheme == null || uri.host.isEmpty
? null
: uri
.replace(scheme: scheme, path: uri.path.isEmpty ? '/' : uri.path)
.removeFragment();
} on FormatException {
return null;
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../theme/theme.dart';
import 'modal_presentation.dart';
/// Confirms a destructive action using a UIKit alert on iOS.
/// Returns true only when the person explicitly chooses the destructive action.
Future<bool> showDestructiveConfirmation({
required BuildContext context,
required String title,
required String message,
required String confirmLabel,
}) async {
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) {
return await const MethodChannel(
'buzz/confirmation_dialog',
).invokeMethod<bool>('present', {
'title': title,
'message': message,
'confirmLabel': confirmLabel,
'cancelLabel': 'Cancel',
'dark': context.theme.brightness == Brightness.dark,
}) ==
true;
}
return await showBuzzDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog.adaptive(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(
backgroundColor: context.colors.error,
),
child: Text(confirmLabel),
),
],
),
) ==
true;
}

View File

@@ -140,8 +140,8 @@ class FrostedAppBar extends StatelessWidget {
/// Whether to apply the translucent blur treatment behind the app bar.
///
/// A page can leave its painted backdrop exposed at rest, then turn this on
/// when scrolling moves content beneath the controls.
/// The backdrop stays clear at rest. This enables the treatment when the
/// surrounding scroll scope reports content beneath the controls.
final bool frosted;
/// Opacity of the frosted surface above the blurred backdrop.
@@ -182,6 +182,9 @@ class FrostedAppBar extends StatelessWidget {
/// Native replacements for composite Flutter actions, including menus.
final List<IosNavigationAction>? nativeActions;
/// Reports when native header images and layout are ready to reveal.
final ValueChanged<bool>? onNativeReadyChanged;
/// Uses a composable Flutter header while a backdrop covers the native view.
final ValueListenable<bool>? nativeViewSuppressed;
@@ -197,6 +200,7 @@ class FrostedAppBar extends StatelessWidget {
this.nativeLayoutDuration = Duration.zero,
this.nativeLeading,
this.nativeActions,
this.onNativeReadyChanged,
this.nativeViewSuppressed,
this.leading,
this.automaticallyImplyLeading = true,
@@ -266,6 +270,7 @@ class FrostedAppBar extends StatelessWidget {
right: 0,
height: barHeight,
child: IosNavigationBar(
onReadyChanged: onNativeReadyChanged,
title:
nativeTitle ??
(title is Text ? (title as Text).data ?? '' : ''),
@@ -320,8 +325,9 @@ class FrostedAppBar extends StatelessWidget {
}
final topPadding = MediaQuery.paddingOf(context).top;
final scrollUnder = FrostedScrollUnderScope.maybeOf(context);
final paintsBottomDivider =
showBottomDivider && (scrollUnder?.isScrolledUnder ?? true);
final isScrolledUnder = scrollUnder?.isScrolledUnder ?? false;
final paintsFrost = frosted && isScrolledUnder;
final paintsBottomDivider = showBottomDivider && isScrolledUnder;
final canPop = Navigator.canPop(context);
final effectiveTitleStyle = _effectiveTitleStyle(context, titleStyle);
final barContentHeight = _barContentHeight(
@@ -440,11 +446,11 @@ class FrostedAppBar extends StatelessWidget {
key: const ValueKey('frosted-app-bar-background'),
padding: EdgeInsets.only(top: topPadding),
decoration: BoxDecoration(
color: !frosted
? Colors.transparent
: gradient == null
color: gradient != null
? null
: paintsFrost
? context.colors.surface.withValues(alpha: frostedSurfaceOpacity)
: null,
: Colors.transparent,
gradient: gradient,
border: showBottomDivider
? Border(
@@ -461,7 +467,7 @@ class FrostedAppBar extends StatelessWidget {
);
final child = ClipRect(
child: frosted
child: paintsFrost
? BackdropFilter(
filter: ImageFilter.blur(
sigmaX: frostedBlurSigma,

View File

@@ -26,6 +26,9 @@ class IosNavigationAction {
this.onPressed,
this.children = const [],
this.selected = false,
this.onAvatarBoundsChanged,
this.avatarHidden = false,
this.plain = false,
});
@@ -41,6 +44,12 @@ class IosNavigationAction {
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;
@@ -55,6 +64,9 @@ class IosNavigationAction {
'imageUrl': imageUrl,
'enabled': onPressed != null || children.isNotEmpty,
'selected': selected,
'tracksAvatarBounds': onAvatarBoundsChanged != null,
'avatarHidden': avatarHidden,
'plain': plain,
'children': [
for (var i = 0; i < children.length; i++) children[i]._encode('$id.$i'),
@@ -108,6 +120,7 @@ class IosNavigationBar extends HookConsumerWidget {
this.actions = const [],
this.onBack,
this.foregroundColor,
this.onReadyChanged,
});
static const viewType = 'buzz/ios_navigation_bar';
@@ -124,9 +137,14 @@ class IosNavigationBar extends HookConsumerWidget {
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;
@@ -145,17 +163,17 @@ class IosNavigationBar extends HookConsumerWidget {
action.avatarInitial,
action.avatarIsAgent,
]);
final retainedImages = useRef(<String, ({String key, String data})>{});
final avatarImages = useState(<String, ({String key, String data})>{});
final avatarKey = jsonEncode([
for (final entry in avatarActions.entries)
[entry.key, imageKey(entry.value)],
]);
final avatarFuture = useMemoized(
() async {
final images = <String, String>{};
await Future.wait(
avatarActions.entries.map((entry) async {
final bytes = await nativeAvatarImage(
useEffect(
() {
var active = true;
for (final entry in avatarActions.entries) {
unawaited(
nativeAvatarImage(
url: entry.value.imageUrl,
initial: entry.value.avatarInitial!,
isAgent: entry.value.avatarIsAgent,
@@ -163,11 +181,24 @@ class IosNavigationBar extends HookConsumerWidget {
foreground: colors.onPrimaryContainer,
networkImage: (url) =>
MediaImageProvider(url: url, auth: auth, client: client),
);
if (bytes != null) images[entry.key] = base64Encode(bytes);
}),
);
return images;
).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,
@@ -177,12 +208,9 @@ class IosNavigationBar extends HookConsumerWidget {
colors.onPrimaryContainer,
],
);
final avatarImages = useFuture(avatarFuture, preserveState: false).data;
Map<String, Object?> encodeAction(IosNavigationAction action, String id) {
final key = imageKey(action);
final image = avatarImages?[id];
if (image != null) retainedImages.value[id] = (key: key, data: image);
final retained = retainedImages.value[id];
final retained = avatarImages.value[id];
return {
...action._encode(id),
'avatarBackground': colors.primaryContainer.toARGB32(),
@@ -217,6 +245,20 @@ class IosNavigationBar extends HookConsumerWidget {
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(
@@ -250,9 +292,32 @@ class IosNavigationBar extends HookConsumerWidget {
useEffect(() {
final current = channel.value;
if (current == null) return null;
unawaited(current.invokeMethod<void>('configure', payload));
return null;
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(() {
@@ -274,6 +339,7 @@ class IosNavigationBar extends HookConsumerWidget {
}, [channel.value, offset, collapseRange]);
return UiKitView(
key: viewKey,
viewType: viewType,
creationParams: payload,
creationParamsCodec: const StandardMessageCodec(),

View File

@@ -11,11 +11,15 @@ class SkeletonReveal extends HookWidget {
final Widget content;
final bool shimmerEnabled;
/// Reports when loaded content has finished its reveal and layout frame.
final ValueChanged<bool>? onReadyChanged;
const SkeletonReveal({
required this.loading,
required this.skeleton,
required this.content,
this.shimmerEnabled = true,
this.onReadyChanged,
super.key,
});
@@ -44,6 +48,24 @@ class SkeletonReveal extends HookWidget {
return null;
}, [loading, reducedMotion]);
useEffect(() {
var active = true;
void report([AnimationStatus? _]) {
final ready = !loading && reveal.isCompleted;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (active) onReadyChanged?.call(ready);
});
WidgetsBinding.instance.ensureVisualUpdate();
}
reveal.addStatusListener(report);
report();
return () {
active = false;
reveal.removeStatusListener(report);
};
}, [loading, reveal, onReadyChanged]);
return AnimatedBuilder(
animation: reveal,
builder: (context, _) {

View File

@@ -104,6 +104,33 @@ class _Config extends RelayConfigNotifier {
RelayConfig(baseUrl: 'https://relay.example', nsec: nsec);
}
class _InitializingSortSession extends ConnectedRelaySession {
_InitializingSortSession(this.firstSnapshot);
final NostrEvent firstSnapshot;
final secondSnapshot = Completer<List<NostrEvent>>();
void Function(NostrEvent)? onEvent;
var fetchCount = 0;
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
fetchCount++;
return fetchCount == 1 ? [firstSnapshot] : secondSnapshot.future;
}
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) callback, {
void Function(String)? onClosed,
}) async {
onEvent = callback;
return () => onEvent = null;
}
}
/// Missed heads, one per lane, each marked with `zz` so the persisted
/// cache shows whether that lane's provider re-read on resume.
const _missedHeads = {
@@ -161,6 +188,54 @@ void main() {
});
}
fakeAsyncTest('sort readiness waits for the post-subscription snapshot', (
clock,
) {
final relay = SidebarRelay();
final first = relay.event('channel-sort', {
'version': 1,
'groups': {'channels': 'alpha'},
}, nowSeconds());
final session = _InitializingSortSession(first);
final c = ProviderContainer(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
relayConfigProvider.overrideWith(() => _Config(relay.keys.nsec)),
relaySessionProvider.overrideWith(() => session),
activeCommunityProvider.overrideWith(
(ref) async => Community(
id: 'c',
name: 'c',
relayUrl: 'wss://relay.example',
addedAt: DateTime(2026),
),
),
appLifecycleProvider.overrideWith(_Lifecycle.new),
],
);
c.read(activeCommunityProvider);
clock.flushMicrotasks();
c.listen(channelSortProvider, (_, _) {});
clock.flushMicrotasks();
expect(session.fetchCount, 2);
// A live preference arrives while the second history snapshot is pending.
final latest = relay.event('channel-sort', {
'version': 1,
'groups': {'channels': 'recent'},
}, nowSeconds() + 1);
session.onEvent!(latest);
expect(
c.read(channelSortProvider).sortModeFor('channels'),
ChannelSortMode.recent,
);
expect(c.read(channelSortProvider).isReady, isFalse);
session.secondSnapshot.complete([latest]);
clock.flushMicrotasks();
expect(c.read(channelSortProvider).isReady, isTrue);
c.dispose();
});
for (final lane in _lanes) {
Map<String, Object> blob(Map<String, (bool, int)> entries) => {
'version': 1,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,154 @@
part of '../channels_page_test.dart';
void _communityUnreadLandingTests(
Widget Function({required List<Override> overrides}) buildTestable,
) {
testWidgets('unread deadline lands the destination without a 20-second wait', (
tester,
) async {
final communities = [
for (final id in ['alpha', 'bravo'])
Community(
id: id,
name: id,
relayUrl: 'wss://$id.example.com',
addedAt: DateTime(2025),
),
];
final communitiesNotifier = _FakeCommunityListNotifier(communities);
final session = _UnreadDeadlineLandingSession(communitiesNotifier);
await tester.pumpWidget(
buildTestable(
overrides: [
channelSectionsProvider.overrideWith(_LandingSections.new),
channelStarsProvider.overrideWith(_LandingStars.new),
channelMutesProvider.overrideWith(_LandingMutes.new),
channelSortProvider.overrideWith(_LandingSort.new),
readStateProvider.overrideWith(
() => _FakeReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'pk',
contexts: {},
version: 0,
),
),
),
myPubkeyProvider.overrideWith((ref) => 'pk'),
relaySessionProvider.overrideWith(() => session),
communityListProvider.overrideWith(() => communitiesNotifier),
activeCommunityProvider.overrideWith((ref) async {
await ref.watch(communityListProvider.future);
return communities.firstWhere(
(c) => c.id == communitiesNotifier.activeId,
);
}),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('alpha'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('community-menu-switch')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('community-switcher-row-bravo')));
// Exercise the real ChannelsNotifier and actual picker. The whole landing
// must finish in three seconds of animation frames, not the error deadline.
for (var frame = 0; frame < 30; frame++) {
await tester.pump(const Duration(milliseconds: 100));
}
expect(session.destinationUnreadRequests, greaterThan(0));
expect(find.byKey(const Key('community-switcher-page')), findsNothing);
expect(find.byKey(const Key('community-switch-loading')), findsNothing);
expect(find.text('bravo'), findsOneWidget);
expect(find.text('bravo-channel'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
});
}
class _UnreadDeadlineLandingSession extends RelaySessionNotifier {
_UnreadDeadlineLandingSession(this.communities);
final _FakeCommunityListNotifier communities;
int destinationUnreadRequests = 0;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
NostrEvent event(int kind, List<List<String>> tags) => NostrEvent(
id: '$kind-${communities.activeId}',
pubkey: 'pk',
createdAt: 1,
kind: kind,
tags: tags,
content: '',
sig: 'sig',
);
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
if (filters.length == 1 && filters.single.kinds.contains(39002)) {
return filters.single.until == null
? [
event(39002, [
['d', '${communities.activeId}-channel'],
['p', 'pk'],
]),
]
: [];
}
if (communities.activeId == 'bravo' &&
filters.isNotEmpty &&
filters.every((f) => f.since != null)) {
destinationUnreadRequests++;
throw RelayException(503, '{"error":"query timed out"}');
}
return [];
}
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => filter.kinds.contains(39000)
? [
event(39000, [
['d', '${communities.activeId}-channel'],
['name', '${communities.activeId}-channel'],
['t', 'stream'],
['public'],
]),
]
: [];
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String)? onClosed,
}) async => () {};
}
class _LandingSections extends ChannelSectionsNotifier {
@override
ChannelSectionsState build() => const ChannelSectionsState(isReady: true);
}
class _LandingStars extends ChannelStarsNotifier {
@override
ChannelStarsState build() => const ChannelStarsState(isReady: true);
}
class _LandingMutes extends ChannelMutesNotifier {
@override
ChannelMutesState build() => const ChannelMutesState(isReady: true);
}
class _LandingSort extends ChannelSortNotifier {
@override
ChannelSortState build() => const ChannelSortState(isReady: true);
}

View File

@@ -0,0 +1,177 @@
part of 'channels_provider_test.dart';
void _unreadReadinessCases() {
test(
'retired unread completion cannot overwrite destination settlement',
() async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, 'me')],
metadata: [_meta(id: _channelA, name: 'Alpha')],
)..pauseNextUnreadCatchUpQuery();
final container = _buildContainer(session: session);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
await session.nextUnreadCatchUpQueryStarted;
final notifier = container.read(channelsProvider.notifier);
session.memberships = [_membership(_channelB, 'me')];
session.metadata = [_meta(id: _channelB, name: 'Bravo')];
container
.read(relayConfigProvider.notifier)
.update(baseUrl: 'https://bravo.example');
await container.read(channelsProvider.future);
await notifier.waitForUnreadCatchUp();
// Alpha finishes after Bravo has already settled. Its finally path must
// not replace Bravo's generation, even when Alpha's request failed.
session.failClaimedUnreadCatchUpQuery = true;
session.resumePausedUnreadCatchUpQuery();
await _settle();
var settled = false;
unawaited(notifier.waitForUnreadCatchUp().then((_) => settled = true));
await _settle();
expect(
settled,
isTrue,
reason: 'Retired history overwrote destination settlement',
);
expect(
container.read(channelsProvider).requireValue.single.id,
_channelB,
);
},
);
for (final failure in ['deadline', 'exception', 'read-state unavailable']) {
test('destination unread $failure permits degraded landing', () async {
final session = _UnreadFailureSession(failure);
final container = ProviderContainer(
retry: (_, _) => null,
overrides: [
appLifecycleProvider.overrideWith(() => _FakeAppLifecycleNotifier()),
relaySessionProvider.overrideWith(() => session),
myPubkeyProvider.overrideWith(
(ref) => ref.watch(_testPubkeyProvider),
),
if (failure == 'read-state unavailable')
readStateProvider.overrideWith(
_UnavailableDestinationReadState.new,
),
],
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final notifier = container.read(channelsProvider.notifier);
await notifier.waitForUnreadCatchUp();
session.memberships = [_membership(_channelB, 'me')];
session.metadata = [_meta(id: _channelB, name: 'Bravo')];
session.failDestination = true;
container
.read(relayConfigProvider.notifier)
.update(baseUrl: 'https://bravo.example');
expect(
(await container.read(channelsProvider.future)).single.id,
_channelB,
);
var landed = false;
unawaited(notifier.waitForUnreadCatchUp().then((_) => landed = true));
if (failure != 'read-state unavailable') {
await session.started.future;
await _settle();
expect(landed, isFalse);
session.emit(
const NostrEvent(
id: 'retained-mention',
pubkey: 'alice',
createdAt: 50,
kind: 9,
tags: [
['h', _channelB],
['p', 'me'],
],
content: 'Hi',
sig: 'sig',
),
);
expect(
notifier.observedUnreadEventsByChannel[_channelB],
contains('retained-mention'),
);
session.release.complete();
}
// No 20-second presentation timeout or unrelated reconciliation needed.
await _settle();
expect(
landed,
isTrue,
reason: 'Terminal history must settle this destination',
);
expect(
container.read(channelsProvider).requireValue.single.id,
_channelB,
);
if (failure != 'read-state unavailable') {
expect(
notifier.observedUnreadEventsByChannel[_channelB],
contains('retained-mention'),
);
}
});
}
}
class _UnreadFailureSession extends _FakeRelaySession {
_UnreadFailureSession(this.failure)
: super(
memberships: [_membership(_channelA, 'me')],
metadata: [_meta(id: _channelA, name: 'Alpha')],
);
final String failure;
bool failDestination = false;
final started = Completer<void>();
final release = Completer<void>();
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
if (failDestination &&
filters.isNotEmpty &&
filters.every((filter) => filter.since != null)) {
started.complete();
await release.future;
if (failure == 'deadline') {
throw RelayException(503, '{"error":"query timed out"}');
}
throw StateError('Unread history unavailable');
}
return super.queryRelay(filters, timeout: timeout);
}
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
if (failDestination &&
filter.since != null &&
filter.kinds.contains(EventKind.streamMessageV2)) {
throw StateError('Unread fallback unavailable');
}
return super.fetchHistory(filter, timeout: timeout);
}
}
class _UnavailableDestinationReadState extends ReadStateNotifier {
@override
ReadStateState build() {
if (ref.watch(relayConfigProvider).baseUrl == 'https://bravo.example') {
throw StateError('Destination read state unavailable');
}
return const ReadStateState.inert();
}
}

View File

@@ -7,9 +7,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/read_state/read_state_provider.dart';
part 'channels_provider_live_cases.dart';
part 'channels_provider_terminal_cases.dart';
part 'channels_provider_readiness_cases.dart';
/// Tests for [ChannelsNotifier] in the pure-Nostr world.
///
@@ -24,6 +26,7 @@ part 'channels_provider_terminal_cases.dart';
/// records [subscribe] calls so we can assert filter shapes and emit live
/// events on demand.
void main() {
_unreadReadinessCases();
const myPk = 'me';
test(
@@ -877,6 +880,117 @@ void main() {
);
});
test(
'presentation waits for unread catch-up without delaying the channel list',
() async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk)],
metadata: [_meta(id: _channelA, name: 'general')],
recentMessages: const [
NostrEvent(
id: 'initial-mention',
pubkey: 'alice',
createdAt: 50,
kind: 9,
tags: [
['h', _channelA],
['p', myPk],
],
content: 'hello',
sig: 'sig',
),
],
)..pauseNextUnreadCatchUpQuery();
final container = _buildContainer(session: session);
addTearDown(container.dispose);
final channels = await container.read(channelsProvider.future);
expect(channels.single.name, 'general');
final notifier = container.read(channelsProvider.notifier);
var ready = false;
final presentation = notifier.waitForUnreadCatchUp().then(
(_) => ready = true,
);
await session.nextUnreadCatchUpQueryStarted;
await _settle();
expect(ready, isFalse);
expect(notifier.observedUnreadEventsByChannel, isEmpty);
session.resumePausedUnreadCatchUpQuery();
await presentation;
expect(
notifier.observedUnreadEventsByChannel[_channelA],
contains('initial-mention'),
);
expect(ready, isTrue);
},
);
test(
'landing waits for destination unread generation after disconnect',
() async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk)],
metadata: [_meta(id: _channelA, name: 'Alpha')],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final notifier = container.read(channelsProvider.notifier);
await notifier.waitForUnreadCatchUp();
session.memberships = [_membership(_channelB, myPk)];
session.metadata = [_meta(id: _channelB, name: 'Bravo')];
session.pauseNextSubscribe();
final subscribing = session.nextSubscribeStarted;
container
.read(relayConfigProvider.notifier)
.update(baseUrl: 'https://bravo.example');
expect(
(await container.read(channelsProvider.future)).single.id,
_channelB,
);
await subscribing;
var ready = false;
final landing = notifier.waitForUnreadCatchUp().then((_) => ready = true);
session.setStatus(SessionStatus.disconnected);
session.resumePausedSubscribe();
await _settle();
expect(
ready,
isFalse,
reason: 'Alpha history cannot satisfy Bravo readiness',
);
session.recentMessages = const [
NostrEvent(
id: 'bravo-mention',
pubkey: 'alice',
createdAt: 50,
kind: 9,
tags: [
['h', _channelB],
['p', myPk],
],
content: 'Bravo mention',
sig: 'sig',
),
];
session.pauseNextUnreadCatchUpQuery();
final catchingUp = session.nextUnreadCatchUpQueryStarted;
session.setStatus(SessionStatus.connected);
await catchingUp;
await _settle();
expect(ready, isFalse);
session.resumePausedUnreadCatchUpQuery();
await landing;
expect(
notifier.observedUnreadEventsByChannel[_channelB],
contains('bravo-mention'),
);
expect(ready, isTrue);
},
);
test('community switch discards a parked unread catch-up', () async {
final session = _FakeRelaySession(
memberships: const [],
@@ -2201,7 +2315,7 @@ void main() {
);
test(
'refreshes cached channels after a disconnected community switch',
'waits for destination channels after a disconnected community switch',
() async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk)],
@@ -2222,12 +2336,21 @@ void main() {
.read(relayConfigProvider.notifier)
.update(baseUrl: 'https://new-community.example');
await Future<void>.delayed(Duration.zero);
expect(container.read(channelsProvider).value?.single.name, 'general');
expect(container.read(channelsProvider).isLoading, isTrue);
expect(container.read(channelsProvider.notifier).hasLoaded, isFalse);
var destinationReady = false;
final destination = container.read(channelsProvider.future).then((value) {
destinationReady = true;
return value;
});
await _settle();
expect(destinationReady, isFalse);
session.setStatus(SessionStatus.connected);
await Future<void>.delayed(Duration.zero);
expect(container.read(channelsProvider).value?.single.name, 'random');
expect((await destination).single.name, 'random');
expect(container.read(channelsProvider).isLoading, isFalse);
},
);

View File

@@ -1094,6 +1094,13 @@ void main() {
await tester.pump();
expect(find.byType(BuzzLoadingIndicator), findsOneWidget);
final spinner = find.byType(BuzzLoadingIndicator);
expect(tester.getSize(spinner), const Size.square(18));
expect(
tester.widget<BuzzLoadingIndicator>(spinner).color,
tester.element(spinner).colors.onSecondaryContainer,
);
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(
find.bySemanticsLabel('Cancel voice note loading'),

View File

@@ -285,13 +285,20 @@ void main() {
expect(notifier.denied, isTrue);
});
testWidgets('keeps the add-community header outside SAS', (tester) async {
testWidgets('keeps only Back on the clear add-community header', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(child: const PairingPage(addingCommunity: true)),
);
expect(find.byType(AppBar), findsOneWidget);
expect(find.text('Add Community'), findsOneWidget);
expect(find.text('Add Community'), findsNothing);
final appBar = tester.widget<AppBar>(find.byType(AppBar));
expect(appBar.backgroundColor, Colors.transparent);
expect(appBar.surfaceTintColor, Colors.transparent);
expect(appBar.elevation, 0);
expect(appBar.scrolledUnderElevation, 0);
expect(find.byIcon(LucideIcons.arrowLeft), findsOneWidget);
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 B

View File

@@ -1,93 +1,547 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:buzz/shared/community/community_icon_cache.dart';
import 'package:buzz/shared/emoji/emoji_avatar.dart';
import 'package:buzz/shared/community/community_icon_provider.dart';
import 'package:buzz/shared/theme/theme_provider.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
test('fetches the NIP-11 icon over the relay HTTP URL', () async {
late http.Request capturedRequest;
final client = http_testing.MockClient((request) async {
capturedRequest = request;
return http.Response('{"icon":"data:image/png;base64,icon-bytes"}', 200);
const relay = 'wss://relay.example.com';
const key = 'https://relay.example.com/';
final artwork = emojiAvatarDataUrl('🐝', 0xFFFFCC00);
late SharedPreferences prefs;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
});
ProviderContainer containerFor(
Future<http.Response> Function(http.Request) handler,
) {
final container = ProviderContainer(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
communityIconHttpClientProvider.overrideWithValue(MockClient(handler)),
],
);
addTearDown(container.dispose);
return container;
}
test('fetches and saves inline NIP-11 artwork using public HTTP', () async {
final container = containerFor((request) async {
expect(request.url.toString(), key);
expect(request.headers['Accept'], 'application/nostr+json');
expect(request.headers.containsKey('authorization'), isFalse);
return http.Response(jsonEncode({'icon': artwork}), 200);
});
final container = ProviderContainer(
overrides: [communityIconHttpClientProvider.overrideWithValue(client)],
);
addTearDown(container.dispose);
final icon = await container.read(
communityIconProvider('wss://relay.example.com').future,
);
expect(capturedRequest.url, Uri.parse('https://relay.example.com'));
expect(capturedRequest.headers['Accept'], 'application/nostr+json');
expect(icon, 'data:image/png;base64,icon-bytes');
expect(await container.read(communityIconProvider(relay).future), artwork);
expect(container.read(communityIconCacheProvider)[key], artwork);
expect(prefs.getString('buzz.community-icons.v3'), contains('image/svg'));
});
test('returns null when the relay has no usable icon', () async {
final client = http_testing.MockClient(
(_) async => http.Response('{"icon":""}', 200),
);
final container = ProviderContainer(
overrides: [communityIconHttpClientProvider.overrideWithValue(client)],
);
addTearDown(container.dispose);
test(
'saved artwork is synchronous after reopening and an offline restart',
() async {
final first = containerFor(
(_) async => http.Response(jsonEncode({'icon': artwork}), 200),
);
final presentation = communityIconPresentationProvider(relay);
final listener = first.listen(presentation, (_, _) {});
await first.read(communityIconProvider(relay).future);
listener.close();
await first.pump();
first.invalidate(communityIconProvider);
expect(first.read(presentation), artwork);
final icon = await container.read(
communityIconProvider('https://relay.example.com').future,
);
final response = Completer<http.Response>();
final restarted = containerFor((_) => response.future);
final subscription = restarted.listen(presentation, (_, _) {});
addTearDown(subscription.close);
expect(restarted.read(presentation), artwork);
expect(
restarted.read(
communityIconPresentationProvider('wss://other.example.com'),
),
isNull,
);
response.complete(http.Response('offline', 503));
await restarted.read(communityIconProvider(relay).future);
expect(restarted.read(presentation), artwork);
},
);
expect(icon, isNull);
test(
'downloads remote image bytes and retains them when refresh fails',
() async {
var offline = false;
final png = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGNgAAAAAgABSK+kcQAAAABJRU5ErkJggg==',
);
final container = containerFor((request) async {
if (offline) return http.Response('offline', 503);
if (request.url.path == '/icon.png') {
expect(request.headers.containsKey('authorization'), isFalse);
return http.Response.bytes(
png,
200,
headers: {'content-type': 'image/png'},
);
}
return http.Response(
'{"icon":"https://relay.example.com/icon.png"}',
200,
);
});
final provider = communityIconProvider(relay);
final subscription = container.listen(provider, (_, _) {});
addTearDown(subscription.close);
final saved = await container.read(provider.future);
expect(saved, isNotNull);
final dimensions = await _dimensions(
UriData.parse(saved!).contentAsBytes(),
);
expect(dimensions, (1, 1));
offline = true;
container.invalidate(provider);
expect(await container.read(provider.future), saved);
final restarted = containerFor(
(_) async => http.Response('offline', 503),
);
expect(restarted.read(communityIconPresentationProvider(relay)), saved);
},
);
test(
'refresh replaces changed artwork and clears explicitly removed icons',
() async {
var document = jsonEncode({'icon': artwork});
final container = containerFor((_) async => http.Response(document, 200));
final provider = communityIconProvider(relay);
final presentation = communityIconPresentationProvider(relay);
final subscription = container.listen(presentation, (_, _) {});
addTearDown(subscription.close);
await container.read(provider.future);
final newArtwork = emojiAvatarDataUrl('🥳', 0xFFFF8652);
document = jsonEncode({'icon': newArtwork});
container.invalidate(provider);
expect(container.read(presentation), artwork);
await container.read(provider.future);
expect(container.read(presentation), newArtwork);
document = '{"icon":""}';
container.invalidate(provider);
await container.read(provider.future);
expect(container.read(presentation), isNull);
expect(container.read(communityIconCacheProvider), isEmpty);
},
);
test('an invalidated older lookup cannot overwrite newer artwork', () async {
final slow = Completer<http.Response>();
var calls = 0;
final container = containerFor(
(_) => ++calls == 1
? slow.future
: Future.value(http.Response(jsonEncode({'icon': artwork}), 200)),
);
final provider = communityIconProvider(relay);
final subscription = container.listen(provider, (_, _) {});
addTearDown(subscription.close);
final oldLookup = container.read(provider.future);
container.invalidate(provider);
await container.read(provider.future);
slow.complete(http.Response('{"icon":""}', 200));
await oldLookup;
expect(container.read(communityIconCacheProvider)[key], artwork);
});
test('returns null when relay information cannot be loaded', () async {
final client = http_testing.MockClient(
(_) async => http.Response('unavailable', 503),
test('ignores corrupt persistence and bounds cache entries', () async {
await prefs.setString('buzz.community-icons.v3', '{bad json');
final container = containerFor((_) async => http.Response('{}', 200));
expect(container.read(communityIconCacheProvider), isEmpty);
final cache = container.read(communityIconCacheProvider.notifier);
for (var i = 0; i < 35; i++) {
await cache.remember('https://relay$i.example.com/', artwork);
}
expect(container.read(communityIconCacheProvider).length, 32);
expect(
container
.read(communityIconCacheProvider)
.containsKey('https://relay0.example.com/'),
isFalse,
);
final container = ProviderContainer(
overrides: [communityIconHttpClientProvider.overrideWithValue(client)],
);
addTearDown(container.dispose);
final icon = await container.read(
communityIconProvider('https://relay.example.com').future,
);
expect(icon, isNull);
});
test('refreshes a transient failure when explicitly invalidated', () async {
var requestCount = 0;
final client = http_testing.MockClient((_) async {
requestCount++;
if (requestCount == 1) {
return http.Response('unavailable', 503);
}
return http.Response(
'{"icon":"https://relay.example.com/icon.png"}',
200,
for (final saved in [false, true]) {
for (final rejection in ['oversized', 'invalid MIME', 'HTTP failure']) {
test('$rejection never exposes an unchecked URL, saved=$saved', () async {
final container = containerFor((request) async {
if (request.url.path == '/') {
return http.Response(
'{"icon":"https://relay.example.com/rejected.png"}',
200,
);
}
return http.Response.bytes(
List.filled(rejection == 'oversized' ? 256 * 1024 + 1 : 4, 0),
rejection == 'HTTP failure' ? 503 : 200,
headers: {
'content-type': rejection == 'invalid MIME'
? 'text/html'
: 'image/png',
},
);
});
if (saved) {
await container
.read(communityIconCacheProvider.notifier)
.remember(key, artwork);
}
final presentation = communityIconPresentationProvider(relay);
final subscription = container.listen(presentation, (_, _) {});
addTearDown(subscription.close);
final expected = saved ? artwork : null;
expect(
await container.read(communityIconProvider(relay).future),
expected,
);
expect(container.read(presentation), expected);
expect(container.read(communityIconCacheProvider)[key], expected);
});
}
}
for (final stalledImage in [false, true]) {
test('timeout aborts the request, image=$stalledImage', () async {
var aborted = false;
var pending = false;
final started = Completer<void>();
final client = MockClient.streaming((request, _) async {
if (stalledImage && request.url.path == '/') {
return http.StreamedResponse(
Stream.value(
utf8.encode('{"icon":"https://relay.example.com/stalled.png"}'),
),
200,
);
}
expect(request, isA<http.AbortableRequest>());
pending = true;
started.complete();
await (request as http.AbortableRequest).abortTrigger;
pending = false;
aborted = true;
throw http.RequestAbortedException(request.url);
});
final container = ProviderContainer(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
communityIconHttpClientProvider.overrideWithValue(client),
],
);
addTearDown(container.dispose);
addTearDown(client.close);
final subscription = container.listen(
communityIconProvider(relay),
(_, _) {},
);
addTearDown(subscription.close);
final result = container.read(communityIconProvider(relay).future);
await started.future;
expect(pending, isTrue);
expect(await result, isNull);
expect(aborted, isTrue);
expect(pending, isFalse);
});
}
for (final inline in [false, true]) {
test('rejects compressed oversized dimensions, inline=$inline', () async {
final bytes = await File(
'test/fixtures/community_icons/compressed-16000.png',
).readAsBytes();
expect(bytes.length, lessThan(256 * 1024));
// Read the encoded descriptor, never allocate this 256-million-pixel image.
expect(await _dimensions(bytes), (16000, 16000));
final source = Uri.dataFromBytes(bytes, mimeType: 'image/png').toString();
final container = containerFor((request) async {
if (request.url.path == '/') {
return http.Response(
jsonEncode({
'icon': inline ? source : 'https://relay.example.com/icon.png',
}),
200,
);
}
return http.Response.bytes(
bytes,
200,
headers: {'content-type': 'image/png'},
);
});
expect(await container.read(communityIconProvider(relay).future), isNull);
expect(container.read(communityIconCacheProvider), isEmpty);
expect(prefs.getString('buzz.community-icons.v3'), isNull);
final restarted = containerFor(
(_) async => http.Response('offline', 503),
);
expect(restarted.read(communityIconPresentationProvider(relay)), isNull);
});
}
test(
'old cache cannot reintroduce oversized artwork after restart',
() async {
final bytes = await File(
'test/fixtures/community_icons/compressed-16000.png',
).readAsBytes();
await prefs.setString(
'buzz.community-icons.v1',
jsonEncode({
key: Uri.dataFromBytes(bytes, mimeType: 'image/png').toString(),
}),
);
final container = containerFor(
(_) async => http.Response('offline', 503),
);
expect(container.read(communityIconPresentationProvider(relay)), isNull);
expect(container.read(communityIconCacheProvider), isEmpty);
},
);
for (final remote in [false, true]) {
test('rejects SVG-wrapped oversized raster, remote=$remote', () async {
final bytes = await File(
'test/fixtures/community_icons/compressed-8192.png',
).readAsBytes();
expect(await _dimensions(bytes), (8192, 8192));
final svg =
'<svg xmlns="http://www.w3.org/2000/svg">'
'<image href="data:image/png;base64,${base64Encode(bytes)}"/>'
'</svg>';
expect(utf8.encode(svg).length, lessThan(256 * 1024));
final wrapped = Uri.dataFromString(
svg,
mimeType: 'image/svg+xml',
encoding: utf8,
).toString();
final container = containerFor((request) async {
if (request.url.path == '/icon.svg') {
return http.Response(
svg,
200,
headers: {'content-type': 'image/svg+xml'},
);
}
return http.Response(
jsonEncode({'icon': remote ? '${key}icon.svg' : wrapped}),
200,
);
});
expect(await container.read(communityIconProvider(relay).future), isNull);
expect(container.read(communityIconCacheProvider), isEmpty);
expect(prefs.getString('buzz.community-icons.v3'), isNull);
// Even an entry persisted by the previous version must not render.
await prefs.setString(
'buzz.community-icons.v2',
jsonEncode({key: wrapped}),
);
final restarted = containerFor(
(_) async => http.Response('offline', 503),
);
expect(restarted.read(communityIconPresentationProvider(relay)), isNull);
expect(restarted.read(communityIconCacheProvider), isEmpty);
});
}
for (final radius in ['', ' rx="112"', ' rx="256"']) {
test('retains desktop emoji artwork with radius $radius', () async {
final svg = UriData.parse(
artwork,
).contentAsString(encoding: utf8).replaceFirst(' rx="256"', radius);
final container = containerFor(
(_) async => http.Response(
jsonEncode({
'icon': Uri.dataFromString(
svg,
mimeType: 'image/svg+xml',
encoding: utf8,
).toString(),
}),
200,
),
);
expect(
await container.read(communityIconProvider(relay).future),
artwork,
);
});
final container = ProviderContainer(
overrides: [communityIconHttpClientProvider.overrideWithValue(client)],
);
final provider = communityIconProvider('https://relay.example.com');
final subscription = container.listen(provider, (_, _) {});
addTearDown(() {
subscription.close();
container.dispose();
}
for (final extra in [
'<image href="https://relay.example.com/huge.png"/>',
'<image href="data:image/png;base64,AAAA"/>',
'<style>text { fill: url(https://relay.example.com/resource); }</style>',
]) {
test('rejects resource added to otherwise valid emoji: $extra', () async {
final svg = UriData.parse(
artwork,
).contentAsString(encoding: utf8).replaceFirst('</svg>', '$extra</svg>');
final container = containerFor(
(_) async => http.Response(
jsonEncode({
'icon': Uri.dataFromString(
svg,
mimeType: 'image/svg+xml',
encoding: utf8,
).toString(),
}),
200,
),
);
await container
.read(communityIconCacheProvider.notifier)
.remember(key, artwork);
expect(
await container.read(communityIconProvider(relay).future),
artwork,
);
expect(container.read(communityIconCacheProvider)[key], artwork);
});
}
expect(await container.read(provider.future), isNull);
expect(requestCount, 1);
test(
'cached raster is bounded before rendering and after offline restart',
() async {
final bytes = await File(
'test/fixtures/community_icons/landscape-1024.png',
).readAsBytes();
expect(await _dimensions(bytes), (1024, 512));
final container = containerFor(
(_) async => http.Response(
jsonEncode({
'icon': Uri.dataFromBytes(bytes, mimeType: 'image/png').toString(),
}),
200,
),
);
final subscription = container.listen(
communityIconProvider(relay),
(_, _) {},
);
addTearDown(subscription.close);
final saved = await container.read(communityIconProvider(relay).future);
expect(saved, isNotNull);
expect(await _dimensions(UriData.parse(saved!).contentAsBytes()), (
512,
256,
));
final restarted = containerFor(
(_) async => http.Response('offline', 503),
);
final restored = restarted.read(communityIconPresentationProvider(relay));
expect(restored, saved);
expect(await _dimensions(UriData.parse(restored!).contentAsBytes()), (
512,
256,
));
},
);
container.invalidate(provider);
expect(
await container.read(provider.future),
'https://relay.example.com/icon.png',
for (final stalledImage in [false, true]) {
test(
'invalidation and disposal abort pending headers, image=$stalledImage',
() async {
var started = 0;
var active = 0;
var aborted = 0;
final client = MockClient.streaming((request, _) async {
if (stalledImage && request.url.path == '/') {
return http.StreamedResponse(
Stream.value(
utf8.encode('{"icon":"https://relay.example.com/stalled.png"}'),
),
200,
);
}
started++;
active++;
await (request as http.AbortableRequest).abortTrigger;
active--;
aborted++;
throw http.RequestAbortedException(request.url);
});
final container = ProviderContainer(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
communityIconHttpClientProvider.overrideWithValue(client),
],
);
addTearDown(container.dispose);
addTearDown(client.close);
final provider = communityIconProvider(relay);
final subscription = container.listen(provider, (_, _) {});
for (var iteration = 0; iteration < 3; iteration++) {
await _waitFor(() => started == iteration + 1);
expect(active, 1);
container.invalidate(provider);
await _waitFor(() => aborted == iteration + 1);
}
await _waitFor(() => started == 4);
expect(active, 1);
subscription.close();
await container.pump();
await _waitFor(() => active == 0);
expect(aborted, 4);
},
);
expect(requestCount, 2);
}
test('oversized downloads do not replace a usable saved image', () async {
final container = containerFor(
(request) async => request.url.path == '/'
? http.Response('{"icon":"https://relay.example.com/large.png"}', 200)
: http.Response.bytes(
List.filled(256 * 1024 + 1, 0),
200,
headers: {'content-type': 'image/png'},
),
);
await container
.read(communityIconCacheProvider.notifier)
.remember(key, artwork);
expect(await container.read(communityIconProvider(relay).future), artwork);
});
}
Future<(int, int)> _dimensions(Uint8List bytes) async {
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
try {
final descriptor = await ui.ImageDescriptor.encoded(buffer);
try {
return (descriptor.width, descriptor.height);
} finally {
descriptor.dispose();
}
} finally {
buffer.dispose();
}
}
Future<void> _waitFor(bool Function() ready) async {
for (var i = 0; i < 100 && !ready(); i++) {
await Future<void>.delayed(Duration.zero);
}
expect(ready(), isTrue, reason: 'Request cancellation did not settle');
}

View File

@@ -67,43 +67,47 @@ void main() {
);
});
testWidgets('page divider appears only after content scrolls under header', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: FrostedScaffold(
appBar: const FrostedAppBar(title: Text('Theme')),
body: ListView.builder(
key: const ValueKey('page-scroll-view'),
padding: const EdgeInsets.only(top: 57),
itemCount: 40,
itemBuilder: (_, index) =>
SizedBox(height: 48, child: Text('Theme option $index')),
testWidgets(
'page frost and divider appear only while content scrolls under header',
(tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: FrostedScaffold(
appBar: const FrostedAppBar(title: Text('Theme')),
body: ListView.builder(
key: const ValueKey('page-scroll-view'),
padding: const EdgeInsets.only(top: 57),
itemCount: 40,
itemBuilder: (_, index) =>
SizedBox(height: 48, child: Text('Theme option $index')),
),
),
),
),
);
);
expect(_appBarBorder(tester)?.bottom.color.a, 0);
expect(_appBarBorder(tester)?.bottom.color.a, 0);
expect(find.byType(BackdropFilter), findsNothing);
await tester.drag(
find.byKey(const ValueKey('page-scroll-view')),
const Offset(0, -120),
);
await tester.pumpAndSettle();
await tester.drag(
find.byKey(const ValueKey('page-scroll-view')),
const Offset(0, -120),
);
await tester.pumpAndSettle();
final border = _appBarBorder(tester);
expect(border, isNotNull);
expect(border!.bottom.color.a, greaterThan(0));
final border = _appBarBorder(tester);
expect(border, isNotNull);
expect(border!.bottom.color.a, greaterThan(0));
expect(find.byType(BackdropFilter), findsOneWidget);
await tester.drag(
find.byKey(const ValueKey('page-scroll-view')),
const Offset(0, 500),
);
await tester.pumpAndSettle();
await tester.drag(
find.byKey(const ValueKey('page-scroll-view')),
const Offset(0, 500),
);
await tester.pumpAndSettle();
expect(_appBarBorder(tester)?.bottom.color.a, 0);
});
expect(_appBarBorder(tester)?.bottom.color.a, 0);
expect(find.byType(BackdropFilter), findsNothing);
},
);
}

View File

@@ -1,3 +1,7 @@
import 'dart:async';
import 'package:buzz/shared/relay/media_image.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/shared/widgets/frosted_scaffold.dart';
@@ -9,6 +13,223 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
void main() {
testWidgets(
'native readiness waits for configuration and layout acknowledgement',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
const channel = MethodChannel('buzz/ios_navigation_bar/844');
final applied = Completer<void>();
final readiness = <bool>[];
final calls = <String>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
call,
) async {
calls.add(call.method);
if (call.method == 'prepareForReveal') await applied.future;
return null;
});
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
),
);
await tester.pumpWidget(
_testApp(
home: SizedBox(
height: 100,
child: IosNavigationBar(
title: 'Bravo',
onReadyChanged: readiness.add,
),
),
),
);
tester.widget<UiKitView>(find.byType(UiKitView)).onPlatformViewCreated!(
844,
);
await tester.pumpAndSettle();
expect(calls, contains('prepareForReveal'));
expect(readiness.last, false);
applied.complete();
await tester.pumpAndSettle();
await tester.pump();
await tester.pump();
expect(readiness.last, true);
await tester.pumpWidget(const SizedBox());
debugDefaultTargetPlatformOverride = null;
},
);
testWidgets('native landing ignores pending profile artwork', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final pending = Completer<http.Response>();
var requested = false;
final client = MockClient((_) {
requested = true;
return pending.future;
});
addTearDown(client.close);
const channel = MethodChannel('buzz/ios_navigation_bar/846');
final readiness = <bool>[];
final calls = <String>[];
final createdChannels = <MethodChannel>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform_views,
(call) async {
if (call.method == 'create') {
final id = (call.arguments as Map)['id'];
final native = MethodChannel('${IosNavigationBar.viewType}/$id');
createdChannels.add(native);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
native,
(call) async {
calls.add(call.method);
return null;
},
);
}
return null;
},
);
addTearDown(() {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform_views,
null,
);
for (final native in createdChannels) {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
native,
null,
);
}
});
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
call,
) async {
calls.add(call.method);
return null;
});
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
),
);
await tester.pumpWidget(
ProviderScope(
overrides: [mediaHttpClientProvider.overrideWithValue(client)],
child: _testApp(
home: IosNavigationBar(
title: 'Bravo',
leading: IosNavigationAction(
label: 'Community settings',
avatarInitial: 'B',
onAvatarBoundsChanged: (_) {},
),
actions: const [
IosNavigationAction(
label: 'Profile',
avatarInitial: 'P',
imageUrl: 'https://slow.example.com/profile.png',
),
],
onReadyChanged: readiness.add,
),
),
),
);
tester.widget<UiKitView>(find.byType(UiKitView)).onPlatformViewCreated!(
846,
);
await tester.pumpAndSettle();
Map leading() =>
(tester.widget<UiKitView>(find.byType(UiKitView)).creationParams
as Map)['leading']
as Map;
await tester.runAsync(() async {
for (
var attempt = 0;
attempt < 100 && leading()['imageData'] == null;
attempt++
) {
await Future<void>.delayed(const Duration(milliseconds: 10));
await tester.pump();
}
});
await tester.pumpAndSettle();
expect(requested, isTrue);
expect(pending.isCompleted, isFalse);
expect(calls, contains('prepareForReveal'));
expect(readiness.last, isTrue);
expect(leading()['imageData'], isNotNull);
pending.complete(http.Response('offline', 503));
await tester.pumpAndSettle();
await tester.pumpWidget(const SizedBox());
debugDefaultTargetPlatformOverride = null;
});
testWidgets(
'native avatar reports global transition bounds and retains a hidden slot',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
const channel = MethodChannel('${IosNavigationBar.viewType}/842');
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
(_) async => null,
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
),
);
Rect? measured;
await tester.pumpWidget(
_testApp(
home: Padding(
padding: const EdgeInsets.only(top: 30),
child: IosNavigationBar(
title: 'Alpha',
leading: IosNavigationAction(
label: 'Community settings',
avatarInitial: 'A',
avatarHidden: true,
onAvatarBoundsChanged: (bounds) => measured = bounds,
onPressed: () {},
),
),
),
),
);
final view = tester.widget<UiKitView>(find.byType(UiKitView));
final leading = (view.creationParams as Map)['leading'] as Map;
expect(leading['tracksAvatarBounds'], isTrue);
expect(leading['avatarHidden'], isTrue);
view.onPlatformViewCreated!(842);
await tester.pump();
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
channel.name,
channel.codec.encodeMethodCall(
const MethodCall('avatarBounds', {
'id': 'leading',
'x': 16.0,
'y': 4.0,
'width': 36.0,
'height': 36.0,
}),
),
(_) {},
);
expect(measured, const Rect.fromLTWH(16, 34, 36, 36));
await tester.pumpWidget(const SizedBox());
debugDefaultTargetPlatformOverride = null;
},
);
for (final reverse in [false, true]) {
testWidgets('initial material tracks content above reverse=$reverse', (
tester,

View File

@@ -29,6 +29,7 @@ void main() {
Widget buildRevealTestable({
required ValueNotifier<bool> loading,
bool disableAnimations = false,
ValueChanged<bool>? onReadyChanged,
}) {
return MaterialApp(
theme: AppTheme.light(),
@@ -43,6 +44,7 @@ void main() {
valueListenable: loading,
builder: (context, isLoading, _) => SkeletonReveal(
loading: isLoading,
onReadyChanged: onReadyChanged,
skeleton: const SkeletonBar(width: 120, height: 16),
content: const Text('Loaded content'),
),
@@ -51,6 +53,28 @@ void main() {
);
}
testWidgets('reports ready only after the loaded reveal finishes', (
tester,
) async {
final loading = ValueNotifier(true);
addTearDown(loading.dispose);
final readiness = <bool>[];
await tester.pumpWidget(
buildRevealTestable(loading: loading, onReadyChanged: readiness.add),
);
expect(readiness.last, false);
loading.value = false;
await tester.pump();
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(readiness.last, false);
await tester.pump(const Duration(milliseconds: 220));
expect(readiness.last, true);
loading.value = true;
await tester.pump();
expect(readiness.last, false);
});
double layerOpacity(WidgetTester tester, String key) =>
tester.widget<Opacity>(find.byKey(Key(key))).opacity;