diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index c2ae0477aa..6079bca445 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; BZZ00000000000000000006 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; BZZ00000000000000000007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; BZZ00000000000000000008 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = NotificationService.entitlements; sourceTree = ""; }; @@ -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 */, diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 43ba6a8e73..1ab8cea7b0 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -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" ) diff --git a/mobile/ios/Runner/IosNavigationBar.swift b/mobile/ios/Runner/IosNavigationBar.swift index 6d603b2461..068d83b77f 100644 --- a/mobile/ios/Runner/IosNavigationBar.swift +++ b/mobile/ios/Runner/IosNavigationBar.swift @@ -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 } diff --git a/mobile/ios/Runner/NativeConfirmationDialog.swift b/mobile/ios/Runner/NativeConfirmationDialog.swift new file mode 100644 index 0000000000..f8365b9013 --- /dev/null +++ b/mobile/ios/Runner/NativeConfirmationDialog.swift @@ -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) + } +} diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index bed82a686e..4c34d115ad 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -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) diff --git a/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart b/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart index f58eabcacb..6d52c95f84 100644 --- a/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart +++ b/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart @@ -24,11 +24,13 @@ class ChannelMutesState { class ChannelMutesNotifier extends Notifier { 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 { Future.microtask(() async { await manager.initialize(); if (_manager != manager) return; + _isInitialized = true; _emitManagerState(manager); }); @@ -98,7 +101,7 @@ class ChannelMutesNotifier extends Notifier { void _emitManagerState(ChannelMutesManager manager) { if (_manager != manager) return; state = ChannelMutesState( - isReady: true, + isReady: _isInitialized, store: manager.store, version: state.version + 1, ); diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart b/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart index 6466e6a4d4..31d424cc95 100644 --- a/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart +++ b/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart @@ -24,11 +24,13 @@ class ChannelSectionsState { class ChannelSectionsNotifier extends Notifier { 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 { Future.microtask(() async { await manager.initialize(); if (_manager != manager) return; + _isInitialized = true; _emitManagerState(manager); }); @@ -116,7 +119,7 @@ class ChannelSectionsNotifier extends Notifier { void _emitManagerState(ChannelSectionsManager manager) { if (_manager != manager) return; state = ChannelSectionsState( - isReady: true, + isReady: _isInitialized, store: manager.store, version: state.version + 1, ); diff --git a/mobile/lib/features/channels/channel_sort/channel_sort_provider.dart b/mobile/lib/features/channels/channel_sort/channel_sort_provider.dart index 4fdbbe00f3..afc0fd492c 100644 --- a/mobile/lib/features/channels/channel_sort/channel_sort_provider.dart +++ b/mobile/lib/features/channels/channel_sort/channel_sort_provider.dart @@ -27,11 +27,13 @@ class ChannelSortState { class ChannelSortNotifier extends Notifier { 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 { Future.microtask(() async { await manager.initialize(); if (_manager != manager) return; + _isInitialized = true; _emitManagerState(manager); }); @@ -109,7 +112,7 @@ class ChannelSortNotifier extends Notifier { void _emitManagerState(ChannelSortManager manager) { if (_manager != manager) return; state = ChannelSortState( - isReady: true, + isReady: _isInitialized, store: manager.store, version: state.version + 1, ); diff --git a/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart b/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart index a341417837..9bac36d7b3 100644 --- a/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart +++ b/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart @@ -24,11 +24,13 @@ class ChannelStarsState { class ChannelStarsNotifier extends Notifier { 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 { Future.microtask(() async { await manager.initialize(); if (_manager != manager) return; + _isInitialized = true; _emitManagerState(manager); }); @@ -98,7 +101,7 @@ class ChannelStarsNotifier extends Notifier { void _emitManagerState(ChannelStarsManager manager) { if (_manager != manager) return; state = ChannelStarsState( - isReady: true, + isReady: _isInitialized, store: manager.store, version: state.version + 1, ); diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index c87a6663a4..10a2268175 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -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(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 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( 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, diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index 33364e2b21..835b4da20f 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -6,6 +6,7 @@ class _ChannelsBody extends StatelessWidget { final bool showError; final SessionStatus sessionStatus; final bool showConnectionSkeleton; + final ValueChanged 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( diff --git a/mobile/lib/features/channels/channels_page/community.dart b/mobile/lib/features/channels/channels_page/community.dart index 85c708ee3d..3e966a62d4 100644 --- a/mobile/lib/features/channels/channels_page/community.dart +++ b/mobile/lib/features/channels/channels_page/community.dart @@ -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(builder: builder)); } + Future 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( - 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 _confirmRemoveCommunity( Community community, { required bool closeSheetAfterRemoval, }) async { - final confirmed = await showBuzzDialog( - 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 _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, + ), + ), + ), ), ), ); diff --git a/mobile/lib/features/channels/channels_page/community_switcher.dart b/mobile/lib/features/channels/channels_page/community_switcher.dart new file mode 100644 index 0000000000..44fd2d3894 --- /dev/null +++ b/mobile/lib/features/channels/channels_page/community_switcher.dart @@ -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>(( + 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 Function() prepareLanding; + final ValueChanged onFlightChanged; + final ValueChanged 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(null); + final failedCommunityId = useState(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 waitForContent() async { + final ready = Completer(); + 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 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 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( + 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 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 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, + ), + ), + ], + ), + ), + ); +} diff --git a/mobile/lib/features/channels/channels_page/community_switcher_action.dart b/mobile/lib/features/channels/channels_page/community_switcher_action.dart new file mode 100644 index 0000000000..856f0e928b --- /dev/null +++ b/mobile/lib/features/channels/channels_page/community_switcher_action.dart @@ -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; +} diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index f63c271804..0d72eba0b7 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -52,6 +52,9 @@ class ChannelsNotifier extends AsyncNotifier> { final Map _liveSubscriptionsByChunk = {}; Future _liveSubscriptionQueue = Future.value(); + Future _unreadCatchUp = Future.value(); + int? _settledUnreadGeneration; + Completer _unreadReadinessChanged = Completer(); Set _desiredLiveChannelIds = const {}; int _subscriptionVersion = 0; int _nextLiveChunkGeneration = 0; @@ -93,6 +96,36 @@ class ChannelsNotifier extends AsyncNotifier> { 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 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(); + previous.complete(); + } + Map> get observedUnreadEventsByChannel => Map>.unmodifiable({ @@ -106,6 +139,9 @@ class ChannelsNotifier extends AsyncNotifier> { 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> { }); 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> { 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> { } 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(); + } } } diff --git a/mobile/lib/features/channels/channels_provider_lifecycle.dart b/mobile/lib/features/channels/channels_provider_lifecycle.dart index 8c88377aa9..9078d08dad 100644 --- a/mobile/lib/features/channels/channels_provider_lifecycle.dart +++ b/mobile/lib/features/channels/channels_provider_lifecycle.dart @@ -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(); diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 836de72705..b88d86a89e 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -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; diff --git a/mobile/lib/features/settings/theme_picker_page/theme_home_preview.dart b/mobile/lib/features/settings/theme_picker_page/theme_home_preview.dart index 795d3a5f89..4d475814ec 100644 --- a/mobile/lib/features/settings/theme_picker_page/theme_home_preview.dart +++ b/mobile/lib/features/settings/theme_picker_page/theme_home_preview.dart @@ -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 ?? diff --git a/mobile/lib/shared/community/community_icon_artwork.dart b/mobile/lib/shared/community/community_icon_artwork.dart new file mode 100644 index 0000000000..643518bd4a --- /dev/null +++ b/mobile/lib/shared/community/community_icon_artwork.dart @@ -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'^[^<>]{1,64}$', +); + +/// Rejects pathological raster dimensions before decoding and persists only a +/// small first-frame PNG or a regenerated, resource-free emoji SVG. +Future 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; + } +} diff --git a/mobile/lib/shared/community/community_icon_cache.dart b/mobile/lib/shared/community/community_icon_cache.dart new file mode 100644 index 0000000000..1e04d5d6ee --- /dev/null +++ b/mobile/lib/shared/community/community_icon_cache.dart @@ -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.new, + ); + +/// A bounded, best-effort local cache; it never stores community credentials. +class CommunityIconCache extends Notifier> { + SharedPreferences? _prefs; + Future _writes = Future.value(); + + @override + Map 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) 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 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 _bounded(Map 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); + } +} diff --git a/mobile/lib/shared/community/community_icon_provider.dart b/mobile/lib/shared/community/community_icon_provider.dart index 9e5ed8433a..9bcf3ecad2 100644 --- a/mobile/lib/shared/community/community_icon_provider.dart +++ b/mobile/lib/shared/community/community_icon_provider.dart @@ -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((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((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((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(); + 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) 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) 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 bytes, String mimeType})?> _download( + http.Client client, + Uri uri, + Future cancelled, { + bool metadata = false, +}) async { + final abort = Completer(); + 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>? 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 = []; + 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; } diff --git a/mobile/lib/shared/widgets/confirmation_dialog.dart b/mobile/lib/shared/widgets/confirmation_dialog.dart new file mode 100644 index 0000000000..c41b883c5a --- /dev/null +++ b/mobile/lib/shared/widgets/confirmation_dialog.dart @@ -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 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('present', { + 'title': title, + 'message': message, + 'confirmLabel': confirmLabel, + 'cancelLabel': 'Cancel', + 'dark': context.theme.brightness == Brightness.dark, + }) == + true; + } + return await showBuzzDialog( + 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; +} diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart index 3bb2dcceaf..674ca6abb5 100644 --- a/mobile/lib/shared/widgets/frosted_app_bar.dart +++ b/mobile/lib/shared/widgets/frosted_app_bar.dart @@ -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? nativeActions; + /// Reports when native header images and layout are ready to reveal. + final ValueChanged? onNativeReadyChanged; + /// Uses a composable Flutter header while a backdrop covers the native view. final ValueListenable? 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, diff --git a/mobile/lib/shared/widgets/ios_navigation_bar.dart b/mobile/lib/shared/widgets/ios_navigation_bar.dart index 2b3da7fdfc..d737498d33 100644 --- a/mobile/lib/shared/widgets/ios_navigation_bar.dart +++ b/mobile/lib/shared/widgets/ios_navigation_bar.dart @@ -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 children; final bool selected; + /// Reports the avatar's global bounds for a transition into this bar item. + final ValueChanged? 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? onReadyChanged; + @override Widget build(BuildContext context, WidgetRef ref) { final channel = useState(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({}); + final avatarImages = useState({}); final avatarKey = jsonEncode([ for (final entry in avatarActions.entries) [entry.key, imageKey(entry.value)], ]); - final avatarFuture = useMemoized( - () async { - final images = {}; - 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 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; + 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('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('configure', payload); + if (!active) return; + if (latest.value.onReadyChanged != null) { + await current.invokeMethod('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(), diff --git a/mobile/lib/shared/widgets/skeleton_reveal.dart b/mobile/lib/shared/widgets/skeleton_reveal.dart index cb0d523dbe..2352c7aaf5 100644 --- a/mobile/lib/shared/widgets/skeleton_reveal.dart +++ b/mobile/lib/shared/widgets/skeleton_reveal.dart @@ -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? 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, _) { diff --git a/mobile/test/features/channels/channel_reader_recovery_test.dart b/mobile/test/features/channels/channel_reader_recovery_test.dart index 6db4be1b59..0c48d97f9f 100644 --- a/mobile/test/features/channels/channel_reader_recovery_test.dart +++ b/mobile/test/features/channels/channel_reader_recovery_test.dart @@ -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>(); + void Function(NostrEvent)? onEvent; + var fetchCount = 0; + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + fetchCount++; + return fetchCount == 1 ? [firstSnapshot] : secondSnapshot.future; + } + + @override + Future 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 blob(Map entries) => { 'version': 1, diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index a9a627c838..cb0749df92 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -15,6 +15,9 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/misc.dart'; +import 'package:buzz/features/channels/channel_stars/channel_stars_provider.dart'; +import 'package:buzz/features/channels/channel_mutes/channel_mutes_provider.dart'; +import 'package:buzz/features/channels/channel_sort/channel_sort_provider.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; @@ -25,21 +28,27 @@ import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart'; import 'package:buzz/features/profile/profile_avatar.dart'; +import 'package:buzz/features/pairing/pairing_page.dart'; +import 'package:buzz/features/pairing/pairing_qr_scanner.dart'; import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/features/profile/presence_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/utils/string_utils.dart'; import 'package:buzz/shared/auth/auth.dart'; import 'package:buzz/shared/community/community_icon_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/avatar_image.dart'; +import 'package:buzz/shared/emoji/emoji_avatar.dart'; import 'package:buzz/shared/widgets/buzz_loading_indicator.dart'; import 'package:buzz/shared/widgets/frosted_app_bar.dart'; +import 'package:buzz/shared/widgets/ios_navigation_bar.dart'; import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; import 'package:buzz/shared/widgets/skeleton.dart'; part 'channels_page_test/presence_tests.dart'; +part 'channels_page_test/unread_readiness_tests.dart'; void main() { setUpAll(() async { @@ -64,6 +73,7 @@ void main() { double bottomPadding = 0, Map communityIcons = const {}, ValueChanged? onCommunityIconLoad, + Future Function(String)? loadCommunityIcon, TextScaler textScaler = TextScaler.noScaling, Gradient? topSectionGradient, ValueChanged? onSettingsTransitionProgress, @@ -79,6 +89,7 @@ void main() { presenceProvider.overrideWith(() => _FakePresenceNotifier()), communityIconProvider.overrideWith((ref, relayUrl) async { onCommunityIconLoad?.call(relayUrl); + if (loadCommunityIcon != null) return loadCommunityIcon(relayUrl); return communityIcons[relayUrl]; }), dmDirectoryPreviewEnabledProvider.overrideWith( @@ -128,6 +139,8 @@ void main() { ); } + _communityUnreadLandingTests(buildTestable); + final testChannels = [ Channel( id: '1', @@ -862,7 +875,7 @@ void main() { final appBar = find.byType(FrostedAppBar).last; final communityAvatar = find.descendant( of: appBar, - matching: find.byType(AvatarImage), + matching: find.byType(ClipRSuperellipse), ); final profileAvatar = find.descendant( of: appBar, @@ -1141,7 +1154,7 @@ void main() { await tester.pumpAndSettle(); final communityAvatar = find.descendant( of: find.byType(FrostedAppBar).last, - matching: find.byType(AvatarImage), + matching: find.byType(ClipRSuperellipse), ); await tester.tap(communityAvatar); await tester.pump(); @@ -1178,31 +1191,27 @@ void main() { ), ); await tester.pumpAndSettle(); + final semantics = tester.ensureSemantics(); + try { + await tester.pump(); + expect( + find.bySemanticsLabel(RegExp('Community settings')), + findsOneWidget, + ); + } finally { + semantics.dispose(); + } await tester.tap(find.text('Alpha')); await tester.pumpAndSettle(); expect(find.byType(BuzzSheetHeader), findsNothing); expect(find.text('Community settings'), findsOneWidget); expect(find.text('Invite'), findsOneWidget); expect(find.text('Appearance'), findsOneWidget); - expect(find.text('Remove community'), findsOneWidget); - expect( - tester.getTopLeft(find.text('Remove community')).dy, - greaterThan(tester.getTopLeft(find.text('Appearance')).dy), - ); - expect( - tester.getTopLeft(find.text('Remove community')).dy, - lessThan(tester.getTopLeft(find.text('Switch communities')).dy), - ); - await tester.tap(find.text('Remove community')); - await tester.pumpAndSettle(); - expect(find.text('Remove community?'), findsOneWidget); - await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); - expect(find.text('Community settings'), findsOneWidget); + expect(find.text('Remove community'), findsNothing); expect( tester.getTopLeft(find.text('Appearance')).dy, - lessThan(tester.getTopLeft(find.text('Switch communities')).dy), + lessThan(tester.getTopLeft(find.text('Switch Community')).dy), ); if (Platform.environment['COMMUNITY_SCREENSHOTS'] case final directory?) { final boundary = tester @@ -1233,6 +1242,148 @@ void main() { }); } + for (final role in [CommunityMemberRole.admin, CommunityMemberRole.member]) { + testWidgets('prefetches $role permissions and keeps an open menu stable', ( + tester, + ) async { + final ready = Completer(); + var lookups = 0; + final permission = FutureProvider((ref) { + lookups++; + return ready.future; + }); + final community = Community( + id: 'alpha', + name: 'Alpha', + relayUrl: 'wss://alpha.example.com', + addedAt: DateTime(2025), + ); + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + communityListProvider.overrideWith( + () => _FakeCommunityListNotifier([community]), + ), + activeCommunityProvider.overrideWith((ref) async => community), + currentCommunityRoleProvider.overrideWith( + (ref) => ref.watch(permission), + ), + ], + ), + ); + await tester.pumpAndSettle(); + expect(lookups, 1); + await tester.tap(find.text('Alpha')); + await tester.pumpAndSettle(); + final appearanceBefore = tester.getRect(find.text('Appearance')); + expect(find.text('Invite'), findsNothing); + ready.complete(role); + await tester.pumpAndSettle(); + expect(find.text('Invite'), findsNothing); + expect(tester.getRect(find.text('Appearance')), appearanceBefore); + Navigator.of(tester.element(find.text('Appearance'))).pop(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Alpha')); + await tester.pumpAndSettle(); + expect( + find.text('Invite'), + role == CommunityMemberRole.admin ? findsOneWidget : findsNothing, + ); + expect(lookups, 1); + }); + } + + for (final removingInactive in [false, true]) { + for (final confirmed in [false, true]) { + testWidgets( + 'iOS switcher removal uses native confirmation (inactive: $removingInactive, confirmed: $confirmed)', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/confirmation_dialog'); + final answer = Completer(); + final calls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) { + calls.add(call); + return answer.future; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + final communities = [ + for (final name in ['Alpha', 'Bravo']) + Community( + id: name.toLowerCase(), + name: name, + relayUrl: 'wss://${name.toLowerCase()}.example.com', + addedAt: DateTime(2025), + ), + ]; + final notifier = _FakeCommunityListNotifier(communities); + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith( + () => _FakeNotifier(testChannels), + ), + communityListProvider.overrideWith(() => notifier), + activeCommunityProvider.overrideWith( + (ref) async => communities.first, + ), + ], + ), + ); + await tester.pumpAndSettle(); + tester + .widget(find.byType(FrostedAppBar)) + .nativeLeading! + .onPressed!(); + await tester.pumpAndSettle(); + expect(find.text('Remove community'), findsNothing); + await tester.tap(find.byKey(const Key('community-menu-switch'))); + await tester.pumpAndSettle(); + tester + .widget(find.byType(IosNavigationBar).last) + .actions + .single + .onPressed!(); + await tester.pumpAndSettle(); + await tester.tap( + find.byTooltip(removingInactive ? 'Remove Bravo' : 'Remove Alpha'), + ); + await tester.pumpAndSettle(); + expect(calls.single.method, 'present'); + expect(calls.single.arguments['title'], 'Remove community?'); + expect(calls.single.arguments['confirmLabel'], 'Remove'); + expect(calls.single.arguments['cancelLabel'], 'Cancel'); + expect( + calls.single.arguments['message'], + contains(removingInactive ? 'Bravo' : 'Alpha'), + ); + expect(find.byType(AlertDialog), findsNothing); + expect(notifier.removedIds, isEmpty); + answer.complete(confirmed); + await tester.pumpAndSettle(); + expect( + notifier.removedIds, + confirmed ? [removingInactive ? 'bravo' : 'alpha'] : isEmpty, + ); + expect( + find.byKey(const Key('community-switcher-page')), + confirmed && !removingInactive ? findsNothing : findsOneWidget, + ); + await tester.pumpWidget(const SizedBox()); + debugDefaultTargetPlatformOverride = null; + }, + ); + } + } + testWidgets('community settings hides invite for members', (tester) async { final community = Community( id: 'alpha', @@ -1259,6 +1410,15 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Invite'), findsNothing); expect(find.text('Appearance'), findsOneWidget); + await tester.tap(find.byKey(const Key('community-menu-switch'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('community-switcher-sheet')), findsNothing); + expect(find.byKey(const Key('community-switcher-page')), findsOneWidget); + await tester.tap(find.byTooltip('Close community switcher')); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('community-switcher-page')), findsNothing); + expect(find.byKey(const Key('community-switcher-sheet')), findsNothing); + expect(find.text('Alpha'), findsOneWidget); }); testWidgets('community switcher separates selection from edit removal', ( @@ -1305,19 +1465,23 @@ void main() { await tester.tap(find.text('Alpha')); await tester.pumpAndSettle(); - expect(find.text('Community settings'), findsOneWidget); + expect(find.text('Switch communities'), findsNothing); + expect(find.text('Edit'), findsNothing); + expect(find.byKey(const Key('community-switcher-options')), findsNothing); + expect(find.byKey(const Key('community-menu-switch')), findsOneWidget); + await tester.tap(find.byKey(const Key('community-menu-switch'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('community-switcher-sheet')), findsNothing); + + expect(find.text('Switch Community'), findsOneWidget); final options = find.byKey(const Key('community-switcher-options')); expect(options, findsOneWidget); - final editButton = find.byKey(const Key('community-switcher-edit')); - expect(tester.getSize(editButton), const Size(56, 44)); - expect(find.byTooltip('Close sheet'), findsNothing); - expect( - tester.getCenter(editButton).dx, - greaterThan(tester.getCenter(find.text('Community settings')).dx), - ); - expect(find.text('alpha.example.com'), findsOneWidget); - expect(find.text('bravo.example.com'), findsOneWidget); + + expect(find.byTooltip('Close community switcher'), findsOneWidget); + expect(find.text('alpha.example.com'), findsNothing); + expect(find.text('bravo.example.com'), findsNothing); + expect(find.text('Rename'), findsNothing); expect( find.descendant( @@ -1330,12 +1494,12 @@ void main() { expect(find.byIcon(LucideIcons.trash2), findsNothing); expect( tester.getSize(find.byKey(const Key('community-switcher-avatar-alpha'))), - const Size.square(36), + const Size.square(88), ); - final alphaAvatar = tester.widget( + final alphaAvatar = tester.widget( find.descendant( of: find.byKey(const Key('community-switcher-avatar-alpha')), - matching: find.byType(AvatarImage), + matching: find.byType(AvatarImageContent), ), ); expect(alphaAvatar.imageUrl, startsWith('data:image/png;base64,')); @@ -1346,13 +1510,22 @@ void main() { const Key('community-switcher-selection-bravo'), ); expect( - tester.getCenter(editButton).dx, - closeTo(tester.getCenter(activeSelection).dx, 0.01), + tester + .getTopLeft(find.byKey(const Key('community-switcher-row-alpha'))) + .dy, + tester + .getTopLeft(find.byKey(const Key('community-switcher-row-bravo'))) + .dy, ); - expect(tester.getSize(activeSelection), const Size.square(40)); expect( - tester.getSize(find.byKey(const Key('community-switcher-circle-alpha'))), - const Size.square(24), + tester.getTopLeft(find.byKey(const Key('community-switcher-add'))).dy, + greaterThan( + tester + .getBottomLeft( + find.byKey(const Key('community-switcher-row-alpha')), + ) + .dy, + ), ); expect( find.descendant( @@ -1370,44 +1543,12 @@ void main() { ); expect( find.descendant(of: options, matching: find.byType(Divider)), - findsNWidgets(2), + findsNothing, ); await tester.tap(find.text('Edit')); await tester.pump(); - final activeAction = find.byKey( - const Key('community-switcher-action-alpha'), - ); - final actionSwitcher = tester.widget( - find.descendant( - of: activeAction, - matching: find.byType(AnimatedSwitcher), - ), - ); - expect(actionSwitcher.duration, const Duration(milliseconds: 250)); - expect(activeSelection, findsOneWidget); - expect( - find.byKey(const Key('community-switcher-remove-alpha')), - findsOneWidget, - ); - - await tester.pump(const Duration(milliseconds: 125)); - - final transitioningOpacities = tester - .widgetList( - find.descendant(of: activeAction, matching: find.byType(Opacity)), - ) - .map((opacity) => opacity.opacity); - expect( - transitioningOpacities.any((opacity) => opacity > 0 && opacity < 1), - isTrue, - ); - expect( - find.descendant(of: activeAction, matching: find.byType(ImageFiltered)), - findsWidgets, - ); - await tester.pumpAndSettle(); expect(find.text('Done'), findsOneWidget); @@ -1431,7 +1572,7 @@ void main() { await tester.pumpAndSettle(); expect(communityNotifier.removedIds, ['bravo']); - expect(find.text('Community settings'), findsOneWidget); + expect(find.text('Switch Community'), findsOneWidget); expect(find.text('Bravo'), findsNothing); expect(find.text('Done'), findsOneWidget); @@ -1473,6 +1614,8 @@ void main() { await tester.tap(find.text('Alpha')); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('community-menu-switch'))); + await tester.pumpAndSettle(); expect(iconLoads, greaterThan(initialIconLoads)); }); @@ -1503,14 +1646,90 @@ void main() { await tester.tap(find.text('Alpha')); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('community-menu-switch'))); + await tester.pumpAndSettle(); expect(tester.takeException(), isNull); expect( - tester.getSize(find.text('Community settings')).height, + tester.getSize(find.byKey(const Key('community-switcher-title'))).height, greaterThan(32), ); }); + testWidgets( + 'Edit morphs the check in place and fades other actions reversibly', + (tester) async { + final communities = [ + for (final id in ['alpha', 'bravo']) + Community( + id: id, + name: id, + relayUrl: 'wss://$id.example.com', + addedAt: DateTime(2025), + ), + ]; + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + communityListProvider.overrideWith( + () => _FakeCommunityListNotifier(communities), + ), + activeCommunityProvider.overrideWith( + (ref) async => communities.first, + ), + ], + ), + ); + 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(); + final badge = find.byKey(const Key('community-switcher-badge-alpha')); + final center = tester.getCenter(badge); + final avatar = find.byKey(const Key('community-switcher-avatar-alpha')); + final avatarBounds = tester.getRect(avatar); + double opacity() => tester + .widget( + find.byKey(const Key('community-switcher-action-opacity-bravo')), + ) + .opacity; + double scale() => tester + .widget( + find.byKey(const Key('community-switcher-action-scale-bravo')), + ) + .transform + .storage[0]; + expect(opacity(), 0); + expect(find.byIcon(LucideIcons.check), findsOneWidget); + await tester.tap(find.text('Edit')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.getCenter(badge), center); + expect(tester.getRect(avatar), avatarBounds); + expect(tester.getSize(badge).width, inExclusiveRange(24, 36)); + expect(opacity(), inExclusiveRange(0, 1)); + expect(scale(), inExclusiveRange(.9, 1)); + final midSize = tester.getSize(badge); + await tester.tap(find.text('Done')); + await tester.pump(); + expect(tester.getSize(badge), midSize); + await tester.pumpAndSettle(); + expect(tester.getCenter(badge), center); + expect(opacity(), 0); + expect(find.byIcon(LucideIcons.check), findsOneWidget); + expect(find.byIcon(LucideIcons.trash2), findsNothing); + await tester.tap(find.text('Edit')); + await tester.pumpAndSettle(); + expect(tester.getCenter(badge), center); + expect(opacity(), 1); + expect(scale(), 1); + expect(find.byIcon(LucideIcons.trash2), findsNWidgets(2)); + expect(tester.takeException(), isNull); + }, + ); + testWidgets('community switcher disables icon motion when requested', ( tester, ) async { @@ -1541,16 +1760,16 @@ void main() { 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.text('Edit')); await tester.pump(); - final actionSwitcher = tester.widget( - find.descendant( - of: find.byKey(const Key('community-switcher-action-alpha')), - matching: find.byType(AnimatedSwitcher), - ), + expect(find.byIcon(LucideIcons.trash2), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('community-switcher-badge-alpha'))), + const Size.square(36), ); - expect(actionSwitcher.duration, Duration.zero); expect( find.byKey(const Key('community-switcher-selection-alpha')), findsNothing, @@ -1561,6 +1780,604 @@ void main() { ); }); + for (final nativeHeader in [false, true]) { + for (final reduceMotion in [false, true]) { + testWidgets( + 'community selection holds enlarged until content loads (native header: $nativeHeader, reduced motion: $reduceMotion)', + (tester) async { + debugDefaultTargetPlatformOverride = nativeHeader + ? TargetPlatform.iOS + : TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final communities = [ + Community( + id: 'alpha', + name: 'Alpha', + relayUrl: 'wss://alpha.example.com', + addedAt: DateTime(2025), + ), + Community( + id: 'bravo', + name: 'Bravo', + relayUrl: 'wss://bravo.example.com', + addedAt: DateTime(2025), + ), + ]; + final notifier = _FakeCommunityListNotifier(communities) + ..switchCompleter = Completer(); + final loadedChannels = Completer>(); + final unreadCatchUp = Completer(); + final profiles = _SwitchingUserCache(); + final progress = []; + await tester.pumpWidget( + buildTestable( + communityIcons: nativeHeader + ? { + 'wss://bravo.example.com': emojiAvatarDataUrl( + '🥳', + 0xFFFF8652, + ), + } + : const {}, + disableAnimations: reduceMotion, + // An offline cosmetic refresh must not block ready content. + loadCommunityIcon: nativeHeader + ? null + : (relayUrl) => relayUrl.contains('bravo') + ? Completer().future + : Future.value(null), + onSettingsTransitionProgress: progress.add, + overrides: [ + userCacheProvider.overrideWith(() => profiles), + readStateProvider.overrideWith( + () => _FakeReadStateNotifier( + const ReadStateState( + isReady: true, + pubkey: 'aabb', + contexts: {'1': 100}, + version: 0, + ), + ), + ), + channelsProvider.overrideWith( + () => _FakeNotifier( + testChannels, + load: () => notifier.activeId == 'bravo' + ? loadedChannels.future + : Future.value(testChannels), + unreadCatchUp: unreadCatchUp.future, + ), + ), + communityListProvider.overrideWith(() => notifier), + activeCommunityProvider.overrideWith((ref) async { + await ref.watch(communityListProvider.future); + return communities.firstWhere( + (community) => community.id == notifier.activeId, + ); + }), + ], + ), + ); + await tester.pumpAndSettle(); + final Rect destinationRect; + if (nativeHeader) { + destinationRect = const Rect.fromLTWH(16, 68, 36, 36); + final bar = tester.widget( + find.byType(FrostedAppBar), + ); + bar.nativeLeading!.onAvatarBoundsChanged!(destinationRect); + bar.nativeLeading!.onPressed!(); + } else { + final headerAvatar = find.descendant( + of: find.byType(FrostedAppBar).last, + matching: find.byType(ClipRSuperellipse), + ); + destinationRect = tester.getRect(headerAvatar); + await tester.tap(find.text('Alpha')); + } + final destination = destinationRect.center; + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('community-menu-switch'))); + await tester.pumpAndSettle(); + expect(progress.last, 1); + if (nativeHeader) { + var navigation = tester.widget( + find.byType(IosNavigationBar).last, + ); + expect(navigation.leading!.symbol, 'xmark'); + expect(navigation.leading!.label, 'Close community switcher'); + expect(navigation.actions.single.label, 'Edit'); + navigation.actions.single.onPressed!(); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('community-switcher-remove-bravo')), + findsOneWidget, + ); + navigation = tester.widget( + find.byType(IosNavigationBar).last, + ); + expect(navigation.actions.single.label, 'Done'); + navigation.actions.single.onPressed!(); + await tester.pumpAndSettle(); + } + await tester.tap( + find.byKey(const Key('community-switcher-row-bravo')), + ); + await tester.pump(); + final flyingAvatar = find.byKey(const Key('community-flying-avatar')); + expect(notifier.switchedIds, reduceMotion ? ['bravo'] : isEmpty); + if (nativeHeader) { + expect( + tester + .widget(find.byType(FrostedAppBar)) + .nativeLeading! + .avatarHidden, + isTrue, + ); + } + final origin = tester.getCenter(flyingAvatar); + await tester.pump(const Duration(milliseconds: 105)); + if (!reduceMotion) { + expect(notifier.switchedIds, isEmpty); + final inFlight = tester.getCenter(flyingAvatar); + const center = Offset(195, 422); + expect( + (inFlight - center).distance, + lessThan((origin - center).distance), + ); + expect((inFlight - center).distance, greaterThan(0)); + final delta = center - origin; + final travel = inFlight - origin; + // A curved approach differs from the old straight-line tween. + expect( + (delta.dx * travel.dy - delta.dy * travel.dx).abs(), + greaterThan(1), + ); + } + await tester.pump(const Duration(milliseconds: 325)); + await tester.pump(); + expect(notifier.switchedIds, ['bravo']); + final loader = find.byKey(const Key('community-switch-loading')); + final loadingIndicator = tester.widget(loader); + final loaderContext = tester.element(loader); + expect(loadingIndicator.size, 18); + expect( + loadingIndicator.color, + loaderContext.colors.onSecondaryContainer, + ); + expect(tester.getSize(loader), const Size.square(18)); + final spinner = find.descendant( + of: loader, + matching: find.byType(RotationTransition), + ); + final turns = tester.widget(spinner).turns; + final before = turns.value; + await tester.pump(const Duration(milliseconds: 125)); + expect( + (turns.value - before + 1) % 1, + closeTo(reduceMotion ? 0 : 0.25, 0.001), + ); + final initial = tester.widget( + find.descendant(of: flyingAvatar, matching: find.byType(RichText)), + ); + expect( + initial.text.style?.decoration, + anyOf(isNull, TextDecoration.none), + ); + expect(tester.getCenter(flyingAvatar), const Offset(195, 422)); + expect( + tester.widget(flyingAvatar).transform.storage[0], + 1.5, + ); + expect( + find.byKey(const Key('community-switch-loading')), + findsOneWidget, + ); + expect( + tester + .widget( + find.byKey(const Key('community-loading-backdrop')), + ) + .opacity, + 1, + ); + // Storage completing does not release the avatar: the new channel list + // is still pending, and the old snapshot must not count as ready. + notifier.switchCompleter!.complete(); + await tester.pump(); + await tester.pump(const Duration(seconds: 3)); + for (final id in ['alpha', 'bravo']) { + expect( + find.descendant( + of: find.byKey(Key('community-switcher-selection-$id')), + matching: find.byIcon(LucideIcons.check), + ), + id == 'alpha' ? findsOneWidget : findsNothing, + ); + } + expect(tester.getCenter(flyingAvatar), const Offset(195, 422)); + expect( + tester.widget(flyingAvatar).transform.storage[0], + 1.5, + ); + expect( + find.byKey(const Key('community-switch-loading')), + findsOneWidget, + ); + await tester.binding.handlePopRoute(); + await tester.tapAt(const Offset(280, 150)); + expect(notifier.switchedIds, ['bravo']); + loadedChannels.complete(testChannels); + // Data is ready, but the page is still revealing its content. + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + if (!reduceMotion || nativeHeader) { + expect(tester.getCenter(flyingAvatar), const Offset(195, 422)); + expect( + find.byKey(const Key('community-switch-loading')), + findsOneWidget, + ); + } + await tester.pump(const Duration(milliseconds: 220)); + if (nativeHeader) { + // Native images/layout may finish after the Flutter body. Keep the + // hold, and use fresh bounds rather than the old community's slot. + expect(tester.getCenter(flyingAvatar), const Offset(195, 422)); + final header = tester.widget( + find.byType(FrostedAppBar), + ); + header.nativeLeading!.onAvatarBoundsChanged!( + const Rect.fromLTWH(24, 76, 36, 36), + ); + header.onNativeReadyChanged!(true); + } + await tester.pump(); + await tester.pump(); + await tester.pump(); + await tester.pump(); + // The loaded list and header are ready, but unread history and DM + // labels must also settle before the background can be revealed. + expect(tester.getCenter(flyingAvatar), const Offset(195, 422)); + final container = ProviderScope.containerOf( + tester.element(find.byType(ChannelsPage)), + ); + (container.read(channelsProvider.notifier) as _FakeNotifier) + .addUnread('1', _observed(id: 'late-mention', createdAt: 200)); + unreadCatchUp.complete(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.getCenter(flyingAvatar), const Offset(195, 422)); + expect( + tester + .widget( + find.byKey(const Key('community-loading-backdrop')), + ) + .opacity, + 1, + ); + profiles.finish(); + await tester.pump(); + await tester.pump(); + await tester.pump(); + await tester.pump(); + expect( + tester.widget(find.text('general')).style!.fontWeight, + FontWeight.w700, + ); + expect(find.text('Alice ready'), findsOneWidget); + expect(progress.last, 0); + if (!reduceMotion) { + await tester.pump(const Duration(milliseconds: 65)); + expect(tester.getCenter(flyingAvatar).dx, greaterThan(195)); + await tester.pump(const Duration(milliseconds: 195)); + expect(tester.getCenter(flyingAvatar).dy, lessThan(422)); + expect( + tester.getCenter(flyingAvatar).dy, + greaterThan(destination.dy), + ); + expect( + tester.widget(flyingAvatar).transform.storage[0], + lessThan(1.5), + ); + expect( + find.byKey(const Key('community-switch-loading')), + findsNothing, + ); + final opacity = tester + .widget( + find.byKey(const Key('community-loading-backdrop')), + ) + .opacity; + expect(opacity, 1); + await tester.pump(const Duration(milliseconds: 259)); + expect( + (tester.getCenter(flyingAvatar) - + (nativeHeader ? const Offset(42, 94) : destination)) + .distance, + lessThan(0.5), + ); + await tester.pump(const Duration(milliseconds: 11)); + await tester.pump(); + final landed = tester.getCenter(flyingAvatar); + await tester.pump(const Duration(milliseconds: 80)); + expect(tester.getCenter(flyingAvatar), landed); + expect( + tester + .widget( + find.byKey(const Key('community-loading-backdrop')), + ) + .opacity, + allOf(greaterThan(0), lessThan(1)), + ); + } + await tester.pumpAndSettle(); + expect(notifier.switchedIds, ['bravo']); + expect( + find.byKey(const Key('community-switcher-page')), + findsNothing, + ); + if (nativeHeader) { + final bar = tester.widget( + find.byType(FrostedAppBar), + ); + expect(bar.nativeTitle, 'Bravo'); + expect(bar.nativeLeading!.avatarHidden, isFalse); + } else { + expect(find.text('Bravo'), findsOneWidget); + } + expect(progress.last, 0); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + debugDefaultTargetPlatformOverride = null; + }, + ); + } + } + + testWidgets('failed community switch restores the picker and allows retry', ( + tester, + ) async { + final communities = [ + Community( + id: 'alpha', + name: 'Alpha', + relayUrl: 'wss://alpha.example.com', + addedAt: DateTime(2025), + ), + Community( + id: 'bravo', + name: 'Bravo', + relayUrl: 'wss://bravo.example.com', + addedAt: DateTime(2025), + ), + ]; + final notifier = _FakeCommunityListNotifier(communities)..failSwitch = true; + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + communityListProvider.overrideWith(() => notifier), + activeCommunityProvider.overrideWith( + (ref) async => communities.first, + ), + ], + ), + ); + 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'))); + await tester.pumpAndSettle(); + expect( + find.text('Could not switch communities. Please try again.'), + findsOneWidget, + ); + expect(find.byKey(const Key('community-flying-avatar')), findsNothing); + expect( + tester + .widget(find.byKey(const Key('community-picker-opacity'))) + .opacity, + 1, + ); + notifier.failSwitch = false; + await tester.tap(find.byKey(const Key('community-switcher-row-bravo'))); + await tester.pumpAndSettle(); + expect(notifier.switchedIds, ['bravo', 'bravo']); + expect(find.byKey(const Key('community-switcher-page')), findsNothing); + expect(tester.takeException(), isNull); + }); + + for (final timeout in [false, true]) { + testWidgets( + 'community loading failure can retry the active selection (timeout: $timeout)', + (tester) async { + final communities = [ + Community( + id: 'alpha', + name: 'Alpha', + relayUrl: 'wss://alpha.example.com', + addedAt: DateTime(2025), + ), + Community( + id: 'bravo', + name: 'Bravo', + relayUrl: 'wss://bravo.example.com', + addedAt: DateTime(2025), + ), + ]; + final notifier = _FakeCommunityListNotifier(communities); + final pending = Completer>(); + var retry = false; + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith( + () => _FakeNotifier( + testChannels, + load: () { + if (notifier.activeId == 'alpha' || retry) { + return Future.value(testChannels); + } + return pending.future; + }, + ), + ), + communityListProvider.overrideWith(() => notifier), + activeCommunityProvider.overrideWith((ref) async { + await ref.watch(communityListProvider.future); + return communities.firstWhere( + (community) => community.id == notifier.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'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 440)); + await tester.pump(); + await tester.pump(); + expect( + find.byKey(const Key('community-switch-loading')), + findsOneWidget, + ); + if (timeout) { + await tester.pump(const Duration(seconds: 21)); + } else { + pending.completeError(StateError('Relay unavailable')); + await tester.pump(); + } + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('community-switcher-page')), + findsOneWidget, + ); + expect(find.byKey(const Key('community-switch-loading')), findsNothing); + expect( + find.text( + timeout + ? 'This community is taking longer to load. Select it to try again, or choose another.' + : 'Could not switch communities. Please try again.', + ), + findsOneWidget, + ); + retry = true; + await tester.tap(find.byKey(const Key('community-switcher-row-bravo'))); + await tester.pumpAndSettle(); + expect(notifier.switchedIds, ['bravo', 'bravo']); + expect(find.byKey(const Key('community-switcher-page')), findsNothing); + expect(find.text('Bravo'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + } + + for (final count in [0, 1, 3, 12]) { + testWidgets('community grid fits $count communities with Add last', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final communities = [ + for (var i = 0; i < count; i++) + Community( + id: '$i', + name: 'Community $i', + relayUrl: 'wss://community-$i.example.com', + addedAt: DateTime(2025), + ), + ]; + await tester.pumpWidget( + buildTestable( + textScaler: TextScaler.linear(2), + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier([])), + communityListProvider.overrideWith( + () => _FakeCommunityListNotifier(communities), + ), + activeCommunityProvider.overrideWith( + (ref) async => communities.firstOrNull, + ), + ], + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text(count == 0 ? 'Community' : 'Community 0')); + await tester.pumpAndSettle(); + final switchRow = find.byKey(const Key('community-menu-switch')); + await tester.ensureVisible(switchRow); + await tester.tap(switchRow); + await tester.pumpAndSettle(); + final add = find.byKey(const Key('community-switcher-add')); + await tester.ensureVisible(add); + await tester.pumpAndSettle(); + expect(tester.getRect(add).bottom, lessThanOrEqualTo(568)); + expect(tester.getRect(add).top, greaterThanOrEqualTo(0)); + if (count > 0) { + final last = find.byKey(Key('community-switcher-row-${count - 1}')); + final addPosition = tester.getTopLeft(add); + final lastPosition = tester.getTopLeft(last); + if (count.isOdd) { + expect(addPosition.dy, lastPosition.dy); + expect(addPosition.dx, greaterThan(lastPosition.dx)); + } else { + expect(addPosition.dy, greaterThan(lastPosition.dy)); + } + } + expect(tester.takeException(), isNull); + }); + } + + testWidgets('Add Community opens the pairing page after the picker exits', ( + tester, + ) async { + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + communityListProvider.overrideWith( + () => _FakeCommunityListNotifier([]), + ), + activeCommunityProvider.overrideWith((ref) async => null), + ], + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Community')); + 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-add'))); + // The pairing welcome bee repeats; advance finite frames instead of settling. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(); + expect(find.byKey(const Key('community-switcher-page')), findsNothing); + expect(find.byType(PairingPage), findsOneWidget); + expect(find.byType(FallbackPairingQrScanner), findsNothing); + expect(find.text('Scan a QR code'), findsOneWidget); + expect(find.text('Use pairing code'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }); + testWidgets('quick actions slide behind navigation when leaving home', ( tester, ) async { @@ -2670,10 +3487,14 @@ Widget _buildSettingsPage(BuildContext context) => class _FakeNotifier extends ChannelsNotifier { final List _channels; + final Future> Function()? load; + final Future? unreadCatchUp; final Map> _observedEventsByChannel; _FakeNotifier( this._channels, { + this.load, + this.unreadCatchUp, Map> observedEventsByChannel = const {}, }) : _observedEventsByChannel = { for (final entry in observedEventsByChannel.entries) @@ -2681,7 +3502,16 @@ class _FakeNotifier extends ChannelsNotifier { }; @override - Future> build() async => _channels; + Future> build() async => + load == null ? _channels : await load!(); + + @override + Future waitForUnreadCatchUp() async => await unreadCatchUp; + + void addUnread(String channelId, ObservedUnreadEvent event) { + (_observedEventsByChannel[channelId] ??= {})[event.id] = event; + state = AsyncData([...state.requireValue]); + } @override Future ensureDirectoryLoaded() async { @@ -2818,6 +3648,19 @@ class _FakeCommunityListNotifier extends CommunityListNotifier { List _communities; final List removedIds = []; + final List switchedIds = []; + bool failSwitch = false; + String activeId = 'alpha'; + Completer? switchCompleter; + + @override + Future switchCommunity(String id) async { + switchedIds.add(id); + if (failSwitch) throw StateError('Storage unavailable'); + await switchCompleter?.future; + activeId = id; + state = AsyncData([..._communities]); + } @override Future> build() async => _communities; @@ -2970,3 +3813,23 @@ class _CommunitySettingsLifecycle extends AppLifecycleNotifier { @override AppLifecycleState build() => AppLifecycleState.resumed; } + +class _SwitchingUserCache extends UserCacheNotifier { + final loaded = Completer(); + + @override + Map build() => {}; + + @override + UserProfile? get(String pubkey) => state[pubkey]; + + @override + Future preload(List pubkeys) => loaded.future; + + void finish() { + state = { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice ready'), + }; + loaded.complete(true); + } +} diff --git a/mobile/test/features/channels/channels_page_test/unread_readiness_tests.dart b/mobile/test/features/channels/channels_page_test/unread_readiness_tests.dart new file mode 100644 index 0000000000..836c73276c --- /dev/null +++ b/mobile/test/features/channels/channels_page_test/unread_readiness_tests.dart @@ -0,0 +1,154 @@ +part of '../channels_page_test.dart'; + +void _communityUnreadLandingTests( + Widget Function({required List 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> tags) => NostrEvent( + id: '$kind-${communities.activeId}', + pubkey: 'pk', + createdAt: 1, + kind: kind, + tags: tags, + content: '', + sig: 'sig', + ); + + @override + Future> queryRelay( + List 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> 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 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); +} diff --git a/mobile/test/features/channels/channels_provider_readiness_cases.dart b/mobile/test/features/channels/channels_provider_readiness_cases.dart new file mode 100644 index 0000000000..da7479e21a --- /dev/null +++ b/mobile/test/features/channels/channels_provider_readiness_cases.dart @@ -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(); + final release = Completer(); + + @override + Future> queryRelay( + List 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> 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(); + } +} diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 6f3d4ea514..7e100548d5 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -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.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.delayed(Duration.zero); - expect(container.read(channelsProvider).value?.single.name, 'random'); + expect((await destination).single.name, 'random'); + expect(container.read(channelsProvider).isLoading, isFalse); }, ); diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 150ccd4290..a829d4b4cc 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -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(spinner).color, + tester.element(spinner).colors.onSecondaryContainer, + ); + expect(find.byType(CircularProgressIndicator), findsNothing); expect( find.bySemanticsLabel('Cancel voice note loading'), diff --git a/mobile/test/features/pairing/pairing_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart index 74840cd5ff..b34ef51ae3 100644 --- a/mobile/test/features/pairing/pairing_page_test.dart +++ b/mobile/test/features/pairing/pairing_page_test.dart @@ -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(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); }); diff --git a/mobile/test/fixtures/community_icons/compressed-16000.png b/mobile/test/fixtures/community_icons/compressed-16000.png new file mode 100644 index 0000000000..73759b553b Binary files /dev/null and b/mobile/test/fixtures/community_icons/compressed-16000.png differ diff --git a/mobile/test/fixtures/community_icons/compressed-8192.png b/mobile/test/fixtures/community_icons/compressed-8192.png new file mode 100644 index 0000000000..d33e453734 Binary files /dev/null and b/mobile/test/fixtures/community_icons/compressed-8192.png differ diff --git a/mobile/test/fixtures/community_icons/landscape-1024.png b/mobile/test/fixtures/community_icons/landscape-1024.png new file mode 100644 index 0000000000..fa321f9c40 Binary files /dev/null and b/mobile/test/fixtures/community_icons/landscape-1024.png differ diff --git a/mobile/test/shared/community/community_icon_provider_test.dart b/mobile/test/shared/community/community_icon_provider_test.dart index b2992c3967..aecc3d45d7 100644 --- a/mobile/test/shared/community/community_icon_provider_test.dart +++ b/mobile/test/shared/community/community_icon_provider_test.dart @@ -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 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(); + 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(); + 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(); + 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()); + 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 = + '' + '' + ''; + 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 [ + '', + '', + '', + ]) { + test('rejects resource added to otherwise valid emoji: $extra', () async { + final svg = UriData.parse( + artwork, + ).contentAsString(encoding: utf8).replaceFirst('', '$extra'); + 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 _waitFor(bool Function() ready) async { + for (var i = 0; i < 100 && !ready(); i++) { + await Future.delayed(Duration.zero); + } + expect(ready(), isTrue, reason: 'Request cancellation did not settle'); +} diff --git a/mobile/test/shared/widgets/frosted_scaffold_test.dart b/mobile/test/shared/widgets/frosted_scaffold_test.dart index 16a958f0e9..2c11bee975 100644 --- a/mobile/test/shared/widgets/frosted_scaffold_test.dart +++ b/mobile/test/shared/widgets/frosted_scaffold_test.dart @@ -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); + }, + ); } diff --git a/mobile/test/shared/widgets/ios_navigation_bar_test.dart b/mobile/test/shared/widgets/ios_navigation_bar_test.dart index 9667849258..b923e9c247 100644 --- a/mobile/test/shared/widgets/ios_navigation_bar_test.dart +++ b/mobile/test/shared/widgets/ios_navigation_bar_test.dart @@ -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(); + final readiness = []; + final calls = []; + 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(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(); + var requested = false; + final client = MockClient((_) { + requested = true; + return pending.future; + }); + addTearDown(client.close); + const channel = MethodChannel('buzz/ios_navigation_bar/846'); + final readiness = []; + final calls = []; + final createdChannels = []; + 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(find.byType(UiKitView)).onPlatformViewCreated!( + 846, + ); + await tester.pumpAndSettle(); + Map leading() => + (tester.widget(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.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(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, diff --git a/mobile/test/shared/widgets/skeleton_test.dart b/mobile/test/shared/widgets/skeleton_test.dart index 5b2d25bd63..7256a84452 100644 --- a/mobile/test/shared/widgets/skeleton_test.dart +++ b/mobile/test/shared/widgets/skeleton_test.dart @@ -29,6 +29,7 @@ void main() { Widget buildRevealTestable({ required ValueNotifier loading, bool disableAnimations = false, + ValueChanged? 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 = []; + 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(find.byKey(Key(key))).opacity;