From dbf62f2295babdbf2d8fe3130d0cd8f611388081 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sat, 22 Aug 2026 23:23:57 +0100 Subject: [PATCH 01/34] Add mobile profile editing Signed-off-by: kenny lopez --- mobile/ios/Podfile.lock | 95 ++ mobile/ios/Runner.xcodeproj/project.pbxproj | 26 + mobile/ios/Runner/AppDelegate.swift | 27 + .../ios/Runner/JumpToLatestGlassButton.swift | 136 ++- .../ios/Runner/NativeProfileTextEditor.swift | 372 +++++++ mobile/ios/Runner/NativeSkinToneControl.swift | 137 +++ mobile/lib/app.dart | 6 + .../lib/features/activity/activity_page.dart | 2 +- .../channels/channel_details_page.dart | 3 +- .../lib/features/channels/channels_page.dart | 6 +- .../features/invites/invite_create_page.dart | 5 +- .../profile/animated_avatar_capture.dart | 958 +++++++++++++++++ .../capture_controls.dart | 140 +++ .../review_controls.dart | 355 +++++++ .../profile/avatar_background_grid.dart | 79 ++ .../profile/avatar_editor_option_button.dart | 93 ++ .../profile/ios_profile_text_editor.dart | 20 + .../profile/profile_avatar_crop_page.dart | 408 ++++++++ .../profile/profile_avatar_editor.dart | 534 ++++++++++ .../emoji_avatar_picker.dart | 357 +++++++ .../features/profile/profile_edit_page.dart | 501 +++++++++ .../profile/profile_photo_transition.dart | 11 + .../features/profile/profile_provider.dart | 63 +- .../features/profile/profile_text_editor.dart | 174 ++++ mobile/lib/features/search/search_page.dart | 2 +- .../features/settings/accent_picker_page.dart | 5 +- .../lib/features/settings/settings_page.dart | 119 +++ .../settings_page/connection_section.dart | 31 +- .../features/settings/theme_picker_page.dart | 2 +- mobile/lib/shared/animated_avatar.dart | 5 + mobile/lib/shared/emoji/emoji_avatar.dart | 42 + .../lib/shared/emoji/native_emoji_glyph.dart | 29 +- .../shared/profile/user_cache_provider.dart | 5 + mobile/lib/shared/relay/media_upload.dart | 52 +- .../relay/media_upload/platform_bindings.dart | 34 + mobile/lib/shared/relay/nostr_models.dart | 1 + mobile/lib/shared/widgets/app_list.dart | 7 +- mobile/lib/shared/widgets/avatar_image.dart | 19 +- .../lib/shared/widgets/buzz_sheet_header.dart | 3 +- .../lib/shared/widgets/frosted_app_bar.dart | 185 +++- .../widgets/ios_glass_navigation_action.dart | 96 ++ .../widgets/ios_native_segmented_control.dart | 77 ++ .../widgets/ios_native_skin_tone_control.dart | 69 ++ .../shared/widgets/playing_avatar_image.dart | 48 + mobile/pubspec.lock | 40 + mobile/pubspec.yaml | 2 + .../features/activity/activity_page_test.dart | 1 + .../channels/channel_detail_page_test.dart | 3 +- .../features/channels/channels_page_test.dart | 9 +- .../profile/profile_edit_page_test.dart | 977 ++++++++++++++++++ .../profile/profile_provider_test.dart | 86 ++ .../profile/settings_profile_header_test.dart | 25 + .../features/search/search_page_test.dart | 3 +- .../settings/connection_section_test.dart | 54 + .../features/settings/settings_page_test.dart | 119 ++- mobile/test/shared/animated_avatar_test.dart | 9 +- .../shared/emoji/native_emoji_glyph_test.dart | 67 +- .../shared/widgets/avatar_image_test.dart | 6 + .../shared/widgets/frosted_app_bar_test.dart | 63 ++ 59 files changed, 6651 insertions(+), 152 deletions(-) create mode 100644 mobile/ios/Runner/NativeProfileTextEditor.swift create mode 100644 mobile/ios/Runner/NativeSkinToneControl.swift create mode 100644 mobile/lib/features/profile/animated_avatar_capture.dart create mode 100644 mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart create mode 100644 mobile/lib/features/profile/animated_avatar_capture/review_controls.dart create mode 100644 mobile/lib/features/profile/avatar_background_grid.dart create mode 100644 mobile/lib/features/profile/avatar_editor_option_button.dart create mode 100644 mobile/lib/features/profile/ios_profile_text_editor.dart create mode 100644 mobile/lib/features/profile/profile_avatar_crop_page.dart create mode 100644 mobile/lib/features/profile/profile_avatar_editor.dart create mode 100644 mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart create mode 100644 mobile/lib/features/profile/profile_edit_page.dart create mode 100644 mobile/lib/features/profile/profile_photo_transition.dart create mode 100644 mobile/lib/features/profile/profile_text_editor.dart create mode 100644 mobile/lib/shared/emoji/emoji_avatar.dart create mode 100644 mobile/lib/shared/relay/media_upload/platform_bindings.dart create mode 100644 mobile/lib/shared/widgets/ios_glass_navigation_action.dart create mode 100644 mobile/lib/shared/widgets/ios_native_segmented_control.dart create mode 100644 mobile/lib/shared/widgets/ios_native_skin_tone_control.dart create mode 100644 mobile/lib/shared/widgets/playing_avatar_image.dart create mode 100644 mobile/test/features/profile/profile_edit_page_test.dart diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 05267ed7c13..491da241e8f 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -13,14 +13,71 @@ PODS: - flutter_secure_storage_darwin (10.0.0): - Flutter - FlutterMacOS + - google_mlkit_commons (0.11.1): + - Flutter + - MLKitVision (~> 10.0.0) + - google_mlkit_selfie_segmentation (0.10.1): + - Flutter + - google_mlkit_commons + - GoogleMLKit/SegmentationSelfie (~> 9.0.0) + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - GoogleMLKit/MLKitCore (9.0.0): + - MLKitCommon (~> 14.0.0) + - GoogleMLKit/SegmentationSelfie (9.0.0): + - GoogleMLKit/MLKitCore + - MLKitSegmentationSelfie (~> 1.0.0-beta14) + - GoogleToolboxForMac/Defines (4.2.1) + - GoogleToolboxForMac/Logger (4.2.1): + - GoogleToolboxForMac/Defines (= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (4.2.1)": + - GoogleToolboxForMac/Defines (= 4.2.1) + - GoogleUtilities/Environment (8.0.2): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.0.2): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.0.2) + - GoogleUtilities/UserDefaults (8.0.2): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMSessionFetcher/Core (3.5.0) - image_picker_ios (0.0.1): - Flutter - local_auth_darwin (0.0.1): - Flutter - FlutterMacOS + - MLImage (1.0.0-beta8) + - MLKitCommon (14.0.0): + - GoogleDataTransport (~> 10.0) + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GoogleUtilities/Logger (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLKitSegmentationCommon (1.0.0-beta14): + - MLKitCommon (~> 14.0) + - MLKitXenoCommon (= 1.0.0-beta16) + - MLKitSegmentationSelfie (1.0.0-beta14): + - MLKitSegmentationCommon (= 1.0.0-beta14) + - MLKitVision (10.0.0): + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLImage (= 1.0.0-beta8) + - MLKitCommon (~> 14.0) + - MLKitXenoCommon (1.0.0-beta16): + - MLKitCommon (~> 14.0) + - MLKitVision (~> 10.0) - mobile_scanner (7.0.0): - Flutter - FlutterMacOS + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) - open_filex (0.0.2): - Flutter - package_info_plus (0.4.5): @@ -28,6 +85,7 @@ PODS: - photo_manager (3.11.0): - Flutter - FlutterMacOS + - PromisesObjC (2.4.0) - share_plus (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): @@ -47,6 +105,8 @@ DEPENDENCIES: - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - Flutter (from `Flutter`) - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) + - google_mlkit_commons (from `.symlinks/plugins/google_mlkit_commons/ios`) + - google_mlkit_selfie_segmentation (from `.symlinks/plugins/google_mlkit_selfie_segmentation/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) @@ -58,6 +118,22 @@ DEPENDENCIES: - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) +SPEC REPOS: + trunk: + - GoogleDataTransport + - GoogleMLKit + - GoogleToolboxForMac + - GoogleUtilities + - GTMSessionFetcher + - MLImage + - MLKitCommon + - MLKitSegmentationCommon + - MLKitSegmentationSelfie + - MLKitVision + - MLKitXenoCommon + - nanopb + - PromisesObjC + EXTERNAL SOURCES: app_badge_plus: :path: ".symlinks/plugins/app_badge_plus/ios" @@ -73,6 +149,10 @@ EXTERNAL SOURCES: :path: Flutter flutter_secure_storage_darwin: :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" + google_mlkit_commons: + :path: ".symlinks/plugins/google_mlkit_commons/ios" + google_mlkit_selfie_segmentation: + :path: ".symlinks/plugins/google_mlkit_selfie_segmentation/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" local_auth_darwin: @@ -102,12 +182,27 @@ SPEC CHECKSUMS: file_selector_ios: ec57ec07954363dd730b642e765e58f199bb621a Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 + google_mlkit_commons: a5e4ffae5bc59ea4c7b9025dc72cb6cb79dc1166 + google_mlkit_selfie_segmentation: 0317616b7e460f242bd13a805b70f4e0ba636336 + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleMLKit: b1eee21a41c57704fe72483b15c85cb2c0cd7444 + GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8 + GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb + MLImage: 0de5c6c2bf9e93b80ef752e2797f0836f03b58c0 + MLKitCommon: 47d47b50a031d00db62f1b0efe5a1d8b09a3b2e6 + MLKitSegmentationCommon: f634fb3c20c1d8bace22a2c026bdaa0ca17613d9 + MLKitSegmentationSelfie: 62371f35c33d49c3e7474443677b6a0c7ac9c98b + MLKitVision: 39a5a812db83c4a0794445088e567f3631c11961 + MLKitXenoCommon: 1a4268c1222a6043047af5bb9435028206c63287 mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 photo_manager: 6ab48c2ce7ec21aa06d59e6cc049f0b6d9ba7f94 + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 0bbdeaa9802..43df76f6810 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -22,6 +22,8 @@ 4A71C0132F40A00100A17E01 /* NativeMessageActionSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0142F40A00100A17E01 /* NativeMessageActionSurface.swift */; }; 4A71C0152F40B00100A17E01 /* HuddleMediaPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0162F40B00100A17E01 /* HuddleMediaPlugin.swift */; }; 4A71C0172F40C00100A17E01 /* HuddleAudioEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0182F40C00100A17E01 /* HuddleAudioEngine.swift */; }; + 4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */; }; + 4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */; }; 331C809D294A63AB00263BE5 /* UIKitEncoded.png in Resources */ = {isa = PBXBuildFile; fileRef = 331C809C294A618700263BE5 /* UIKitEncoded.png */; }; 331C809F294A63AB00263BE5 /* UIKitEncoded.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 331C809E294A618700263BE5 /* UIKitEncoded.jpg */; }; 33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; }; @@ -75,6 +77,8 @@ 4A71C0142F40A00100A17E01 /* NativeMessageActionSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageActionSurface.swift; sourceTree = ""; }; 4A71C0162F40B00100A17E01 /* HuddleMediaPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuddleMediaPlugin.swift; sourceTree = ""; }; 4A71C0182F40C00100A17E01 /* HuddleAudioEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuddleAudioEngine.swift; sourceTree = ""; }; + 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeProfileTextEditor.swift; sourceTree = ""; }; + 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSkinToneControl.swift; sourceTree = ""; }; 331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = ""; }; 331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -206,6 +210,8 @@ 4A71C0142F40A00100A17E01 /* NativeMessageActionSurface.swift */, 4A71C0162F40B00100A17E01 /* HuddleMediaPlugin.swift */, 4A71C0182F40C00100A17E01 /* HuddleAudioEngine.swift */, + 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */, + 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); @@ -255,6 +261,7 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, E0B5862D106D142B580309AF /* [CP] Embed Pods Frameworks */, + ED5DDC1D42A9D342928222CC /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -420,6 +427,23 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; + ED5DDC1D42A9D342928222CC /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -449,6 +473,8 @@ 4A71C0132F40A00100A17E01 /* NativeMessageActionSurface.swift in Sources */, 4A71C0152F40B00100A17E01 /* HuddleMediaPlugin.swift in Sources */, 4A71C0172F40C00100A17E01 /* HuddleAudioEngine.swift in Sources */, + 4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */, + 4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, ); diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 65878e64d8e..1ef121fff4a 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -11,6 +11,7 @@ import UserNotifications private var concentricSheetSurfaceChannel: FlutterMethodChannel? private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator? private var nativeEmojiPickerCoordinator: NativeEmojiPickerCoordinator? + private var nativeProfileTextEditorCoordinator: NativeProfileTextEditorCoordinator? private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel? private var huddleMediaPlugin: HuddleMediaPlugin? @@ -110,6 +111,24 @@ import UserNotifications ) } + if let segmentedControlRegistrar = engineBridge.pluginRegistry.registrar( + forPlugin: "BuzzNativeSegmentedControl" + ) { + segmentedControlRegistrar.register( + NativeSegmentedControlFactory(messenger: messenger), + withId: "buzz/native_segmented_control" + ) + } + + if let skinToneRegistrar = engineBridge.pluginRegistry.registrar( + forPlugin: "BuzzNativeSkinToneControl" + ) { + skinToneRegistrar.register( + NativeSkinToneControlFactory(messenger: messenger), + withId: "buzz/native_skin_tone_control" + ) + } + if let stickyDateGlassRegistrar = engineBridge.pluginRegistry.registrar( forPlugin: "BuzzStickyDateGlassHeader" ) { @@ -134,6 +153,14 @@ import UserNotifications messenger: messenger, parentViewController: nativeEmojiPickerRegistrar?.viewController ) + + let nativeProfileTextEditorRegistrar = engineBridge.pluginRegistry.registrar( + forPlugin: "BuzzNativeProfileTextEditor" + ) + nativeProfileTextEditorCoordinator = NativeProfileTextEditorCoordinator( + messenger: messenger, + parentViewController: nativeProfileTextEditorRegistrar?.viewController + ) if #available(iOS 16.0, *), let nativeMessageActionsRegistrar = engineBridge.pluginRegistry.registrar( forPlugin: "BuzzNativeMessageActionSurface" diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift index f613dea7119..8a95831808a 100644 --- a/mobile/ios/Runner/JumpToLatestGlassButton.swift +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -172,6 +172,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { private let containerView: UIView private let channel: FlutterMethodChannel private let button = NavigationGlassButton(type: .system) + private var buttonLabel: String? init( frame: CGRect, @@ -195,8 +196,12 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { (arguments?["hitTargetWidth"] as? NSNumber)?.doubleValue ?? 48 let hitTargetHeight = (arguments?["hitTargetHeight"] as? NSNumber)?.doubleValue ?? 48 + let label = arguments?["label"] as? String + buttonLabel = label let icon = arguments?["icon"] as? String let symbolName = icon == "close" ? "xmark" : "chevron.backward" + let controlWidth = + (arguments?["controlWidth"] as? NSNumber)?.doubleValue ?? 40 var configuration: UIButton.Configuration if #available(iOS 26.0, *) { @@ -206,14 +211,28 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { configuration.baseBackgroundColor = UIColor.secondarySystemBackground } configuration.cornerStyle = .capsule - configuration.image = UIImage( - systemName: symbolName, - withConfiguration: UIImage.SymbolConfiguration( - pointSize: 17, - weight: .semibold + if let label { + configuration.title = label + configuration.titleLineBreakMode = .byClipping + configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { + incoming in + var outgoing = incoming + let preferred = UIFont.preferredFont(forTextStyle: .subheadline) + outgoing.font = UIFont.systemFont(ofSize: preferred.pointSize, weight: .semibold) + return outgoing + } + } else { + configuration.image = UIImage( + systemName: symbolName, + withConfiguration: UIImage.SymbolConfiguration( + pointSize: 17, + weight: .semibold + ) ) - ) + } button.configuration = configuration + button.titleLabel?.numberOfLines = 1 + button.titleLabel?.lineBreakMode = .byClipping button.hitTargetInsets = UIEdgeInsets( top: max(0, (hitTargetHeight - 40) / 2), left: max(0, buttonCenterX - 20), @@ -246,7 +265,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { constant: buttonCenterX ), button.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), - button.widthAnchor.constraint(equalToConstant: 40), + button.widthAnchor.constraint(equalToConstant: controlWidth), button.heightAnchor.constraint(equalToConstant: 40), ]) } @@ -311,10 +330,13 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { let colorValue = (arguments?["foregroundColor"] as? NSNumber)?.uint32Value let foregroundColor = colorValue.map(Self.color(from:)) let enabled = arguments?["enabled"] as? Bool ?? true + let busy = arguments?["busy"] as? Bool ?? false containerView.overrideUserInterfaceStyle = interfaceStyle button.overrideUserInterfaceStyle = interfaceStyle button.isEnabled = enabled + button.configuration?.showsActivityIndicator = busy + button.configuration?.title = busy ? nil : buttonLabel if let foregroundColor { button.configuration?.baseForegroundColor = foregroundColor } @@ -339,3 +361,103 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { channel.setMethodCallHandler(nil) } } + +final class NativeSegmentedControlFactory: NSObject, FlutterPlatformViewFactory { + private let messenger: FlutterBinaryMessenger + + init(messenger: FlutterBinaryMessenger) { + self.messenger = messenger + super.init() + } + + func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + FlutterStandardMessageCodec.sharedInstance() + } + + func create( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any? + ) -> FlutterPlatformView { + NativeSegmentedControlPlatformView( + frame: frame, + viewIdentifier: viewId, + arguments: args, + messenger: messenger + ) + } +} + +final class NativeSegmentedControlPlatformView: NSObject, FlutterPlatformView { + private let containerView: UIView + private let channel: FlutterMethodChannel + private let segmentedControl: UISegmentedControl + + init( + frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any?, + messenger: FlutterBinaryMessenger + ) { + let arguments = args as? [String: Any] + let items = arguments?["items"] as? [String] ?? [] + containerView = UIView(frame: frame) + channel = FlutterMethodChannel( + name: "buzz/native_segmented_control/\(viewId)", + binaryMessenger: messenger + ) + segmentedControl = UISegmentedControl(items: items) + super.init() + + containerView.backgroundColor = .clear + containerView.isOpaque = false + segmentedControl.translatesAutoresizingMaskIntoConstraints = false + segmentedControl.addTarget( + self, + action: #selector(selectionChanged), + for: .valueChanged + ) + applyState(from: arguments) + + channel.setMethodCallHandler { [weak self] call, result in + guard call.method == "setState" else { + result(FlutterMethodNotImplemented) + return + } + self?.applyState(from: call.arguments) + result(nil) + } + + containerView.addSubview(segmentedControl) + NSLayoutConstraint.activate([ + segmentedControl.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + segmentedControl.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + segmentedControl.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + ]) + } + + func view() -> UIView { + containerView + } + + @objc private func selectionChanged() { + channel.invokeMethod("changed", arguments: segmentedControl.selectedSegmentIndex) + } + + private func applyState(from value: Any?) { + let arguments = value as? [String: Any] + let selectedIndex = (arguments?["selectedIndex"] as? NSNumber)?.intValue ?? 0 + let brightness = arguments?["brightness"] as? String + let enabled = arguments?["enabled"] as? Bool ?? true + let interfaceStyle: UIUserInterfaceStyle = brightness == "dark" ? .dark : .light + + containerView.overrideUserInterfaceStyle = interfaceStyle + segmentedControl.overrideUserInterfaceStyle = interfaceStyle + segmentedControl.selectedSegmentIndex = selectedIndex + segmentedControl.isEnabled = enabled + } + + deinit { + channel.setMethodCallHandler(nil) + } +} diff --git a/mobile/ios/Runner/NativeProfileTextEditor.swift b/mobile/ios/Runner/NativeProfileTextEditor.swift new file mode 100644 index 00000000000..9aec1d49972 --- /dev/null +++ b/mobile/ios/Runner/NativeProfileTextEditor.swift @@ -0,0 +1,372 @@ +import Flutter +import UIKit + +final class NativeProfileTextEditorCoordinator: NSObject, + UIAdaptivePresentationControllerDelegate +{ + private let channel: FlutterMethodChannel + private weak var parentViewController: UIViewController? + private weak var presentedController: UIViewController? + private var pendingResult: FlutterResult? + + init( + messenger: FlutterBinaryMessenger, + parentViewController: UIViewController? + ) { + channel = FlutterMethodChannel( + name: "buzz/profile_text_editor", + binaryMessenger: messenger + ) + self.parentViewController = parentViewController + super.init() + channel.setMethodCallHandler { [weak self] call, result in + self?.handle(call, result: result) + } + } + + private func handle( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + guard call.method == "present" else { + result(FlutterMethodNotImplemented) + return + } + guard + let arguments = call.arguments as? [String: Any], + let title = arguments["title"] as? String, + let initialValue = arguments["initialValue"] as? String, + let placeholder = arguments["placeholder"] as? String, + let multiline = arguments["multiline"] as? Bool + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected profile text editor configuration.", + details: nil + ) + ) + return + } + + DispatchQueue.main.async { [weak self] in + self?.present( + title: title, + initialValue: initialValue, + placeholder: placeholder, + multiline: multiline, + result: result + ) + } + } + + @MainActor + private func present( + title: String, + initialValue: String, + placeholder: String, + multiline: Bool, + result: @escaping FlutterResult + ) { + guard presentedController == nil else { + result( + FlutterError( + code: "already_presented", + message: "A profile editor is already open.", + details: nil + ) + ) + return + } + guard + let presenter = topViewController( + from: parentViewController ?? activeWindowRootViewController() + ) + else { + result( + FlutterError( + code: "presentation_failed", + message: "Unable to find a view controller for the profile editor.", + details: nil + ) + ) + return + } + + let editor = NativeProfileTextEditorViewController( + title: title, + initialValue: initialValue, + placeholder: placeholder, + multiline: multiline, + onCancel: { [weak self] in self?.finish(value: nil) }, + onSet: { [weak self] value in self?.finish(value: value) } + ) + let navigationController = UINavigationController(rootViewController: editor) + if UIDevice.current.userInterfaceIdiom == .pad { + navigationController.modalPresentationStyle = .formSheet + } + + pendingResult = result + presentedController = navigationController + presenter.present(navigationController, animated: true) { [weak self] in + navigationController.presentationController?.delegate = self + } + } + + @MainActor + private func finish(value: String?) { + guard let controller = presentedController else { + resolve(value: value) + return + } + controller.dismiss(animated: true) { [weak self] in + self?.resolve(value: value) + } + } + + func presentationControllerDidDismiss( + _ presentationController: UIPresentationController + ) { + resolve(value: nil) + } + + @MainActor + private func resolve(value: String?) { + let result = pendingResult + pendingResult = nil + presentedController = nil + result?(value) + } + + @MainActor + private func activeWindowRootViewController() -> UIViewController? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + .flatMap(\.windows) + .first(where: \.isKeyWindow)? + .rootViewController + } + + @MainActor + private func topViewController( + from viewController: UIViewController? + ) -> UIViewController? { + if let navigationController = viewController as? UINavigationController { + return topViewController(from: navigationController.visibleViewController) + } + if let tabBarController = viewController as? UITabBarController { + return topViewController(from: tabBarController.selectedViewController) + } + if let presentedViewController = viewController?.presentedViewController { + return topViewController(from: presentedViewController) + } + return viewController + } +} + +private final class NativeProfileTextEditorViewController: + UITableViewController, + UITextFieldDelegate, + UITextViewDelegate +{ + private let initialValue: String + private let placeholder: String + private let multiline: Bool + private let onCancel: () -> Void + private let onSet: (String) -> Void + + private lazy var textField: UITextField = { + let field = UITextField() + field.translatesAutoresizingMaskIntoConstraints = false + field.placeholder = placeholder + field.text = initialValue + field.font = .preferredFont(forTextStyle: .body) + field.adjustsFontForContentSizeCategory = true + field.clearButtonMode = .whileEditing + field.returnKeyType = .done + field.autocapitalizationType = .sentences + field.delegate = self + field.addTarget(self, action: #selector(textDidChange), for: .editingChanged) + return field + }() + + private lazy var textView: UITextView = { + let view = UITextView() + view.translatesAutoresizingMaskIntoConstraints = false + view.text = initialValue + view.font = .preferredFont(forTextStyle: .body) + view.adjustsFontForContentSizeCategory = true + view.backgroundColor = .clear + view.textContainerInset = .zero + view.textContainer.lineFragmentPadding = 0 + view.autocapitalizationType = .sentences + view.delegate = self + return view + }() + + private lazy var placeholderLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = placeholder + label.font = .preferredFont(forTextStyle: .body) + label.adjustsFontForContentSizeCategory = true + label.textColor = .placeholderText + label.numberOfLines = 0 + return label + }() + + init( + title: String, + initialValue: String, + placeholder: String, + multiline: Bool, + onCancel: @escaping () -> Void, + onSet: @escaping (String) -> Void + ) { + self.initialValue = initialValue + self.placeholder = placeholder + self.multiline = multiline + self.onCancel = onCancel + self.onSet = onSet + super.init(style: .insetGrouped) + self.title = title + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.keyboardDismissMode = .interactive + tableView.alwaysBounceVertical = false + navigationItem.leftBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .cancel, + target: self, + action: #selector(cancelTapped) + ) + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "Set", + style: .done, + target: self, + action: #selector(setTapped) + ) + updateNavigation() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + if multiline { + textView.becomeFirstResponder() + } else { + textField.becomeFirstResponder() + } + } + + private var currentValue: String { + multiline ? textView.text : (textField.text ?? "") + } + + private var hasUnsavedChanges: Bool { + currentValue.trimmingCharacters(in: .whitespacesAndNewlines) + != initialValue.trimmingCharacters(in: .whitespacesAndNewlines) + } + + override var isModalInPresentation: Bool { + get { hasUnsavedChanges } + set {} + } + + @objc private func textDidChange() { + updateNavigation() + } + + func textViewDidChange(_ textView: UITextView) { + updateNavigation() + } + + private func updateNavigation() { + navigationItem.rightBarButtonItem?.isEnabled = hasUnsavedChanges + navigationController?.isModalInPresentation = hasUnsavedChanges + placeholderLabel.isHidden = !textView.text.isEmpty + } + + @objc private func cancelTapped() { + guard hasUnsavedChanges else { + onCancel() + return + } + let alert = UIAlertController( + title: "Discard changes?", + message: nil, + preferredStyle: .actionSheet + ) + alert.addAction(UIAlertAction(title: "Keep Editing", style: .cancel)) + alert.addAction( + UIAlertAction(title: "Discard", style: .destructive) { [weak self] _ in + self?.onCancel() + } + ) + if let popover = alert.popoverPresentationController { + popover.barButtonItem = navigationItem.leftBarButtonItem + } + present(alert, animated: true) + } + + @objc private func setTapped() { + guard hasUnsavedChanges else { return } + onSet(currentValue) + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + setTapped() + return false + } + + override func numberOfSections(in tableView: UITableView) -> Int { 1 } + + override func tableView( + _ tableView: UITableView, + numberOfRowsInSection section: Int + ) -> Int { 1 } + + override func tableView( + _ tableView: UITableView, + heightForRowAt indexPath: IndexPath + ) -> CGFloat { + multiline ? 132 : 52 + } + + override func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.selectionStyle = .none + if multiline { + cell.contentView.addSubview(textView) + textView.addSubview(placeholderLabel) + NSLayoutConstraint.activate([ + textView.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor), + textView.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor), + textView.topAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.topAnchor), + textView.bottomAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.bottomAnchor), + placeholderLabel.leadingAnchor.constraint(equalTo: textView.leadingAnchor), + placeholderLabel.trailingAnchor.constraint(equalTo: textView.trailingAnchor), + placeholderLabel.topAnchor.constraint(equalTo: textView.topAnchor), + ]) + placeholderLabel.isHidden = !textView.text.isEmpty + } else { + cell.contentView.addSubview(textField) + NSLayoutConstraint.activate([ + textField.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor), + textField.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor), + textField.topAnchor.constraint(equalTo: cell.contentView.topAnchor), + textField.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor), + ]) + } + return cell + } +} diff --git a/mobile/ios/Runner/NativeSkinToneControl.swift b/mobile/ios/Runner/NativeSkinToneControl.swift new file mode 100644 index 00000000000..d6541363d25 --- /dev/null +++ b/mobile/ios/Runner/NativeSkinToneControl.swift @@ -0,0 +1,137 @@ +import Flutter +import UIKit + +final class NativeSkinToneControlFactory: NSObject, FlutterPlatformViewFactory { + private let messenger: FlutterBinaryMessenger + + init(messenger: FlutterBinaryMessenger) { + self.messenger = messenger + super.init() + } + + func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + FlutterStandardMessageCodec.sharedInstance() + } + + func create( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any? + ) -> FlutterPlatformView { + NativeSkinToneControlPlatformView( + frame: frame, + viewIdentifier: viewId, + arguments: args, + messenger: messenger + ) + } +} + +private final class NativeSkinToneControlPlatformView: NSObject, FlutterPlatformView { + private let containerView: UIView + private let channel: FlutterMethodChannel + private let button = UIButton(type: .system) + private var colors: [UIColor] = [] + private var labels: [String] = [] + private var value = 0 + + init( + frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any?, + messenger: FlutterBinaryMessenger + ) { + containerView = UIView(frame: frame) + channel = FlutterMethodChannel( + name: "buzz/native_skin_tone_control/\(viewId)", + binaryMessenger: messenger + ) + super.init() + + let arguments = args as? [String: Any] + colors = (arguments?["colors"] as? [NSNumber] ?? []).map { + Self.color(fromARGB: $0.uint32Value) + } + labels = arguments?["labels"] as? [String] ?? [] + value = (arguments?["value"] as? NSNumber)?.intValue ?? 0 + let style = arguments?["brightness"] as? String == "dark" + ? UIUserInterfaceStyle.dark + : UIUserInterfaceStyle.light + containerView.overrideUserInterfaceStyle = style + + containerView.backgroundColor = .clear + containerView.isOpaque = false + button.translatesAutoresizingMaskIntoConstraints = false + button.accessibilityLabel = "Skin tone" + button.showsMenuAsPrimaryAction = true + containerView.addSubview(button) + NSLayoutConstraint.activate([ + button.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), + button.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + button.widthAnchor.constraint(equalToConstant: 44), + button.heightAnchor.constraint(equalToConstant: 44), + ]) + + channel.setMethodCallHandler { [weak self] call, result in + guard call.method == "setValue", let value = call.arguments as? NSNumber else { + result(FlutterMethodNotImplemented) + return + } + self?.value = value.intValue + self?.refresh() + result(nil) + } + refresh() + } + + func view() -> UIView { containerView } + + private func refresh() { + guard !colors.isEmpty else { return } + value = min(max(0, value), colors.count - 1) + var configuration: UIButton.Configuration + if #available(iOS 26.0, *) { + configuration = .glass() + } else { + configuration = .gray() + configuration.baseBackgroundColor = .secondarySystemBackground + } + configuration.cornerStyle = .capsule + configuration.image = Self.toneImage(color: colors[value], size: 20) + button.configuration = configuration + button.menu = UIMenu(children: colors.indices.map { index in + UIAction( + title: index < labels.count ? labels[index] : "Skin tone \(index + 1)", + image: Self.toneImage(color: colors[index], size: 18), + state: index == value ? .on : .off + ) { [weak self] _ in + self?.value = index + self?.refresh() + self?.channel.invokeMethod("changed", arguments: index) + } + }) + } + + private static func toneImage(color: UIColor, size: CGFloat) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size)) + return renderer.image { context in + let rect = CGRect(x: 0.5, y: 0.5, width: size - 1, height: size - 1) + color.setFill() + context.cgContext.fillEllipse(in: rect) + UIColor.label.withAlphaComponent(0.35).setStroke() + context.cgContext.setLineWidth(1) + context.cgContext.strokeEllipse(in: rect) + }.withRenderingMode(.alwaysOriginal) + } + + private static func color(fromARGB value: UInt32) -> UIColor { + UIColor( + red: CGFloat((value >> 16) & 0xff) / 255, + green: CGFloat((value >> 8) & 0xff) / 255, + blue: CGFloat(value & 0xff) / 255, + alpha: CGFloat((value >> 24) & 0xff) / 255 + ) + } + + deinit { channel.setMethodCallHandler(nil) } +} diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index e020bd50bff..5ea55521522 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -16,6 +16,8 @@ import 'features/channels/channel_detail_page.dart'; import 'features/channels/deep_link_dispatcher.dart'; import 'features/profile/user_status_cache_provider.dart'; import 'features/profile/settings_profile_header.dart'; +import 'features/profile/profile_edit_page.dart'; +import 'features/profile/profile_text_editor.dart'; import 'features/settings/settings_page.dart'; import 'shared/auth/auth.dart'; import 'shared/deeplink/pending_deep_link_provider.dart'; @@ -156,6 +158,10 @@ class App extends HookConsumerWidget { Widget _buildSettingsPage(BuildContext context) => SettingsPage( profileHeader: const SettingsProfileHeader(), + profileEditPageBuilder: (_) => + const ProfileEditPage(startInPhotoEditor: true), + onEditDisplayName: showProfileDisplayNameEditor, + onEditProfileDescription: showProfileDescriptionEditor, invitePageBuilder: (_) => const CommunityInvitePage(), identityRecoveryPageBuilder: (_) => const PairingPage(addingCommunity: true, identityRecoveryOnly: true), diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 5f7b11db29c..04cb2ff8cdd 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -377,7 +377,7 @@ class ActivityPage extends HookConsumerWidget { automaticallyImplyLeading: false, horizontalInset: Grid.gutter, showBottomDivider: true, - bottomDividerOpacity: 0.06, + bottomDividerOpacity: 0.07, title: Text('Activity', style: headerTitleStyle), titleStyle: headerTitleStyle, actions: [ diff --git a/mobile/lib/features/channels/channel_details_page.dart b/mobile/lib/features/channels/channel_details_page.dart index 0fc68f7f3a8..3f2485cc1fa 100644 --- a/mobile/lib/features/channels/channel_details_page.dart +++ b/mobile/lib/features/channels/channel_details_page.dart @@ -255,6 +255,7 @@ class ChannelDetailsPage extends HookConsumerWidget { return FrostedScaffold( backgroundColor: context.colors.surface, appBar: FrostedAppBar( + centerTitle: true, leading: usesNativeIosGlassBackButton ? IosGlassNavigationButton( key: const ValueKey('channel-details-ios-glass-back'), @@ -279,7 +280,7 @@ class ChannelDetailsPage extends HookConsumerWidget { frostedBlurSigma: _channelDetailsHeaderFrostMaxBlurSigma * headerFrostProgress.value, showBottomDivider: headerFrostProgress.value > 0, - bottomDividerOpacity: 0.15 * headerFrostProgress.value, + bottomDividerOpacity: 0.07 * headerFrostProgress.value, title: AnimatedSwitcher( duration: reducedMotion ? Duration.zero diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index d50ed8c46e8..25f957e6653 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -334,6 +334,7 @@ class ChannelsPage extends HookConsumerWidget { : 20, showBottomDivider: false, leading: _CommunityIndicator(onTap: openCommunitySwitcher), + centerTitle: false, titleStyle: headerTitleStyle, title: _CommunityHeaderTitle( style: headerTitleStyle, @@ -346,6 +347,7 @@ class ChannelsPage extends HookConsumerWidget { child: Center( child: ProfileAvatar( size: _kTopSectionProfileAvatarSize, + showPresence: false, onTap: () { unawaited(HapticFeedback.lightImpact()); final route = _SettingsPageRoute( @@ -391,8 +393,8 @@ class _SettingsPageRoute extends PageRouteBuilder { transitionsBuilder: _buildSettingsTransition, opaque: false, allowSnapshotting: false, - transitionDuration: const Duration(milliseconds: 220), - reverseTransitionDuration: const Duration(milliseconds: 190), + transitionDuration: const Duration(milliseconds: 150), + reverseTransitionDuration: const Duration(milliseconds: 150), ); final ValueChanged onTransitionProgress; diff --git a/mobile/lib/features/invites/invite_create_page.dart b/mobile/lib/features/invites/invite_create_page.dart index b6ce235f65e..f126f1210c4 100644 --- a/mobile/lib/features/invites/invite_create_page.dart +++ b/mobile/lib/features/invites/invite_create_page.dart @@ -32,7 +32,10 @@ class CommunityInvitePage extends ConsumerWidget { final roleAsync = ref.watch(currentCommunityRoleProvider); return FrostedScaffold( backgroundColor: context.colors.surface, - appBar: const FrostedAppBar(title: Text('Invite to community')), + appBar: const FrostedAppBar( + centerTitle: true, + title: Text('Invite to community'), + ), body: roleAsync.when( loading: () => const Center( child: BuzzLoadingIndicator( diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart new file mode 100644 index 00000000000..2288b563844 --- /dev/null +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -0,0 +1,958 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:camera/camera.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:google_mlkit_selfie_segmentation/google_mlkit_selfie_segmentation.dart'; +import 'package:image/image.dart' as image; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../../shared/animated_avatar.dart'; +import '../../shared/emoji/emoji_avatar.dart'; +import '../../shared/relay/relay.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; +import 'avatar_background_grid.dart'; +import 'avatar_editor_option_button.dart'; + +part 'animated_avatar_capture/review_controls.dart'; +part 'animated_avatar_capture/capture_controls.dart'; + +const _captureDuration = Duration(seconds: 3); +const _captureFrameInterval = Duration(milliseconds: 125); +const _captureFrameCount = 24; +const _outputSize = 256; +const _mobileDefaultPersonScale = 1.15; +const _animatedReviewRailHeight = 88.0; + +enum _AnimatedReviewSection { person, color, poster } + +/// Records and prepares a short camera animation for a profile avatar. +class AnimatedAvatarCapture extends HookConsumerWidget { + const AnimatedAvatarCapture({ + super.key, + required this.height, + required this.onPrepareChanged, + }); + + final double height; + final ValueChanged Function()?> onPrepareChanged; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = useState(null); + final controllerRef = useRef(null); + final isInitializing = useState(true); + final isRecording = useState(false); + final isPreparingFrames = useState(false); + final isProcessing = useState(false); + final progress = useState(0.0); + final frames = useState>(const []); + final posterIndex = useState(0); + final previewFrameIndex = useState(0); + final scale = useState(_mobileDefaultPersonScale); + final offset = useState(Offset.zero); + final shapeScale = useState(1.12); + final shapeOffset = useState(const Offset(0, -7 / 60)); + final activeSection = useState(_AnimatedReviewSection.person); + final backdropColor = useState(emojiAvatarColors[8]); + final personOutline = useState(true); + final gestureStartScale = useRef(_mobileDefaultPersonScale); + final error = useState(null); + final encodedCache = useRef<_EncodedAvatarCache?>(null); + + final encodeKey = frames.value.isEmpty + ? null + : _EncodeKey( + frames: frames.value, + posterIndex: posterIndex.value, + scale: scale.value, + offsetX: offset.value.dx, + offsetY: offset.value.dy, + backdropColor: backdropColor.value, + personOutline: personOutline.value, + shapeScale: shapeScale.value, + shapeOffsetX: shapeOffset.value.dx, + shapeOffsetY: shapeOffset.value.dy, + ); + + useEffect(() { + encodedCache.value = null; + final key = encodeKey; + if (key == null) return null; + final timer = Timer(const Duration(milliseconds: 180), () { + if (encodedCache.value?.key == key) return; + final future = compute(_encodeAvatar, key.toRequest()); + encodedCache.value = _EncodedAvatarCache(key, future); + }); + return timer.cancel; + }, [encodeKey]); + + useEffect(() { + if (frames.value.length < 2) return null; + final timer = Timer.periodic(_captureFrameInterval, (_) { + if (activeSection.value == _AnimatedReviewSection.poster) return; + previewFrameIndex.value = + (previewFrameIndex.value + 1) % frames.value.length; + }); + return timer.cancel; + }, [frames.value]); + + useEffect(() { + var disposed = false; + + Future initialize() async { + try { + final cameras = await availableCameras(); + if (disposed || cameras.isEmpty) return; + final selected = cameras.firstWhere( + (camera) => camera.lensDirection == CameraLensDirection.front, + orElse: () => cameras.first, + ); + final next = CameraController( + selected, + ResolutionPreset.medium, + enableAudio: false, + imageFormatGroup: defaultTargetPlatform == TargetPlatform.iOS + ? ImageFormatGroup.bgra8888 + : ImageFormatGroup.yuv420, + ); + await next.initialize(); + if (disposed) { + await next.dispose(); + return; + } + controllerRef.value = next; + controller.value = next; + } catch (_) { + if (!disposed) error.value = 'Could not access the camera.'; + } finally { + if (!disposed) isInitializing.value = false; + } + } + + unawaited(initialize()); + return () { + disposed = true; + onPrepareChanged(null); + final active = controllerRef.value; + controllerRef.value = null; + unawaited(active?.dispose() ?? Future.value()); + }; + }, const []); + + Future prepare() async { + final key = encodeKey; + if (key == null) return null; + isProcessing.value = true; + error.value = null; + try { + final cached = encodedCache.value; + final encoding = cached != null && cached.key == key + ? cached.future + : compute(_encodeAvatar, key.toRequest()); + encodedCache.value = _EncodedAvatarCache(key, encoding); + final result = await encoding; + final service = ref.read(mediaUploadServiceProvider); + final uploads = await Future.wait([ + service.uploadBytes(result.animation, mimeType: 'image/png'), + service.uploadBytes(result.poster, mimeType: 'image/png'), + ]); + return buildAnimatedAvatarUrl(uploads[1].url, uploads[0].url); + } catch (_) { + error.value = "We couldn't create that animation. Try again."; + return null; + } finally { + if (context.mounted) isProcessing.value = false; + } + } + + useEffect( + () { + final nextPrepare = frames.value.isEmpty ? null : prepare; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) onPrepareChanged(nextPrepare); + }); + return null; + }, + [ + frames.value, + posterIndex.value, + scale.value, + offset.value, + backdropColor.value, + personOutline.value, + shapeScale.value, + shapeOffset.value, + ], + ); + + Future record() async { + final active = controller.value; + if (active == null || isRecording.value) return; + frames.value = const []; + posterIndex.value = 0; + previewFrameIndex.value = 0; + scale.value = _mobileDefaultPersonScale; + offset.value = Offset.zero; + shapeScale.value = 1.12; + shapeOffset.value = const Offset(0, -7 / 60); + error.value = null; + progress.value = 0; + isRecording.value = true; + final captured = []; + final startedAt = DateTime.now(); + var lastFrameAt = DateTime.fromMillisecondsSinceEpoch(0); + var converting = false; + + final timer = Timer.periodic(const Duration(milliseconds: 40), (_) { + if (!context.mounted) return; + final elapsed = DateTime.now().difference(startedAt); + progress.value = + (elapsed.inMilliseconds / _captureDuration.inMilliseconds).clamp( + 0, + 1, + ); + }); + + try { + await active.startImageStream((cameraImage) async { + final now = DateTime.now(); + if (converting || + now.difference(lastFrameAt) < _captureFrameInterval || + now.difference(startedAt) >= _captureDuration) { + return; + } + converting = true; + lastFrameAt = now; + try { + final request = _FrameRequest.fromCameraImage( + cameraImage, + sensorOrientation: active.description.sensorOrientation, + mirror: + active.description.lensDirection == CameraLensDirection.front, + ); + captured.add(await compute(_convertCameraFrame, request)); + } finally { + converting = false; + } + }); + await Future.delayed(_captureDuration); + await active.stopImageStream(); + while (converting) { + await Future.delayed(const Duration(milliseconds: 10)); + } + if (captured.length < 2) { + throw StateError('Not enough frames were captured.'); + } + isRecording.value = false; + isPreparingFrames.value = true; + // Camera image conversion throughput differs by platform. Cut out only + // the frames each device captured, then resample the same three-second + // window onto one fixed timeline so Android and iOS produce the same + // frame count and playback cadence without duplicating segmentation. + final cutouts = await _removeBackgrounds(captured); + final processed = List.unmodifiable( + _resampleCapturedFrames(cutouts, _captureFrameCount), + ); + if (!context.mounted) return; + await Future.wait([ + for (final frame in processed) + precacheImage(MemoryImage(frame), context), + ]); + if (context.mounted) frames.value = processed; + } catch (_) { + if (active.value.isStreamingImages) await active.stopImageStream(); + error.value = 'Recording failed. Try again.'; + } finally { + timer.cancel(); + if (context.mounted) { + progress.value = 1; + isRecording.value = false; + isPreparingFrames.value = false; + } + } + } + + if (frames.value.isNotEmpty) { + final selectedFrame = + frames.value[activeSection.value == _AnimatedReviewSection.poster + ? posterIndex.value + : previewFrameIndex.value]; + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final previewTop = activeSection.value == _AnimatedReviewSection.color + ? -avatarBackgroundPreviewShift + : 0.0; + final controlsTop = previewTop + 220; + return SizedBox( + height: height, + child: Stack( + clipBehavior: Clip.none, + children: [ + AnimatedPositioned( + key: const ValueKey('animated-review-preview-position'), + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + left: 0, + right: 0, + top: previewTop, + height: 220, + child: Center( + child: GestureDetector( + key: const ValueKey('animated-avatar-review-preview'), + behavior: HitTestBehavior.opaque, + onScaleStart: (_) => gestureStartScale.value = scale.value, + onScaleUpdate: (details) { + final next = Offset( + (offset.value.dx + details.focalPointDelta.dx / 96).clamp( + -1, + 1, + ), + (offset.value.dy + details.focalPointDelta.dy / 96).clamp( + -1, + 1, + ), + ); + offset.value = next; + scale.value = (gestureStartScale.value * details.scale) + .clamp(0.7, 2.0) + .toDouble(); + }, + child: SizedBox.square( + dimension: 220, + child: Stack( + fit: StackFit.expand, + children: [ + ClipOval( + child: Stack( + fit: StackFit.expand, + children: [ + Center( + child: Transform.translate( + offset: + const Offset(0, 20.625) + + shapeOffset.value * 51.5625, + child: Transform.scale( + scale: shapeScale.value, + child: Container( + width: 172, + height: 172, + decoration: BoxDecoration( + color: Color(backdropColor.value), + shape: BoxShape.circle, + ), + ), + ), + ), + ), + _AnimatedPersonPreview( + bytes: selectedFrame, + offset: offset.value * 48, + scale: scale.value, + outline: personOutline.value, + outlineColor: _personOutlineColor( + backdropColor.value, + ), + ), + ], + ), + ), + IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: context.colors.onSurface.withValues( + alpha: 0.1, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + AnimatedPositioned( + key: const ValueKey('animated-review-controls-position'), + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + left: 0, + right: 0, + top: controlsTop, + bottom: _animatedReviewRailHeight, + child: Align( + alignment: Alignment.center, + child: SizedBox( + width: double.infinity, + child: switch (activeSection.value) { + _AnimatedReviewSection.person => _AnimatedFramingControl( + key: const ValueKey('animated-review-you'), + scale: scale.value, + outline: personOutline.value, + onScaleChanged: (value) => scale.value = value, + onOutlineChanged: (value) => personOutline.value = value, + ), + _AnimatedReviewSection.color => AvatarBackgroundGrid( + key: const ValueKey('animated-review-background'), + selectedColor: backdropColor.value, + onColorSelected: (value) => backdropColor.value = value, + colorKeyPrefix: 'animated-avatar-color', + ), + _AnimatedReviewSection.poster => _AnimatedFrameControl( + key: const ValueKey('animated-review-frame'), + frames: frames.value, + selectedIndex: posterIndex.value, + onSelected: (value) => posterIndex.value = value, + ), + }, + ), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + height: _animatedReviewRailHeight, + child: _AnimatedReviewNav( + selected: activeSection.value, + onSelected: (section) => activeSection.value = section, + onRetake: isProcessing.value + ? null + : () { + frames.value = const []; + onPrepareChanged(null); + }, + ), + ), + if (error.value != null) + Positioned( + left: 0, + right: 0, + bottom: _animatedReviewRailHeight + Grid.xs, + child: _ErrorText(error.value!), + ), + ], + ), + ); + } + + final active = controller.value; + return SizedBox( + height: height, + child: Column( + children: [ + SizedBox.square( + key: const ValueKey('animated-avatar-capture-preview'), + dimension: 228, + child: Stack( + fit: StackFit.expand, + children: [ + Padding( + padding: const EdgeInsets.all(4), + child: ClipOval( + child: ColoredBox( + color: Colors.black, + child: isPreparingFrames.value + ? Center( + child: BuzzLoadingIndicator( + size: 44, + color: context.colors.onSurface, + semanticLabel: 'Preparing animated avatar', + ), + ) + : active != null + ? _AspectCorrectCameraPreview(controller: active) + : Center( + child: isInitializing.value + ? const BuzzLoadingIndicator( + color: Colors.white, + semanticLabel: 'Starting camera', + ) + : const Icon( + LucideIcons.cameraOff, + color: Colors.white, + size: 32, + ), + ), + ), + ), + ), + if (isRecording.value) + Padding( + // A progress stroke is centred on its circular path. Inset + // it so the outer half is not clipped by the preview stack. + padding: const EdgeInsets.all(2), + child: CircularProgressIndicator( + key: const ValueKey('animated-avatar-recording-ring'), + value: progress.value, + strokeWidth: 4, + strokeCap: StrokeCap.round, + color: context.colors.onSurface, + backgroundColor: context.colors.outlineVariant, + ), + ), + ], + ), + ), + const SizedBox(height: Grid.xs), + Text( + isRecording.value + ? 'Recording…' + : isPreparingFrames.value + ? 'Cutting you out of the background…' + : 'Line up your shot.', + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + if (error.value != null) _ErrorText(error.value!), + const Spacer(), + _AnimatedRecordButton( + busy: isRecording.value || isPreparingFrames.value, + onPressed: active == null + ? null + : () { + unawaited(HapticFeedback.mediumImpact()); + unawaited(record()); + }, + ), + ], + ), + ); + } +} + +List _resampleCapturedFrames( + List frames, + int targetCount, +) { + if (frames.isEmpty || targetCount <= 0) return const []; + if (targetCount == 1) return [frames.first]; + if (frames.length == 1) return List.filled(targetCount, frames.single); + if (frames.length == targetCount) return frames; + + return List.generate(targetCount, (index) { + final progress = index / (targetCount - 1); + final sourceIndex = (progress * (frames.length - 1)).round(); + return frames[sourceIndex]; + }, growable: false); +} + +Future> _removeBackgrounds(List frames) async { + final segmenter = SelfieSegmenter( + mode: SegmenterMode.stream, + enableRawSizeMask: false, + ); + final directory = await getTemporaryDirectory(); + final results = []; + try { + for (var index = 0; index < frames.length; index++) { + final file = File('${directory.path}/buzz-avatar-frame-$index.png'); + await file.writeAsBytes(frames[index], flush: false); + final mask = await segmenter.processImage( + InputImage.fromFilePath(file.path), + ); + await file.delete().catchError((_) => file); + if (mask == null) { + results.add(frames[index]); + continue; + } + results.add( + await compute( + _applySegmentationMask, + _MaskRequest( + frame: frames[index], + maskWidth: mask.width, + maskHeight: mask.height, + confidences: Float32List.fromList(mask.confidences), + ), + ), + ); + } + } finally { + await segmenter.close(); + } + return results; +} + +class _ErrorText extends StatelessWidget { + const _ErrorText(this.message); + + final String message; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: Grid.xs), + child: Text( + message, + textAlign: TextAlign.center, + style: context.textTheme.bodySmall?.copyWith(color: context.colors.error), + ), + ); +} + +Color _personOutlineColor(int backdropColor) { + final color = Color(backdropColor); + return color.computeLuminance() > 0.74 + ? const Color(0xFF111111) + : Colors.white; +} + +@immutable +class _FramePlane { + const _FramePlane(this.bytes, this.bytesPerRow, this.bytesPerPixel); + + final Uint8List bytes; + final int bytesPerRow; + final int bytesPerPixel; +} + +@immutable +class _MaskRequest { + const _MaskRequest({ + required this.frame, + required this.maskWidth, + required this.maskHeight, + required this.confidences, + }); + + final Uint8List frame; + final int maskWidth; + final int maskHeight; + final Float32List confidences; +} + +Uint8List _applySegmentationMask(_MaskRequest request) { + final result = image.decodePng(request.frame)!.convert(numChannels: 4); + for (var y = 0; y < result.height; y++) { + final maskY = (y * request.maskHeight / result.height).floor().clamp( + 0, + request.maskHeight - 1, + ); + for (var x = 0; x < result.width; x++) { + final maskX = (x * request.maskWidth / result.width).floor().clamp( + 0, + request.maskWidth - 1, + ); + final confidence = request.confidences[maskY * request.maskWidth + maskX]; + final alpha = ((confidence - 0.28) / (0.72 - 0.28)).clamp(0, 1); + final pixel = result.getPixel(x, y); + pixel.a = (alpha * 255).round(); + } + } + return image.encodePng(result, level: 4); +} + +@immutable +class _FrameRequest { + const _FrameRequest({ + required this.width, + required this.height, + required this.planes, + required this.isBgra, + required this.sensorOrientation, + required this.mirror, + }); + + factory _FrameRequest.fromCameraImage( + CameraImage frame, { + required int sensorOrientation, + required bool mirror, + }) => _FrameRequest( + width: frame.width, + height: frame.height, + planes: frame.planes + .map( + (plane) => _FramePlane( + Uint8List.fromList(plane.bytes), + plane.bytesPerRow, + frame.format.group == ImageFormatGroup.bgra8888 + ? 4 + : plane.bytesPerPixel ?? 1, + ), + ) + .toList(growable: false), + isBgra: frame.format.group == ImageFormatGroup.bgra8888, + sensorOrientation: sensorOrientation, + mirror: mirror, + ); + + final int width; + final int height; + final List<_FramePlane> planes; + final bool isBgra; + final int sensorOrientation; + final bool mirror; +} + +Uint8List _convertCameraFrame(_FrameRequest request) { + var result = image.Image(width: request.width, height: request.height); + if (request.isBgra) { + final plane = request.planes.first; + for (var y = 0; y < request.height; y++) { + for (var x = 0; x < request.width; x++) { + final index = y * plane.bytesPerRow + x * plane.bytesPerPixel; + result.setPixelRgba( + x, + y, + plane.bytes[index + 2], + plane.bytes[index + 1], + plane.bytes[index], + plane.bytes[index + 3], + ); + } + } + } else { + final yPlane = request.planes[0]; + final uPlane = request.planes[1]; + final vPlane = request.planes[2]; + for (var y = 0; y < request.height; y++) { + for (var x = 0; x < request.width; x++) { + final yValue = yPlane.bytes[y * yPlane.bytesPerRow + x]; + final uvX = x ~/ 2; + final uvY = y ~/ 2; + final u = + uPlane.bytes[uvY * uPlane.bytesPerRow + uvX * uPlane.bytesPerPixel]; + final v = + vPlane.bytes[uvY * vPlane.bytesPerRow + uvX * vPlane.bytesPerPixel]; + final red = (yValue + 1.402 * (v - 128)).round().clamp(0, 255); + final green = (yValue - 0.344136 * (u - 128) - 0.714136 * (v - 128)) + .round() + .clamp(0, 255); + final blue = (yValue + 1.772 * (u - 128)).round().clamp(0, 255); + result.setPixelRgb(x, y, red, green, blue); + } + } + } + // camera_avfoundation applies the capture connection's orientation and front + // camera mirroring to BGRA pixel buffers before streaming them to Flutter. + // Android YUV buffers remain sensor-oriented and need both corrections here. + if (!request.isBgra && request.sensorOrientation != 0) { + result = image.copyRotate(result, angle: request.sensorOrientation); + } + if (!request.isBgra && request.mirror) { + result = image.flipHorizontal(result); + } + final side = result.width < result.height ? result.width : result.height; + result = image.copyCrop( + result, + x: (result.width - side) ~/ 2, + y: (result.height - side) ~/ 2, + width: side, + height: side, + ); + result = image.copyResize(result, width: _outputSize, height: _outputSize); + return image.encodePng(result, level: 4); +} + +@immutable +class _EncodeRequest { + const _EncodeRequest({ + required this.frames, + required this.posterIndex, + required this.scale, + required this.offsetX, + required this.offsetY, + required this.backdropColor, + required this.personOutline, + required this.shapeScale, + required this.shapeOffsetX, + required this.shapeOffsetY, + }); + + final List frames; + final int posterIndex; + final double scale; + final double offsetX; + final double offsetY; + final int backdropColor; + final bool personOutline; + final double shapeScale; + final double shapeOffsetX; + final double shapeOffsetY; +} + +@immutable +class _EncodeKey { + const _EncodeKey({ + required this.frames, + required this.posterIndex, + required this.scale, + required this.offsetX, + required this.offsetY, + required this.backdropColor, + required this.personOutline, + required this.shapeScale, + required this.shapeOffsetX, + required this.shapeOffsetY, + }); + + final List frames; + final int posterIndex; + final double scale; + final double offsetX; + final double offsetY; + final int backdropColor; + final bool personOutline; + final double shapeScale; + final double shapeOffsetX; + final double shapeOffsetY; + + _EncodeRequest toRequest() => _EncodeRequest( + frames: frames, + posterIndex: posterIndex, + scale: scale, + offsetX: offsetX, + offsetY: offsetY, + backdropColor: backdropColor, + personOutline: personOutline, + shapeScale: shapeScale, + shapeOffsetX: shapeOffsetX, + shapeOffsetY: shapeOffsetY, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _EncodeKey && + identical(frames, other.frames) && + posterIndex == other.posterIndex && + scale == other.scale && + offsetX == other.offsetX && + offsetY == other.offsetY && + backdropColor == other.backdropColor && + personOutline == other.personOutline && + shapeScale == other.shapeScale && + shapeOffsetX == other.shapeOffsetX && + shapeOffsetY == other.shapeOffsetY; + + @override + int get hashCode => Object.hash( + identityHashCode(frames), + posterIndex, + scale, + offsetX, + offsetY, + backdropColor, + personOutline, + shapeScale, + shapeOffsetX, + shapeOffsetY, + ); +} + +class _EncodedAvatarCache { + const _EncodedAvatarCache(this.key, this.future); + + final _EncodeKey key; + final Future<_EncodedAvatar> future; +} + +@immutable +class _EncodedAvatar { + const _EncodedAvatar(this.animation, this.poster); + + final Uint8List animation; + final Uint8List poster; +} + +_EncodedAvatar _encodeAvatar(_EncodeRequest request) { + final composed = request.frames + .map((bytes) { + final source = image.decodePng(bytes)!; + final cropSize = (source.width / request.scale).round().clamp( + 1, + source.width, + ); + final available = source.width - cropSize; + final x = ((available / 2) - request.offsetX * available / 2) + .round() + .clamp(0, available); + final y = ((available / 2) - request.offsetY * available / 2) + .round() + .clamp(0, available); + final person = image.copyResize( + image.copyCrop(source, x: x, y: y, width: cropSize, height: cropSize), + width: _outputSize, + height: _outputSize, + ); + final frame = image.Image( + width: _outputSize, + height: _outputSize, + numChannels: 4, + ); + final color = request.backdropColor; + image.fillCircle( + frame, + x: (_outputSize / 2 + request.shapeOffsetX * 60).round(), + y: (_outputSize / 2 + 24 + request.shapeOffsetY * 60).round(), + radius: (100 * request.shapeScale).round(), + color: image.ColorRgba8( + (color >> 16) & 0xff, + (color >> 8) & 0xff, + color & 0xff, + 255, + ), + ); + if (request.personOutline) { + final outline = image.Image.from(person); + final outlineColor = _personOutlineColor(request.backdropColor); + for (final pixel in outline) { + pixel + ..r = (outlineColor.r * 255).round() + ..g = (outlineColor.g * 255).round() + ..b = (outlineColor.b * 255).round() + ..a = (pixel.a * 0.92).round(); + } + for (final (x, y) in const [ + (-2, 0), + (2, 0), + (0, -2), + (0, 2), + (-1, -1), + (1, -1), + (-1, 1), + (1, 1), + ]) { + image.compositeImage(frame, outline, dstX: x, dstY: y); + } + } + image.compositeImage(frame, person); + frame.frameDuration = _captureFrameInterval.inMilliseconds; + return frame; + }) + .toList(growable: false); + final pingPong = [ + ...composed, + ...composed.reversed.skip(1).skipLast(1), + ]; + final encoder = image.PngEncoder()..start(pingPong.length); + for (final frame in pingPong) { + encoder.addFrame(frame); + } + final animation = encoder.finish()!; + final poster = image.encodePng( + composed[request.posterIndex.clamp(0, composed.length - 1)], + ); + return _EncodedAvatar(animation, poster); +} + +extension on Iterable { + Iterable skipLast(int count) { + final values = toList(growable: false); + return values.take((values.length - count).clamp(0, values.length)); + } +} diff --git a/mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart b/mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart new file mode 100644 index 00000000000..7d5968721ad --- /dev/null +++ b/mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart @@ -0,0 +1,140 @@ +part of '../animated_avatar_capture.dart'; + +class _AnimatedRecordButton extends StatelessWidget { + const _AnimatedRecordButton({required this.busy, required this.onPressed}); + + final bool busy; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + return LayoutBuilder( + builder: (context, constraints) => Center( + child: AnimatedContainer( + key: const ValueKey('animated-avatar-record-morph'), + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + width: busy ? 64 : constraints.maxWidth, + height: 64, + child: Material( + color: context.colors.onSurface, + borderRadius: BorderRadius.circular(Radii.card), + clipBehavior: Clip.antiAlias, + child: InkWell( + key: const ValueKey('animated-avatar-record'), + onTap: busy ? null : onPressed, + child: Center( + child: AnimatedSwitcher( + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 150), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: busy + ? BuzzLoadingIndicator( + key: const ValueKey('animated-avatar-capturing'), + size: 24, + color: context.colors.surface, + semanticLabel: 'Capturing animated avatar', + ) + : Text( + 'Record', + key: const ValueKey('animated-avatar-record-label'), + style: context.textTheme.labelLarge?.copyWith( + color: context.colors.surface, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _AspectCorrectCameraPreview extends StatelessWidget { + const _AspectCorrectCameraPreview({required this.controller}); + + final CameraController controller; + + @override + Widget build(BuildContext context) { + final orientation = controller.value.deviceOrientation; + final isLandscape = + orientation == DeviceOrientation.landscapeLeft || + orientation == DeviceOrientation.landscapeRight; + final aspectRatio = isLandscape + ? controller.value.aspectRatio + : 1 / controller.value.aspectRatio; + return FittedBox( + fit: BoxFit.cover, + clipBehavior: Clip.hardEdge, + child: SizedBox( + width: 220 * aspectRatio, + height: 220, + child: CameraPreview(controller), + ), + ); + } +} + +class _AnimatedPersonPreview extends StatelessWidget { + const _AnimatedPersonPreview({ + required this.bytes, + required this.offset, + required this.scale, + required this.outline, + required this.outlineColor, + }); + + final Uint8List bytes; + final Offset offset; + final double scale; + final bool outline; + final Color outlineColor; + + @override + Widget build(BuildContext context) { + Widget person({Color? tint, Offset outlineOffset = Offset.zero}) => + Transform.translate( + offset: offset + outlineOffset, + child: Transform.scale( + scale: scale, + child: tint == null + ? Image.memory(bytes, fit: BoxFit.cover) + : Opacity( + opacity: 0.92, + child: ColorFiltered( + colorFilter: ColorFilter.mode(tint, BlendMode.srcIn), + child: Image.memory(bytes, fit: BoxFit.cover), + ), + ), + ), + ); + + return Stack( + fit: StackFit.expand, + children: [ + if (outline) + for (final outlineOffset in const [ + Offset(0, -2.4), + Offset(2.4, 0), + Offset(0, 2.4), + Offset(-2.4, 0), + Offset(1.7, -1.7), + Offset(1.7, 1.7), + Offset(-1.7, 1.7), + Offset(-1.7, -1.7), + ]) + person(tint: outlineColor, outlineOffset: outlineOffset), + person(), + ], + ); + } +} diff --git a/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart b/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart new file mode 100644 index 00000000000..7da739d7a09 --- /dev/null +++ b/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart @@ -0,0 +1,355 @@ +part of '../animated_avatar_capture.dart'; + +class _AnimatedReviewNav extends StatelessWidget { + const _AnimatedReviewNav({ + required this.selected, + required this.onSelected, + required this.onRetake, + }); + + final _AnimatedReviewSection selected; + final ValueChanged<_AnimatedReviewSection> onSelected; + final VoidCallback? onRetake; + + @override + Widget build(BuildContext context) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: AvatarEditorOptionButton( + icon: LucideIcons.userRound, + label: 'You', + selected: selected == _AnimatedReviewSection.person, + onTap: () => onSelected(_AnimatedReviewSection.person), + ), + ), + const SizedBox(width: Grid.half), + Expanded( + child: AvatarEditorOptionButton( + icon: LucideIcons.palette, + label: 'Background', + selected: selected == _AnimatedReviewSection.color, + onTap: () => onSelected(_AnimatedReviewSection.color), + ), + ), + const SizedBox(width: Grid.half), + Expanded( + child: AvatarEditorOptionButton( + icon: LucideIcons.galleryThumbnails, + label: 'Frame', + selected: selected == _AnimatedReviewSection.poster, + onTap: () => onSelected(_AnimatedReviewSection.poster), + ), + ), + const SizedBox(width: Grid.half), + Container(width: 1, height: 64, color: context.colors.outlineVariant), + const SizedBox(width: Grid.half), + Expanded( + child: AvatarEditorOptionButton( + icon: LucideIcons.camera, + label: 'Retake', + selected: false, + onTap: onRetake, + ), + ), + ], + ); +} + +class _AnimatedFramingControl extends HookWidget { + const _AnimatedFramingControl({ + super.key, + required this.scale, + required this.outline, + required this.onScaleChanged, + required this.onOutlineChanged, + }); + + final double scale; + final bool outline; + final ValueChanged onScaleChanged; + final ValueChanged onOutlineChanged; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: _DesktopStyleFramingSlider( + value: scale, + onChanged: onScaleChanged, + ), + ), + const SizedBox(width: Grid.xxs), + DecoratedBox( + decoration: BoxDecoration( + color: context.colors.surface, + borderRadius: BorderRadius.circular(Radii.sm), + border: Border.all( + color: context.colors.onSurface.withValues(alpha: 0.08), + ), + ), + child: IconButton( + key: const ValueKey('animated-avatar-outline'), + tooltip: outline ? 'Turn outline off' : 'Turn outline on', + onPressed: () { + unawaited(HapticFeedback.selectionClick()); + onOutlineChanged(!outline); + }, + icon: Icon( + outline ? LucideIcons.circle : LucideIcons.circleDashed, + size: 24, + ), + constraints: const BoxConstraints.tightFor( + width: 56, + height: 56, + ), + ), + ), + ], + ), + const SizedBox(height: Grid.xxs), + Text( + 'Drag the preview to reposition', + style: context.textTheme.bodySmall, + ), + ], + ); + } +} + +class _DesktopStyleFramingSlider extends HookWidget { + const _DesktopStyleFramingSlider({ + required this.value, + required this.onChanged, + }); + + static const _min = 0.7; + static const _max = 2.0; + + final double value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final lastTick = useRef((value * 20).round()); + + void update(double dx, double width) { + if (width <= 0) return; + final progress = (dx / width).clamp(0.0, 1.0).toDouble(); + final next = _min + progress * (_max - _min); + final tick = (next * 20).round(); + if (tick != lastTick.value) { + lastTick.value = tick; + unawaited(HapticFeedback.selectionClick()); + } + onChanged(next); + } + + final progress = ((value - _min) / (_max - _min)) + .clamp(0.0, 1.0) + .toDouble(); + return Semantics( + slider: true, + label: 'Avatar size', + value: '${(value * 100).round()}%', + onIncrease: () => onChanged((value + 0.05).clamp(_min, _max).toDouble()), + onDecrease: () => onChanged((value - 0.05).clamp(_min, _max).toDouble()), + child: SizedBox( + key: const ValueKey('animated-avatar-scale'), + height: 56, + child: LayoutBuilder( + builder: (context, constraints) { + final handleX = (constraints.maxWidth * progress) + .clamp(2.0, constraints.maxWidth - 2) + .toDouble(); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (details) => + update(details.localPosition.dx, constraints.maxWidth), + onHorizontalDragStart: (details) => + update(details.localPosition.dx, constraints.maxWidth), + onHorizontalDragUpdate: (details) => + update(details.localPosition.dx, constraints.maxWidth), + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.centerLeft, + children: [ + Positioned( + left: 0, + right: 0, + top: 0, + bottom: 0, + child: DecoratedBox( + decoration: BoxDecoration( + color: context.colors.surface, + borderRadius: BorderRadius.circular(Radii.sm), + border: Border.all( + color: context.colors.onSurface.withValues( + alpha: 0.08, + ), + ), + ), + ), + ), + Positioned( + left: 0, + top: 0, + bottom: 0, + width: handleX, + child: DecoratedBox( + decoration: BoxDecoration( + color: context.colors.onSurface.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(Radii.sm), + ), + ), + ), + for (var index = 1; index < 8; index++) + Positioned( + left: constraints.maxWidth * index / 8 - 2, + child: Container( + width: 4, + height: 4, + decoration: BoxDecoration( + color: context.colors.onSurface.withValues( + alpha: 0.16, + ), + shape: BoxShape.circle, + ), + ), + ), + Positioned( + left: handleX - 1.5, + top: 0, + bottom: 0, + child: Container( + width: 3, + decoration: BoxDecoration( + color: context.colors.onSurface, + borderRadius: BorderRadius.circular(Radii.full), + ), + ), + ), + ], + ), + ); + }, + ), + ), + ); + } +} + +class _AnimatedFrameControl extends HookWidget { + const _AnimatedFrameControl({ + super.key, + required this.frames, + required this.selectedIndex, + required this.onSelected, + }); + + final List frames; + final int selectedIndex; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final lastSelection = useRef(selectedIndex); + + void selectAt(double dx, double width) { + if (frames.isEmpty || width <= 0) return; + final progress = (dx / width).clamp(0.0, 1.0); + final next = (progress * (frames.length - 1)).round(); + if (next == lastSelection.value) return; + lastSelection.value = next; + unawaited(HapticFeedback.selectionClick()); + onSelected(next); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + key: const ValueKey('animated-avatar-poster-scrubber'), + height: 64, + child: LayoutBuilder( + builder: (context, constraints) { + const selectorWidth = 52.0; + final progress = frames.length < 2 + ? 0.0 + : selectedIndex / (frames.length - 1); + return Semantics( + slider: true, + label: 'Choose still frame', + value: '${selectedIndex + 1} of ${frames.length}', + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (details) => + selectAt(details.localPosition.dx, constraints.maxWidth), + onHorizontalDragStart: (details) => + selectAt(details.localPosition.dx, constraints.maxWidth), + onHorizontalDragUpdate: (details) => + selectAt(details.localPosition.dx, constraints.maxWidth), + child: Stack( + children: [ + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(Radii.sm), + child: Row( + children: [ + for (final (index, frame) in frames.indexed) + Expanded( + child: Image.memory( + frame, + key: ValueKey( + 'animated-avatar-poster-$index', + ), + height: 64, + fit: BoxFit.cover, + gaplessPlayback: true, + ), + ), + ], + ), + ), + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 75), + curve: Curves.easeOut, + left: (constraints.maxWidth - selectorWidth) * progress, + top: 0, + bottom: 0, + width: selectorWidth, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(Radii.sm), + border: Border.all(color: Colors.white, width: 3), + boxShadow: const [ + BoxShadow( + color: Color(0x52000000), + blurRadius: 16, + offset: Offset(0, 4), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + const SizedBox(height: Grid.xxs), + Text('Choose the still frame', style: context.textTheme.bodySmall), + ], + ); + } +} diff --git a/mobile/lib/features/profile/avatar_background_grid.dart b/mobile/lib/features/profile/avatar_background_grid.dart new file mode 100644 index 00000000000..59c11315991 --- /dev/null +++ b/mobile/lib/features/profile/avatar_background_grid.dart @@ -0,0 +1,79 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../shared/emoji/emoji_avatar.dart'; +import '../../shared/theme/theme.dart'; + +/// Vertical preview shift shared by background editors. +const avatarBackgroundPreviewShift = 136.0; + +/// The shared background-color grid used by emoji and animated avatars. +class AvatarBackgroundGrid extends StatelessWidget { + const AvatarBackgroundGrid({ + super.key, + required this.selectedColor, + required this.onColorSelected, + this.colorKeyPrefix = 'avatar-background-color', + }); + + final int selectedColor; + final ValueChanged onColorSelected; + final String colorKeyPrefix; + + @override + Widget build(BuildContext context) => GridView.builder( + shrinkWrap: true, + padding: EdgeInsets.zero, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 6, + crossAxisSpacing: Grid.xxs, + mainAxisSpacing: Grid.xs, + ), + itemCount: emojiAvatarColors.length, + itemBuilder: (context, index) { + final color = emojiAvatarColors[index]; + final isSelected = color == selectedColor; + return Center( + child: Semantics( + selected: isSelected, + label: 'Avatar color ${index + 1}', + child: InkWell( + key: ValueKey('$colorKeyPrefix-$index'), + borderRadius: BorderRadius.circular(Radii.full), + onTap: () { + unawaited(HapticFeedback.selectionClick()); + onColorSelected(color); + }, + child: Container( + width: 52, + height: 52, + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: Color(color), + shape: BoxShape.circle, + border: Border.all(color: context.colors.outlineVariant), + ), + child: isSelected + ? DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: _selectionColor(Color(color)), + width: 3, + ), + ), + ) + : null, + ), + ), + ), + ); + }, + ); +} + +Color _selectionColor(Color color) => + color.computeLuminance() > 0.55 ? const Color(0xFF111111) : Colors.white; diff --git a/mobile/lib/features/profile/avatar_editor_option_button.dart b/mobile/lib/features/profile/avatar_editor_option_button.dart new file mode 100644 index 00000000000..f2166b893c0 --- /dev/null +++ b/mobile/lib/features/profile/avatar_editor_option_button.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../shared/theme/theme.dart'; + +/// A labelled circular option used by the profile avatar editor rails. +class AvatarEditorOptionButton extends StatelessWidget { + const AvatarEditorOptionButton({ + super.key, + required this.icon, + required this.label, + required this.selected, + required this.onTap, + this.labelMaxWidth, + }); + + final IconData icon; + final String label; + final bool selected; + final VoidCallback? onTap; + final double? labelMaxWidth; + + @override + Widget build(BuildContext context) => InkResponse( + radius: 34, + onTap: onTap == null + ? null + : () { + unawaited(HapticFeedback.selectionClick()); + onTap!(); + }, + child: SizedBox( + width: double.infinity, + child: Column( + children: [ + AnimatedContainer( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + width: 64, + height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected + ? context.colors.onSurface + : context.colors.surfaceContainerHighest, + ), + child: Icon( + icon, + size: 26, + color: selected + ? context.colors.surface + : context.colors.onSurface, + ), + ), + const SizedBox(height: Grid.quarter), + if (labelMaxWidth case final width?) + SizedBox( + height: 20, + child: OverflowBox( + maxWidth: width, + maxHeight: 20, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + color: selected + ? context.colors.onSurface + : context.colors.onSurfaceVariant, + ), + ), + ), + ) + else + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + color: selected + ? context.colors.onSurface + : context.colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ); +} diff --git a/mobile/lib/features/profile/ios_profile_text_editor.dart b/mobile/lib/features/profile/ios_profile_text_editor.dart new file mode 100644 index 00000000000..7ae6819862e --- /dev/null +++ b/mobile/lib/features/profile/ios_profile_text_editor.dart @@ -0,0 +1,20 @@ +import 'package:flutter/services.dart'; + +/// Opens the native iOS form used to edit a single profile text field. +class IosProfileTextEditor { + IosProfileTextEditor._(); + + static const _channel = MethodChannel('buzz/profile_text_editor'); + + static Future present({ + required String title, + required String initialValue, + required String placeholder, + required bool multiline, + }) => _channel.invokeMethod('present', { + 'title': title, + 'initialValue': initialValue, + 'placeholder': placeholder, + 'multiline': multiline, + }); +} diff --git a/mobile/lib/features/profile/profile_avatar_crop_page.dart b/mobile/lib/features/profile/profile_avatar_crop_page.dart new file mode 100644 index 00000000000..9b02547d4ac --- /dev/null +++ b/mobile/lib/features/profile/profile_avatar_crop_page.dart @@ -0,0 +1,408 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:image/image.dart' as image; + +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; + +const _cropOutputSize = 512; + +/// Lets a person pan and zoom a selected image inside the final avatar mask. +class ProfileAvatarCropPage extends HookWidget { + const ProfileAvatarCropPage({super.key, required this.imageBytes}); + + final Future imageBytes; + + @override + Widget build(BuildContext context) { + final controller = useTransformationController(); + final preparedBytes = useState(null); + final loadError = useState(false); + final dimensions = useMemoized( + () => preparedBytes.value == null + ? null + : _decodeDimensions(preparedBytes.value!), + [preparedBytes.value], + ); + final initializedForSize = useRef(null); + final cropGeometry = useRef<_CropGeometry?>(null); + final displaySize = useRef(null); + final isClampingTransform = useRef(false); + final isCropping = useState(false); + + useEffect(() { + void clampTransform() { + if (isClampingTransform.value) return; + final geometry = cropGeometry.value; + final imageSize = displaySize.value; + if (geometry == null || imageSize == null) return; + + final matrix = controller.value.clone(); + final scale = matrix.getMaxScaleOnAxis(); + final cropLeft = (geometry.canvasWidth - geometry.cropDiameter) / 2; + final cropTop = (geometry.canvasHeight - geometry.cropDiameter) / 2; + final cropRight = cropLeft + geometry.cropDiameter; + final cropBottom = cropTop + geometry.cropDiameter; + final minX = cropRight - imageSize.width * scale; + final minY = cropBottom - imageSize.height * scale; + final nextX = matrix.storage[12].clamp(minX, cropLeft).toDouble(); + final nextY = matrix.storage[13].clamp(minY, cropTop).toDouble(); + if ((nextX - matrix.storage[12]).abs() < 0.01 && + (nextY - matrix.storage[13]).abs() < 0.01) { + return; + } + matrix.storage[12] = nextX; + matrix.storage[13] = nextY; + isClampingTransform.value = true; + controller.value = matrix; + isClampingTransform.value = false; + } + + controller.addListener(clampTransform); + return () => controller.removeListener(clampTransform); + }, [controller]); + + useEffect(() { + var disposed = false; + imageBytes.then( + (bytes) { + if (disposed) return; + if (bytes == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) Navigator.pop(context); + }); + return; + } + preparedBytes.value = bytes; + }, + onError: (_) { + if (!disposed) loadError.value = true; + }, + ); + return () => disposed = true; + }, [imageBytes]); + + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + centerTitle: true, + titleSpacing: 0, + title: Text( + 'Position Photo', + maxLines: 1, + style: context.textTheme.titleSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + leadingWidth: 120, + leading: Padding( + padding: const EdgeInsets.only(left: Grid.gutter), + child: Align( + alignment: Alignment.centerLeft, + child: _CropHeaderButton( + label: 'Cancel', + onPressed: isCropping.value ? null : () => Navigator.pop(context), + ), + ), + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: Grid.gutter), + child: _CropHeaderButton( + key: const ValueKey('avatar-crop-use-photo'), + label: 'Save', + loading: isCropping.value, + onPressed: isCropping.value || preparedBytes.value == null + ? null + : () async { + isCropping.value = true; + unawaited(HapticFeedback.lightImpact()); + try { + if (!context.mounted) return; + final geometry = cropGeometry.value; + if (geometry == null) return; + final cropped = await compute( + _cropAvatar, + _CropRequest( + bytes: preparedBytes.value!, + sourceWidth: dimensions!.width, + sourceHeight: dimensions.height, + canvasWidth: geometry.canvasWidth, + canvasHeight: geometry.canvasHeight, + cropDiameter: geometry.cropDiameter, + transform: List.from( + controller.value.storage, + ), + ), + ); + if (context.mounted) Navigator.pop(context, cropped); + } finally { + if (context.mounted) isCropping.value = false; + } + }, + ), + ), + ], + ), + body: loadError.value + ? const Center( + child: Text( + "We couldn't prepare that photo.", + style: TextStyle(color: Colors.white), + ), + ) + : preparedBytes.value == null || dimensions == null + ? const Center( + child: BuzzLoadingIndicator( + color: Colors.white, + semanticLabel: 'Preparing photo', + ), + ) + : LayoutBuilder( + builder: (context, constraints) { + final canvasWidth = constraints.maxWidth; + final canvasHeight = math.max(1.0, constraints.maxHeight); + final cropDiameter = math.min(canvasWidth, canvasHeight); + cropGeometry.value = _CropGeometry( + canvasWidth: canvasWidth, + canvasHeight: canvasHeight, + cropDiameter: cropDiameter, + ); + final aspect = dimensions.width / dimensions.height; + final displayWidth = aspect >= 1 + ? cropDiameter * aspect + : cropDiameter; + final displayHeight = aspect >= 1 + ? cropDiameter + : cropDiameter / aspect; + displaySize.value = Size(displayWidth, displayHeight); + final canvasSize = Size(canvasWidth, canvasHeight); + if (initializedForSize.value != canvasSize) { + initializedForSize.value = canvasSize; + controller.value = Matrix4.identity() + ..translateByDouble( + (canvasWidth - displayWidth) / 2, + (canvasHeight - displayHeight) / 2, + 0, + 1, + ); + } + final maskBottom = (canvasHeight + cropDiameter) / 2; + return Stack( + children: [ + SizedBox( + width: canvasWidth, + height: canvasHeight, + child: Stack( + fit: StackFit.expand, + children: [ + ClipRect( + child: InteractiveViewer( + key: const ValueKey('avatar-crop-viewer'), + transformationController: controller, + constrained: false, + panEnabled: true, + scaleEnabled: true, + minScale: 1, + maxScale: 5, + boundaryMargin: EdgeInsets.all( + math.max(canvasWidth, canvasHeight), + ), + child: Image.memory( + preparedBytes.value!, + width: displayWidth, + height: displayHeight, + fit: BoxFit.fill, + gaplessPlayback: true, + ), + ), + ), + IgnorePointer( + child: CustomPaint( + painter: _CropMaskPainter(cropDiameter), + ), + ), + ], + ), + ), + Positioned( + left: 0, + right: 0, + top: math.min( + maskBottom + Grid.twelve, + canvasHeight - 24, + ), + child: const Center( + child: Text( + 'Move and scale', + style: TextStyle(color: Colors.white70), + ), + ), + ), + ], + ); + }, + ), + ); + } +} + +class _CropHeaderButton extends StatelessWidget { + const _CropHeaderButton({ + super.key, + required this.label, + required this.onPressed, + this.loading = false, + }); + + final String label; + final VoidCallback? onPressed; + final bool loading; + + @override + Widget build(BuildContext context) => ConstrainedBox( + constraints: const BoxConstraints(minWidth: 64), + child: SizedBox( + height: 40, + child: TextButton( + style: TextButton.styleFrom( + foregroundColor: Colors.white, + disabledForegroundColor: Colors.white54, + backgroundColor: Colors.white.withValues(alpha: 0.18), + disabledBackgroundColor: Colors.white.withValues(alpha: 0.1), + shape: const StadiumBorder(), + padding: const EdgeInsets.symmetric(horizontal: Grid.twelve), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: onPressed, + child: loading + ? const BuzzLoadingIndicator( + size: 18, + color: Colors.white, + semanticLabel: 'Saving photo', + ) + : Text(label, maxLines: 1, softWrap: false), + ), + ), + ); +} + +class _CropMaskPainter extends CustomPainter { + const _CropMaskPainter(this.cropDiameter); + + final double cropDiameter; + + @override + void paint(Canvas canvas, Size size) { + final circle = Rect.fromCircle( + center: size.center(Offset.zero), + radius: cropDiameter / 2 - 2, + ); + final mask = Path() + ..fillType = PathFillType.evenOdd + ..addRect(Offset.zero & size) + ..addOval(circle); + canvas.drawPath( + mask, + Paint()..color = Colors.black.withValues(alpha: 0.58), + ); + canvas.drawOval( + circle, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..color = Colors.white.withValues(alpha: 0.9), + ); + } + + @override + bool shouldRepaint(_CropMaskPainter oldDelegate) => + oldDelegate.cropDiameter != cropDiameter; +} + +class _CropGeometry { + const _CropGeometry({ + required this.canvasWidth, + required this.canvasHeight, + required this.cropDiameter, + }); + + final double canvasWidth; + final double canvasHeight; + final double cropDiameter; +} + +class _ImageDimensions { + const _ImageDimensions(this.width, this.height); + final int width; + final int height; +} + +_ImageDimensions _decodeDimensions(Uint8List bytes) { + final decoded = image.decodeImage(bytes); + if (decoded == null) throw const FormatException('Unsupported image'); + return _ImageDimensions(decoded.width, decoded.height); +} + +class _CropRequest { + const _CropRequest({ + required this.bytes, + required this.sourceWidth, + required this.sourceHeight, + required this.canvasWidth, + required this.canvasHeight, + required this.cropDiameter, + required this.transform, + }); + + final Uint8List bytes; + final int sourceWidth; + final int sourceHeight; + final double canvasWidth; + final double canvasHeight; + final double cropDiameter; + final List transform; +} + +Uint8List _cropAvatar(_CropRequest request) { + final source = image.decodeImage(request.bytes); + if (source == null) throw const FormatException('Unsupported image'); + final matrix = Matrix4.fromList(request.transform); + final scale = matrix.getMaxScaleOnAxis(); + final aspect = request.sourceWidth / request.sourceHeight; + final displayWidth = aspect >= 1 + ? request.cropDiameter * aspect + : request.cropDiameter; + final pixelsPerPoint = request.sourceWidth / displayWidth; + final cropSize = (request.cropDiameter / scale * pixelsPerPoint) + .round() + .clamp(1, source.width < source.height ? source.width : source.height); + final cropLeft = (request.canvasWidth - request.cropDiameter) / 2; + final cropTop = (request.canvasHeight - request.cropDiameter) / 2; + final x = ((cropLeft - matrix.storage[12]) / scale * pixelsPerPoint) + .round() + .clamp(0, source.width - cropSize); + final y = ((cropTop - matrix.storage[13]) / scale * pixelsPerPoint) + .round() + .clamp(0, source.height - cropSize); + final cropped = image.copyCrop( + source, + x: x, + y: y, + width: cropSize, + height: cropSize, + ); + final resized = image.copyResize( + cropped, + width: _cropOutputSize, + height: _cropOutputSize, + ); + return image.encodeJpg(resized, quality: 90); +} diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart new file mode 100644 index 00000000000..c81fd8376ad --- /dev/null +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -0,0 +1,534 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/emoji/emoji_avatar.dart'; +import '../../shared/emoji/emoji_data.dart'; +import '../../shared/emoji/emoji_data_provider.dart'; +import '../../shared/emoji/emoji_search.dart'; +import '../../shared/emoji/native_emoji_glyph.dart'; +import '../../shared/relay/relay.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/ios_native_segmented_control.dart'; +import '../../shared/widgets/ios_native_skin_tone_control.dart'; +import '../../shared/widgets/playing_avatar_image.dart'; +import 'animated_avatar_capture.dart'; +import 'avatar_background_grid.dart'; +import 'avatar_editor_option_button.dart'; +import 'profile_avatar_crop_page.dart'; + +part 'profile_avatar_editor/emoji_avatar_picker.dart'; + +/// Avatar kinds shared with the desktop profile editor. +enum ProfileAvatarMode { image, emoji, animated } + +const _previewSize = 220.0; +const _motionDuration = Duration(milliseconds: 240); +const _previewSquishDuration = Duration(milliseconds: 200); +const Curve _entranceCurve = Curves.easeOutCubic; +const Curve _exitCurve = Curves.easeInCubic; +const Curve _modeTransitionCurve = Cubic(0.22, 1, 0.36, 1); +const double _modeTransitionDistance = 24; +const double _previewBlockSize = 228; +const double _modeControlHeight = 40; +const double _editorControlsBottom = Grid.xl + Grid.xxs; +const double _editorRailHeight = 88; +const double _previewControlGap = Grid.twelve; +const double _emojiPickerPreviewShift = 140; +// Settings' avatar center sits 96dp below its standard app bar, including its +// lower 8dp app-bar rail. The expanded editor preview is centred on screen. +const double _settingsAvatarCenterBelowAppBar = 96; + +/// In-page profile avatar editor used on Android and iOS. +class ProfileAvatarEditor extends HookConsumerWidget { + const ProfileAvatarEditor({ + super.key, + required this.currentAvatarUrl, + required this.fallbackInitial, + required this.draftAvatarUrl, + required this.mode, + required this.transition, + required this.onModeChanged, + required this.onDraftChanged, + required this.onAnimatedPrepareChanged, + }); + + final String? currentAvatarUrl; + final String fallbackInitial; + final String? draftAvatarUrl; + final ProfileAvatarMode mode; + final Animation transition; + final ValueChanged onModeChanged; + final ValueChanged onDraftChanged; + final ValueChanged Function()?> onAnimatedPrepareChanged; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final selectedEmoji = useState('😊'); + final initialColor = useMemoized( + () => emojiAvatarColors[Random().nextInt(18)], + const [], + ); + final selectedColor = useState(initialColor); + final emojiSection = useState(_EmojiEditorSection.emoji); + final emojiPreviewKey = useState(0); + final isPickingImage = useState(false); + final error = useState(null); + final dataset = ref.watch(emojiDatasetOrEmptyProvider); + final modeTransitionDirection = useRef(1.0); + final modeTransitionController = useAnimationController( + duration: _motionDuration, + initialValue: 1, + ); + final modeTransitionValue = useAnimation(modeTransitionController); + final modeTransitionProgress = reduceMotion + ? 1.0 + : _modeTransitionCurve.transform(modeTransitionValue); + + void selectMode(ProfileAvatarMode nextMode) { + if (nextMode == mode) return; + modeTransitionDirection.value = nextMode.index > mode.index ? 1 : -1; + unawaited(HapticFeedback.selectionClick()); + onAnimatedPrepareChanged(null); + if (!reduceMotion) modeTransitionController.value = 0; + onModeChanged(nextMode); + if (reduceMotion) { + modeTransitionController.value = 1; + } else { + unawaited(modeTransitionController.forward()); + } + } + + void updateEmojiPreview() { + emojiPreviewKey.value++; + onDraftChanged( + emojiAvatarDataUrl(selectedEmoji.value, selectedColor.value), + ); + } + + Future selectImage({required bool camera}) async { + if (isPickingImage.value) return; + isPickingImage.value = true; + error.value = null; + try { + final service = ref.read(mediaUploadServiceProvider); + unawaited(HapticFeedback.lightImpact()); + final picked = camera + ? await service.captureImage() + : await service.pickGalleryImage(); + if (picked == null || !context.mounted) return; + final preparedPhoto = await service.prepareImageBytes(picked); + if (!context.mounted) return; + final cropped = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ProfileAvatarCropPage( + imageBytes: Future.value(preparedPhoto), + ), + ), + ); + if (cropped == null) return; + final upload = await service.uploadBytes( + cropped, + mimeType: 'image/jpeg', + ); + if (context.mounted) onDraftChanged(upload.url); + } catch (_) { + error.value = "We couldn't prepare that photo. Try again."; + } finally { + if (context.mounted) isPickingImage.value = false; + } + } + + final previewUrl = (draftAvatarUrl?.isNotEmpty ?? false) + ? draftAvatarUrl + : currentAvatarUrl; + final fixedPreviewContent = switch (mode) { + ProfileAvatarMode.image => PlayingAvatarImage( + key: const ValueKey('avatar-editor-fixed-preview'), + imageUrl: previewUrl, + radius: _previewSize / 2, + backgroundColor: context.colors.primaryContainer, + fallback: Text( + fallbackInitial, + style: context.textTheme.displayLarge?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ), + ), + ProfileAvatarMode.emoji => _EmojiAvatarPreview( + key: const ValueKey('avatar-editor-fixed-preview'), + emoji: selectedEmoji.value, + color: Color(selectedColor.value), + animationKey: emojiPreviewKey.value, + reduceMotion: reduceMotion, + ), + ProfileAvatarMode.animated => null, + }; + final fixedPreview = fixedPreviewContent == null + ? null + : Stack( + alignment: Alignment.center, + children: [ + fixedPreviewContent, + if (isPickingImage.value) + Container( + key: const ValueKey('avatar-image-loading-overlay'), + width: _previewSize, + height: _previewSize, + decoration: BoxDecoration( + color: context.colors.surface.withValues(alpha: 0.72), + shape: BoxShape.circle, + ), + child: const Center( + child: BuzzLoadingIndicator( + semanticLabel: 'Preparing photo', + ), + ), + ), + ], + ); + + final curvedEntrance = CurvedAnimation( + parent: transition, + curve: _entranceCurve, + // The controller runs from 1 → 0 on exit. An ease-in value curve makes + // that visible reverse movement ease out from expanded to collapsed. + reverseCurve: _exitCurve, + ); + final appBarHeight = frostedAppBarHeight(context); + final topPadding = appBarHeight + Grid.xs; + + return LayoutBuilder( + builder: (context, constraints) { + final viewportHeight = constraints.maxHeight; + final basePreviewTop = viewportHeight / 2 - _previewBlockSize / 2; + final maximumShift = max( + 0.0, + basePreviewTop - + (topPadding + _modeControlHeight + _previewControlGap), + ); + final requestedShift = mode != ProfileAvatarMode.emoji + ? 0.0 + : emojiSection.value == _EmojiEditorSection.emoji + ? _emojiPickerPreviewShift + : avatarBackgroundPreviewShift; + final previewShift = min(requestedShift, maximumShift); + final previewTop = basePreviewTop - previewShift; + final fixedContentTop = + previewTop + _previewBlockSize + _previewControlGap; + final modeTop = mode == ProfileAvatarMode.animated + ? basePreviewTop + : fixedContentTop; + final modeHeight = max( + 0.0, + viewportHeight - _editorControlsBottom - modeTop, + ); + final modeContent = switch (mode) { + ProfileAvatarMode.image => _ImageMode( + key: const ValueKey(0), + height: modeHeight, + isPicking: isPickingImage.value, + onCamera: () => unawaited(selectImage(camera: true)), + onLibrary: () => unawaited(selectImage(camera: false)), + ), + ProfileAvatarMode.emoji => _EmojiMode( + key: const ValueKey(1), + height: modeHeight, + activeSection: emojiSection.value, + dataset: dataset, + selectedEmoji: selectedEmoji.value, + selectedColor: selectedColor.value, + onSectionChanged: (section) => emojiSection.value = section, + onEmojiSelected: (emoji) { + selectedEmoji.value = emoji; + updateEmojiPreview(); + }, + onColorSelected: (color) { + selectedColor.value = color; + updateEmojiPreview(); + }, + ), + ProfileAvatarMode.animated => AnimatedAvatarCapture( + key: const ValueKey(2), + height: modeHeight, + onPrepareChanged: onAnimatedPrepareChanged, + ), + }; + final collapsedPreviewOffset = + appBarHeight + + _settingsAvatarCenterBelowAppBar - + (previewTop + _previewBlockSize / 2); + final transitionedModeContent = mode == ProfileAvatarMode.animated + ? modeContent + : ClipRect( + child: Transform.translate( + key: const ValueKey('avatar-mode-transition-transform'), + offset: Offset( + modeTransitionDirection.value * + _modeTransitionDistance * + (1 - modeTransitionProgress), + 0, + ), + child: Opacity( + key: const ValueKey('avatar-mode-transition-opacity'), + opacity: modeTransitionProgress, + child: modeContent, + ), + ), + ); + + return Stack( + key: const ValueKey('avatar-editor-content'), + clipBehavior: Clip.none, + children: [ + Positioned( + left: Grid.gutter, + right: Grid.gutter, + top: topPadding, + child: FadeTransition( + opacity: curvedEntrance, + child: ScaleTransition( + scale: Tween(begin: 0.96, end: 1.0).animate(curvedEntrance), + child: Builder( + builder: (context) { + if (defaultTargetPlatform == TargetPlatform.iOS) { + return IosNativeSegmentedControl( + key: const ValueKey('avatar-mode-control'), + items: const ['Image', 'Emoji', 'Animated'], + selectedIndex: mode.index, + onChanged: (index) { + if (index < 0 || + index >= ProfileAvatarMode.values.length) { + return; + } + selectMode(ProfileAvatarMode.values[index]); + }, + ); + } + return _AvatarModeControl( + selected: mode, + reduceMotion: reduceMotion, + onSelected: selectMode, + ); + }, + ), + ), + ), + ), + if (fixedPreview != null) + AnimatedPositioned( + key: const ValueKey('avatar-preview-position'), + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + left: Grid.gutter, + right: Grid.gutter, + top: previewTop, + height: _previewBlockSize, + child: Center( + child: AnimatedBuilder( + animation: curvedEntrance, + child: fixedPreview, + builder: (context, child) { + final progress = curvedEntrance.value; + return Transform.translate( + key: const ValueKey('avatar-editor-entrance-transform'), + offset: Offset( + 0, + collapsedPreviewOffset * (1 - progress), + ), + child: Transform.scale( + scale: + 128 / _previewSize + + (1 - 128 / _previewSize) * progress, + child: child, + ), + ); + }, + ), + ), + ), + AnimatedPositioned( + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + left: Grid.gutter, + right: Grid.gutter, + top: modeTop, + height: modeHeight, + child: FadeTransition( + opacity: curvedEntrance, + child: transitionedModeContent, + ), + ), + if (error.value != null) + Positioned( + left: Grid.gutter, + right: Grid.gutter, + bottom: _editorControlsBottom + _editorRailHeight, + child: Text( + error.value!, + textAlign: TextAlign.center, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ), + ], + ); + }, + ); + } +} + +class _AvatarModeControl extends StatelessWidget { + const _AvatarModeControl({ + required this.selected, + required this.reduceMotion, + required this.onSelected, + }); + + final ProfileAvatarMode selected; + final bool reduceMotion; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) => Material( + key: const ValueKey('avatar-mode-control'), + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.full), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(Grid.quarter), + child: LayoutBuilder( + builder: (context, constraints) { + final segmentWidth = + constraints.maxWidth / ProfileAvatarMode.values.length; + return Stack( + children: [ + TweenAnimationBuilder( + tween: Tween(end: selected.index.toDouble()), + duration: reduceMotion ? Duration.zero : _motionDuration, + curve: Curves.easeInOut, + builder: (context, position, child) => Transform.translate( + offset: Offset(segmentWidth * position, 0), + child: child, + ), + child: SizedBox( + width: segmentWidth, + height: 36, + child: DecoratedBox( + key: const ValueKey('avatar-mode-indicator'), + decoration: BoxDecoration( + color: context.colors.surface, + borderRadius: BorderRadius.circular(Radii.full), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), + ), + ), + ), + Row( + children: [ + for (final mode in ProfileAvatarMode.values) + Expanded( + child: InkWell( + key: ValueKey('avatar-mode-${mode.name}'), + borderRadius: BorderRadius.circular(Radii.full), + onTap: () => onSelected(mode), + child: SizedBox( + height: 36, + child: Center( + child: Text( + switch (mode) { + ProfileAvatarMode.image => 'Image', + ProfileAvatarMode.emoji => 'Emoji', + ProfileAvatarMode.animated => 'Animated', + }, + style: context.textTheme.labelLarge?.copyWith( + fontWeight: mode == selected + ? FontWeight.w600 + : FontWeight.w500, + ), + ), + ), + ), + ), + ), + ], + ), + ], + ); + }, + ), + ), + ); +} + +class _ImageMode extends StatelessWidget { + const _ImageMode({ + super.key, + required this.height, + required this.isPicking, + required this.onCamera, + required this.onLibrary, + }); + + final double height; + final bool isPicking; + final VoidCallback onCamera; + final VoidCallback onLibrary; + + @override + Widget build(BuildContext context) => SizedBox( + height: height, + child: Column( + children: [ + const Spacer(), + Row( + children: [ + const Spacer(), + Expanded( + child: AvatarEditorOptionButton( + key: const ValueKey('image-source-camera'), + icon: LucideIcons.camera, + label: 'Camera', + selected: false, + onTap: isPicking ? null : onCamera, + labelMaxWidth: 96, + ), + ), + const SizedBox(width: Grid.half), + Expanded( + child: AvatarEditorOptionButton( + key: const ValueKey('image-source-library'), + icon: LucideIcons.images, + label: 'Photo Library', + selected: false, + onTap: isPicking ? null : onLibrary, + labelMaxWidth: 104, + ), + ), + const Spacer(), + ], + ), + ], + ), + ); +} diff --git a/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart b/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart new file mode 100644 index 00000000000..600baaeda68 --- /dev/null +++ b/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart @@ -0,0 +1,357 @@ +part of '../profile_avatar_editor.dart'; + +const _emojiPreviewGlyphSize = _previewSize * 258 / 512; +const _skinTones = [ + (label: 'Default', color: Color(0xFFFFC93A)), + (label: 'Light', color: Color(0xFFFFDAB7)), + (label: 'Medium-light', color: Color(0xFFE7B98F)), + (label: 'Medium', color: Color(0xFFC88C61)), + (label: 'Medium-dark', color: Color(0xFFA46134)), + (label: 'Dark', color: Color(0xFF5D4437)), +]; + +enum _EmojiEditorSection { background, emoji } + +class _EmojiMode extends HookConsumerWidget { + const _EmojiMode({ + super.key, + required this.height, + required this.activeSection, + required this.dataset, + required this.selectedEmoji, + required this.selectedColor, + required this.onSectionChanged, + required this.onEmojiSelected, + required this.onColorSelected, + }); + + final double height; + final _EmojiEditorSection activeSection; + final EmojiDataset dataset; + final String selectedEmoji; + final int selectedColor; + final ValueChanged<_EmojiEditorSection> onSectionChanged; + final ValueChanged onEmojiSelected; + final ValueChanged onColorSelected; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final skinTone = useState(0); + final searchController = useTextEditingController(); + useListenable(searchController); + final visibleEmoji = useMemoized(() { + final entries = _emojiForSkinTone(dataset, skinTone.value); + final query = searchController.text.trim(); + return query.isEmpty ? entries : searchEmoji(query, entries); + }, [dataset, skinTone.value, searchController.text]); + + void selectSkinTone(int next) { + if (skinTone.value == next) return; + unawaited(HapticFeedback.selectionClick()); + skinTone.value = next; + } + + return SizedBox( + key: const ValueKey('emoji-avatar-picker-content'), + height: height, + child: Column( + children: [ + Expanded( + child: activeSection == _EmojiEditorSection.background + ? AvatarBackgroundGrid( + key: const ValueKey('emoji-background-editor'), + selectedColor: selectedColor, + onColorSelected: onColorSelected, + colorKeyPrefix: 'emoji-avatar-color', + ) + : Column( + key: const ValueKey('emoji-glyph-editor'), + children: [ + Row( + children: [ + Expanded( + child: TextField( + key: const ValueKey('emoji-avatar-search'), + controller: searchController, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: 'Search emoji', + filled: true, + fillColor: + context.colors.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric( + vertical: 10, + ), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(Radii.full), + ), + borderSide: BorderSide.none, + ), + enabledBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(Radii.full), + ), + borderSide: BorderSide.none, + ), + focusedBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(Radii.full), + ), + borderSide: BorderSide.none, + ), + prefixIcon: const Icon( + LucideIcons.search, + size: 20, + ), + suffixIcon: searchController.text.isEmpty + ? null + : IconButton( + tooltip: 'Clear search', + onPressed: searchController.clear, + icon: const Icon( + LucideIcons.x, + size: 18, + ), + ), + ), + ), + ), + const SizedBox(width: Grid.xxs), + _AvatarSkinToneSelector( + value: skinTone.value, + onChanged: selectSkinTone, + ), + ], + ), + const SizedBox(height: Grid.xxs), + Expanded( + child: dataset.isEmpty + ? const Center(child: CircularProgressIndicator()) + : visibleEmoji.isEmpty + ? Center( + child: Text( + 'No emoji found', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ) + : GridView.builder( + key: const ValueKey('emoji-avatar-grid'), + padding: EdgeInsets.zero, + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: + defaultTargetPlatform == + TargetPlatform.iOS + ? 8 + : 7, + ), + itemCount: visibleEmoji.length, + itemBuilder: (context, index) { + final entry = visibleEmoji[index]; + final isSelected = + entry.native == selectedEmoji; + return InkWell( + key: ValueKey( + 'emoji-avatar-${entry.tileId}', + ), + borderRadius: BorderRadius.circular( + Radii.sm, + ), + onTap: () { + unawaited( + HapticFeedback.selectionClick(), + ); + onEmojiSelected(entry.native); + }, + child: DecoratedBox( + decoration: BoxDecoration( + color: isSelected + ? context.colors.primaryContainer + : Colors.transparent, + borderRadius: BorderRadius.circular( + Radii.sm, + ), + ), + child: Center( + child: NativeEmojiGlyph( + emoji: entry.native, + size: 30, + opticalBoxSize: 30, + ), + ), + ), + ); + }, + ), + ), + ], + ), + ), + const SizedBox(height: Grid.xs), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Spacer(), + Expanded( + child: AvatarEditorOptionButton( + key: const ValueKey('emoji-editor-background'), + icon: LucideIcons.palette, + label: 'Background', + selected: activeSection == _EmojiEditorSection.background, + onTap: () => onSectionChanged(_EmojiEditorSection.background), + labelMaxWidth: 96, + ), + ), + const SizedBox(width: Grid.half), + Expanded( + child: AvatarEditorOptionButton( + key: const ValueKey('emoji-editor-emoji'), + icon: LucideIcons.smile, + label: 'Emoji', + selected: activeSection == _EmojiEditorSection.emoji, + onTap: () => onSectionChanged(_EmojiEditorSection.emoji), + labelMaxWidth: 80, + ), + ), + const Spacer(), + ], + ), + ], + ), + ); + } +} + +class _EmojiAvatarPreview extends StatelessWidget { + const _EmojiAvatarPreview({ + super.key, + required this.emoji, + required this.color, + required this.animationKey, + required this.reduceMotion, + }); + + final String emoji; + final Color color; + final int animationKey; + final bool reduceMotion; + + @override + Widget build(BuildContext context) => AnimatedContainer( + key: const ValueKey('emoji-avatar-preview'), + width: _previewSize, + height: _previewSize, + duration: reduceMotion ? Duration.zero : _previewSquishDuration, + curve: _entranceCurve, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + child: Center( + child: TweenAnimationBuilder( + key: ValueKey('emoji-avatar-preview-glyph-$animationKey'), + tween: Tween(begin: 0.88, end: 1), + duration: reduceMotion ? Duration.zero : _previewSquishDuration, + curve: _entranceCurve, + builder: (context, scaleY, child) => Transform.scale( + scaleX: 0.96 + ((scaleY - 0.88) / 0.12) * 0.04, + scaleY: scaleY, + child: child, + ), + child: NativeEmojiGlyph( + emoji: emoji, + size: _emojiPreviewGlyphSize, + opticalBoxSize: _emojiPreviewGlyphSize, + ), + ), + ), + ); +} + +class _AvatarSkinToneSelector extends StatelessWidget { + const _AvatarSkinToneSelector({required this.value, required this.onChanged}); + + final int value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final selected = _skinTones[_validSkinTone(value)]; + if (defaultTargetPlatform == TargetPlatform.iOS) { + return IosNativeSkinToneControl( + key: const ValueKey('emoji-avatar-skin-tone'), + value: value, + colors: [for (final tone in _skinTones) tone.color], + labels: [for (final tone in _skinTones) tone.label], + onChanged: onChanged, + ); + } + return PopupMenuButton( + key: const ValueKey('emoji-avatar-skin-tone'), + initialValue: value, + tooltip: 'Skin tone', + position: PopupMenuPosition.under, + onSelected: onChanged, + itemBuilder: (context) => [ + for (final (index, tone) in _skinTones.indexed) + PopupMenuItem( + value: index, + child: Row( + children: [ + _SkinToneDot(color: tone.color), + const SizedBox(width: Grid.xs), + Expanded(child: Text(tone.label)), + if (index == value) + Icon( + LucideIcons.check, + size: 18, + color: context.colors.primary, + ), + ], + ), + ), + ], + child: SizedBox.square( + dimension: 48, + child: Center(child: _SkinToneDot(color: selected.color)), + ), + ); + } +} + +class _SkinToneDot extends StatelessWidget { + const _SkinToneDot({required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) => Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: context.colors.outlineVariant), + ), + ); +} + +int _validSkinTone(int? value) => + value != null && value >= 0 && value < _skinTones.length ? value : 0; + +List _emojiForSkinTone(EmojiDataset dataset, int skinTone) { + final variantsById = >{}; + for (final entry in dataset.all) { + variantsById.putIfAbsent(entry.id, () => []).add(entry); + } + return [ + for (final variants in variantsById.values) + variants.firstWhere( + (entry) => entry.skinIndex == skinTone, + orElse: () => variants.firstWhere( + (entry) => entry.skinIndex == 0, + orElse: () => variants.first, + ), + ), + ]; +} diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart new file mode 100644 index 00000000000..71752a94bb4 --- /dev/null +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -0,0 +1,501 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/profile/user_profile.dart'; +import '../../shared/emoji/emoji_data_provider.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/app_list.dart'; +import '../../shared/widgets/app_list_card.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; +import '../../shared/widgets/ios_glass_navigation_action.dart'; +import '../../shared/widgets/ios_glass_navigation_button.dart'; +import '../../shared/widgets/modal_presentation.dart'; +import '../../shared/widgets/playing_avatar_image.dart'; +import 'ios_profile_text_editor.dart'; +import 'profile_avatar_editor.dart'; +import 'profile_provider.dart'; + +/// Edits the current user's public profile metadata. +class ProfileEditPage extends HookConsumerWidget { + const ProfileEditPage({super.key, this.startInPhotoEditor = false}); + + /// Opens directly into the photo editor when launched from Settings. + final bool startInPhotoEditor; + + static const _avatarRadius = 64.0; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final profile = ref.watch(profileProvider).asData?.value; + final isEditingAvatar = useState(startInPhotoEditor); + final avatarDraft = useState(null); + final avatarDraftMode = useState(null); + final isSavingAvatar = useState(false); + final prepareAnimatedAvatar = useRef Function()?>(null); + final canPrepareAnimatedAvatar = useState(false); + final avatarMode = useState(ProfileAvatarMode.image); + final avatarTransition = useAnimationController( + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 200), + ); + useEffect(() { + if (!startInPhotoEditor) return null; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) unawaited(avatarTransition.forward(from: 0)); + }); + return null; + }, const []); + // Warm the emoji index while the profile is visible so the first editor + // transition never waits for asset decoding. + ref.watch(emojiDatasetProvider); + + Future showFlutterFieldEditor({ + required String title, + required String initialValue, + required String hintText, + required Future Function(String value) onSave, + bool multiline = false, + }) => showBuzzModalBottomSheet( + context: context, + title: title, + isScrollControlled: true, + requestFocus: true, + builder: (_) => _ProfileTextEditSheet( + initialValue: initialValue, + hintText: hintText, + multiline: multiline, + onSave: onSave, + ), + ); + + Future editField({ + required String title, + required String initialValue, + required String hintText, + required Future Function(String value) onSave, + bool multiline = false, + }) async { + if (defaultTargetPlatform == TargetPlatform.iOS) { + try { + final value = await IosProfileTextEditor.present( + title: title, + initialValue: initialValue, + placeholder: hintText, + multiline: multiline, + ); + if (value == null) return; + try { + await onSave(value); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("We couldn't save this change. Try again."), + ), + ); + } + } + return; + } on MissingPluginException { + // Keep the Flutter editor available in previews and older builds. + } on PlatformException { + // Fall back when the native presenter is temporarily unavailable. + } + } + + await showFlutterFieldEditor( + title: title, + initialValue: initialValue, + hintText: hintText, + multiline: multiline, + onSave: onSave, + ); + } + + void openAvatarEditor() { + isEditingAvatar.value = true; + unawaited(avatarTransition.forward(from: 0)); + } + + Future closeAvatarEditor({bool whileSaving = false}) async { + if (isSavingAvatar.value && !whileSaving) return; + await avatarTransition.reverse(); + if (!context.mounted) return; + if (startInPhotoEditor) { + Navigator.of(context).pop(); + return; + } + isEditingAvatar.value = false; + avatarDraft.value = null; + avatarDraftMode.value = null; + prepareAnimatedAvatar.value = null; + canPrepareAnimatedAvatar.value = false; + avatarMode.value = ProfileAvatarMode.image; + } + + Future saveAvatar() async { + if (isSavingAvatar.value) return; + isSavingAvatar.value = true; + try { + var nextAvatar = avatarDraftMode.value == avatarMode.value + ? avatarDraft.value + : null; + if (avatarMode.value == ProfileAvatarMode.animated) { + nextAvatar = await prepareAnimatedAvatar.value?.call(); + } + if (nextAvatar == null || nextAvatar.isEmpty) return; + await ref.read(profileProvider.notifier).updateAvatarUrl(nextAvatar); + if (context.mounted) await closeAvatarEditor(whileSaving: true); + } finally { + if (context.mounted) isSavingAvatar.value = false; + } + } + + final canSaveAvatar = avatarMode.value == ProfileAvatarMode.animated + ? canPrepareAnimatedAvatar.value + : avatarDraftMode.value == avatarMode.value && + avatarDraft.value != null; + final activeDraft = avatarDraftMode.value == avatarMode.value + ? avatarDraft.value + : null; + + return PopScope( + canPop: !isEditingAvatar.value, + onPopInvokedWithResult: (didPop, _) { + if (!didPop && isEditingAvatar.value) { + unawaited(closeAvatarEditor()); + } + }, + child: FrostedScaffold( + backgroundColor: context.colors.surface, + resizeToAvoidBottomInset: isEditingAvatar.value ? false : null, + appBar: FrostedAppBar( + centerTitle: true, + title: AnimatedSwitcher( + duration: reduceMotion + ? const Duration(milliseconds: 120) + : const Duration(milliseconds: 220), + child: Text( + isEditingAvatar.value ? 'Edit Photo' : 'Profile', + key: ValueKey(isEditingAvatar.value), + ), + ), + leading: isEditingAvatar.value + ? defaultTargetPlatform == TargetPlatform.iOS + ? IosGlassNavigationButton( + key: const ValueKey('avatar-editor-back'), + icon: IosGlassNavigationIcon.back, + semanticLabel: 'Back to profile', + onPressed: () => unawaited(closeAvatarEditor()), + ) + : IconButton( + key: const ValueKey('avatar-editor-back'), + tooltip: 'Back to profile', + onPressed: () => unawaited(closeAvatarEditor()), + icon: const Icon(LucideIcons.arrowLeft), + ) + : null, + actions: isEditingAvatar.value + ? [ + if (defaultTargetPlatform == TargetPlatform.iOS) + IosGlassNavigationAction( + key: const ValueKey('avatar-save'), + label: 'Save', + width: 72, + isBusy: isSavingAvatar.value, + onPressed: canSaveAvatar && !isSavingAvatar.value + ? () { + unawaited(HapticFeedback.lightImpact()); + unawaited(saveAvatar()); + } + : null, + ) + else + Padding( + padding: const EdgeInsets.only(right: Grid.xs), + child: _ProfileActionPill( + key: const ValueKey('avatar-save'), + label: 'Save', + isBusy: isSavingAvatar.value, + onTap: canSaveAvatar && !isSavingAvatar.value + ? () { + unawaited(HapticFeedback.lightImpact()); + unawaited(saveAvatar()); + } + : null, + ), + ), + ] + : const [], + ), + body: isEditingAvatar.value + ? ProfileAvatarEditor( + key: const ValueKey('profile-avatar-editor-page'), + currentAvatarUrl: profile?.avatarUrl, + fallbackInitial: profile?.initial ?? '?', + draftAvatarUrl: activeDraft, + mode: avatarMode.value, + transition: avatarTransition, + onModeChanged: (nextMode) => avatarMode.value = nextMode, + onDraftChanged: (url) { + avatarDraft.value = url; + avatarDraftMode.value = url == null ? null : avatarMode.value; + }, + onAnimatedPrepareChanged: (prepare) { + prepareAnimatedAvatar.value = prepare; + canPrepareAnimatedAvatar.value = prepare != null; + }, + ) + : ListView( + key: const ValueKey('profile-information-page'), + padding: EdgeInsets.only( + top: frostedAppBarHeight(context) + Grid.sm, + bottom: Grid.sm, + ), + children: [ + _ProfilePhotoEditor( + profile: profile, + onEditPhoto: openAvatarEditor, + ), + AppListCard( + key: const ValueKey('profile-info-card'), + dividerIndent: Grid.xs, + verticalPadding: Grid.sm, + children: [ + AppListRow( + key: const ValueKey('profile-display-name-row'), + title: 'Display name', + subtitle: _fieldValue(profile?.displayName), + subtitleMaxLines: 1, + trailing: const _EditChevron(), + onTap: () => unawaited( + editField( + title: 'Display name', + initialValue: profile?.displayName ?? '', + hintText: 'Display name', + onSave: ref + .read(profileProvider.notifier) + .updateDisplayName, + ), + ), + ), + AppListRow( + key: const ValueKey('profile-description-row'), + title: 'Profile description', + subtitle: _fieldValue(profile?.about), + subtitleMaxLines: 3, + trailing: const _EditChevron(), + onTap: () => unawaited( + editField( + title: 'Profile description', + initialValue: profile?.about ?? '', + hintText: 'Profile description', + multiline: true, + onSave: ref + .read(profileProvider.notifier) + .updateAbout, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +String _fieldValue(String? value) { + final trimmed = value?.trim() ?? ''; + return trimmed.isEmpty ? 'Not set' : trimmed; +} + +class _ProfilePhotoEditor extends StatelessWidget { + const _ProfilePhotoEditor({required this.profile, required this.onEditPhoto}); + + final UserProfile? profile; + final VoidCallback onEditPhoto; + + @override + Widget build(BuildContext context) => Column( + children: [ + PlayingAvatarImage( + key: const ValueKey('profile-edit-avatar'), + imageUrl: profile?.avatarUrl, + radius: ProfileEditPage._avatarRadius, + backgroundColor: context.colors.primaryContainer, + fallback: Text( + profile?.initial ?? '?', + style: context.textTheme.displaySmall?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ), + ), + const SizedBox(height: Grid.twelve), + _ProfileActionPill( + key: const ValueKey('profile-edit-photo-pill'), + semanticLabel: 'Edit profile photo', + label: 'Edit Photo', + onTap: onEditPhoto, + ), + ], + ); +} + +class _ProfileActionPill extends StatelessWidget { + const _ProfileActionPill({ + super.key, + required this.label, + required this.onTap, + this.semanticLabel, + this.isBusy = false, + }); + + final String label; + final String? semanticLabel; + final VoidCallback? onTap; + final bool isBusy; + + @override + Widget build(BuildContext context) => Semantics( + button: true, + enabled: onTap != null, + label: semanticLabel ?? label, + child: Material( + color: onTap == null + ? context.colors.surfaceContainerHighest.withValues(alpha: 0.5) + : context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.full), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.xxs, + ), + child: isBusy + ? const BuzzLoadingIndicator( + size: 18, + semanticLabel: 'Saving profile photo', + ) + : Text( + label, + style: context.textTheme.labelLarge?.copyWith( + color: onTap == null + ? context.colors.onSurfaceVariant + : context.colors.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ); +} + +class _EditChevron extends StatelessWidget { + const _EditChevron(); + + @override + Widget build(BuildContext context) => Icon( + LucideIcons.chevronRight, + size: 18, + color: context.colors.onSurfaceVariant, + ); +} + +class _ProfileTextEditSheet extends HookWidget { + const _ProfileTextEditSheet({ + required this.initialValue, + required this.hintText, + required this.multiline, + required this.onSave, + }); + + final String initialValue; + final String hintText; + final bool multiline; + final Future Function(String value) onSave; + + @override + Widget build(BuildContext context) { + final controller = useTextEditingController(text: initialValue); + useListenable(controller); + final isSaving = useState(false); + final error = useState(null); + final hasChanges = controller.text.trim() != initialValue.trim(); + + Future save() async { + if (!hasChanges || isSaving.value) return; + isSaving.value = true; + error.value = null; + try { + await onSave(controller.text); + if (context.mounted) Navigator.of(context).pop(); + } catch (_) { + error.value = "We couldn't save this change. Try again."; + } finally { + if (context.mounted) isSaving.value = false; + } + } + + return SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB( + Grid.gutter, + Grid.xxs, + Grid.gutter, + MediaQuery.viewInsetsOf(context).bottom + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + key: const ValueKey('profile-field-input'), + controller: controller, + autofocus: true, + enabled: !isSaving.value, + minLines: multiline ? 4 : 1, + maxLines: multiline ? 6 : 1, + textCapitalization: TextCapitalization.sentences, + textInputAction: multiline + ? TextInputAction.newline + : TextInputAction.done, + onSubmitted: multiline ? null : (_) => unawaited(save()), + decoration: InputDecoration(hintText: hintText), + ), + if (error.value != null) ...[ + const SizedBox(height: Grid.xxs), + Text( + error.value!, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ], + const SizedBox(height: Grid.xs), + FilledButton( + key: const ValueKey('profile-field-save'), + onPressed: hasChanges && !isSaving.value ? save : null, + child: Text(isSaving.value ? 'Saving…' : 'Save'), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/profile/profile_photo_transition.dart b/mobile/lib/features/profile/profile_photo_transition.dart new file mode 100644 index 00000000000..b1f852d7fbf --- /dev/null +++ b/mobile/lib/features/profile/profile_photo_transition.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + +PageRoute profilePhotoEditorRoute({required WidgetBuilder builder}) => + PageRouteBuilder( + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (context, _, _) => builder(context), + // The editor animates its avatar and controls in place. Keeping the route + // still prevents a competing full-page push transition. + transitionsBuilder: (_, _, _, child) => child, + ); diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 6fa351d630d..69f43cfff30 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -1,16 +1,20 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/profile/user_cache_provider.dart'; +import '../../shared/profile/user_profile.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; -import '../../shared/profile/user_profile.dart'; /// The current user's profile (kind:0 metadata) loaded over the relay /// WebSocket. Returns null when no nsec is configured or when the user has /// not yet published a profile. class ProfileNotifier extends AsyncNotifier { + Map _metadata = {}; + @override Future build() { ref.watch(relayConfigProvider); @@ -24,7 +28,18 @@ class ProfileNotifier extends AsyncNotifier { final session = ref.read(relaySessionProvider.notifier); final events = await session.fetchHistory(NostrFilters.profile(myPk)); - if (events.isEmpty) return null; + if (events.isEmpty) { + _metadata = {}; + return null; + } + try { + final decoded = jsonDecode(events.first.content); + _metadata = decoded is Map + ? Map.from(decoded) + : {}; + } catch (_) { + _metadata = {}; + } final data = ProfileData.fromEvent(events.first); return UserProfile( pubkey: data.pubkey, @@ -38,6 +53,50 @@ class ProfileNotifier extends AsyncNotifier { Future refresh() async { state = await AsyncValue.guard(_fetch); } + + /// Updates the current user's display name while preserving the other + /// metadata fields in their latest kind:0 profile event. + Future updateDisplayName(String displayName) => + _publishProfilePatch({'display_name': displayName.trim()}); + + /// Updates the current user's profile description. + Future updateAbout(String about) => + _publishProfilePatch({'about': about.trim()}); + + /// Updates the current user's profile photo URL. + Future updateAvatarUrl(String avatarUrl) => + _publishProfilePatch({'picture': avatarUrl.trim()}); + + Future _publishProfilePatch(Map patch) async { + final pubkey = ref.read(myPubkeyProvider); + if (pubkey == null) { + throw StateError('Cannot update profile without a signing identity.'); + } + + final nextMetadata = {..._metadata, ...patch}; + final config = ref.read(relayConfigProvider); + final relay = SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: config.nsec, + ); + await relay.submit( + kind: EventKind.profile, + content: jsonEncode(nextMetadata), + tags: const [], + ); + + _metadata = nextMetadata; + final profile = UserProfile( + pubkey: pubkey, + displayName: + _metadata['display_name'] as String? ?? _metadata['name'] as String?, + avatarUrl: _metadata['picture'] as String?, + about: _metadata['about'] as String?, + nip05Handle: _metadata['nip05'] as String?, + ); + state = AsyncData(profile); + ref.read(userCacheProvider.notifier).put(profile); + } } final profileProvider = AsyncNotifierProvider( diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart new file mode 100644 index 00000000000..3d162b7bd8d --- /dev/null +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -0,0 +1,174 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/modal_presentation.dart'; +import 'ios_profile_text_editor.dart'; +import 'profile_provider.dart'; + +/// Opens the current user's display-name editor from a profile action surface. +Future showProfileDisplayNameEditor(BuildContext context) async { + final container = ProviderScope.containerOf(context, listen: false); + final profile = container.read(profileProvider).asData?.value; + await _showProfileTextEditor( + context: context, + title: 'Display name', + initialValue: profile?.displayName ?? '', + hintText: 'Display name', + onSave: container.read(profileProvider.notifier).updateDisplayName, + ); +} + +/// Opens the current user's profile-description editor. +Future showProfileDescriptionEditor(BuildContext context) async { + final container = ProviderScope.containerOf(context, listen: false); + final profile = container.read(profileProvider).asData?.value; + await _showProfileTextEditor( + context: context, + title: 'Profile description', + initialValue: profile?.about ?? '', + hintText: 'Profile description', + multiline: true, + onSave: container.read(profileProvider.notifier).updateAbout, + ); +} + +Future _showProfileTextEditor({ + required BuildContext context, + required String title, + required String initialValue, + required String hintText, + required Future Function(String value) onSave, + bool multiline = false, +}) async { + if (defaultTargetPlatform == TargetPlatform.iOS) { + try { + final value = await IosProfileTextEditor.present( + title: title, + initialValue: initialValue, + placeholder: hintText, + multiline: multiline, + ); + if (value == null) return; + try { + await onSave(value); + } catch (_) { + if (context.mounted) _showSaveError(context); + } + return; + } on MissingPluginException { + // Previews and older builds retain the complete Flutter fallback. + } on PlatformException { + // A temporary native presentation failure should not block editing. + } + } + + if (!context.mounted) return; + await showBuzzModalBottomSheet( + context: context, + title: title, + isScrollControlled: true, + requestFocus: true, + builder: (_) => _ProfileTextEditSheet( + initialValue: initialValue, + hintText: hintText, + multiline: multiline, + onSave: onSave, + ), + ); +} + +void _showSaveError(BuildContext context) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("We couldn't save this change. Try again.")), + ); +} + +class _ProfileTextEditSheet extends HookWidget { + const _ProfileTextEditSheet({ + required this.initialValue, + required this.hintText, + required this.multiline, + required this.onSave, + }); + + final String initialValue; + final String hintText; + final bool multiline; + final Future Function(String value) onSave; + + @override + Widget build(BuildContext context) { + final controller = useTextEditingController(text: initialValue); + useListenable(controller); + final isSaving = useState(false); + final error = useState(null); + final hasChanges = controller.text.trim() != initialValue.trim(); + + Future save() async { + if (!hasChanges || isSaving.value) return; + isSaving.value = true; + error.value = null; + try { + await onSave(controller.text); + if (context.mounted) Navigator.of(context).pop(); + } catch (_) { + error.value = "We couldn't save this change. Try again."; + } finally { + if (context.mounted) isSaving.value = false; + } + } + + return SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB( + Grid.gutter, + Grid.xxs, + Grid.gutter, + MediaQuery.viewInsetsOf(context).bottom + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + key: const ValueKey('profile-field-input'), + controller: controller, + autofocus: true, + enabled: !isSaving.value, + minLines: multiline ? 4 : 1, + maxLines: multiline ? 6 : 1, + textCapitalization: TextCapitalization.sentences, + textInputAction: multiline + ? TextInputAction.newline + : TextInputAction.done, + onSubmitted: multiline ? null : (_) => unawaited(save()), + decoration: InputDecoration(hintText: hintText), + ), + if (error.value != null) ...[ + const SizedBox(height: Grid.xxs), + Text( + error.value!, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ], + const SizedBox(height: Grid.xs), + FilledButton( + key: const ValueKey('profile-field-save'), + onPressed: hasChanges && !isSaving.value ? save : null, + child: Text(isSaving.value ? 'Saving…' : 'Save'), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 563a46c9e8b..877f402a93c 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -214,7 +214,7 @@ class SearchPage extends HookConsumerWidget { automaticallyImplyLeading: false, horizontalInset: Grid.twelve, showBottomDivider: true, - bottomDividerOpacity: 0.06, + bottomDividerOpacity: 0.07, titleStyle: headerTitleStyle, // Keep this mounted through the search-field morph so it can fade in // beneath the returning field rather than popping in afterward. diff --git a/mobile/lib/features/settings/accent_picker_page.dart b/mobile/lib/features/settings/accent_picker_page.dart index 88965e251f9..a0ee79a51df 100644 --- a/mobile/lib/features/settings/accent_picker_page.dart +++ b/mobile/lib/features/settings/accent_picker_page.dart @@ -21,7 +21,10 @@ class AccentPickerPage extends ConsumerWidget { final colorScheme = context.colors; return FrostedScaffold( - appBar: const FrostedAppBar(title: Text('Accent Color')), + appBar: const FrostedAppBar( + centerTitle: true, + title: Text('Accent Color'), + ), body: ListView( padding: EdgeInsets.only( top: frostedAppBarHeight(context), diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index eb4194eb155..e7ffe7eceb3 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -13,12 +13,14 @@ import '../../shared/clipboard_utils.dart'; import '../../shared/community/community_membership_provider.dart'; import '../../shared/relay/relay.dart'; import '../pairing/pairing_provider.dart'; +import '../profile/profile_photo_transition.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/app_list.dart'; import '../../shared/widgets/app_list_card.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/ios_glass_navigation_button.dart'; +import '../../shared/widgets/ios_glass_navigation_action.dart'; import '../../shared/widgets/modal_presentation.dart'; import 'accent_picker_page.dart'; import 'theme_picker_page.dart'; @@ -27,6 +29,10 @@ part 'settings_page/appearance_section.dart'; part 'settings_page/community_section.dart'; part 'settings_page/connection_section.dart'; +Widget _emptyProfileEditPage(BuildContext context) => const SizedBox.shrink(); + +enum _ProfileEditAction { displayName, description, photo } + class SettingsPage extends HookConsumerWidget { /// Creates the settings page. const SettingsPage({ @@ -34,6 +40,9 @@ class SettingsPage extends HookConsumerWidget { required this.profileHeader, required this.invitePageBuilder, required this.identityRecoveryPageBuilder, + this.profileEditPageBuilder = _emptyProfileEditPage, + this.onEditDisplayName, + this.onEditProfileDescription, }); /// Header widget displayed at the top of settings. @@ -45,6 +54,15 @@ class SettingsPage extends HookConsumerWidget { /// Builds the identity-recovery page pushed from the recovery settings row. final WidgetBuilder identityRecoveryPageBuilder; + /// Builds the current-user profile editor opened from the top action. + final WidgetBuilder profileEditPageBuilder; + + /// Opens the display-name editor after the Edit Profile sheet closes. + final Future Function(BuildContext context)? onEditDisplayName; + + /// Opens the profile-description editor after the Edit Profile sheet closes. + final Future Function(BuildContext context)? onEditProfileDescription; + @override Widget build(BuildContext context, WidgetRef ref) { final packageInfoFuture = useMemoized(() => PackageInfo.fromPlatform()); @@ -54,6 +72,75 @@ class SettingsPage extends HookConsumerWidget { bottomHeight: Grid.xxs, ); + Future showEditProfileSheet() async { + final action = await showBuzzModalBottomSheet<_ProfileEditAction>( + context: context, + title: 'Edit profile', + builder: (sheetContext) => SafeArea( + top: false, + child: Padding( + key: const ValueKey('edit-profile-sheet-content'), + padding: const EdgeInsets.only(bottom: Grid.xs), + // AppListCard normally fills the height offered by a page section. + // Give it unbounded vertical space here so this compact action sheet + // hugs its three rows instead of filling the modal height cap. + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppListCard( + key: const ValueKey('edit-profile-options'), + dividerIndent: Grid.xs, + verticalPadding: 0, + children: [ + AppListRow( + key: const ValueKey('edit-profile-display-name'), + title: 'Display name', + trailing: const _RowChevron(), + onTap: () => Navigator.pop( + sheetContext, + _ProfileEditAction.displayName, + ), + ), + AppListRow( + key: const ValueKey('edit-profile-description'), + title: 'Profile description', + trailing: const _RowChevron(), + onTap: () => Navigator.pop( + sheetContext, + _ProfileEditAction.description, + ), + ), + AppListRow( + key: const ValueKey('edit-profile-photo'), + title: 'Photo', + trailing: const _RowChevron(), + onTap: () => + Navigator.pop(sheetContext, _ProfileEditAction.photo), + ), + ], + ), + ], + ), + ), + ), + ); + if (!context.mounted || action == null) return; + unawaited(HapticFeedback.selectionClick()); + switch (action) { + case _ProfileEditAction.displayName: + await onEditDisplayName?.call(context); + break; + case _ProfileEditAction.description: + await onEditProfileDescription?.call(context); + break; + case _ProfileEditAction.photo: + await Navigator.of( + context, + ).push(profilePhotoEditorRoute(builder: profileEditPageBuilder)); + break; + } + } + return FrostedScaffold( backgroundColor: context.colors.surface, appBar: FrostedAppBar( @@ -84,6 +171,38 @@ class SettingsPage extends HookConsumerWidget { icon: const Icon(LucideIcons.x), ), ), + actions: [ + if (Theme.of(context).platform == TargetPlatform.iOS) + IosGlassNavigationAction( + key: const ValueKey('settings-edit-profile'), + label: 'Edit', + foregroundColor: navigationPrimaryForeground(context), + onPressed: () => unawaited(showEditProfileSheet()), + ) + else + Material( + key: const ValueKey('settings-edit-profile'), + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.full), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => unawaited(showEditProfileSheet()), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.xxs, + ), + child: Text( + 'Edit', + style: context.textTheme.labelLarge?.copyWith( + color: context.colors.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], bottomHeight: Grid.xxs, bottom: const SizedBox.expand(), ), diff --git a/mobile/lib/features/settings/settings_page/connection_section.dart b/mobile/lib/features/settings/settings_page/connection_section.dart index 8ab89f9e963..7f989b29db0 100644 --- a/mobile/lib/features/settings/settings_page/connection_section.dart +++ b/mobile/lib/features/settings/settings_page/connection_section.dart @@ -16,11 +16,6 @@ class _ConnectionSection extends ConsumerWidget { label: 'Connection', verticalPadding: Grid.twelve, children: [ - AppListRow( - icon: LucideIcons.server, - title: 'Connected to', - subtitle: config.baseUrl, - ), if (nsec != null && nsec.isNotEmpty && community != null) ...[ _IdentityRow(nsec: nsec), AppListRow( @@ -128,19 +123,19 @@ class _IdentityRow extends StatelessWidget { final privHex = nostr.Nip19.decode(payload: nsec).data; final pubkey = privHex.isNotEmpty ? nostr.Keys(privHex).public : 'unknown'; - return AppListRow( - icon: LucideIcons.key, - title: 'Identity (pubkey)', - subtitle: pubkey, - subtitleStyle: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - fontFamily: 'GeistMono', - fontSize: 11, - ), - subtitleMaxLines: 2, - trailing: IconButton( - icon: const Icon(LucideIcons.copy, size: 16), - onPressed: () async { + return Semantics( + button: true, + label: 'Copy identity public key', + value: pubkey, + child: AppListRow( + icon: LucideIcons.key, + title: 'Identity (pubkey)', + trailing: Icon( + LucideIcons.copy, + size: 18, + color: context.colors.onSurfaceVariant, + ), + onTap: () async { await copyToClipboard(context, pubkey, message: 'Pubkey copied'); }, ), diff --git a/mobile/lib/features/settings/theme_picker_page.dart b/mobile/lib/features/settings/theme_picker_page.dart index bd2c8fae325..ce0e0d3bf56 100644 --- a/mobile/lib/features/settings/theme_picker_page.dart +++ b/mobile/lib/features/settings/theme_picker_page.dart @@ -73,7 +73,7 @@ class ThemePickerPage extends HookConsumerWidget { }, const []); return FrostedScaffold( - appBar: const FrostedAppBar(title: Text('Theme')), + appBar: const FrostedAppBar(centerTitle: true, title: Text('Theme')), body: Column( children: [ SizedBox(height: frostedAppBarHeight(context)), diff --git a/mobile/lib/shared/animated_avatar.dart b/mobile/lib/shared/animated_avatar.dart index 41efcecb1e9..f44321746e9 100644 --- a/mobile/lib/shared/animated_avatar.dart +++ b/mobile/lib/shared/animated_avatar.dart @@ -18,6 +18,11 @@ class AnimatedAvatarDescriptor { final String animationUrl; } +/// Builds the desktop-compatible animated-avatar URL from a static poster and +/// an animated image. +String buildAnimatedAvatarUrl(String posterUrl, String animationUrl) => + '$posterUrl$_animatedAvatarSeparator${Uri.encodeComponent(animationUrl)}'; + /// Parses the Buzz animated-avatar fragment scheme from [url]. /// /// Returns `null` when the poster or animation URL is missing, malformed, or diff --git a/mobile/lib/shared/emoji/emoji_avatar.dart b/mobile/lib/shared/emoji/emoji_avatar.dart new file mode 100644 index 00000000000..9cac8a9fe04 --- /dev/null +++ b/mobile/lib/shared/emoji/emoji_avatar.dart @@ -0,0 +1,42 @@ +/// Desktop-compatible colors offered for emoji avatar backgrounds. +const emojiAvatarColors = [ + 0xFFFFF4CC, + 0xFFFFE75C, + 0xFFFFB84D, + 0xFFFF8652, + 0xFFF6534F, + 0xFFFF6B9A, + 0xFFFB60C4, + 0xFFD66BFF, + 0xFFB141FF, + 0xFF7C5CFF, + 0xFF476CFF, + 0xFF3399FF, + 0xFF63C6F2, + 0xFF41EBC1, + 0xFF2ED3A2, + 0xFF73EF75, + 0xFF9FE870, + 0xFFC7D36F, + 0xFFCCCCCC, + 0xFF8A8F98, + 0xFF4B5563, + 0xFF000000, + 0xFFFFFFFF, +]; + +/// Creates the same inline SVG avatar URL used by the desktop editor. +String emojiAvatarDataUrl(String emoji, int colorValue) { + final color = + '#${(colorValue & 0xFFFFFF).toRadixString(16).padLeft(6, '0').toUpperCase()}'; + final escapedEmoji = emoji + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>'); + final svg = + '' + '' + '$escapedEmoji' + ''; + return 'data:image/svg+xml,${Uri.encodeComponent(svg)}'; +} diff --git a/mobile/lib/shared/emoji/native_emoji_glyph.dart b/mobile/lib/shared/emoji/native_emoji_glyph.dart index 7831c1b32b8..6014b875d42 100644 --- a/mobile/lib/shared/emoji/native_emoji_glyph.dart +++ b/mobile/lib/shared/emoji/native_emoji_glyph.dart @@ -9,14 +9,35 @@ import 'package:flutter/material.dart'; class NativeEmojiGlyph extends StatelessWidget { final String emoji; final double size; + final double? opticalBoxSize; - const NativeEmojiGlyph({super.key, required this.emoji, required this.size}); + const NativeEmojiGlyph({ + super.key, + required this.emoji, + required this.size, + this.opticalBoxSize, + }); @override Widget build(BuildContext context) { - final glyph = Text(emoji, style: TextStyle(fontSize: size)); - if (defaultTargetPlatform != TargetPlatform.iOS) return glyph; + Widget glyph = Text( + emoji, + maxLines: 1, + softWrap: false, + style: TextStyle(fontSize: size, height: 1), + ); + if (defaultTargetPlatform == TargetPlatform.iOS) { + glyph = Transform.translate(offset: const Offset(0, -1), child: glyph); + } + final boxSize = opticalBoxSize; + if (boxSize == null) return glyph; - return Transform.translate(offset: const Offset(0, -1), child: glyph); + // Emoji sequences have very different typographic advances. Constrain + // them to the same square optical box so wide glyphs scale down around the + // same center instead of appearing to lean toward one edge. + return SizedBox.square( + dimension: boxSize, + child: FittedBox(fit: BoxFit.scaleDown, child: glyph), + ); } } diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index c04bf952ae1..fd8d3fafcd7 100644 --- a/mobile/lib/shared/profile/user_cache_provider.dart +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -33,6 +33,11 @@ class UserCacheNotifier extends Notifier> { return null; } + /// Stores a profile that was fetched or updated outside the batch loader. + void put(UserProfile profile) { + state = {...state, profile.pubkey.toLowerCase(): profile}; + } + /// Preload profiles for a list of pubkeys (e.g. channel members). void preload(List pubkeys) { final uncached = pubkeys diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 3161a70362d..160aa1c2c56 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -17,6 +17,8 @@ import 'media_auth.dart'; import 'mp4_fast_start.dart'; import 'relay_provider.dart'; +part 'media_upload/platform_bindings.dart'; + const _mediaUploadPath = '/upload'; const _legacyMediaUploadPath = '/media/upload'; const _mediaUploadPlatformChannelName = 'buzz/media_upload'; @@ -44,17 +46,6 @@ final _mediaUploadPlatformChannel = MethodChannel( _mediaUploadPlatformChannelName, ); -/// Whether saving media needs Android's pre-scoped-storage runtime permission. -Future requiresLegacyMediaStoragePermission() async { - if (defaultTargetPlatform != TargetPlatform.android) { - return false; - } - return await _mediaUploadPlatformChannel.invokeMethod( - _requiresLegacyMediaStoragePermissionMethod, - ) ?? - false; -} - const _allowedImageMimeTypes = { 'image/jpeg', 'image/png', @@ -67,6 +58,7 @@ const _maxFileSizeBytes = 100 * 1024 * 1024; // 100MB const _mediaPolicyUploadMessage = "We couldn't prepare this image for upload."; typedef PickGalleryImage = Future Function(); +typedef PickCameraImage = Future Function(); /// Selects multiple gallery images for upload in picker order. typedef PickGalleryImages = Future> Function(); @@ -218,6 +210,7 @@ class MediaUploadService { final String _baseUrl; final String? _nsec; final PickGalleryImage _pickGalleryImage; + final PickCameraImage? _pickCameraImage; final PickGalleryImages _pickGalleryImages; final PickGalleryVideo _pickGalleryVideo; final PickAttachmentFile? _pickAttachmentFile; @@ -234,6 +227,7 @@ class MediaUploadService { required String baseUrl, required String? nsec, required PickGalleryImage pickGalleryImage, + PickCameraImage? pickCameraImage, PickGalleryImages? pickGalleryImages, required PickGalleryVideo pickGalleryVideo, PickAttachmentFile? pickAttachmentFile, @@ -247,6 +241,7 @@ class MediaUploadService { }) : _baseUrl = baseUrl, _nsec = nsec, _pickGalleryImage = pickGalleryImage, + _pickCameraImage = pickCameraImage, _pickGalleryImages = pickGalleryImages ?? (() async { @@ -277,6 +272,23 @@ class MediaUploadService { return uploadImage(pickedImage); } + /// Opens the system photo library without uploading the selected image. + Future pickGalleryImage() => _pickGalleryImage(); + + /// Opens the system camera without uploading the captured image. + Future captureImage() async => _pickCameraImage?.call(); + + /// Produces displayable, sanitized image bytes before an upload begins. + Future prepareImageBytes(XFile image) async => + (await _prepareUploadImage(image)).bytes; + + /// Opens the system camera and uploads the captured image. + Future captureAndUploadImage() async { + final pickedImage = await _pickCameraImage?.call(); + if (pickedImage == null) return null; + return uploadImage(pickedImage); + } + /// Opens the system picker with multi-selection enabled. Future> pickGalleryImages() => _pickGalleryImages(); @@ -979,21 +991,3 @@ Future _invokeRequiredPlatformBytesMethod( } return result; } - -final mediaUploadServiceProvider = Provider((ref) { - final config = ref.watch(relayConfigProvider); - final picker = ImagePicker(); - final service = MediaUploadService( - baseUrl: config.baseUrl, - nsec: config.nsec, - pickGalleryImage: () => picker.pickImage( - source: ImageSource.gallery, - requestFullMetadata: false, - ), - pickGalleryImages: () => picker.pickMultiImage(requestFullMetadata: false), - pickGalleryVideo: () => picker.pickVideo(source: ImageSource.gallery), - pickAttachmentFile: file_selector.openFile, - ); - ref.onDispose(service.dispose); - return service; -}); diff --git a/mobile/lib/shared/relay/media_upload/platform_bindings.dart b/mobile/lib/shared/relay/media_upload/platform_bindings.dart new file mode 100644 index 00000000000..527871f11f7 --- /dev/null +++ b/mobile/lib/shared/relay/media_upload/platform_bindings.dart @@ -0,0 +1,34 @@ +part of '../media_upload.dart'; + +/// Whether saving media needs Android's pre-scoped-storage runtime permission. +Future requiresLegacyMediaStoragePermission() async { + if (defaultTargetPlatform != TargetPlatform.android) { + return false; + } + return await _mediaUploadPlatformChannel.invokeMethod( + _requiresLegacyMediaStoragePermissionMethod, + ) ?? + false; +} + +final mediaUploadServiceProvider = Provider((ref) { + final config = ref.watch(relayConfigProvider); + final picker = ImagePicker(); + final service = MediaUploadService( + baseUrl: config.baseUrl, + nsec: config.nsec, + pickGalleryImage: () => picker.pickImage( + source: ImageSource.gallery, + requestFullMetadata: false, + ), + pickCameraImage: () => picker.pickImage( + source: ImageSource.camera, + requestFullMetadata: false, + ), + pickGalleryImages: () => picker.pickMultiImage(requestFullMetadata: false), + pickGalleryVideo: () => picker.pickVideo(source: ImageSource.gallery), + pickAttachmentFile: file_selector.openFile, + ); + ref.onDispose(service.dispose); + return service; +}); diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart index 98039ff2ab2..34fe60ed6b7 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; /// /// Keep in sync with `desktop/src/shared/constants/kinds.ts`. abstract final class EventKind { + static const profile = 0; static const note = 1; static const contactList = 3; static const deletion = 5; diff --git a/mobile/lib/shared/widgets/app_list.dart b/mobile/lib/shared/widgets/app_list.dart index 787e66d577e..d2a4ea8e834 100644 --- a/mobile/lib/shared/widgets/app_list.dart +++ b/mobile/lib/shared/widgets/app_list.dart @@ -26,6 +26,7 @@ class AppListRow extends StatelessWidget { this.trailing, this.titleColor, this.onTap, + this.verticalPadding = _rowVerticalPadding, }); final IconData? icon; @@ -42,12 +43,16 @@ class AppListRow extends StatelessWidget { final Color? titleColor; final VoidCallback? onTap; + /// Vertical inset around the row content. Compact sheets may use + /// [Grid.twelve] while standard settings rows retain the default. + final double verticalPadding; + @override Widget build(BuildContext context) { final row = Padding( padding: EdgeInsets.symmetric( horizontal: AppListInset.of(context), - vertical: _rowVerticalPadding, + vertical: verticalPadding, ), child: Row( // Centred rather than baseline-aligned to the title: on a two-line row diff --git a/mobile/lib/shared/widgets/avatar_image.dart b/mobile/lib/shared/widgets/avatar_image.dart index 7b40739c4c7..a983920ee12 100644 --- a/mobile/lib/shared/widgets/avatar_image.dart +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../animated_avatar.dart'; +import '../emoji/native_emoji_glyph.dart'; import '../relay/relay.dart'; /// A circular avatar that supports both remote URLs and inline image data. @@ -85,16 +86,16 @@ class _AvatarImageContentState extends State { _EmojiAvatarSource(:final emoji, :final color) => ColoredBox( color: color, child: LayoutBuilder( - builder: (_, constraints) => Center( - child: Text( - emoji, - textScaler: TextScaler.noScaling, - style: TextStyle( - fontSize: constraints.biggest.shortestSide * 258 / 512, - height: 1, + builder: (_, constraints) { + final glyphSize = constraints.biggest.shortestSide * 258 / 512; + return Center( + child: NativeEmojiGlyph( + emoji: emoji, + size: glyphSize, + opticalBoxSize: glyphSize, ), - ), - ), + ); + }, ), ), _SvgAvatarSource(:final svg) => SvgPicture.string( diff --git a/mobile/lib/shared/widgets/buzz_sheet_header.dart b/mobile/lib/shared/widgets/buzz_sheet_header.dart index fed46a8d607..14551f76fdc 100644 --- a/mobile/lib/shared/widgets/buzz_sheet_header.dart +++ b/mobile/lib/shared/widgets/buzz_sheet_header.dart @@ -89,6 +89,7 @@ class _SheetCloseButton extends StatelessWidget { onPressed: closeSheet, width: 44, height: 44, + foregroundColor: context.colors.primary, ); } @@ -100,7 +101,7 @@ class _SheetCloseButton extends StatelessWidget { style: IconButton.styleFrom( padding: EdgeInsets.zero, backgroundColor: context.colors.surfaceContainerHighest, - foregroundColor: context.colors.onSurface, + foregroundColor: context.colors.primary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(Radii.dialog), ), diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart index d20b4dcf99b..42d0b541811 100644 --- a/mobile/lib/shared/widgets/frosted_app_bar.dart +++ b/mobile/lib/shared/widgets/frosted_app_bar.dart @@ -13,8 +13,8 @@ const _kBottomBorderWidth = 1.0; TextStyle _effectiveTitleStyle(BuildContext context, TextStyle? titleStyle) { final baseStyle = - context.textTheme.titleMedium ?? - const TextStyle(fontSize: 20, height: 1.3); + context.textTheme.titleSmall ?? + const TextStyle(fontSize: 16, height: 1.4); return baseStyle.copyWith(fontWeight: FontWeight.w600).merge(titleStyle); } @@ -26,7 +26,7 @@ double _barContentHeight( final style = _effectiveTitleStyle(context, titleStyle); final scaledFontSize = MediaQuery.textScalerOf( context, - ).scale(style.fontSize ?? 20); + ).scale(style.fontSize ?? 16); final scaledTitleHeight = scaledFontSize * (style.height ?? 1); final effectiveTitleHeight = titleContentHeight > scaledTitleHeight ? titleContentHeight @@ -48,7 +48,7 @@ double frostedAppBarLowerTitleHeight( final style = _effectiveTitleStyle(context, titleStyle); final scaledFontSize = MediaQuery.textScalerOf( context, - ).scale(style.fontSize ?? 20); + ).scale(style.fontSize ?? 16); final titleHeight = scaledFontSize * (style.height ?? 1); return titleHeight > 40 ? titleHeight : 40; } @@ -85,6 +85,11 @@ class FrostedAppBar extends StatelessWidget { /// Widget displayed in the center/title area. final Widget? title; + /// Whether [title] is centered in the full bar rather than flowing after the + /// leading control. Page titles should keep the default; identity-style + /// headers can opt out. + final bool centerTitle; + /// Optional style merged over the default title style. final TextStyle? titleStyle; @@ -141,20 +146,21 @@ class FrostedAppBar extends StatelessWidget { this.leading, this.automaticallyImplyLeading = true, this.title, + this.centerTitle = false, this.titleStyle, this.titleContentHeight = 0, this.bottom, this.bottomHeight = 0, this.bottomOverlap = 0, this.actions = const [], - this.horizontalInset = Grid.quarter, + this.horizontalInset = Grid.xxs, this.iconColor, this.gradient, this.frosted = true, this.frostedSurfaceOpacity = 0.5, this.frostedBlurSigma = 20, this.showBottomDivider = true, - this.bottomDividerOpacity = 0.15, + this.bottomDividerOpacity = 0.07, }) : assert(bottom == null || bottomHeight > 0), assert(bottomOverlap >= 0), assert(bottom != null || bottomOverlap == 0), @@ -176,6 +182,7 @@ class FrostedAppBar extends StatelessWidget { automaticallyImplyLeading && canPop && Theme.of(context).platform == TargetPlatform.iOS; + final effectiveIconColor = iconColor ?? context.colors.primary; final effectiveLeading = leading ?? @@ -187,14 +194,14 @@ class FrostedAppBar extends StatelessWidget { onPressed: () => Navigator.of(context).maybePop(), width: iosGlassChannelHeaderLeadingWidth, buttonCenterX: iosGlassChannelHeaderButtonCenterX, - foregroundColor: iconColor, + foregroundColor: effectiveIconColor, ) : SizedBox( width: 48, height: 48, child: IconButton( onPressed: () => Navigator.of(context).pop(), - color: iconColor, + color: effectiveIconColor, icon: const Icon(LucideIcons.chevronLeft), tooltip: 'Back', ), @@ -206,39 +213,40 @@ class FrostedAppBar extends StatelessWidget { child: Padding( padding: EdgeInsets.symmetric(horizontal: horizontalInset), child: IconTheme.merge( - data: IconThemeData(color: iconColor), - child: Row( - children: [ - ?effectiveLeading, - if (title != null) - Expanded( - child: Padding( - padding: EdgeInsets.only( - left: effectiveLeading != null - ? usesAutomaticIosGlassBackButton - ? iosGlassChannelHeaderTitleSpacing - : 0 - : horizontalInset < Grid.gutter - ? Grid.gutter - horizontalInset - : 0, - right: actions.isEmpty - ? horizontalInset < Grid.gutter - ? Grid.gutter - horizontalInset - : 0 - : 0, - ), - child: DefaultTextStyle.merge( - style: effectiveTitleStyle, - overflow: TextOverflow.ellipsis, - maxLines: 1, - child: title!, - ), + data: IconThemeData(color: effectiveIconColor), + child: _CenteredNavigationLayout( + leading: effectiveLeading, + title: title == null + ? null + : DefaultTextStyle.merge( + style: effectiveTitleStyle, + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: centerTitle ? TextAlign.center : TextAlign.start, + child: centerTitle + ? title! + : Padding( + padding: EdgeInsets.only( + left: effectiveLeading != null + ? usesAutomaticIosGlassBackButton + ? iosGlassChannelHeaderTitleSpacing + : 0 + : horizontalInset < Grid.gutter + ? Grid.gutter - horizontalInset + : 0, + right: + actions.isEmpty && + horizontalInset < Grid.gutter + ? Grid.gutter - horizontalInset + : 0, + ), + child: title!, + ), ), - ) - else - const Spacer(), - ...actions, - ], + actions: actions.isEmpty + ? null + : Row(mainAxisSize: MainAxisSize.min, children: actions), + centered: centerTitle, ), ), ), @@ -312,3 +320,100 @@ class FrostedAppBar extends StatelessWidget { return Positioned(top: 0, left: 0, right: 0, child: child); } } + +enum _NavigationSlot { leading, title, actions } + +class _CenteredNavigationLayout extends StatelessWidget { + const _CenteredNavigationLayout({ + this.leading, + this.title, + this.actions, + required this.centered, + }); + + final Widget? leading; + final Widget? title; + final Widget? actions; + final bool centered; + + @override + Widget build(BuildContext context) { + if (!centered) { + return Row( + children: [ + ?leading, + if (title != null) Expanded(child: title!) else const Spacer(), + ?actions, + ], + ); + } + return CustomMultiChildLayout( + delegate: _CenteredNavigationLayoutDelegate(), + children: [ + if (leading != null) + LayoutId(id: _NavigationSlot.leading, child: leading!), + if (title != null) LayoutId(id: _NavigationSlot.title, child: title!), + if (actions != null) + LayoutId(id: _NavigationSlot.actions, child: actions!), + ], + ); + } +} + +class _CenteredNavigationLayoutDelegate extends MultiChildLayoutDelegate { + @override + void performLayout(Size size) { + Size leadingSize = Size.zero; + if (hasChild(_NavigationSlot.leading)) { + leadingSize = layoutChild( + _NavigationSlot.leading, + BoxConstraints.loose(size), + ); + positionChild( + _NavigationSlot.leading, + Offset(0, (size.height - leadingSize.height) / 2), + ); + } + + Size actionsSize = Size.zero; + if (hasChild(_NavigationSlot.actions)) { + actionsSize = layoutChild( + _NavigationSlot.actions, + BoxConstraints.loose(size), + ); + positionChild( + _NavigationSlot.actions, + Offset( + size.width - actionsSize.width, + (size.height - actionsSize.height) / 2, + ), + ); + } + + if (hasChild(_NavigationSlot.title)) { + final occupiedSideWidth = leadingSize.width > actionsSize.width + ? leadingSize.width + : actionsSize.width; + final sideWidth = occupiedSideWidth == 0 + ? 0.0 + : occupiedSideWidth + Grid.xs; + final titleWidth = (size.width - sideWidth * 2).clamp(0.0, size.width); + final titleSize = layoutChild( + _NavigationSlot.title, + BoxConstraints(maxWidth: titleWidth, maxHeight: size.height), + ); + positionChild( + _NavigationSlot.title, + Offset( + (size.width - titleSize.width) / 2, + (size.height - titleSize.height) / 2, + ), + ); + } + } + + @override + bool shouldRelayout( + covariant _CenteredNavigationLayoutDelegate oldDelegate, + ) => false; +} diff --git a/mobile/lib/shared/widgets/ios_glass_navigation_action.dart b/mobile/lib/shared/widgets/ios_glass_navigation_action.dart new file mode 100644 index 00000000000..02f73786b20 --- /dev/null +++ b/mobile/lib/shared/widgets/ios_glass_navigation_action.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show PlatformViewHitTestBehavior; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import '../theme/theme.dart'; +import 'ios_glass_navigation_button.dart'; + +/// A native iOS glass capsule for a short navigation-bar text action. +class IosGlassNavigationAction extends HookWidget { + const IosGlassNavigationAction({ + super.key, + required this.label, + required this.onPressed, + this.width = 64, + this.height = 48, + this.foregroundColor, + this.isBusy = false, + }); + + final String label; + final VoidCallback? onPressed; + final double width; + final double height; + final Color? foregroundColor; + final bool isBusy; + + @override + Widget build(BuildContext context) { + assert(defaultTargetPlatform == TargetPlatform.iOS); + final nativeChannel = useState(null); + final onPressedRef = useRef(onPressed)..value = onPressed; + final brightness = context.theme.brightness.name; + final effectiveForeground = foregroundColor ?? context.colors.primary; + final foregroundValue = effectiveForeground.toARGB32(); + final enabled = onPressed != null; + + useEffect(() { + final channel = nativeChannel.value; + if (channel == null) return null; + channel.setMethodCallHandler((call) async { + if (call.method == 'pressed') onPressedRef.value?.call(); + }); + return () => channel.setMethodCallHandler(null); + }, [nativeChannel.value]); + + useEffect(() { + final channel = nativeChannel.value; + if (channel != null) { + unawaited( + channel.invokeMethod('setAppearance', { + 'brightness': brightness, + 'foregroundColor': foregroundValue, + 'enabled': enabled, + 'busy': isBusy, + }), + ); + } + return null; + }, [nativeChannel.value, brightness, foregroundValue, enabled, isBusy]); + + return Tooltip( + message: label, + excludeFromSemantics: true, + child: SizedBox( + width: width, + height: height, + child: UiKitView( + viewType: IosGlassNavigationButton.viewType, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + creationParams: { + 'label': label, + 'accessibilityLabel': label, + 'brightness': brightness, + 'foregroundColor': foregroundValue, + 'enabled': enabled, + 'busy': isBusy, + 'buttonCenterX': width / 2, + 'controlWidth': width - 8, + 'hitTargetWidth': width, + 'hitTargetHeight': height, + }, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: (viewId) { + nativeChannel.value = MethodChannel( + '${IosGlassNavigationButton.viewType}/$viewId', + ); + }, + ), + ), + ); + } +} diff --git a/mobile/lib/shared/widgets/ios_native_segmented_control.dart b/mobile/lib/shared/widgets/ios_native_segmented_control.dart new file mode 100644 index 00000000000..8ddd60c8c05 --- /dev/null +++ b/mobile/lib/shared/widgets/ios_native_segmented_control.dart @@ -0,0 +1,77 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show PlatformViewHitTestBehavior; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// A platform-native iOS segmented control embedded in Flutter. +class IosNativeSegmentedControl extends HookWidget { + const IosNativeSegmentedControl({ + super.key, + required this.items, + required this.selectedIndex, + required this.onChanged, + this.height = 40, + }); + + static const viewType = 'buzz/native_segmented_control'; + + final List items; + final int selectedIndex; + final ValueChanged? onChanged; + final double height; + + @override + Widget build(BuildContext context) { + assert(defaultTargetPlatform == TargetPlatform.iOS); + final nativeChannel = useState(null); + final onChangedRef = useRef(onChanged)..value = onChanged; + final brightness = Theme.of(context).brightness.name; + final enabled = onChanged != null; + + useEffect(() { + final channel = nativeChannel.value; + if (channel == null) return null; + channel.setMethodCallHandler((call) async { + if (call.method == 'changed' && call.arguments is int) { + onChangedRef.value?.call(call.arguments as int); + } + }); + return () => channel.setMethodCallHandler(null); + }, [nativeChannel.value]); + + useEffect(() { + final channel = nativeChannel.value; + if (channel != null) { + unawaited( + channel.invokeMethod('setState', { + 'selectedIndex': selectedIndex, + 'brightness': brightness, + 'enabled': enabled, + }), + ); + } + return null; + }, [nativeChannel.value, selectedIndex, brightness, enabled]); + + return SizedBox( + height: height, + child: UiKitView( + viewType: viewType, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + creationParams: { + 'items': items, + 'selectedIndex': selectedIndex, + 'brightness': brightness, + 'enabled': enabled, + }, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: (viewId) { + nativeChannel.value = MethodChannel('$viewType/$viewId'); + }, + ), + ); + } +} diff --git a/mobile/lib/shared/widgets/ios_native_skin_tone_control.dart b/mobile/lib/shared/widgets/ios_native_skin_tone_control.dart new file mode 100644 index 00000000000..7450d370b0b --- /dev/null +++ b/mobile/lib/shared/widgets/ios_native_skin_tone_control.dart @@ -0,0 +1,69 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show PlatformViewHitTestBehavior; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// A native iOS glass menu for selecting an emoji skin tone. +class IosNativeSkinToneControl extends HookWidget { + const IosNativeSkinToneControl({ + super.key, + required this.value, + required this.colors, + required this.labels, + required this.onChanged, + }); + + static const viewType = 'buzz/native_skin_tone_control'; + + final int value; + final List colors; + final List labels; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + assert(defaultTargetPlatform == TargetPlatform.iOS); + final nativeChannel = useState(null); + final onChangedRef = useRef(onChanged)..value = onChanged; + + useEffect(() { + final channel = nativeChannel.value; + if (channel == null) return null; + channel.setMethodCallHandler((call) async { + if (call.method == 'changed' && call.arguments is int) { + onChangedRef.value(call.arguments as int); + } + }); + return () => channel.setMethodCallHandler(null); + }, [nativeChannel.value]); + + useEffect(() { + final channel = nativeChannel.value; + if (channel != null) { + unawaited(channel.invokeMethod('setValue', value)); + } + return null; + }, [nativeChannel.value, value]); + + return SizedBox.square( + dimension: 48, + child: UiKitView( + viewType: viewType, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + creationParams: { + 'value': value, + 'colors': [for (final color in colors) color.toARGB32()], + 'labels': labels, + 'brightness': Theme.of(context).brightness.name, + }, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: (viewId) { + nativeChannel.value = MethodChannel('$viewType/$viewId'); + }, + ), + ); + } +} diff --git a/mobile/lib/shared/widgets/playing_avatar_image.dart b/mobile/lib/shared/widgets/playing_avatar_image.dart new file mode 100644 index 00000000000..c6698765173 --- /dev/null +++ b/mobile/lib/shared/widgets/playing_avatar_image.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +import '../animated_avatar.dart'; +import 'avatar_image.dart'; +import 'progressive_animated_avatar.dart'; + +/// A circular avatar that opts into playback for animated profile descriptors. +class PlayingAvatarImage extends StatelessWidget { + const PlayingAvatarImage({ + super.key, + required this.imageUrl, + required this.radius, + required this.fallback, + this.backgroundColor, + }); + + final String? imageUrl; + final double radius; + final Color? backgroundColor; + final Widget fallback; + + @override + Widget build(BuildContext context) { + final descriptor = parseAnimatedAvatarUrl(imageUrl); + if (descriptor == null) { + return AvatarImage( + imageUrl: imageUrl, + radius: radius, + backgroundColor: backgroundColor, + fallback: fallback, + ); + } + + return CircleAvatar( + radius: radius, + backgroundColor: Colors.transparent, + child: ClipOval( + child: SizedBox.square( + dimension: radius * 2, + child: ProgressiveAnimatedAvatar( + descriptor: descriptor, + fallback: fallback, + ), + ), + ), + ); + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index d83c73b98d0..0d4e95a80c9 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -81,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + archive: + dependency: transitive + description: + name: archive + sha256: be169cf6ac481e052c4538715d88841d567150dfe1df38aaec76461a4e7b39f2 + url: "https://pub.dev" + source: hosted + version: "4.1.0" args: dependency: transitive description: @@ -584,6 +592,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + google_mlkit_commons: + dependency: transitive + description: + name: google_mlkit_commons + sha256: "3e69fea4211727732cc385104e675ad1e40b29f12edd492ee52fa108423a6124" + url: "https://pub.dev" + source: hosted + version: "0.11.1" + google_mlkit_selfie_segmentation: + dependency: "direct main" + description: + name: google_mlkit_selfie_segmentation + sha256: "1688273f3bd9d3c78cdb6f4136c2b2c40c7013bd53d56392c0d52a84cdf3cb29" + url: "https://pub.dev" + source: hosted + version: "0.10.1" gpt_markdown: dependency: "direct main" description: @@ -656,6 +680,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: "direct main" + description: + name: image + sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" + url: "https://pub.dev" + source: hosted + version: "4.9.2" image_picker: dependency: "direct main" description: @@ -1088,6 +1120,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" provider: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index cd1eac39ac1..6c51dc9cc5c 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -34,6 +34,8 @@ dependencies: file_selector: ^1.1.0 camera: ^0.12.0+2 image_picker: ^1.1.2 + image: ^4.8.0 + google_mlkit_selfie_segmentation: ^0.10.1 photo_manager: ^3.11.0 video_player: ^2.10.1 package_info_plus: ^10.0.0 diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index 96ad80aa003..f770e1ccb11 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -197,6 +197,7 @@ void main() { expect(appBar.frosted, isTrue); expect(appBar.showBottomDivider, isTrue); expect(appBar.bottomHeight, Grid.xxs); + expect(appBar.centerTitle, isFalse); expect(find.byTooltip('Back'), findsNothing); }); diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 4178dbc76a3..195dcc25ad8 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -8119,6 +8119,7 @@ void main() { expect(detailsAppBar.frostedSurfaceOpacity, 0); expect(detailsAppBar.frostedBlurSigma, 0); expect(detailsAppBar.showBottomDivider, isFalse); + expect(detailsAppBar.centerTitle, isTrue); final descriptionBottom = tester .getRect(find.byKey(const ValueKey('channel-details-description'))) @@ -8173,7 +8174,7 @@ void main() { expect(detailsAppBar.frostedSurfaceOpacity, 0.5); expect(detailsAppBar.frostedBlurSigma, 20); expect(detailsAppBar.showBottomDivider, isTrue); - expect(detailsAppBar.bottomDividerOpacity, 0.15); + expect(detailsAppBar.bottomDividerOpacity, 0.07); expect( tester .widget( diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 9cd955ce52d..323ed839ba9 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -627,6 +627,11 @@ void main() { expect(tester.getSize(communityAvatar), const Size.square(40)); expect(tester.getSize(profileAvatar), const Size.square(36)); + expect( + tester.widget(profileAvatar).badge, + isNull, + reason: 'The current user does not need an online dot on Home.', + ); final communityRect = tester.getRect(communityAvatar); final profileRect = tester.getRect(profileAvatar); expect(profileRect.center.dy, communityRect.center.dy); @@ -748,6 +753,8 @@ void main() { expect(route, isNot(isA>())); expect(route?.opaque, isFalse); expect(route?.allowSnapshotting, isFalse); + expect(route?.transitionDuration, const Duration(milliseconds: 150)); + expect(route?.reverseTransitionDuration, const Duration(milliseconds: 150)); }); testWidgets('reports monotonic Settings route progress', (tester) async { @@ -834,7 +841,7 @@ void main() { final forwardScaleProgress = (1.04 - forwardScale) / 0.04; expect( forwardOpacity, - closeTo(Curves.easeOutQuad.transform(95 / 220), 0.02), + closeTo(Curves.easeOutQuad.transform(95 / 150), 0.02), ); expect(forwardScaleProgress, closeTo(forwardOpacity, 0.001)); await tester.pumpAndSettle(); diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart new file mode 100644 index 00000000000..b748c2e5f1c --- /dev/null +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -0,0 +1,977 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:buzz/features/profile/profile_edit_page.dart'; +import 'package:buzz/features/profile/avatar_background_grid.dart'; +import 'package:buzz/features/profile/avatar_editor_option_button.dart'; +import 'package:buzz/features/profile/profile_photo_transition.dart'; +import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/shared/emoji/emoji_avatar.dart'; +import 'package:buzz/shared/emoji/native_emoji_glyph.dart'; +import 'package:buzz/shared/profile/user_profile.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/widgets/frosted_app_bar.dart'; +import 'package:buzz/shared/widgets/ios_native_segmented_control.dart'; +import 'package:buzz/shared/widgets/playing_avatar_image.dart'; +import 'package:buzz/shared/widgets/progressive_animated_avatar.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:image/image.dart' as image; +import 'package:image_picker/image_picker.dart'; + +import '../../helpers/widget_helpers.dart'; + +const _editorControlBottomForTest = Grid.xl + Grid.xxs; + +void main() { + testWidgets('can open directly into the photo editor from Settings', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(startInPhotoEditor: true), + ), + ); + await tester.pump(); + + expect(find.text('Edit Photo'), findsOneWidget); + expect( + find.byKey(const ValueKey('profile-avatar-editor-page')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('profile-information-page')), + findsNothing, + ); + + final entrance = find.byKey( + const ValueKey('avatar-editor-entrance-transform'), + ); + final initialOffset = tester + .widget(entrance) + .transform + .getTranslation() + .y; + final screenHeight = + tester.view.physicalSize.height / tester.view.devicePixelRatio; + final appBarHeight = frostedAppBarHeight(tester.element(entrance)); + final expectedOffset = appBarHeight + 96 - screenHeight / 2; + expect(initialOffset, closeTo(expectedOffset, 0.01)); + + await tester.pump(const Duration(milliseconds: 100)); + final midpointOffset = tester + .widget(entrance) + .transform + .getTranslation() + .y; + expect(midpointOffset, greaterThan(initialOffset)); + expect(midpointOffset, lessThan(0)); + + await tester.pumpAndSettle(); + expect( + tester.widget(entrance).transform.getTranslation().y, + closeTo(0, 0.01), + ); + expect( + tester + .getCenter(find.byKey(const ValueKey('avatar-editor-fixed-preview'))) + .dy, + closeTo(screenHeight / 2, 0.01), + ); + }); + + testWidgets('return motion eases out into the Settings avatar position', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: MaterialApp( + theme: AppTheme.light(), + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => Navigator.of(context).push( + profilePhotoEditorRoute( + builder: (_) => + const ProfileEditPage(startInPhotoEditor: true), + ), + ), + child: const Text('Open editor'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open editor')); + await tester.pumpAndSettle(); + final entrance = find.byKey( + const ValueKey('avatar-editor-entrance-transform'), + ); + expect( + tester.widget(entrance).transform.getTranslation().y, + closeTo(0, 0.01), + ); + + await tester.tap(find.byKey(const ValueKey('avatar-editor-back'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + final quarterOffset = tester + .widget(entrance) + .transform + .getTranslation() + .y; + final screenHeight = + tester.view.physicalSize.height / tester.view.devicePixelRatio; + final appBarHeight = frostedAppBarHeight(tester.element(entrance)); + final collapsedOffset = appBarHeight + 96 - screenHeight / 2; + expect(quarterOffset, lessThan(collapsedOffset / 2)); + expect(quarterOffset, greaterThan(collapsedOffset)); + + await tester.pumpAndSettle(); + expect(find.text('Open editor'), findsOneWidget); + expect( + find.byKey(const ValueKey('profile-avatar-editor-page')), + findsNothing, + ); + }); + + testWidgets('uses the native segmented control for photo modes on iOS', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pump(); + + expect(find.byType(IosNativeSegmentedControl), findsOneWidget); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('uses the native profile text form on iOS', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const channel = MethodChannel('buzz/profile_text_editor'); + MethodCall? receivedCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + receivedCall = call; + return 'Alice Native'; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + final notifier = _FakeProfileNotifier(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('profile-display-name-row'))); + await tester.pumpAndSettle(); + + expect(receivedCall?.method, 'present'); + expect(receivedCall?.arguments, { + 'title': 'Display name', + 'initialValue': 'Alice', + 'placeholder': 'Display name', + 'multiline': false, + }); + expect(notifier.savedDisplayNames, ['Alice Native']); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('shows profile fields and saves each one from a sheet', ( + tester, + ) async { + final notifier = _FakeProfileNotifier(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Edit Photo'), findsOneWidget); + expect(find.text('Display name'), findsOneWidget); + expect(find.text('Profile description'), findsOneWidget); + expect(find.text('Alice'), findsOneWidget); + expect(find.text('Building Buzz'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('profile-display-name-row'))); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('profile-field-input')), + 'Alice L', + ); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('profile-field-save'))); + await tester.pumpAndSettle(); + + expect(notifier.savedDisplayNames, ['Alice L']); + expect(find.text('Alice L'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('profile-description-row'))); + await tester.pumpAndSettle(); + final field = tester.widget( + find.byKey(const ValueKey('profile-field-input')), + ); + expect(field.minLines, 4); + await tester.enterText( + find.byKey(const ValueKey('profile-field-input')), + 'Making collaboration feel effortless.', + ); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('profile-field-save'))); + await tester.pumpAndSettle(); + + expect(notifier.savedDescriptions, [ + 'Making collaboration feel effortless.', + ]); + }); + + testWidgets('uses an icon-free grouped card with inset dividers', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byKey(const ValueKey('profile-info-card')), + matching: find.byType(Icon), + ), + findsNWidgets(2), + ); + expect( + find.descendant( + of: find.byKey(const ValueKey('profile-info-card')), + matching: find.byType(Divider), + ), + findsOneWidget, + ); + }); + + testWidgets('uploads a selected photo and updates the profile', ( + tester, + ) async { + final notifier = _FakeProfileNotifier(); + final uploadService = _FakeMediaUploadService(); + addTearDown(uploadService.dispose); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + mediaUploadServiceProvider.overrideWithValue(uploadService), + ], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('avatar-mode-control')), findsOneWidget); + expect(find.text('Image'), findsOneWidget); + expect(find.text('Camera'), findsOneWidget); + expect(find.text('Photo Library'), findsOneWidget); + expect(find.text('Emoji'), findsOneWidget); + expect(find.text('Animated'), findsOneWidget); + expect(find.text('Photo'), findsNothing); + final modeControl = find.byKey(const ValueKey('avatar-mode-control')); + final preview = find.descendant( + of: find.byKey(const ValueKey('avatar-editor-content')), + matching: find.byType(AvatarImage), + ); + expect( + tester.getTopLeft(modeControl).dy, + lessThan(tester.getTopLeft(preview).dy), + ); + expect(tester.widget(preview).radius, 110); + await tester.tap(find.text('Photo Library')); + await tester.pumpAndSettle(); + expect(find.text('Position Photo'), findsOneWidget); + final cancelButton = find.ancestor( + of: find.text('Cancel'), + matching: find.byType(TextButton), + ); + final saveButton = find.byKey(const ValueKey('avatar-crop-use-photo')); + expect(tester.getRect(cancelButton).left, Grid.gutter); + expect( + tester.getSize(find.text('Cancel')).width, + lessThan(tester.getSize(cancelButton).width), + ); + expect( + tester.getSize(find.byType(Scaffold).last).width - + tester.getRect(saveButton).right, + Grid.gutter, + ); + final cropViewerFinder = find.byKey(const ValueKey('avatar-crop-viewer')); + final cropViewer = tester.widget(cropViewerFinder); + final cropViewport = tester.getSize(cropViewerFinder); + final cropDiameter = math.min(cropViewport.width, cropViewport.height); + final expectedX = (cropViewport.width - cropDiameter) / 2; + final expectedY = (cropViewport.height - cropDiameter) / 2; + await tester.drag(cropViewerFinder, const Offset(1000, 1000)); + await tester.pump(); + expect( + cropViewer.transformationController!.value.storage[12], + closeTo(expectedX, 0.01), + ); + expect( + cropViewer.transformationController!.value.storage[13], + closeTo(expectedY, 0.01), + ); + await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo'))); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 200)), + ); + await tester.pumpAndSettle(); + expect(notifier.savedAvatarUrls, isEmpty); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + + expect(notifier.savedAvatarUrls, ['https://relay.example/profile.png']); + }); + + testWidgets('centers every preview while controls fill the page gutters', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(800, 900); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + + final screenSize = tester.view.physicalSize / tester.view.devicePixelRatio; + final expectedContentWidth = screenSize.width - Grid.gutter * 2; + expect( + tester + .getCenter(find.byKey(const ValueKey('avatar-editor-fixed-preview'))) + .dy, + closeTo(screenSize.height / 2, 0.01), + ); + expect( + tester.getSize(find.byKey(const ValueKey(0))).width, + closeTo(expectedContentWidth, 0.01), + ); + expect(find.byKey(const ValueKey('image-source-camera')), findsOneWidget); + expect(find.byKey(const ValueKey('image-source-library')), findsOneWidget); + final cameraSurface = find.descendant( + of: find.byKey(const ValueKey('image-source-camera')), + matching: find.byType(AnimatedContainer), + ); + final librarySurface = find.descendant( + of: find.byKey(const ValueKey('image-source-library')), + matching: find.byType(AnimatedContainer), + ); + final imageControlGap = + tester.getRect(librarySurface).left - + tester.getRect(cameraSurface).right; + expect(imageControlGap, greaterThan(Grid.twelve)); + expect( + screenSize.height - + tester + .getRect(find.byKey(const ValueKey('image-source-camera'))) + .bottom, + _editorControlBottomForTest, + ); + + await tester.tap(find.text('Emoji')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + expect( + tester.getCenter(find.byKey(const ValueKey('emoji-avatar-preview'))).dy, + closeTo(screenSize.height / 2 - 140, 0.01), + ); + expect( + tester + .getSize(find.byKey(const ValueKey('emoji-avatar-picker-content'))) + .width, + closeTo(expectedContentWidth, 0.01), + ); + expect( + find.byKey(const ValueKey('emoji-editor-background')), + findsOneWidget, + ); + expect(find.byKey(const ValueKey('emoji-editor-emoji')), findsOneWidget); + expect( + find.descendant( + of: find.byKey(const ValueKey('emoji-avatar-picker-content')), + matching: find.byType(AnimatedSwitcher), + ), + findsNothing, + ); + final backgroundSurface = find.descendant( + of: find.byKey(const ValueKey('emoji-editor-background')), + matching: find.byType(AnimatedContainer), + ); + final emojiSurface = find.descendant( + of: find.byKey(const ValueKey('emoji-editor-emoji')), + matching: find.byType(AnimatedContainer), + ); + expect( + tester.getRect(emojiSurface).left - + tester.getRect(backgroundSurface).right, + closeTo(imageControlGap, 0.01), + ); + expect( + screenSize.height - + tester + .getRect(find.byKey(const ValueKey('emoji-editor-background'))) + .bottom, + _editorControlBottomForTest, + ); + + final expandedPickerHeight = tester + .getSize(find.byKey(const ValueKey('emoji-avatar-picker-content'))) + .height; + await tester.tap(find.byKey(const ValueKey('emoji-editor-background'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + expect( + tester.getCenter(find.byKey(const ValueKey('emoji-avatar-preview'))).dy, + closeTo(screenSize.height / 2 - avatarBackgroundPreviewShift, 0.01), + ); + expect( + tester + .getSize(find.byKey(const ValueKey('emoji-avatar-picker-content'))) + .height, + lessThan(expandedPickerHeight), + ); + + await tester.tap(find.text('Animated')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + expect( + tester + .getCenter( + find.byKey(const ValueKey('animated-avatar-capture-preview')), + ) + .dy, + closeTo(screenSize.height / 2, 0.01), + ); + expect( + tester.getSize(find.byKey(const ValueKey(2))).width, + closeTo(expectedContentWidth, 0.01), + ); + final recordButton = find.byKey( + const ValueKey('animated-avatar-record-morph'), + ); + expect(tester.getSize(recordButton).height, 64); + expect( + tester.getSize(recordButton).width, + closeTo(expectedContentWidth, 0.01), + ); + expect( + screenSize.height - tester.getRect(recordButton).bottom, + _editorControlBottomForTest, + ); + expect( + find.byKey(const ValueKey('avatar-mode-transition-transform')), + findsNothing, + ); + }); + + testWidgets('waits for the photo picker before opening Position Photo', ( + tester, + ) async { + final uploadService = _FakeMediaUploadService(delayGallery: true); + addTearDown(uploadService.dispose); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(_FakeProfileNotifier.new), + mediaUploadServiceProvider.overrideWithValue(uploadService), + ], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Photo Library')); + await tester.pump(); + + expect(find.text('Position Photo'), findsNothing); + expect( + find.byKey(const ValueKey('avatar-image-loading-overlay')), + findsOneWidget, + ); + + uploadService.completeGallerySelection(); + await tester.pumpAndSettle(); + expect(find.text('Position Photo'), findsOneWidget); + }); + + testWidgets('captures a camera photo and updates the profile', ( + tester, + ) async { + final notifier = _FakeProfileNotifier(); + final uploadService = _FakeMediaUploadService(); + addTearDown(uploadService.dispose); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + mediaUploadServiceProvider.overrideWithValue(uploadService), + ], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Camera')); + await tester.pumpAndSettle(); + expect(find.text('Position Photo'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo'))); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 200)), + ); + await tester.pumpAndSettle(); + expect(notifier.savedAvatarUrls, isEmpty); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + + expect(notifier.savedAvatarUrls, ['https://relay.example/camera.png']); + }); + + testWidgets('saves a desktop-compatible emoji avatar', (tester) async { + final notifier = _FakeProfileNotifier(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Emoji')); + await tester.pump(); + expect(find.byKey(const ValueKey('emoji-avatar-search')), findsOneWidget); + expect( + find.byKey(const ValueKey('emoji-avatar-skin-tone')), + findsOneWidget, + ); + expect( + tester.getSize(find.byKey(const ValueKey('emoji-avatar-preview'))), + const Size.square(220), + ); + await tester.enterText( + find.byKey(const ValueKey('emoji-avatar-search')), + 'rocket', + ); + await tester.pump(const Duration(milliseconds: 300)); + final focusedSearch = tester.widget( + find.byKey(const ValueKey('emoji-avatar-search')), + ); + final focusedBorder = + focusedSearch.decoration?.focusedBorder! as OutlineInputBorder; + expect( + focusedBorder.borderRadius, + const BorderRadius.all(Radius.circular(Radii.full)), + ); + expect( + tester + .widget(find.byKey(const ValueKey('emoji-avatar-search'))) + .controller + ?.text, + 'rocket', + ); + await tester.tap(find.byKey(const ValueKey('emoji-avatar-skin-tone'))); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Dark'), findsOneWidget); + await tester.tapAt(const Offset(8, 8)); + await tester.pump(const Duration(milliseconds: 300)); + await tester.enterText( + find.byKey(const ValueKey('emoji-avatar-search')), + '', + ); + await tester.pump(const Duration(milliseconds: 300)); + final previewFinder = find.byKey(const ValueKey('emoji-avatar-preview')); + final previewBefore = tester.widget(previewFinder); + final decorationBefore = previewBefore.decoration! as BoxDecoration; + expect(decorationBefore.color, isNot(Colors.white)); + final glyph = tester.widget( + find.descendant( + of: previewFinder, + matching: find.byType(NativeEmojiGlyph), + ), + ); + expect(glyph.size, closeTo(220 * 258 / 512, 0.01)); + final nextColorIndex = emojiAvatarColors.indexWhere( + (value) => Color(value) != decorationBefore.color, + ); + final nextColor = find.byKey( + ValueKey('emoji-avatar-color-$nextColorIndex'), + ); + await tester.tap(find.byKey(const ValueKey('emoji-editor-background'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + await tester.tap(nextColor); + await tester.pump(); + final previewAfter = tester.widget(previewFinder); + expect(previewAfter.decoration, isNot(previewBefore.decoration)); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + + final avatarUrl = notifier.savedAvatarUrls.single; + expect(avatarUrl, startsWith('data:image/svg+xml,')); + expect(Uri.decodeComponent(avatarUrl), contains('😊')); + expect( + find.byKey(const ValueKey('profile-information-page')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('profile-avatar-editor-page')), + findsNothing, + ); + }); + + testWidgets('keeps emoji drafts scoped to the emoji mode', (tester) async { + final notifier = _FakeProfileNotifier(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tap(find.byKey(const ValueKey('emoji-editor-background'))); + await tester.pump(const Duration(milliseconds: 200)); + await tester.tap(find.byKey(const ValueKey('emoji-avatar-color-1'))); + await tester.pump(); + await tester.tap(find.text('Image')); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pump(); + + expect(notifier.savedAvatarUrls, isEmpty); + expect( + find.byKey(const ValueKey('profile-avatar-editor-page')), + findsOneWidget, + ); + }); + + testWidgets('moves segment content in the selected direction', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Emoji')); + await tester.pump(); + final forwardTransform = tester.widget( + find.byKey(const ValueKey('avatar-mode-transition-transform')), + ); + expect(forwardTransform.transform.getTranslation().x, greaterThan(0)); + await tester.pump(const Duration(milliseconds: 240)); + expect( + tester + .widget( + find.byKey(const ValueKey('avatar-mode-transition-transform')), + ) + .transform + .getTranslation() + .x, + closeTo(0, 0.01), + ); + + await tester.tap(find.text('Image')); + await tester.pump(); + final reverseTransform = tester.widget( + find.byKey(const ValueKey('avatar-mode-transition-transform')), + ); + expect(reverseTransform.transform.getTranslation().x, lessThan(0)); + }); + + testWidgets('plays an animated avatar on the profile and image editor', ( + tester, + ) async { + const avatar = + 'https://relay.example/poster.png#buzz-anim=https%3A%2F%2Frelay.example%2Fanimation.png'; + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith( + () => _FakeProfileNotifier( + profile: const UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + about: 'Building Buzz', + avatarUrl: avatar, + ), + ), + ), + ], + child: const ProfileEditPage(), + ), + ); + await tester.pump(); + + expect(find.byType(PlayingAvatarImage), findsOneWidget); + expect(find.byType(ProgressiveAnimatedAvatar), findsOneWidget); + + await tester.tap(find.text('Edit Photo')); + await tester.pump(); + expect(find.byType(PlayingAvatarImage), findsOneWidget); + expect(find.byType(ProgressiveAnimatedAvatar), findsOneWidget); + }); + + testWidgets('keeps emoji actions anchored when search opens the keyboard', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(800, 900); + addTearDown(tester.view.reset); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Emoji')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + + final action = find.byKey(const ValueKey('emoji-editor-background')); + final actionBottomBefore = tester.getRect(action).bottom; + await tester.tap(find.byKey(const ValueKey('emoji-avatar-search'))); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pump(); + + expect(tester.getRect(action).bottom, actionBottomBefore); + expect( + tester.getRect(find.byKey(const ValueKey('emoji-avatar-search'))).bottom, + lessThan(600), + ); + expect( + tester + .widgetList(find.byType(Scaffold)) + .any((scaffold) => scaffold.resizeToAvoidBottomInset == false), + isTrue, + ); + }); + + testWidgets('uses high-contrast inverse colors for avatar action icons', ( + tester, + ) async { + final theme = AppTheme.dark(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Row( + children: [ + Expanded( + child: AvatarEditorOptionButton( + icon: Icons.palette, + label: 'Inactive', + selected: false, + onTap: () {}, + ), + ), + Expanded( + child: AvatarEditorOptionButton( + icon: Icons.face, + label: 'Active', + selected: true, + onTap: () {}, + ), + ), + ], + ), + ), + ), + ); + + expect( + tester.widget(find.byIcon(Icons.palette)).color, + theme.colorScheme.onSurface, + ); + expect( + tester.widget(find.byIcon(Icons.face)).color, + theme.colorScheme.surface, + ); + }); + + testWidgets('uses the shared animated background grid for emoji avatars', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pump(); + await tester.tap(find.text('Edit Photo')); + await tester.pump(const Duration(milliseconds: 250)); + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 250)); + await tester.tap(find.byKey(const ValueKey('emoji-editor-background'))); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.byType(AvatarBackgroundGrid), findsOneWidget); + final firstColor = find.byKey(const ValueKey('emoji-avatar-color-0')); + expect(tester.getSize(firstColor), const Size.square(52)); + }); +} + +class _FakeProfileNotifier extends ProfileNotifier { + _FakeProfileNotifier({ + this.profile = const UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + about: 'Building Buzz', + ), + }); + + final UserProfile profile; + final savedDisplayNames = []; + final savedDescriptions = []; + final savedAvatarUrls = []; + + @override + Future build() async => profile; + + @override + Future updateDisplayName(String displayName) async { + savedDisplayNames.add(displayName); + final current = state.requireValue!; + state = AsyncData( + UserProfile( + pubkey: current.pubkey, + displayName: displayName, + avatarUrl: current.avatarUrl, + about: current.about, + nip05Handle: current.nip05Handle, + ), + ); + } + + @override + Future updateAbout(String about) async { + savedDescriptions.add(about); + final current = state.requireValue!; + state = AsyncData( + UserProfile( + pubkey: current.pubkey, + displayName: current.displayName, + avatarUrl: current.avatarUrl, + about: about, + nip05Handle: current.nip05Handle, + ), + ); + } + + @override + Future updateAvatarUrl(String avatarUrl) async { + savedAvatarUrls.add(avatarUrl); + } +} + +class _FakeMediaUploadService extends MediaUploadService { + _FakeMediaUploadService({this.delayGallery = false}) + : super( + baseUrl: 'https://relay.example', + nsec: null, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + ); + + var _camera = false; + final bool delayGallery; + final _gallerySelection = Completer(); + + XFile _image() => XFile.fromData( + image.encodePng(image.Image(width: 8, height: 8)), + mimeType: 'image/png', + name: 'avatar.png', + ); + + @override + Future pickGalleryImage() async { + _camera = false; + if (delayGallery) return _gallerySelection.future; + return _image(); + } + + void completeGallerySelection() => _gallerySelection.complete(_image()); + + @override + Future captureImage() async { + _camera = true; + return _image(); + } + + @override + Future prepareImageBytes(XFile image) => image.readAsBytes(); + + @override + Future uploadBytes( + Uint8List bytes, { + required String mimeType, + ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, + }) async => BlobDescriptor( + url: _camera + ? 'https://relay.example/camera.png' + : 'https://relay.example/profile.png', + sha256: _camera ? 'camera-hash' : 'hash', + size: bytes.length, + type: mimeType, + uploaded: 1, + ); +} diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index 4b4f7ed1922..577dd4bbd57 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -6,8 +8,57 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:nostr/nostr.dart' as nostr; void main() { + test('profile updates preserve existing kind:0 metadata', () async { + final keys = nostr.Keys.generate(); + final relaySession = _ProfileRelaySession( + NostrEvent( + id: 'profile-1', + pubkey: keys.public, + createdAt: 1, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({ + 'name': 'alice', + 'display_name': 'Alice', + 'about': 'Building Buzz', + 'picture': 'https://relay.example/alice.png', + 'nip05': 'alice@example.com', + 'custom': 'preserve-me', + }), + sig: 'sig', + ), + ); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith( + () => _FixedRelayConfigNotifier(keys.nsec), + ), + relaySessionProvider.overrideWith(() => relaySession), + ], + ); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + await container.read(profileProvider.notifier).updateDisplayName('Alice L'); + + expect(relaySession.published, hasLength(1)); + final content = + jsonDecode(relaySession.published.single.content) + as Map; + expect(content['display_name'], 'Alice L'); + expect(content['about'], 'Building Buzz'); + expect(content['picture'], 'https://relay.example/alice.png'); + expect(content['nip05'], 'alice@example.com'); + expect(content['custom'], 'preserve-me'); + expect( + container.read(profileProvider).requireValue?.displayName, + 'Alice L', + ); + }); + test( 'manual presence persists until Online restores automatic mode', () async { @@ -62,6 +113,41 @@ void main() { ); } +class _FixedRelayConfigNotifier extends RelayConfigNotifier { + _FixedRelayConfigNotifier(this.nsec); + + final String nsec; + + @override + RelayConfig build() => + RelayConfig(baseUrl: 'https://relay.example', nsec: nsec); +} + +class _ProfileRelaySession extends RelaySessionNotifier { + _ProfileRelaySession(this.profile); + + final NostrEvent profile; + final List published = []; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => [profile]; + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + published.add(event); + return event; + } +} + ProviderContainer _buildContainer(SharedPreferences prefs) => ProviderContainer( overrides: [ savedPrefsProvider.overrideWithValue(prefs), diff --git a/mobile/test/features/profile/settings_profile_header_test.dart b/mobile/test/features/profile/settings_profile_header_test.dart index 63be8d81e34..a22c8c58f81 100644 --- a/mobile/test/features/profile/settings_profile_header_test.dart +++ b/mobile/test/features/profile/settings_profile_header_test.dart @@ -40,6 +40,31 @@ void main() { expect((header.padding as EdgeInsets).bottom, Grid.twelve); }); + testWidgets('shows the display name directly beneath the avatar', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(_FakeProfileNotifier.new), + presenceProvider.overrideWith(() => _FakePresenceNotifier('online')), + userStatusProvider.overrideWith(() => _FakeUserStatusNotifier(null)), + customEmojiListProvider.overrideWithValue(const []), + ], + child: const SettingsProfileHeader(), + ), + ); + await tester.pumpAndSettle(); + + final avatar = find.byKey(const ValueKey('settings-profile-avatar')); + final name = find.text('Test'); + expect(name, findsOneWidget); + expect( + tester.getTopLeft(name).dy, + greaterThan(tester.getBottomLeft(avatar).dy), + ); + }); + testWidgets('shows the poster until the animated avatar is ready', ( tester, ) async { diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index e2951323600..26177980fec 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -95,9 +95,10 @@ void main() { expect(appBar.gradient, isNull); expect(appBar.frosted, isTrue); expect(appBar.showBottomDivider, isTrue); - expect(appBar.bottomDividerOpacity, 0.06); + expect(appBar.bottomDividerOpacity, 0.07); expect(appBar.bottomHeight, 57); expect(appBar.leading, isNull); + expect(appBar.centerTitle, isFalse); expect(find.text('Search'), findsOneWidget); final promptText = find.descendant( of: find.byKey(const Key('search-field-container')), diff --git a/mobile/test/features/settings/connection_section_test.dart b/mobile/test/features/settings/connection_section_test.dart index 2a197222ab5..b8e3464c2e9 100644 --- a/mobile/test/features/settings/connection_section_test.dart +++ b/mobile/test/features/settings/connection_section_test.dart @@ -6,13 +6,67 @@ import 'package:buzz/shared/auth/auth.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:shared_preferences/shared_preferences.dart'; import '../../helpers/widget_helpers.dart'; void main() { + testWidgets('shows a compact copyable identity row', (tester) async { + MethodCall? clipboardCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') clipboardCall = call; + return null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null), + ); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + relayConfigProvider.overrideWith(_RelayConfigNotifier.new), + authProvider.overrideWith(_AuthNotifier.new), + pairingProvider.overrideWith( + () => _PairingNotifier(Future.value(true)), + ), + savedPrefsProvider.overrideWithValue(prefs), + ], + child: SettingsPage( + profileHeader: const SizedBox.shrink(), + invitePageBuilder: (_) => const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ); + await tester.pump(); + await tester.ensureVisible(find.text('Identity (pubkey)')); + await tester.pumpAndSettle(); + + final expectedPubkey = nostr.Keys( + '1111111111111111111111111111111111111111111111111111111111111111', + ).public; + expect(find.text('Connected to'), findsNothing); + expect(find.text('https://relay.test'), findsNothing); + expect(find.text(expectedPubkey), findsNothing); + final copy = tester.getRect(find.byIcon(LucideIcons.copy)); + final chevron = tester.getRect(find.byIcon(LucideIcons.chevronRight).first); + expect(copy.center.dx, closeTo(chevron.center.dx, 0.5)); + + await tester.tap(find.text('Identity (pubkey)')); + await tester.pump(); + expect(clipboardCall?.method, 'Clipboard.setData'); + expect(clipboardCall?.arguments, {'text': expectedPubkey}); + expect(find.text('Pubkey copied'), findsOneWidget); + }); + testWidgets('waits for a resumed frame before navigating after auth', ( tester, ) async { diff --git a/mobile/test/features/settings/settings_page_test.dart b/mobile/test/features/settings/settings_page_test.dart index 9242c5b4bc8..018aeca586d 100644 --- a/mobile/test/features/settings/settings_page_test.dart +++ b/mobile/test/features/settings/settings_page_test.dart @@ -1,6 +1,7 @@ import 'package:buzz/features/settings/settings_page.dart'; import 'package:buzz/shared/community/community_membership_provider.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/app_list.dart'; import 'package:buzz/shared/widgets/app_list_card.dart'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; @@ -9,6 +10,112 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { + testWidgets('opens profile edit choices and routes photo directly', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget( + ProviderScope( + overrides: [savedPrefsProvider.overrideWithValue(prefs)], + child: MaterialApp( + theme: AppTheme.light(), + home: SettingsPage( + profileHeader: const SizedBox.square(dimension: 128), + profileEditPageBuilder: (_) => + const Scaffold(body: Text('Profile editor destination')), + invitePageBuilder: (_) => const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Profile'), findsNothing); + await tester.tap(find.byKey(const ValueKey('settings-edit-profile'))); + await tester.pumpAndSettle(); + expect(find.text('Edit profile'), findsOneWidget); + expect(find.text('Display name'), findsOneWidget); + expect(find.text('Profile description'), findsOneWidget); + expect(find.text('Photo'), findsOneWidget); + final optionCard = find.descendant( + of: find.byKey(const ValueKey('edit-profile-options')), + matching: find.byType(Material), + ); + expect(tester.getSize(optionCard.first).height, greaterThan(150)); + for (final key in const [ + 'edit-profile-display-name', + 'edit-profile-description', + 'edit-profile-photo', + ]) { + expect( + tester.widget(find.byKey(ValueKey(key))).verticalPadding, + Grid.xs, + ); + } + final sheetContent = tester.widget( + find.byKey(const ValueKey('edit-profile-sheet-content')), + ); + expect(sheetContent.padding, const EdgeInsets.only(bottom: Grid.xs)); + final sheetSafeArea = tester.widget( + find.ancestor( + of: find.byKey(const ValueKey('edit-profile-sheet-content')), + matching: find.byType(SafeArea), + ), + ); + expect(sheetSafeArea.top, isFalse); + expect(sheetSafeArea.bottom, isTrue); + final sheetRect = tester.getRect(find.byType(BottomSheet)); + final optionRect = tester.getRect( + find.byKey(const ValueKey('edit-profile-options')), + ); + expect(sheetRect.height, lessThan(340)); + expect(sheetRect.bottom - optionRect.bottom, Grid.xs); + await tester.tap(find.byKey(const ValueKey('edit-profile-photo'))); + await tester.pumpAndSettle(); + expect(find.text('Profile editor destination'), findsOneWidget); + }); + + testWidgets('opens profile text editors after dismissing the choices', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final opened = []; + + await tester.pumpWidget( + ProviderScope( + overrides: [savedPrefsProvider.overrideWithValue(prefs)], + child: MaterialApp( + theme: AppTheme.light(), + home: SettingsPage( + profileHeader: const SizedBox.shrink(), + onEditDisplayName: (_) async => opened.add('name'), + onEditProfileDescription: (_) async => opened.add('description'), + invitePageBuilder: (_) => const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('settings-edit-profile'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('edit-profile-display-name'))); + await tester.pumpAndSettle(); + expect(opened, ['name']); + expect(find.text('Edit profile'), findsNothing); + + await tester.tap(find.byKey(const ValueKey('settings-edit-profile'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('edit-profile-description'))); + await tester.pumpAndSettle(); + expect(opened, ['name', 'description']); + }); + testWidgets('uses the native glass close control on iOS', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); @@ -30,9 +137,19 @@ void main() { ); await tester.pumpAndSettle(); - final nativeClose = tester.widget(find.byType(UiKitView)); + final nativeViews = tester.widgetList(find.byType(UiKitView)); + final nativeClose = nativeViews.singleWhere( + (view) => + (view.creationParams as Map?)?['icon'] == 'close', + ); + final nativeEdit = nativeViews.singleWhere( + (view) => + (view.creationParams as Map?)?['label'] == 'Edit', + ); expect(nativeClose.viewType, 'buzz/navigation_glass'); expect(nativeClose.creationParams, containsPair('icon', 'close')); + expect(nativeEdit.viewType, 'buzz/navigation_glass'); + expect(nativeEdit.creationParams, containsPair('controlWidth', 56.0)); expect(find.byTooltip('Close settings'), findsOneWidget); debugDefaultTargetPlatformOverride = null; }); diff --git a/mobile/test/shared/animated_avatar_test.dart b/mobile/test/shared/animated_avatar_test.dart index 773f957a4b0..e3b66ec6010 100644 --- a/mobile/test/shared/animated_avatar_test.dart +++ b/mobile/test/shared/animated_avatar_test.dart @@ -6,7 +6,7 @@ void main() { const animationUrl = 'https://relay.example/media/animation.png?loop=1'; test('parses the selected poster and animated PNG URLs', () { - final url = '$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}'; + final url = buildAnimatedAvatarUrl(posterUrl, animationUrl); final parsed = parseAnimatedAvatarUrl(url); @@ -14,6 +14,13 @@ void main() { expect(parsed?.animationUrl, animationUrl); }); + test('builds the shared desktop and mobile fragment format', () { + expect( + buildAnimatedAvatarUrl(posterUrl, animationUrl), + '$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}', + ); + }); + test('rejects malformed and non-http animated avatar URLs', () { expect(parseAnimatedAvatarUrl(posterUrl), isNull); expect(parseAnimatedAvatarUrl('$posterUrl#buzz-anim='), isNull); diff --git a/mobile/test/shared/emoji/native_emoji_glyph_test.dart b/mobile/test/shared/emoji/native_emoji_glyph_test.dart index 435423905fa..7a6e0c85582 100644 --- a/mobile/test/shared/emoji/native_emoji_glyph_test.dart +++ b/mobile/test/shared/emoji/native_emoji_glyph_test.dart @@ -1,37 +1,48 @@ import 'package:buzz/shared/emoji/native_emoji_glyph.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - testWidgets('lifts the glyph one logical pixel on iOS', (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - try { - await tester.pumpWidget( - const MaterialApp( - home: Center(child: NativeEmojiGlyph(emoji: '🔥', size: 24)), + testWidgets('uses one centered optical box for narrow and wide emoji', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Row( + children: [ + NativeEmojiGlyph( + key: ValueKey('narrow-emoji'), + emoji: '🙂', + size: 60, + opticalBoxSize: 60, + ), + NativeEmojiGlyph( + key: ValueKey('wide-emoji'), + emoji: '👩‍👩‍👧‍👦', + size: 60, + opticalBoxSize: 60, + ), + ], + ), ), - ); + ), + ); - final transform = tester.widget(find.byType(Transform)); - expect(transform.transform.getTranslation().y, -1); - } finally { - debugDefaultTargetPlatformOverride = null; - } - }); - - testWidgets('keeps the glyph unshifted on Android', (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.android; - try { - await tester.pumpWidget( - const MaterialApp( - home: Center(child: NativeEmojiGlyph(emoji: '🔥', size: 24)), - ), - ); - - expect(find.byType(Transform), findsNothing); - } finally { - debugDefaultTargetPlatformOverride = null; - } + expect( + tester.getSize(find.byKey(const ValueKey('narrow-emoji'))), + const Size.square(60), + ); + expect( + tester.getSize(find.byKey(const ValueKey('wide-emoji'))), + const Size.square(60), + ); + expect( + find.descendant( + of: find.byKey(const ValueKey('wide-emoji')), + matching: find.byType(FittedBox), + ), + findsOneWidget, + ); }); } diff --git a/mobile/test/shared/widgets/avatar_image_test.dart b/mobile/test/shared/widgets/avatar_image_test.dart index cfaab6d4f1c..4631cf58dab 100644 --- a/mobile/test/shared/widgets/avatar_image_test.dart +++ b/mobile/test/shared/widgets/avatar_image_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:buzz/shared/widgets/avatar_image.dart'; +import 'package:buzz/shared/emoji/native_emoji_glyph.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -32,6 +33,11 @@ void main() { final emoji = tester.widget(find.text('🦝')); expect(emoji.style?.height, 1); + final glyph = tester.widget( + find.byType(NativeEmojiGlyph), + ); + expect(glyph.size, closeTo(32 * 258 / 512, 0.001)); + expect(glyph.opticalBoxSize, glyph.size); expect(find.byType(SvgPicture), findsNothing); expect(find.text('R'), findsNothing); expect(tester.takeException(), isNull); diff --git a/mobile/test/shared/widgets/frosted_app_bar_test.dart b/mobile/test/shared/widgets/frosted_app_bar_test.dart index 8f1f40a8b1b..e7a4d534c89 100644 --- a/mobile/test/shared/widgets/frosted_app_bar_test.dart +++ b/mobile/test/shared/widgets/frosted_app_bar_test.dart @@ -87,6 +87,37 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('centers a title between asymmetric navigation controls', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: const Stack( + children: [ + FrostedAppBar( + centerTitle: true, + leading: SizedBox(width: 48, height: 48), + title: Text('Profile', key: ValueKey('centered-title')), + actions: [SizedBox(width: 96, height: 48)], + ), + ], + ), + ), + ); + + final titleRect = tester.getRect( + find.byKey(const ValueKey('centered-title')), + ); + expect( + titleRect.center.dx, + closeTo( + tester.view.physicalSize.width / tester.view.devicePixelRatio / 2, + 0.01, + ), + ); + }); + testWidgets('uses the native glass back control on iOS', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); @@ -148,6 +179,38 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + testWidgets('uses the theme primary color for automatic navigation glyphs', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Builder( + builder: (context) => TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const Stack( + children: [FrostedAppBar(title: Text('Destination'))], + ), + ), + ), + child: const Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + final backButton = tester.widget( + find.byWidgetPredicate( + (widget) => widget is IconButton && widget.tooltip == 'Back', + ), + ); + expect(backButton.color, AppTheme.light().colorScheme.primary); + }); + testWidgets('replaces native glass while a Flutter backdrop is active', ( tester, ) async { From f2fd8e14db11a052b8ca6eaff21b4f686058feaa Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 08:47:59 +0100 Subject: [PATCH 02/34] fix(mobile): harden profile photo editing Signed-off-by: kenny lopez --- .../profile/animated_avatar_capture.dart | 251 ++++++++++-------- .../profile/profile_avatar_draft.dart | 69 +++++ .../profile/profile_avatar_editor.dart | 35 ++- .../features/profile/profile_edit_page.dart | 164 ++++++++---- .../profile/profile_photo_transition.dart | 11 - .../features/profile/profile_provider.dart | 44 ++- .../lib/features/settings/settings_page.dart | 4 +- .../shared/widgets/immediate_page_route.dart | 10 + .../shared/widgets/playing_avatar_image.dart | 4 +- .../profile/profile_edit_page_test.dart | 159 ++++++++++- .../profile/profile_provider_test.dart | 116 ++++++++ 11 files changed, 662 insertions(+), 205 deletions(-) create mode 100644 mobile/lib/features/profile/profile_avatar_draft.dart delete mode 100644 mobile/lib/features/profile/profile_photo_transition.dart create mode 100644 mobile/lib/shared/widgets/immediate_page_route.dart diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index 2288b563844..771bebbbcc3 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math'; import 'package:camera/camera.dart'; import 'package:flutter/foundation.dart'; @@ -12,13 +13,13 @@ import 'package:image/image.dart' as image; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:path_provider/path_provider.dart'; -import '../../shared/animated_avatar.dart'; import '../../shared/emoji/emoji_avatar.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import 'avatar_background_grid.dart'; import 'avatar_editor_option_button.dart'; +import 'profile_avatar_draft.dart'; part 'animated_avatar_capture/review_controls.dart'; part 'animated_avatar_capture/capture_controls.dart'; @@ -41,7 +42,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { }); final double height; - final ValueChanged Function()?> onPrepareChanged; + final ValueChanged Function()?> onPrepareChanged; @override Widget build(BuildContext context, WidgetRef ref) { @@ -65,6 +66,8 @@ class AnimatedAvatarCapture extends HookConsumerWidget { final gestureStartScale = useRef(_mobileDefaultPersonScale); final error = useState(null); final encodedCache = useRef<_EncodedAvatarCache?>(null); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final lifecycle = ref.watch(appLifecycleProvider); final encodeKey = frames.value.isEmpty ? null @@ -94,18 +97,26 @@ class AnimatedAvatarCapture extends HookConsumerWidget { }, [encodeKey]); useEffect(() { - if (frames.value.length < 2) return null; + if (reduceMotion || frames.value.length < 2) return null; final timer = Timer.periodic(_captureFrameInterval, (_) { if (activeSection.value == _AnimatedReviewSection.poster) return; previewFrameIndex.value = (previewFrameIndex.value + 1) % frames.value.length; }); return timer.cancel; - }, [frames.value]); + }, [frames.value, reduceMotion]); useEffect(() { var disposed = false; + if (lifecycle != AppLifecycleState.resumed) { + isInitializing.value = false; + controller.value = null; + frames.value = const []; + onPrepareChanged(null); + return null; + } + Future initialize() async { try { final cameras = await availableCameras(); @@ -144,9 +155,9 @@ class AnimatedAvatarCapture extends HookConsumerWidget { controllerRef.value = null; unawaited(active?.dispose() ?? Future.value()); }; - }, const []); + }, [lifecycle]); - Future prepare() async { + Future prepare() async { final key = encodeKey; if (key == null) return null; isProcessing.value = true; @@ -158,12 +169,10 @@ class AnimatedAvatarCapture extends HookConsumerWidget { : compute(_encodeAvatar, key.toRequest()); encodedCache.value = _EncodedAvatarCache(key, encoding); final result = await encoding; - final service = ref.read(mediaUploadServiceProvider); - final uploads = await Future.wait([ - service.uploadBytes(result.animation, mimeType: 'image/png'), - service.uploadBytes(result.poster, mimeType: 'image/png'), - ]); - return buildAnimatedAvatarUrl(uploads[1].url, uploads[0].url); + return ProfileAnimatedAvatarDraft( + animation: result.animation, + poster: result.poster, + ); } catch (_) { error.value = "We couldn't create that animation. Try again."; return null; @@ -449,88 +458,111 @@ class AnimatedAvatarCapture extends HookConsumerWidget { } final active = controller.value; - return SizedBox( - height: height, - child: Column( - children: [ - SizedBox.square( - key: const ValueKey('animated-avatar-capture-preview'), - dimension: 228, - child: Stack( - fit: StackFit.expand, - children: [ - Padding( - padding: const EdgeInsets.all(4), - child: ClipOval( - child: ColoredBox( - color: Colors.black, - child: isPreparingFrames.value - ? Center( - child: BuzzLoadingIndicator( - size: 44, - color: context.colors.onSurface, - semanticLabel: 'Preparing animated avatar', - ), - ) - : active != null - ? _AspectCorrectCameraPreview(controller: active) - : Center( - child: isInitializing.value - ? const BuzzLoadingIndicator( - color: Colors.white, - semanticLabel: 'Starting camera', - ) - : const Icon( - LucideIcons.cameraOff, - color: Colors.white, - size: 32, - ), + final compactCapture = height < 400; + final textScale = MediaQuery.textScalerOf(context).scale(1); + final statusStyle = context.textTheme.bodyMedium; + final errorStyle = context.textTheme.bodySmall; + final statusHeight = + ((statusStyle?.fontSize ?? 14) * + (statusStyle?.height ?? 1.2) * + textScale) + .floorToDouble(); + final errorHeight = error.value == null + ? 0.0 + : Grid.xs + + (errorStyle?.fontSize ?? 12) * + (errorStyle?.height ?? 1.2) * + textScale * + 2; + final capturePreviewSize = compactCapture ? 180.0 : 228.0; + final recordGap = max( + Grid.xs, + height - capturePreviewSize - Grid.xs - statusHeight - errorHeight - 64, + ); + Widget captureContent() => Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox.square( + key: const ValueKey('animated-avatar-capture-preview'), + dimension: capturePreviewSize, + child: Stack( + fit: StackFit.expand, + children: [ + Padding( + padding: const EdgeInsets.all(4), + child: ClipOval( + child: ColoredBox( + color: Colors.black, + child: isPreparingFrames.value + ? Center( + child: BuzzLoadingIndicator( + size: 44, + color: context.colors.onSurface, + semanticLabel: 'Preparing animated avatar', ), - ), + ) + : active != null + ? _AspectCorrectCameraPreview(controller: active) + : Center( + child: isInitializing.value + ? const BuzzLoadingIndicator( + color: Colors.white, + semanticLabel: 'Starting camera', + ) + : const Icon( + LucideIcons.cameraOff, + color: Colors.white, + size: 32, + ), + ), ), ), - if (isRecording.value) - Padding( - // A progress stroke is centred on its circular path. Inset - // it so the outer half is not clipped by the preview stack. - padding: const EdgeInsets.all(2), - child: CircularProgressIndicator( - key: const ValueKey('animated-avatar-recording-ring'), - value: progress.value, - strokeWidth: 4, - strokeCap: StrokeCap.round, - color: context.colors.onSurface, - backgroundColor: context.colors.outlineVariant, - ), + ), + if (isRecording.value) + Padding( + // A progress stroke is centred on its circular path. Inset + // it so the outer half is not clipped by the preview stack. + padding: const EdgeInsets.all(2), + child: CircularProgressIndicator( + key: const ValueKey('animated-avatar-recording-ring'), + value: progress.value, + strokeWidth: 4, + strokeCap: StrokeCap.round, + color: context.colors.onSurface, + backgroundColor: context.colors.outlineVariant, ), - ], - ), - ), - const SizedBox(height: Grid.xs), - Text( - isRecording.value - ? 'Recording…' - : isPreparingFrames.value - ? 'Cutting you out of the background…' - : 'Line up your shot.', - textAlign: TextAlign.center, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), + ), + ], ), - if (error.value != null) _ErrorText(error.value!), - const Spacer(), - _AnimatedRecordButton( - busy: isRecording.value || isPreparingFrames.value, - onPressed: active == null - ? null - : () { - unawaited(HapticFeedback.mediumImpact()); - unawaited(record()); - }, + ), + const SizedBox(height: Grid.xs), + Text( + isRecording.value + ? 'Recording…' + : isPreparingFrames.value + ? 'Cutting you out of the background…' + : 'Line up your shot.', + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, ), - ], - ), + ), + if (error.value != null) _ErrorText(error.value!), + SizedBox(height: recordGap), + _AnimatedRecordButton( + busy: isRecording.value || isPreparingFrames.value, + onPressed: active == null + ? null + : () { + unawaited(HapticFeedback.mediumImpact()); + unawaited(record()); + }, + ), + ], + ); + return SizedBox( + height: height, + child: SingleChildScrollView(child: captureContent()), ); } } @@ -561,26 +593,31 @@ Future> _removeBackgrounds(List frames) async { try { for (var index = 0; index < frames.length; index++) { final file = File('${directory.path}/buzz-avatar-frame-$index.png'); - await file.writeAsBytes(frames[index], flush: false); - final mask = await segmenter.processImage( - InputImage.fromFilePath(file.path), - ); - await file.delete().catchError((_) => file); - if (mask == null) { - results.add(frames[index]); - continue; - } - results.add( - await compute( - _applySegmentationMask, - _MaskRequest( - frame: frames[index], - maskWidth: mask.width, - maskHeight: mask.height, - confidences: Float32List.fromList(mask.confidences), + try { + await file.writeAsBytes(frames[index], flush: false); + final mask = await segmenter.processImage( + InputImage.fromFilePath(file.path), + ); + if (mask == null) { + results.add(frames[index]); + continue; + } + results.add( + await compute( + _applySegmentationMask, + _MaskRequest( + frame: frames[index], + maskWidth: mask.width, + maskHeight: mask.height, + confidences: Float32List.fromList(mask.confidences), + ), ), - ), - ); + ); + } finally { + if (await file.exists()) { + await file.delete().catchError((_) => file); + } + } } } finally { await segmenter.close(); diff --git a/mobile/lib/features/profile/profile_avatar_draft.dart b/mobile/lib/features/profile/profile_avatar_draft.dart new file mode 100644 index 00000000000..4c26b973527 --- /dev/null +++ b/mobile/lib/features/profile/profile_avatar_draft.dart @@ -0,0 +1,69 @@ +import 'dart:typed_data'; + +import '../../shared/animated_avatar.dart'; +import '../../shared/relay/relay.dart'; + +sealed class ProfileAvatarDraft { + const ProfileAvatarDraft(); + + Future upload(MediaUploadService service); +} + +final class ProfileUrlAvatarDraft extends ProfileAvatarDraft { + const ProfileUrlAvatarDraft(this.url); + + final String url; + + @override + Future upload(MediaUploadService service) async => url; +} + +final class ProfileImageAvatarDraft extends ProfileAvatarDraft { + ProfileImageAvatarDraft(this.bytes); + + final Uint8List bytes; + Future? _uploadedUrl; + + @override + Future upload(MediaUploadService service) async { + final existing = _uploadedUrl; + if (existing != null) return existing; + final upload = service + .uploadBytes(bytes, mimeType: 'image/jpeg') + .then((descriptor) => descriptor.url); + _uploadedUrl = upload; + try { + return await upload; + } catch (_) { + if (identical(_uploadedUrl, upload)) _uploadedUrl = null; + rethrow; + } + } +} + +final class ProfileAnimatedAvatarDraft extends ProfileAvatarDraft { + ProfileAnimatedAvatarDraft({required this.animation, required this.poster}); + + final Uint8List animation; + final Uint8List poster; + Future? _uploadedUrl; + + @override + Future upload(MediaUploadService service) async { + final existing = _uploadedUrl; + if (existing != null) return existing; + final upload = Future.wait( + [ + service.uploadBytes(poster, mimeType: 'image/png'), + service.uploadBytes(animation, mimeType: 'image/png'), + ], + ).then((uploads) => buildAnimatedAvatarUrl(uploads[0].url, uploads[1].url)); + _uploadedUrl = upload; + try { + return await upload; + } catch (_) { + if (identical(_uploadedUrl, upload)) _uploadedUrl = null; + rethrow; + } + } +} diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index c81fd8376ad..f964bf2322a 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -24,6 +24,7 @@ import 'animated_avatar_capture.dart'; import 'avatar_background_grid.dart'; import 'avatar_editor_option_button.dart'; import 'profile_avatar_crop_page.dart'; +import 'profile_avatar_draft.dart'; part 'profile_avatar_editor/emoji_avatar_picker.dart'; @@ -53,7 +54,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { super.key, required this.currentAvatarUrl, required this.fallbackInitial, - required this.draftAvatarUrl, + required this.draft, required this.mode, required this.transition, required this.onModeChanged, @@ -63,12 +64,13 @@ class ProfileAvatarEditor extends HookConsumerWidget { final String? currentAvatarUrl; final String fallbackInitial; - final String? draftAvatarUrl; + final ProfileAvatarDraft? draft; final ProfileAvatarMode mode; final Animation transition; final ValueChanged onModeChanged; - final ValueChanged onDraftChanged; - final ValueChanged Function()?> onAnimatedPrepareChanged; + final ValueChanged onDraftChanged; + final ValueChanged Function()?> + onAnimatedPrepareChanged; @override Widget build(BuildContext context, WidgetRef ref) { @@ -111,7 +113,9 @@ class ProfileAvatarEditor extends HookConsumerWidget { void updateEmojiPreview() { emojiPreviewKey.value++; onDraftChanged( - emojiAvatarDataUrl(selectedEmoji.value, selectedColor.value), + ProfileUrlAvatarDraft( + emojiAvatarDataUrl(selectedEmoji.value, selectedColor.value), + ), ); } @@ -136,11 +140,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { ), ); if (cropped == null) return; - final upload = await service.uploadBytes( - cropped, - mimeType: 'image/jpeg', - ); - if (context.mounted) onDraftChanged(upload.url); + if (context.mounted) onDraftChanged(ProfileImageAvatarDraft(cropped)); } catch (_) { error.value = "We couldn't prepare that photo. Try again."; } finally { @@ -148,10 +148,19 @@ class ProfileAvatarEditor extends HookConsumerWidget { } } - final previewUrl = (draftAvatarUrl?.isNotEmpty ?? false) - ? draftAvatarUrl - : currentAvatarUrl; + final previewUrl = switch (draft) { + ProfileUrlAvatarDraft(:final url) => url, + _ => currentAvatarUrl, + }; final fixedPreviewContent = switch (mode) { + ProfileAvatarMode.image when draft is ProfileImageAvatarDraft => + CircleAvatar( + key: const ValueKey('avatar-editor-fixed-preview'), + radius: _previewSize / 2, + backgroundImage: MemoryImage( + (draft as ProfileImageAvatarDraft).bytes, + ), + ), ProfileAvatarMode.image => PlayingAvatarImage( key: const ValueKey('avatar-editor-fixed-preview'), imageUrl: previewUrl, diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 71752a94bb4..75ac749cc12 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/profile/user_profile.dart'; +import '../../shared/relay/relay.dart'; import '../../shared/emoji/emoji_data_provider.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/app_list.dart'; @@ -21,6 +22,7 @@ import '../../shared/widgets/modal_presentation.dart'; import '../../shared/widgets/playing_avatar_image.dart'; import 'ios_profile_text_editor.dart'; import 'profile_avatar_editor.dart'; +import 'profile_avatar_draft.dart'; import 'profile_provider.dart'; /// Edits the current user's public profile metadata. @@ -35,12 +37,16 @@ class ProfileEditPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final reduceMotion = MediaQuery.disableAnimationsOf(context); - final profile = ref.watch(profileProvider).asData?.value; + final profileAsync = ref.watch(profileProvider); + final profile = profileAsync.asData?.value; + final profileHydrated = profileAsync.hasValue; final isEditingAvatar = useState(startInPhotoEditor); - final avatarDraft = useState(null); + final avatarDraft = useState(null); final avatarDraftMode = useState(null); final isSavingAvatar = useState(false); - final prepareAnimatedAvatar = useRef Function()?>(null); + final prepareAnimatedAvatar = + useRef Function()?>(null); + final avatarSaveError = useState(null); final canPrepareAnimatedAvatar = useState(false); final avatarMode = useState(ProfileAvatarMode.image); final avatarTransition = useAnimationController( @@ -123,6 +129,7 @@ class ProfileEditPage extends HookConsumerWidget { } void openAvatarEditor() { + if (!profileHydrated) return; isEditingAvatar.value = true; unawaited(avatarTransition.forward(from: 0)); } @@ -146,25 +153,34 @@ class ProfileEditPage extends HookConsumerWidget { Future saveAvatar() async { if (isSavingAvatar.value) return; isSavingAvatar.value = true; + avatarSaveError.value = null; try { - var nextAvatar = avatarDraftMode.value == avatarMode.value + var nextDraft = avatarDraftMode.value == avatarMode.value ? avatarDraft.value : null; if (avatarMode.value == ProfileAvatarMode.animated) { - nextAvatar = await prepareAnimatedAvatar.value?.call(); + nextDraft = await prepareAnimatedAvatar.value?.call(); } - if (nextAvatar == null || nextAvatar.isEmpty) return; + if (nextDraft == null) return; + final nextAvatar = await nextDraft.upload( + ref.read(mediaUploadServiceProvider), + ); await ref.read(profileProvider.notifier).updateAvatarUrl(nextAvatar); if (context.mounted) await closeAvatarEditor(whileSaving: true); + } catch (_) { + avatarSaveError.value = + "We couldn't save your profile photo. Try again."; } finally { if (context.mounted) isSavingAvatar.value = false; } } - final canSaveAvatar = avatarMode.value == ProfileAvatarMode.animated - ? canPrepareAnimatedAvatar.value - : avatarDraftMode.value == avatarMode.value && - avatarDraft.value != null; + final canSaveAvatar = + profileHydrated && + (avatarMode.value == ProfileAvatarMode.animated + ? canPrepareAnimatedAvatar.value + : avatarDraftMode.value == avatarMode.value && + avatarDraft.value != null); final activeDraft = avatarDraftMode.value == avatarMode.value ? avatarDraft.value : null; @@ -239,22 +255,68 @@ class ProfileEditPage extends HookConsumerWidget { : const [], ), body: isEditingAvatar.value - ? ProfileAvatarEditor( - key: const ValueKey('profile-avatar-editor-page'), - currentAvatarUrl: profile?.avatarUrl, - fallbackInitial: profile?.initial ?? '?', - draftAvatarUrl: activeDraft, - mode: avatarMode.value, - transition: avatarTransition, - onModeChanged: (nextMode) => avatarMode.value = nextMode, - onDraftChanged: (url) { - avatarDraft.value = url; - avatarDraftMode.value = url == null ? null : avatarMode.value; - }, - onAnimatedPrepareChanged: (prepare) { - prepareAnimatedAvatar.value = prepare; - canPrepareAnimatedAvatar.value = prepare != null; - }, + ? Stack( + children: [ + IgnorePointer( + ignoring: !profileHydrated || isSavingAvatar.value, + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + key: const ValueKey('avatar-editor-scroll-view'), + physics: constraints.maxHeight < 600 + ? const ClampingScrollPhysics() + : const NeverScrollableScrollPhysics(), + child: SizedBox( + height: constraints.maxHeight < 600 + ? 700 + : constraints.maxHeight, + child: ProfileAvatarEditor( + key: const ValueKey('profile-avatar-editor-page'), + currentAvatarUrl: profile?.avatarUrl, + fallbackInitial: profile?.initial ?? '?', + draft: activeDraft, + mode: avatarMode.value, + transition: avatarTransition, + onModeChanged: (nextMode) => + avatarMode.value = nextMode, + onDraftChanged: (draft) { + avatarDraft.value = draft; + avatarDraftMode.value = draft == null + ? null + : avatarMode.value; + }, + onAnimatedPrepareChanged: (prepare) { + prepareAnimatedAvatar.value = prepare; + canPrepareAnimatedAvatar.value = prepare != null; + }, + ), + ), + ), + ), + ), + if (!profileHydrated || avatarSaveError.value != null) + Positioned( + left: Grid.gutter, + right: Grid.gutter, + bottom: Grid.xl, + child: Semantics( + liveRegion: true, + child: Text( + avatarSaveError.value ?? + (profileAsync.hasError + ? "We couldn't load your profile. Go back and try again." + : 'Loading your profile…'), + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: + avatarSaveError.value != null || + profileAsync.hasError + ? context.colors.error + : context.colors.onSurfaceVariant, + ), + ), + ), + ), + ], ) : ListView( key: const ValueKey('profile-information-page'), @@ -265,7 +327,7 @@ class ProfileEditPage extends HookConsumerWidget { children: [ _ProfilePhotoEditor( profile: profile, - onEditPhoto: openAvatarEditor, + onEditPhoto: profileHydrated ? openAvatarEditor : null, ), AppListCard( key: const ValueKey('profile-info-card'), @@ -278,16 +340,18 @@ class ProfileEditPage extends HookConsumerWidget { subtitle: _fieldValue(profile?.displayName), subtitleMaxLines: 1, trailing: const _EditChevron(), - onTap: () => unawaited( - editField( - title: 'Display name', - initialValue: profile?.displayName ?? '', - hintText: 'Display name', - onSave: ref - .read(profileProvider.notifier) - .updateDisplayName, - ), - ), + onTap: !profileHydrated + ? null + : () => unawaited( + editField( + title: 'Display name', + initialValue: profile?.displayName ?? '', + hintText: 'Display name', + onSave: ref + .read(profileProvider.notifier) + .updateDisplayName, + ), + ), ), AppListRow( key: const ValueKey('profile-description-row'), @@ -295,17 +359,19 @@ class ProfileEditPage extends HookConsumerWidget { subtitle: _fieldValue(profile?.about), subtitleMaxLines: 3, trailing: const _EditChevron(), - onTap: () => unawaited( - editField( - title: 'Profile description', - initialValue: profile?.about ?? '', - hintText: 'Profile description', - multiline: true, - onSave: ref - .read(profileProvider.notifier) - .updateAbout, - ), - ), + onTap: !profileHydrated + ? null + : () => unawaited( + editField( + title: 'Profile description', + initialValue: profile?.about ?? '', + hintText: 'Profile description', + multiline: true, + onSave: ref + .read(profileProvider.notifier) + .updateAbout, + ), + ), ), ], ), @@ -325,7 +391,7 @@ class _ProfilePhotoEditor extends StatelessWidget { const _ProfilePhotoEditor({required this.profile, required this.onEditPhoto}); final UserProfile? profile; - final VoidCallback onEditPhoto; + final VoidCallback? onEditPhoto; @override Widget build(BuildContext context) => Column( diff --git a/mobile/lib/features/profile/profile_photo_transition.dart b/mobile/lib/features/profile/profile_photo_transition.dart deleted file mode 100644 index b1f852d7fbf..00000000000 --- a/mobile/lib/features/profile/profile_photo_transition.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:flutter/material.dart'; - -PageRoute profilePhotoEditorRoute({required WidgetBuilder builder}) => - PageRouteBuilder( - transitionDuration: Duration.zero, - reverseTransitionDuration: Duration.zero, - pageBuilder: (context, _, _) => builder(context), - // The editor animates its avatar and controls in place. Keeping the route - // still prevents a competing full-page push transition. - transitionsBuilder: (_, _, _, child) => child, - ); diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 69f43cfff30..7a852ab9775 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -14,43 +14,60 @@ import '../../shared/theme/theme.dart'; /// not yet published a profile. class ProfileNotifier extends AsyncNotifier { Map _metadata = {}; + bool _hasHydrated = false; + int _lastCreatedAt = 0; @override Future build() { ref.watch(relayConfigProvider); ref.watch(relaySessionProvider); + _hasHydrated = false; return _fetch(); } Future _fetch() async { final myPk = ref.read(myPubkeyProvider); - if (myPk == null) return null; + if (myPk == null) { + _metadata = {}; + _lastCreatedAt = 0; + _hasHydrated = true; + return null; + } final session = ref.read(relaySessionProvider.notifier); final events = await session.fetchHistory(NostrFilters.profile(myPk)); if (events.isEmpty) { _metadata = {}; + _lastCreatedAt = 0; + _hasHydrated = true; return null; } - try { - final decoded = jsonDecode(events.first.content); - _metadata = decoded is Map - ? Map.from(decoded) - : {}; - } catch (_) { - _metadata = {}; + final latest = events.reduce((current, event) { + if (event.createdAt != current.createdAt) { + return event.createdAt > current.createdAt ? event : current; + } + return event.id.compareTo(current.id) > 0 ? event : current; + }); + final decoded = jsonDecode(latest.content); + if (decoded is! Map) { + throw const FormatException('Profile metadata must be a JSON object.'); } - final data = ProfileData.fromEvent(events.first); - return UserProfile( + _metadata = Map.from(decoded); + _lastCreatedAt = latest.createdAt; + final data = ProfileData.fromEvent(latest); + final profile = UserProfile( pubkey: data.pubkey, displayName: data.displayName, avatarUrl: data.avatarUrl, about: data.about, nip05Handle: data.nip05, ); + _hasHydrated = true; + return profile; } Future refresh() async { + _hasHydrated = false; state = await AsyncValue.guard(_fetch); } @@ -68,6 +85,9 @@ class ProfileNotifier extends AsyncNotifier { _publishProfilePatch({'picture': avatarUrl.trim()}); Future _publishProfilePatch(Map patch) async { + if (!_hasHydrated || !state.hasValue) { + throw StateError('Cannot update profile before metadata is loaded.'); + } final pubkey = ref.read(myPubkeyProvider); if (pubkey == null) { throw StateError('Cannot update profile without a signing identity.'); @@ -79,13 +99,17 @@ class ProfileNotifier extends AsyncNotifier { session: ref.read(relaySessionProvider.notifier), nsec: config.nsec, ); + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final createdAt = now > _lastCreatedAt ? now : _lastCreatedAt + 1; await relay.submit( kind: EventKind.profile, content: jsonEncode(nextMetadata), tags: const [], + createdAt: createdAt, ); _metadata = nextMetadata; + _lastCreatedAt = createdAt; final profile = UserProfile( pubkey: pubkey, displayName: diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index e7ffe7eceb3..5fd934f251b 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -13,7 +13,6 @@ import '../../shared/clipboard_utils.dart'; import '../../shared/community/community_membership_provider.dart'; import '../../shared/relay/relay.dart'; import '../pairing/pairing_provider.dart'; -import '../profile/profile_photo_transition.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/app_list.dart'; import '../../shared/widgets/app_list_card.dart'; @@ -21,6 +20,7 @@ import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/ios_glass_navigation_button.dart'; import '../../shared/widgets/ios_glass_navigation_action.dart'; +import '../../shared/widgets/immediate_page_route.dart'; import '../../shared/widgets/modal_presentation.dart'; import 'accent_picker_page.dart'; import 'theme_picker_page.dart'; @@ -136,7 +136,7 @@ class SettingsPage extends HookConsumerWidget { case _ProfileEditAction.photo: await Navigator.of( context, - ).push(profilePhotoEditorRoute(builder: profileEditPageBuilder)); + ).push(immediatePageRoute(builder: profileEditPageBuilder)); break; } } diff --git a/mobile/lib/shared/widgets/immediate_page_route.dart b/mobile/lib/shared/widgets/immediate_page_route.dart new file mode 100644 index 00000000000..0282ea62bb0 --- /dev/null +++ b/mobile/lib/shared/widgets/immediate_page_route.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +/// A route for destinations that provide their own in-page transition. +PageRoute immediatePageRoute({required WidgetBuilder builder}) => + PageRouteBuilder( + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (context, _, _) => builder(context), + transitionsBuilder: (_, _, _, child) => child, + ); diff --git a/mobile/lib/shared/widgets/playing_avatar_image.dart b/mobile/lib/shared/widgets/playing_avatar_image.dart index c6698765173..553f29de072 100644 --- a/mobile/lib/shared/widgets/playing_avatar_image.dart +++ b/mobile/lib/shared/widgets/playing_avatar_image.dart @@ -22,9 +22,9 @@ class PlayingAvatarImage extends StatelessWidget { @override Widget build(BuildContext context) { final descriptor = parseAnimatedAvatarUrl(imageUrl); - if (descriptor == null) { + if (descriptor == null || MediaQuery.disableAnimationsOf(context)) { return AvatarImage( - imageUrl: imageUrl, + imageUrl: descriptor?.posterUrl ?? imageUrl, radius: radius, backgroundColor: backgroundColor, fallback: fallback, diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index b748c2e5f1c..ca1e755d065 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -4,7 +4,7 @@ import 'dart:math' as math; import 'package:buzz/features/profile/profile_edit_page.dart'; import 'package:buzz/features/profile/avatar_background_grid.dart'; import 'package:buzz/features/profile/avatar_editor_option_button.dart'; -import 'package:buzz/features/profile/profile_photo_transition.dart'; +import 'package:buzz/shared/widgets/immediate_page_route.dart'; import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/shared/emoji/emoji_avatar.dart'; import 'package:buzz/shared/emoji/native_emoji_glyph.dart'; @@ -98,7 +98,7 @@ void main() { builder: (context) => Scaffold( body: TextButton( onPressed: () => Navigator.of(context).push( - profilePhotoEditorRoute( + immediatePageRoute( builder: (_) => const ProfileEditPage(startInPhotoEditor: true), ), @@ -352,10 +352,104 @@ void main() { ); await tester.pumpAndSettle(); expect(notifier.savedAvatarUrls, isEmpty); + expect(uploadService.uploadCount, 0); await tester.tap(find.byKey(const ValueKey('avatar-save'))); await tester.pumpAndSettle(); expect(notifier.savedAvatarUrls, ['https://relay.example/profile.png']); + expect(uploadService.uploadCount, 1); + }); + + testWidgets('keeps a failed avatar draft visible and retries publish', ( + tester, + ) async { + final notifier = _FakeProfileNotifier(failedAvatarSaves: 1); + final uploadService = _FakeMediaUploadService(); + addTearDown(uploadService.dispose); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + mediaUploadServiceProvider.overrideWithValue(uploadService), + ], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Photo Library')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo'))); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 200)), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + expect( + find.text("We couldn't save your profile photo. Try again."), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('profile-avatar-editor-page')), + findsOneWidget, + ); + expect(uploadService.uploadCount, 1); + + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + expect(notifier.savedAvatarUrls, ['https://relay.example/profile.png']); + expect(uploadService.uploadCount, 1); + }); + + testWidgets('photo modes remain usable on a compact large-type viewport', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(320, 568); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(2)), + child: ProfileEditPage(startInPhotoEditor: true), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 250)); + expect(tester.takeException(), isNull); + expect( + find.byKey(const ValueKey('avatar-editor-scroll-view')), + findsOneWidget, + ); + + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 250)); + expect(tester.takeException(), isNull); + await tester.ensureVisible( + find.byKey(const ValueKey('emoji-editor-background')), + ); + await tester.tap(find.byKey(const ValueKey('emoji-editor-background'))); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.takeException(), isNull); + + await tester.drag( + find.byKey(const ValueKey('avatar-editor-scroll-view')), + const Offset(0, 1000), + ); + await tester.pump(); + final animatedMode = find.byKey(const ValueKey('avatar-mode-animated')); + await tester.tap(animatedMode); + await tester.pump(const Duration(milliseconds: 250)); + expect(tester.takeException(), isNull); + expect( + find.byKey(const ValueKey('animated-avatar-capture-preview')), + findsOneWidget, + ); }); testWidgets('centers every preview while controls fill the page gutters', ( @@ -766,6 +860,39 @@ void main() { expect(find.byType(ProgressiveAnimatedAvatar), findsOneWidget); }); + testWidgets('shows only the animated-avatar poster with Reduce Motion', ( + tester, + ) async { + const avatar = + 'https://relay.example/poster.png#buzz-anim=https%3A%2F%2Frelay.example%2Fanimation.png'; + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith( + () => _FakeProfileNotifier( + profile: const UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + avatarUrl: avatar, + ), + ), + ), + ], + child: const MediaQuery( + data: MediaQueryData(disableAnimations: true), + child: ProfileEditPage(), + ), + ), + ); + await tester.pump(); + + expect(find.byType(ProgressiveAnimatedAvatar), findsNothing); + expect( + tester.widget(find.byType(AvatarImage)).imageUrl, + 'https://relay.example/poster.png', + ); + }); + testWidgets('keeps emoji actions anchored when search opens the keyboard', ( tester, ) async { @@ -876,9 +1003,11 @@ class _FakeProfileNotifier extends ProfileNotifier { displayName: 'Alice', about: 'Building Buzz', ), + this.failedAvatarSaves = 0, }); final UserProfile profile; + int failedAvatarSaves; final savedDisplayNames = []; final savedDescriptions = []; final savedAvatarUrls = []; @@ -918,6 +1047,10 @@ class _FakeProfileNotifier extends ProfileNotifier { @override Future updateAvatarUrl(String avatarUrl) async { + if (failedAvatarSaves > 0) { + failedAvatarSaves--; + throw Exception('profile publish failed'); + } savedAvatarUrls.add(avatarUrl); } } @@ -934,6 +1067,7 @@ class _FakeMediaUploadService extends MediaUploadService { var _camera = false; final bool delayGallery; final _gallerySelection = Completer(); + int uploadCount = 0; XFile _image() => XFile.fromData( image.encodePng(image.Image(width: 8, height: 8)), @@ -965,13 +1099,16 @@ class _FakeMediaUploadService extends MediaUploadService { required String mimeType, ValueChanged? onProgress, UploadCancellationToken? cancellationToken, - }) async => BlobDescriptor( - url: _camera - ? 'https://relay.example/camera.png' - : 'https://relay.example/profile.png', - sha256: _camera ? 'camera-hash' : 'hash', - size: bytes.length, - type: mimeType, - uploaded: 1, - ); + }) async { + uploadCount++; + return BlobDescriptor( + url: _camera + ? 'https://relay.example/camera.png' + : 'https://relay.example/profile.png', + sha256: _camera ? 'camera-hash' : 'hash', + size: bytes.length, + type: mimeType, + uploaded: 1, + ); + } } diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index 577dd4bbd57..18e22b7055d 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:buzz/features/profile/profile_provider.dart'; @@ -59,6 +60,86 @@ void main() { ); }); + test('profile updates fail closed while hydration is pending', () async { + final keys = nostr.Keys.generate(); + final history = Completer>(); + final relaySession = _ControlledProfileRelaySession( + fetch: () => history.future, + ); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + container.read(profileProvider); + await expectLater( + container.read(profileProvider.notifier).updateDisplayName('Unsafe'), + throwsStateError, + ); + expect(relaySession.published, isEmpty); + history.complete(const []); + }); + + test('profile updates fail closed after hydration errors', () async { + final keys = nostr.Keys.generate(); + final relaySession = _ControlledProfileRelaySession( + fetch: () async => throw Exception('relay unavailable'), + ); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + container.read(profileProvider); + await Future.delayed(Duration.zero); + expect(container.read(profileProvider).hasError, isTrue); + await expectLater( + container.read(profileProvider.notifier).updateAbout('Unsafe'), + throwsStateError, + ); + expect(relaySession.published, isEmpty); + }); + + test('a confirmed empty history can publish a new profile', () async { + final keys = nostr.Keys.generate(); + final relaySession = _ControlledProfileRelaySession(fetch: () async => []); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + expect(await container.read(profileProvider.future), isNull); + await container.read(profileProvider.notifier).updateDisplayName('Alice'); + + expect(relaySession.published, hasLength(1)); + expect(jsonDecode(relaySession.published.single.content), { + 'display_name': 'Alice', + }); + }); + + test('same-second profile replacements use monotonic timestamps', () async { + final keys = nostr.Keys.generate(); + final futureTimestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 60; + final relaySession = _ControlledProfileRelaySession( + fetch: () async => [ + NostrEvent( + id: 'profile-future', + pubkey: keys.public, + createdAt: futureTimestamp, + kind: EventKind.profile, + tags: const [], + content: '{}', + sig: 'sig', + ), + ], + ); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + await container.read(profileProvider.notifier).updateDisplayName('Alice'); + await container.read(profileProvider.notifier).updateAbout('Hello'); + + expect(relaySession.published.map((event) => event.createdAt), [ + futureTimestamp + 1, + futureTimestamp + 2, + ]); + }); + test( 'manual presence persists until Online restores automatic mode', () async { @@ -148,6 +229,41 @@ class _ProfileRelaySession extends RelaySessionNotifier { } } +ProviderContainer _profileContainer( + String nsec, + RelaySessionNotifier relaySession, +) => ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(() => _FixedRelayConfigNotifier(nsec)), + relaySessionProvider.overrideWith(() => relaySession), + ], +); + +class _ControlledProfileRelaySession extends RelaySessionNotifier { + _ControlledProfileRelaySession({required this.fetch}); + + final Future> Function() fetch; + final List published = []; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) => fetch(); + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + published.add(event); + return event; + } +} + ProviderContainer _buildContainer(SharedPreferences prefs) => ProviderContainer( overrides: [ savedPrefsProvider.overrideWithValue(prefs), From d922679f185cbda8a3666bb2a68f08efb417cea6 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 08:54:13 +0100 Subject: [PATCH 03/34] test(mobile): cover animated upload retries Signed-off-by: kenny lopez --- .../profile/profile_avatar_draft.dart | 40 ++++++++++-- .../features/profile/profile_provider.dart | 3 +- .../profile/profile_avatar_draft_test.dart | 65 +++++++++++++++++++ 3 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 mobile/test/features/profile/profile_avatar_draft_test.dart diff --git a/mobile/lib/features/profile/profile_avatar_draft.dart b/mobile/lib/features/profile/profile_avatar_draft.dart index 4c26b973527..dd2e84676f0 100644 --- a/mobile/lib/features/profile/profile_avatar_draft.dart +++ b/mobile/lib/features/profile/profile_avatar_draft.dart @@ -47,16 +47,48 @@ final class ProfileAnimatedAvatarDraft extends ProfileAvatarDraft { final Uint8List animation; final Uint8List poster; Future? _uploadedUrl; + Future? _posterUpload; + Future? _animationUpload; + + Future _uploadPoster(MediaUploadService service) { + final existing = _posterUpload; + if (existing != null) return existing; + late final Future upload; + upload = service.uploadBytes(poster, mimeType: 'image/png').catchError(( + Object error, + StackTrace stackTrace, + ) { + if (identical(_posterUpload, upload)) _posterUpload = null; + Error.throwWithStackTrace(error, stackTrace); + }); + _posterUpload = upload; + return upload; + } + + Future _uploadAnimation(MediaUploadService service) { + final existing = _animationUpload; + if (existing != null) return existing; + late final Future upload; + upload = service.uploadBytes(animation, mimeType: 'image/png').catchError(( + Object error, + StackTrace stackTrace, + ) { + if (identical(_animationUpload, upload)) _animationUpload = null; + Error.throwWithStackTrace(error, stackTrace); + }); + _animationUpload = upload; + return upload; + } @override Future upload(MediaUploadService service) async { final existing = _uploadedUrl; if (existing != null) return existing; + // Cache each content-addressed part independently. If one request fails, + // retry only that part so a successful counterpart remains attached to + // this draft instead of becoming an abandoned duplicate. final upload = Future.wait( - [ - service.uploadBytes(poster, mimeType: 'image/png'), - service.uploadBytes(animation, mimeType: 'image/png'), - ], + [_uploadPoster(service), _uploadAnimation(service)], ).then((uploads) => buildAnimatedAvatarUrl(uploads[0].url, uploads[1].url)); _uploadedUrl = upload; try { diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 7a852ab9775..893d36da163 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -46,7 +46,8 @@ class ProfileNotifier extends AsyncNotifier { if (event.createdAt != current.createdAt) { return event.createdAt > current.createdAt ? event : current; } - return event.id.compareTo(current.id) > 0 ? event : current; + // Match the relay replacement tie-breaker: the lowest event id wins. + return event.id.compareTo(current.id) < 0 ? event : current; }); final decoded = jsonDecode(latest.content); if (decoded is! Map) { diff --git a/mobile/test/features/profile/profile_avatar_draft_test.dart b/mobile/test/features/profile/profile_avatar_draft_test.dart new file mode 100644 index 00000000000..fa63908df25 --- /dev/null +++ b/mobile/test/features/profile/profile_avatar_draft_test.dart @@ -0,0 +1,65 @@ +import 'package:buzz/features/profile/profile_avatar_draft.dart'; +import 'package:buzz/shared/animated_avatar.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('animated draft retries only the failed upload part', () async { + final service = _PartiallyFailingUploadService(); + addTearDown(service.dispose); + final draft = ProfileAnimatedAvatarDraft( + poster: Uint8List.fromList([1]), + animation: Uint8List.fromList([2]), + ); + + await expectLater(draft.upload(service), throwsException); + expect(service.uploadedParts, ['poster', 'animation']); + + final url = await draft.upload(service); + expect(service.uploadedParts, ['poster', 'animation', 'animation']); + expect(parseAnimatedAvatarUrl(url)?.posterUrl, 'https://relay/poster.png'); + expect( + parseAnimatedAvatarUrl(url)?.animationUrl, + 'https://relay/animation.png', + ); + + expect(await draft.upload(service), url); + expect(service.uploadedParts, ['poster', 'animation', 'animation']); + }); +} + +final class _PartiallyFailingUploadService extends MediaUploadService { + _PartiallyFailingUploadService() + : super( + baseUrl: 'https://relay.example', + nsec: null, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + ); + + final uploadedParts = []; + var _failedAnimationOnce = false; + + @override + Future uploadBytes( + Uint8List bytes, { + required String mimeType, + ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, + }) async { + final part = bytes.single == 1 ? 'poster' : 'animation'; + uploadedParts.add(part); + if (part == 'animation' && !_failedAnimationOnce) { + _failedAnimationOnce = true; + throw Exception('animation upload failed'); + } + return BlobDescriptor( + url: 'https://relay/$part.png', + sha256: '$part-hash', + size: bytes.length, + type: mimeType, + uploaded: 1, + ); + } +} From d63fb80c79908c7ef908b4bdf34c13c0fee46d8f Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 09:19:55 +0100 Subject: [PATCH 04/34] fix(mobile): orient animated avatar frames Signed-off-by: kenny lopez --- .../profile/animated_avatar_capture.dart | 24 ++++++---- .../profile/animated_avatar_orientation.dart | 24 ++++++++++ .../profile/animated_avatar_capture_test.dart | 48 +++++++++++++++++++ 3 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 mobile/lib/features/profile/animated_avatar_orientation.dart create mode 100644 mobile/test/features/profile/animated_avatar_capture_test.dart diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index 771bebbbcc3..c7b619a2c3d 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -19,6 +19,7 @@ import '../../shared/theme/theme.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import 'avatar_background_grid.dart'; import 'avatar_editor_option_button.dart'; +import 'animated_avatar_orientation.dart'; import 'profile_avatar_draft.dart'; part 'animated_avatar_capture/review_controls.dart'; @@ -242,7 +243,11 @@ class AnimatedAvatarCapture extends HookConsumerWidget { try { final request = _FrameRequest.fromCameraImage( cameraImage, - sensorOrientation: active.description.sensorOrientation, + rotationDegrees: animatedAvatarFrameRotationDegrees( + sensorOrientation: active.description.sensorOrientation, + deviceOrientation: active.value.deviceOrientation, + lensDirection: active.description.lensDirection, + ), mirror: active.description.lensDirection == CameraLensDirection.front, ); @@ -700,13 +705,13 @@ class _FrameRequest { required this.height, required this.planes, required this.isBgra, - required this.sensorOrientation, + required this.rotationDegrees, required this.mirror, }); factory _FrameRequest.fromCameraImage( CameraImage frame, { - required int sensorOrientation, + required int rotationDegrees, required bool mirror, }) => _FrameRequest( width: frame.width, @@ -723,7 +728,7 @@ class _FrameRequest { ) .toList(growable: false), isBgra: frame.format.group == ImageFormatGroup.bgra8888, - sensorOrientation: sensorOrientation, + rotationDegrees: rotationDegrees, mirror: mirror, ); @@ -731,7 +736,7 @@ class _FrameRequest { final int height; final List<_FramePlane> planes; final bool isBgra; - final int sensorOrientation; + final int rotationDegrees; final bool mirror; } @@ -774,11 +779,10 @@ Uint8List _convertCameraFrame(_FrameRequest request) { } } } - // camera_avfoundation applies the capture connection's orientation and front - // camera mirroring to BGRA pixel buffers before streaming them to Flutter. - // Android YUV buffers remain sensor-oriented and need both corrections here. - if (!request.isBgra && request.sensorOrientation != 0) { - result = image.copyRotate(result, angle: request.sensorOrientation); + // iOS pre-rotates and mirrors BGRA buffers. Android YUV buffers remain + // sensor-oriented and need both corrections here. + if (!request.isBgra && request.rotationDegrees != 0) { + result = image.copyRotate(result, angle: request.rotationDegrees); } if (!request.isBgra && request.mirror) { result = image.flipHorizontal(result); diff --git a/mobile/lib/features/profile/animated_avatar_orientation.dart b/mobile/lib/features/profile/animated_avatar_orientation.dart new file mode 100644 index 00000000000..77f2b557c4a --- /dev/null +++ b/mobile/lib/features/profile/animated_avatar_orientation.dart @@ -0,0 +1,24 @@ +import 'package:camera/camera.dart'; +import 'package:flutter/services.dart'; + +/// Returns the clockwise correction for an Android animated-avatar frame. +/// +/// Android image-stream buffers remain sensor-oriented. The correction must +/// account for both the sensor mount and the orientation in which the device +/// is currently held; front-facing frames are mirrored separately after this +/// rotation. +int animatedAvatarFrameRotationDegrees({ + required int sensorOrientation, + required DeviceOrientation deviceOrientation, + required CameraLensDirection lensDirection, +}) { + final deviceOrientationDegrees = switch (deviceOrientation) { + DeviceOrientation.portraitUp => 0, + DeviceOrientation.landscapeRight => 90, + DeviceOrientation.portraitDown => 180, + DeviceOrientation.landscapeLeft => 270, + }; + final facingSign = lensDirection == CameraLensDirection.back ? -1 : 1; + return (sensorOrientation - deviceOrientationDegrees * facingSign + 360) % + 360; +} diff --git a/mobile/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart new file mode 100644 index 00000000000..26cf7cb2287 --- /dev/null +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -0,0 +1,48 @@ +import 'package:buzz/features/profile/animated_avatar_orientation.dart'; +import 'package:camera/camera.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('animatedAvatarFrameRotationDegrees', () { + test('compensates front camera frames for every device orientation', () { + const expected = { + DeviceOrientation.portraitUp: 270, + DeviceOrientation.landscapeRight: 180, + DeviceOrientation.portraitDown: 90, + DeviceOrientation.landscapeLeft: 0, + }; + + for (final entry in expected.entries) { + expect( + animatedAvatarFrameRotationDegrees( + sensorOrientation: 270, + deviceOrientation: entry.key, + lensDirection: CameraLensDirection.front, + ), + entry.value, + ); + } + }); + + test('compensates back camera frames for every device orientation', () { + const expected = { + DeviceOrientation.portraitUp: 90, + DeviceOrientation.landscapeRight: 180, + DeviceOrientation.portraitDown: 270, + DeviceOrientation.landscapeLeft: 0, + }; + + for (final entry in expected.entries) { + expect( + animatedAvatarFrameRotationDegrees( + sensorOrientation: 90, + deviceOrientation: entry.key, + lensDirection: CameraLensDirection.back, + ), + entry.value, + ); + } + }); + }); +} From 4730c9b1daf43fd94573204f3898d7c091d0c7ce Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 09:50:59 +0100 Subject: [PATCH 05/34] fix(mobile): merge profile edits with relay head Signed-off-by: kenny lopez --- .../features/profile/profile_provider.dart | 70 +++++++--- .../profile/profile_provider_test.dart | 129 +++++++++++++++++- 2 files changed, 179 insertions(+), 20 deletions(-) diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 893d36da163..d8780beff45 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -42,18 +42,8 @@ class ProfileNotifier extends AsyncNotifier { _hasHydrated = true; return null; } - final latest = events.reduce((current, event) { - if (event.createdAt != current.createdAt) { - return event.createdAt > current.createdAt ? event : current; - } - // Match the relay replacement tie-breaker: the lowest event id wins. - return event.id.compareTo(current.id) < 0 ? event : current; - }); - final decoded = jsonDecode(latest.content); - if (decoded is! Map) { - throw const FormatException('Profile metadata must be a JSON object.'); - } - _metadata = Map.from(decoded); + final latest = _latestProfileEvent(events)!; + _metadata = _decodeProfileMetadata(latest); _lastCreatedAt = latest.createdAt; final data = ProfileData.fromEvent(latest); final profile = UserProfile( @@ -94,20 +84,45 @@ class ProfileNotifier extends AsyncNotifier { throw StateError('Cannot update profile without a signing identity.'); } - final nextMetadata = {..._metadata, ...patch}; - final config = ref.read(relayConfigProvider); - final relay = SignedEventRelay( - session: ref.read(relaySessionProvider.notifier), - nsec: config.nsec, + final session = ref.read(relaySessionProvider.notifier); + final currentEvents = await session.fetchHistory( + NostrFilters.profile(pubkey), ); + final currentHead = _latestProfileEvent(currentEvents); + if (_lastCreatedAt > 0 && + (currentHead == null || currentHead.createdAt < _lastCreatedAt)) { + throw StateError('Cannot confirm the latest profile metadata.'); + } + final currentMetadata = currentHead == null + ? {} + : _decodeProfileMetadata(currentHead); + final nextMetadata = {...currentMetadata, ...patch}; + final config = ref.read(relayConfigProvider); + final relay = SignedEventRelay(session: session, nsec: config.nsec); final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final createdAt = now > _lastCreatedAt ? now : _lastCreatedAt + 1; + final currentCreatedAt = currentHead?.createdAt ?? 0; + final previousCreatedAt = currentCreatedAt > _lastCreatedAt + ? currentCreatedAt + : _lastCreatedAt; + final createdAt = now > previousCreatedAt ? now : previousCreatedAt + 1; + NostrEvent? signedEvent; await relay.submit( kind: EventKind.profile, content: jsonEncode(nextMetadata), tags: const [], createdAt: createdAt, + onSigned: (event) => signedEvent = event, ); + final submittedEvent = signedEvent; + if (submittedEvent == null) { + throw StateError('Profile update was not signed.'); + } + final verifiedHead = _latestProfileEvent( + await session.fetchHistory(NostrFilters.profile(pubkey)), + ); + if (verifiedHead?.id != submittedEvent.id) { + throw StateError('Profile changed before the update could be confirmed.'); + } _metadata = nextMetadata; _lastCreatedAt = createdAt; @@ -124,6 +139,25 @@ class ProfileNotifier extends AsyncNotifier { } } +NostrEvent? _latestProfileEvent(List events) { + if (events.isEmpty) return null; + return events.reduce((current, event) { + if (event.createdAt != current.createdAt) { + return event.createdAt > current.createdAt ? event : current; + } + // Match the relay replacement tie-breaker: the lowest event id wins. + return event.id.compareTo(current.id) < 0 ? event : current; + }); +} + +Map _decodeProfileMetadata(NostrEvent event) { + final decoded = jsonDecode(event.content); + if (decoded is! Map) { + throw const FormatException('Profile metadata must be a JSON object.'); + } + return Map.from(decoded); +} + final profileProvider = AsyncNotifierProvider( ProfileNotifier.new, ); diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index 18e22b7055d..b2e4019538a 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -140,6 +140,96 @@ void main() { ]); }); + test( + 'profile updates merge the current relay head before publishing', + () async { + final keys = nostr.Keys.generate(); + var history = [ + NostrEvent( + id: 'profile-initial', + pubkey: keys.public, + createdAt: 10, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({ + 'display_name': 'Initial', + 'about': 'Initial about', + 'custom': 'initial', + }), + sig: 'sig', + ), + ]; + final relaySession = _ControlledProfileRelaySession( + fetch: () async => history, + ); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + history = [ + NostrEvent( + id: 'profile-remote', + pubkey: keys.public, + createdAt: 20, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({ + 'display_name': 'Remote', + 'about': 'Remote about', + 'custom': 'remote', + }), + sig: 'sig', + ), + ]; + + await container + .read(profileProvider.notifier) + .updateDisplayName('Mobile'); + + final content = + jsonDecode(relaySession.published.single.content) + as Map; + expect(content, { + 'display_name': 'Mobile', + 'about': 'Remote about', + 'custom': 'remote', + }); + expect(relaySession.published.single.createdAt, greaterThan(20)); + }, + ); + + test( + 'a competing profile head does not become optimistic local state', + () async { + final keys = nostr.Keys.generate(); + final relaySession = _LosingProfileRelaySession( + NostrEvent( + id: 'profile-initial', + pubkey: keys.public, + createdAt: 10, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({'display_name': 'Initial'}), + sig: 'sig', + ), + ); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + + await expectLater( + container.read(profileProvider.notifier).updateDisplayName('Mobile'), + throwsStateError, + ); + expect(relaySession.published, hasLength(1)); + expect( + container.read(profileProvider).requireValue?.displayName, + 'Initial', + ); + }, + ); + test( 'manual presence persists until Online restores automatic mode', () async { @@ -217,7 +307,7 @@ class _ProfileRelaySession extends RelaySessionNotifier { Future> fetchHistory( NostrFilter filter, { Duration timeout = const Duration(seconds: 8), - }) async => [profile]; + }) async => [profile, ...published]; @override Future publish( @@ -252,7 +342,7 @@ class _ControlledProfileRelaySession extends RelaySessionNotifier { Future> fetchHistory( NostrFilter filter, { Duration timeout = const Duration(seconds: 8), - }) => fetch(); + }) async => [...await fetch(), ...published]; @override Future publish( @@ -264,6 +354,41 @@ class _ControlledProfileRelaySession extends RelaySessionNotifier { } } +class _LosingProfileRelaySession extends RelaySessionNotifier { + _LosingProfileRelaySession(this.initial); + + final NostrEvent initial; + final List published = []; + NostrEvent? competing; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => [competing ?? initial]; + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + published.add(event); + competing = NostrEvent( + id: 'profile-competing', + pubkey: initial.pubkey, + createdAt: event.createdAt + 1, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({'display_name': 'Remote'}), + sig: 'sig', + ); + return event; + } +} + ProviderContainer _buildContainer(SharedPreferences prefs) => ProviderContainer( overrides: [ savedPrefsProvider.overrideWithValue(prefs), From 25a46109db6aaf8695dcaa00a0b7752f0b43c512 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 10:07:09 +0100 Subject: [PATCH 06/34] fix(mobile): retain concurrent profile edits Signed-off-by: kenny lopez --- .../profile/ios_profile_text_editor.dart | 29 +++ .../profile/profile_avatar_editor.dart | 23 ++- .../features/profile/profile_edit_page.dart | 38 ++-- .../features/profile/profile_provider.dart | 17 +- .../features/profile/profile_text_editor.dart | 12 +- .../profile/profile_edit_retry_test.dart | 169 ++++++++++++++++++ .../profile/profile_provider_test.dart | 41 +++++ 7 files changed, 307 insertions(+), 22 deletions(-) create mode 100644 mobile/test/features/profile/profile_edit_retry_test.dart diff --git a/mobile/lib/features/profile/ios_profile_text_editor.dart b/mobile/lib/features/profile/ios_profile_text_editor.dart index 7ae6819862e..a715f8ff913 100644 --- a/mobile/lib/features/profile/ios_profile_text_editor.dart +++ b/mobile/lib/features/profile/ios_profile_text_editor.dart @@ -17,4 +17,33 @@ class IosProfileTextEditor { 'placeholder': placeholder, 'multiline': multiline, }); + + /// Keeps the native editor's latest value available until it saves or the + /// user cancels, so a transient publish failure never discards their text. + static Future presentUntilSaved({ + required String title, + required String initialValue, + required String placeholder, + required bool multiline, + required Future Function(String value) onSave, + required void Function() onSaveError, + }) async { + var draft = initialValue; + while (true) { + final value = await present( + title: title, + initialValue: draft, + placeholder: placeholder, + multiline: multiline, + ); + if (value == null) return; + try { + await onSave(value); + return; + } catch (_) { + draft = value; + onSaveError(); + } + } + } } diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index f964bf2322a..f4c80d1c40f 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -31,6 +31,14 @@ part 'profile_avatar_editor/emoji_avatar_picker.dart'; /// Avatar kinds shared with the desktop profile editor. enum ProfileAvatarMode { image, emoji, animated } +/// Builds the animated capture surface for the profile avatar editor. +typedef AnimatedAvatarCaptureBuilder = + Widget Function({ + required double height, + required ValueChanged Function()?> + onPrepareChanged, + }); + const _previewSize = 220.0; const _motionDuration = Duration(milliseconds: 240); const _previewSquishDuration = Duration(milliseconds: 200); @@ -60,6 +68,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { required this.onModeChanged, required this.onDraftChanged, required this.onAnimatedPrepareChanged, + this.animatedCaptureBuilder, }); final String? currentAvatarUrl; @@ -71,6 +80,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { final ValueChanged onDraftChanged; final ValueChanged Function()?> onAnimatedPrepareChanged; + final AnimatedAvatarCaptureBuilder? animatedCaptureBuilder; @override Widget build(BuildContext context, WidgetRef ref) { @@ -266,10 +276,17 @@ class ProfileAvatarEditor extends HookConsumerWidget { updateEmojiPreview(); }, ), - ProfileAvatarMode.animated => AnimatedAvatarCapture( + ProfileAvatarMode.animated => KeyedSubtree( key: const ValueKey(2), - height: modeHeight, - onPrepareChanged: onAnimatedPrepareChanged, + child: + animatedCaptureBuilder?.call( + height: modeHeight, + onPrepareChanged: onAnimatedPrepareChanged, + ) ?? + AnimatedAvatarCapture( + height: modeHeight, + onPrepareChanged: onAnimatedPrepareChanged, + ), ), }; final collapsedPreviewOffset = diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 75ac749cc12..2405ca13f4e 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -27,11 +27,18 @@ import 'profile_provider.dart'; /// Edits the current user's public profile metadata. class ProfileEditPage extends HookConsumerWidget { - const ProfileEditPage({super.key, this.startInPhotoEditor = false}); + const ProfileEditPage({ + super.key, + this.startInPhotoEditor = false, + this.animatedAvatarCaptureBuilder, + }); /// Opens directly into the photo editor when launched from Settings. final bool startInPhotoEditor; + /// Overrides animated capture for focused integration tests. + final AnimatedAvatarCaptureBuilder? animatedAvatarCaptureBuilder; + static const _avatarRadius = 64.0; @override @@ -93,24 +100,21 @@ class ProfileEditPage extends HookConsumerWidget { }) async { if (defaultTargetPlatform == TargetPlatform.iOS) { try { - final value = await IosProfileTextEditor.present( + await IosProfileTextEditor.presentUntilSaved( title: title, initialValue: initialValue, placeholder: hintText, multiline: multiline, - ); - if (value == null) return; - try { - await onSave(value); - } catch (_) { - if (context.mounted) { + onSave: onSave, + onSaveError: () { + if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text("We couldn't save this change. Try again."), ), ); - } - } + }, + ); return; } on MissingPluginException { // Keep the Flutter editor available in previews and older builds. @@ -158,8 +162,13 @@ class ProfileEditPage extends HookConsumerWidget { var nextDraft = avatarDraftMode.value == avatarMode.value ? avatarDraft.value : null; - if (avatarMode.value == ProfileAvatarMode.animated) { + if (avatarMode.value == ProfileAvatarMode.animated && + nextDraft == null) { nextDraft = await prepareAnimatedAvatar.value?.call(); + if (nextDraft != null) { + avatarDraft.value = nextDraft; + avatarDraftMode.value = ProfileAvatarMode.animated; + } } if (nextDraft == null) return; final nextAvatar = await nextDraft.upload( @@ -287,7 +296,14 @@ class ProfileEditPage extends HookConsumerWidget { onAnimatedPrepareChanged: (prepare) { prepareAnimatedAvatar.value = prepare; canPrepareAnimatedAvatar.value = prepare != null; + if (avatarMode.value == + ProfileAvatarMode.animated) { + avatarDraft.value = null; + avatarDraftMode.value = null; + } }, + animatedCaptureBuilder: + animatedAvatarCaptureBuilder, ), ), ), diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index d8780beff45..53ff4f34b99 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -16,6 +16,7 @@ class ProfileNotifier extends AsyncNotifier { Map _metadata = {}; bool _hasHydrated = false; int _lastCreatedAt = 0; + Future _patchQueue = Future.value(); @override Future build() { @@ -75,7 +76,21 @@ class ProfileNotifier extends AsyncNotifier { Future updateAvatarUrl(String avatarUrl) => _publishProfilePatch({'picture': avatarUrl.trim()}); - Future _publishProfilePatch(Map patch) async { + Future _publishProfilePatch(Map patch) { + final previous = _patchQueue; + final released = Completer(); + _patchQueue = released.future; + return () async { + await previous; + try { + await _publishProfilePatchNow(patch); + } finally { + released.complete(); + } + }(); + } + + Future _publishProfilePatchNow(Map patch) async { if (!_hasHydrated || !state.hasValue) { throw StateError('Cannot update profile before metadata is loaded.'); } diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart index 3d162b7bd8d..9ef95e12030 100644 --- a/mobile/lib/features/profile/profile_text_editor.dart +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -48,18 +48,16 @@ Future _showProfileTextEditor({ }) async { if (defaultTargetPlatform == TargetPlatform.iOS) { try { - final value = await IosProfileTextEditor.present( + await IosProfileTextEditor.presentUntilSaved( title: title, initialValue: initialValue, placeholder: hintText, multiline: multiline, + onSave: onSave, + onSaveError: () { + if (context.mounted) _showSaveError(context); + }, ); - if (value == null) return; - try { - await onSave(value); - } catch (_) { - if (context.mounted) _showSaveError(context); - } return; } on MissingPluginException { // Previews and older builds retain the complete Flutter fallback. diff --git a/mobile/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart new file mode 100644 index 00000000000..47fa6fa583c --- /dev/null +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -0,0 +1,169 @@ +import 'package:buzz/features/profile/profile_avatar_draft.dart'; +import 'package:buzz/features/profile/profile_edit_page.dart'; +import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/widget_helpers.dart'; + +void main() { + testWidgets('native text retry retains the failed value', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/profile_text_editor'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return calls.length == 1 ? 'Alice Retained' : null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + final notifier = _RetryProfileNotifier(failedTextSaves: 1); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('profile-display-name-row'))); + await tester.pumpAndSettle(); + + expect(notifier.displayNameAttempts, ['Alice Retained']); + expect(calls, hasLength(2)); + expect( + (calls.last.arguments as Map)['initialValue'], + 'Alice Retained', + ); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('animated save retry reuses its prepared draft', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final notifier = _RetryProfileNotifier(failedAvatarSaves: 1); + final uploadService = _RetryMediaUploadService(); + addTearDown(uploadService.dispose); + var prepareCalls = 0; + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + mediaUploadServiceProvider.overrideWithValue(uploadService), + ], + child: ProfileEditPage( + startInPhotoEditor: true, + animatedAvatarCaptureBuilder: + ({required height, required onPrepareChanged}) => HookBuilder( + builder: (context) { + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + onPrepareChanged(() async { + prepareCalls++; + return ProfileImageAvatarDraft( + Uint8List.fromList([1, 2, 3]), + ); + }); + }); + return null; + }, const []); + return SizedBox(height: height); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Animated')); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + expect( + find.text("We couldn't save your profile photo. Try again."), + findsOneWidget, + ); + expect(prepareCalls, 1); + expect(uploadService.uploadCount, 1); + + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + expect(prepareCalls, 1); + expect(uploadService.uploadCount, 1); + expect(notifier.savedAvatarUrls, ['https://relay.example/avatar.jpg']); + debugDefaultTargetPlatformOverride = null; + }); +} + +class _RetryProfileNotifier extends ProfileNotifier { + _RetryProfileNotifier({this.failedTextSaves = 0, this.failedAvatarSaves = 0}); + + int failedTextSaves; + int failedAvatarSaves; + final displayNameAttempts = []; + final savedAvatarUrls = []; + + @override + Future build() async => const UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + about: 'Building Buzz', + ); + + @override + Future updateDisplayName(String displayName) async { + displayNameAttempts.add(displayName); + if (failedTextSaves > 0) { + failedTextSaves--; + throw Exception('profile publish failed'); + } + } + + @override + Future updateAvatarUrl(String avatarUrl) async { + if (failedAvatarSaves > 0) { + failedAvatarSaves--; + throw Exception('profile publish failed'); + } + savedAvatarUrls.add(avatarUrl); + } +} + +class _RetryMediaUploadService extends MediaUploadService { + _RetryMediaUploadService() + : super( + baseUrl: 'https://relay.example', + nsec: null, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + ); + + int uploadCount = 0; + + @override + Future uploadBytes( + Uint8List bytes, { + required String mimeType, + ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, + }) async { + uploadCount++; + return BlobDescriptor( + url: 'https://relay.example/avatar.jpg', + sha256: 'avatar-hash', + size: bytes.length, + type: mimeType, + uploaded: 1, + ); + } +} diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index b2e4019538a..dc19c89c56c 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -230,6 +230,47 @@ void main() { }, ); + test( + 'overlapping profile updates serialize their full merge cycles', + () async { + final keys = nostr.Keys.generate(); + final relaySession = _ControlledProfileRelaySession( + fetch: () async => [ + NostrEvent( + id: 'profile-initial', + pubkey: keys.public, + createdAt: 10, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({ + 'display_name': 'Initial', + 'about': 'Initial about', + }), + sig: 'sig', + ), + ], + ); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + await Future.wait([ + container.read(profileProvider.notifier).updateDisplayName('Mobile'), + container.read(profileProvider.notifier).updateAbout('Mobile about'), + ]); + + expect(relaySession.published, hasLength(2)); + expect(jsonDecode(relaySession.published.last.content), { + 'display_name': 'Mobile', + 'about': 'Mobile about', + }); + expect( + relaySession.published.last.createdAt, + greaterThan(relaySession.published.first.createdAt), + ); + }, + ); + test( 'manual presence persists until Online restores automatic mode', () async { From 42ef3c2dd56023e7e975e73a8e7f5bfc77783af7 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 10:23:15 +0100 Subject: [PATCH 07/34] fix(mobile): scope profile writes to community Signed-off-by: kenny lopez --- .../features/profile/profile_provider.dart | 49 ++++++++++++++--- .../profile/profile_provider_test.dart | 54 +++++++++++++++++++ 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 53ff4f34b99..9a7d121010d 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -77,32 +77,42 @@ class ProfileNotifier extends AsyncNotifier { _publishProfilePatch({'picture': avatarUrl.trim()}); Future _publishProfilePatch(Map patch) { + final context = _ProfileWriteContext( + config: ref.read(relayConfigProvider), + pubkey: ref.read(myPubkeyProvider), + session: ref.read(relaySessionProvider.notifier), + ); final previous = _patchQueue; final released = Completer(); _patchQueue = released.future; return () async { await previous; try { - await _publishProfilePatchNow(patch); + await _publishProfilePatchNow(patch, context); } finally { released.complete(); } }(); } - Future _publishProfilePatchNow(Map patch) async { + Future _publishProfilePatchNow( + Map patch, + _ProfileWriteContext context, + ) async { if (!_hasHydrated || !state.hasValue) { throw StateError('Cannot update profile before metadata is loaded.'); } - final pubkey = ref.read(myPubkeyProvider); + final pubkey = context.pubkey; if (pubkey == null) { throw StateError('Cannot update profile without a signing identity.'); } + _requireCurrentWriteContext(context); - final session = ref.read(relaySessionProvider.notifier); + final session = context.session; final currentEvents = await session.fetchHistory( NostrFilters.profile(pubkey), ); + _requireCurrentWriteContext(context); final currentHead = _latestProfileEvent(currentEvents); if (_lastCreatedAt > 0 && (currentHead == null || currentHead.createdAt < _lastCreatedAt)) { @@ -112,8 +122,7 @@ class ProfileNotifier extends AsyncNotifier { ? {} : _decodeProfileMetadata(currentHead); final nextMetadata = {...currentMetadata, ...patch}; - final config = ref.read(relayConfigProvider); - final relay = SignedEventRelay(session: session, nsec: config.nsec); + final relay = SignedEventRelay(session: session, nsec: context.config.nsec); final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; final currentCreatedAt = currentHead?.createdAt ?? 0; final previousCreatedAt = currentCreatedAt > _lastCreatedAt @@ -128,6 +137,7 @@ class ProfileNotifier extends AsyncNotifier { createdAt: createdAt, onSigned: (event) => signedEvent = event, ); + _requireCurrentWriteContext(context); final submittedEvent = signedEvent; if (submittedEvent == null) { throw StateError('Profile update was not signed.'); @@ -135,6 +145,7 @@ class ProfileNotifier extends AsyncNotifier { final verifiedHead = _latestProfileEvent( await session.fetchHistory(NostrFilters.profile(pubkey)), ); + _requireCurrentWriteContext(context); if (verifiedHead?.id != submittedEvent.id) { throw StateError('Profile changed before the update could be confirmed.'); } @@ -152,6 +163,32 @@ class ProfileNotifier extends AsyncNotifier { state = AsyncData(profile); ref.read(userCacheProvider.notifier).put(profile); } + + void _requireCurrentWriteContext(_ProfileWriteContext context) { + final currentConfig = ref.read(relayConfigProvider); + final currentSession = ref.read(relaySessionProvider.notifier); + final currentPubkey = ref.read(myPubkeyProvider); + if (currentConfig.storedOrigin != context.config.storedOrigin || + currentConfig.nsec != context.config.nsec || + currentPubkey != context.pubkey || + !identical(currentSession, context.session)) { + throw StateError( + 'Profile update cancelled because the active community changed.', + ); + } + } +} + +class _ProfileWriteContext { + const _ProfileWriteContext({ + required this.config, + required this.pubkey, + required this.session, + }); + + final RelayConfig config; + final String? pubkey; + final RelaySessionNotifier session; } NostrEvent? _latestProfileEvent(List events) { diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index dc19c89c56c..fba40f85902 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -271,6 +271,50 @@ void main() { }, ); + test('profile updates abort when the active community changes', () async { + final keys = nostr.Keys.generate(); + final otherKeys = nostr.Keys.generate(); + final initial = NostrEvent( + id: 'profile-initial', + pubkey: keys.public, + createdAt: 10, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({'display_name': 'Initial'}), + sig: 'sig', + ); + final patchFetchStarted = Completer(); + final patchHistory = Completer>(); + var fetchCount = 0; + final relaySession = _ControlledProfileRelaySession( + fetch: () async { + fetchCount += 1; + if (fetchCount == 1) return [initial]; + if (!patchFetchStarted.isCompleted) patchFetchStarted.complete(); + return patchHistory.future; + }, + ); + final config = _MutableRelayConfigNotifier(keys.nsec); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(() => config), + relaySessionProvider.overrideWith(() => relaySession), + ], + ); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + final update = container + .read(profileProvider.notifier) + .updateDisplayName('Mobile'); + await patchFetchStarted.future; + config.update(baseUrl: 'https://other-relay.example', nsec: otherKeys.nsec); + patchHistory.complete([initial]); + + await expectLater(update, throwsStateError); + expect(relaySession.published, isEmpty); + }); + test( 'manual presence persists until Online restores automatic mode', () async { @@ -335,6 +379,16 @@ class _FixedRelayConfigNotifier extends RelayConfigNotifier { RelayConfig(baseUrl: 'https://relay.example', nsec: nsec); } +class _MutableRelayConfigNotifier extends RelayConfigNotifier { + _MutableRelayConfigNotifier(this.initialNsec); + + final String initialNsec; + + @override + RelayConfig build() => + RelayConfig(baseUrl: 'https://relay.example', nsec: initialNsec); +} + class _ProfileRelaySession extends RelaySessionNotifier { _ProfileRelaySession(this.profile); From 64bb760a37f8edfab364e2e9830dec928edd5eb7 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 10:33:52 +0100 Subject: [PATCH 08/34] test(mobile): bound avatar crop settling Signed-off-by: kenny lopez --- .../profile/profile_edit_page_test.dart | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index ca1e755d065..86ce267ab9d 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -347,10 +347,7 @@ void main() { closeTo(expectedY, 0.01), ); await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo'))); - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 200)), - ); - await tester.pumpAndSettle(); + await _waitForAvatarCropToClose(tester); expect(notifier.savedAvatarUrls, isEmpty); expect(uploadService.uploadCount, 0); await tester.tap(find.byKey(const ValueKey('avatar-save'))); @@ -381,10 +378,7 @@ void main() { await tester.tap(find.text('Photo Library')); await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo'))); - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 200)), - ); - await tester.pumpAndSettle(); + await _waitForAvatarCropToClose(tester); await tester.tap(find.byKey(const ValueKey('avatar-save'))); await tester.pumpAndSettle(); @@ -996,6 +990,21 @@ void main() { }); } +Future _waitForAvatarCropToClose(WidgetTester tester) async { + final cropPage = find.byKey(const ValueKey('avatar-crop-viewer')); + for (var attempt = 0; attempt < 100; attempt += 1) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 25)), + ); + await tester.pump(const Duration(milliseconds: 25)); + if (cropPage.evaluate().isEmpty) { + await tester.pump(const Duration(milliseconds: 250)); + return; + } + } + fail('Avatar crop did not complete within 5 seconds.'); +} + class _FakeProfileNotifier extends ProfileNotifier { _FakeProfileNotifier({ this.profile = const UserProfile( From eb89168e23ab46a48ea0fd8d79e3f70cd3e5b964 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 10:46:47 +0100 Subject: [PATCH 09/34] fix(mobile): preserve avatar work across context changes Signed-off-by: kenny lopez --- .../profile/animated_avatar_capture.dart | 14 +-- .../profile/profile_avatar_draft.dart | 12 ++ .../features/profile/profile_edit_page.dart | 22 +++- .../profile/animated_avatar_capture_test.dart | 64 ++++++++++ .../profile/profile_avatar_draft_test.dart | 50 ++++++++ .../profile/profile_edit_retry_test.dart | 110 ++++++++++++++++-- 6 files changed, 254 insertions(+), 18 deletions(-) diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index c7b619a2c3d..cc386ed4081 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -40,11 +40,14 @@ class AnimatedAvatarCapture extends HookConsumerWidget { super.key, required this.height, required this.onPrepareChanged, + this.initialFrames = const [], }); - final double height; final ValueChanged Function()?> onPrepareChanged; + /// Seeds processed frames in lifecycle-focused widget tests. + @visibleForTesting + final List initialFrames; @override Widget build(BuildContext context, WidgetRef ref) { final controller = useState(null); @@ -54,7 +57,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { final isPreparingFrames = useState(false); final isProcessing = useState(false); final progress = useState(0.0); - final frames = useState>(const []); + final frames = useState>(initialFrames); final posterIndex = useState(0); final previewFrameIndex = useState(0); final scale = useState(_mobileDefaultPersonScale); @@ -69,7 +72,6 @@ class AnimatedAvatarCapture extends HookConsumerWidget { final encodedCache = useRef<_EncodedAvatarCache?>(null); final reduceMotion = MediaQuery.disableAnimationsOf(context); final lifecycle = ref.watch(appLifecycleProvider); - final encodeKey = frames.value.isEmpty ? null : _EncodeKey( @@ -84,7 +86,6 @@ class AnimatedAvatarCapture extends HookConsumerWidget { shapeOffsetX: shapeOffset.value.dx, shapeOffsetY: shapeOffset.value.dy, ); - useEffect(() { encodedCache.value = null; final key = encodeKey; @@ -113,11 +114,11 @@ class AnimatedAvatarCapture extends HookConsumerWidget { if (lifecycle != AppLifecycleState.resumed) { isInitializing.value = false; controller.value = null; - frames.value = const []; - onPrepareChanged(null); return null; } + isInitializing.value = true; + Future initialize() async { try { final cameras = await availableCameras(); @@ -151,7 +152,6 @@ class AnimatedAvatarCapture extends HookConsumerWidget { unawaited(initialize()); return () { disposed = true; - onPrepareChanged(null); final active = controllerRef.value; controllerRef.value = null; unawaited(active?.dispose() ?? Future.value()); diff --git a/mobile/lib/features/profile/profile_avatar_draft.dart b/mobile/lib/features/profile/profile_avatar_draft.dart index dd2e84676f0..55645f147a2 100644 --- a/mobile/lib/features/profile/profile_avatar_draft.dart +++ b/mobile/lib/features/profile/profile_avatar_draft.dart @@ -22,10 +22,15 @@ final class ProfileImageAvatarDraft extends ProfileAvatarDraft { ProfileImageAvatarDraft(this.bytes); final Uint8List bytes; + MediaUploadService? _uploadService; Future? _uploadedUrl; @override Future upload(MediaUploadService service) async { + if (!identical(_uploadService, service)) { + _uploadService = service; + _uploadedUrl = null; + } final existing = _uploadedUrl; if (existing != null) return existing; final upload = service @@ -46,6 +51,7 @@ final class ProfileAnimatedAvatarDraft extends ProfileAvatarDraft { final Uint8List animation; final Uint8List poster; + MediaUploadService? _uploadService; Future? _uploadedUrl; Future? _posterUpload; Future? _animationUpload; @@ -82,6 +88,12 @@ final class ProfileAnimatedAvatarDraft extends ProfileAvatarDraft { @override Future upload(MediaUploadService service) async { + if (!identical(_uploadService, service)) { + _uploadService = service; + _uploadedUrl = null; + _posterUpload = null; + _animationUpload = null; + } final existing = _uploadedUrl; if (existing != null) return existing; // Cache each content-addressed part independently. If one request fails, diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 2405ca13f4e..85d86b51706 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -156,6 +156,20 @@ class ProfileEditPage extends HookConsumerWidget { Future saveAvatar() async { if (isSavingAvatar.value) return; + final saveConfig = ref.read(relayConfigProvider); + final uploadService = ref.read(mediaUploadServiceProvider); + + void requireCurrentCommunity() { + final currentConfig = ref.read(relayConfigProvider); + if (currentConfig.storedOrigin != saveConfig.storedOrigin || + currentConfig.nsec != saveConfig.nsec || + !identical(ref.read(mediaUploadServiceProvider), uploadService)) { + throw StateError( + 'Profile photo save cancelled because the active community changed.', + ); + } + } + isSavingAvatar.value = true; avatarSaveError.value = null; try { @@ -165,16 +179,18 @@ class ProfileEditPage extends HookConsumerWidget { if (avatarMode.value == ProfileAvatarMode.animated && nextDraft == null) { nextDraft = await prepareAnimatedAvatar.value?.call(); + requireCurrentCommunity(); if (nextDraft != null) { avatarDraft.value = nextDraft; avatarDraftMode.value = ProfileAvatarMode.animated; } } if (nextDraft == null) return; - final nextAvatar = await nextDraft.upload( - ref.read(mediaUploadServiceProvider), - ); + requireCurrentCommunity(); + final nextAvatar = await nextDraft.upload(uploadService); + requireCurrentCommunity(); await ref.read(profileProvider.notifier).updateAvatarUrl(nextAvatar); + requireCurrentCommunity(); if (context.mounted) await closeAvatarEditor(whileSaving: true); } catch (_) { avatarSaveError.value = diff --git a/mobile/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart index 26cf7cb2287..e4b0d1f03b2 100644 --- a/mobile/test/features/profile/animated_avatar_capture_test.dart +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -1,7 +1,14 @@ import 'package:buzz/features/profile/animated_avatar_orientation.dart'; +import 'package:buzz/features/profile/animated_avatar_capture.dart'; +import 'package:buzz/features/profile/profile_avatar_draft.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; import 'package:camera/camera.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:image/image.dart' as image; void main() { group('animatedAvatarFrameRotationDegrees', () { @@ -45,4 +52,61 @@ void main() { } }); }); + + testWidgets('completed review frames survive lifecycle changes', ( + tester, + ) async { + final lifecycle = _TestLifecycleNotifier(); + Future Function()? prepare; + final frame = image.encodePng(image.Image(width: 2, height: 2)); + await tester.pumpWidget( + ProviderScope( + overrides: [appLifecycleProvider.overrideWith(() => lifecycle)], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: MediaQuery( + data: const MediaQueryData(disableAnimations: true), + child: ExcludeSemantics( + child: AnimatedAvatarCapture( + height: 600, + initialFrames: [frame, frame], + onPrepareChanged: (value) => prepare = value, + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + expect( + find.byKey(const ValueKey('animated-avatar-review-preview')), + findsOneWidget, + ); + expect(prepare, isNotNull); + + lifecycle.setLifecycle(AppLifecycleState.paused); + await tester.pump(); + lifecycle.setLifecycle(AppLifecycleState.resumed); + await tester.pump(); + + expect( + find.byKey(const ValueKey('animated-avatar-review-preview')), + findsOneWidget, + ); + expect(prepare, isNotNull); + }); +} + +class _TestLifecycleNotifier extends AppLifecycleNotifier { + AppLifecycleState _lifecycle = AppLifecycleState.resumed; + + @override + AppLifecycleState build() => _lifecycle; + + void setLifecycle(AppLifecycleState value) { + _lifecycle = value; + state = value; + } } diff --git a/mobile/test/features/profile/profile_avatar_draft_test.dart b/mobile/test/features/profile/profile_avatar_draft_test.dart index fa63908df25..6cf3f0b578d 100644 --- a/mobile/test/features/profile/profile_avatar_draft_test.dart +++ b/mobile/test/features/profile/profile_avatar_draft_test.dart @@ -27,6 +27,56 @@ void main() { expect(await draft.upload(service), url); expect(service.uploadedParts, ['poster', 'animation', 'animation']); }); + + test('animated draft reuploads every part for a new community', () async { + final first = _RecordingUploadService('first'); + final second = _RecordingUploadService('second'); + addTearDown(first.dispose); + addTearDown(second.dispose); + final draft = ProfileAnimatedAvatarDraft( + poster: Uint8List.fromList([1]), + animation: Uint8List.fromList([2]), + ); + + final firstUrl = await draft.upload(first); + final secondUrl = await draft.upload(second); + + expect(first.uploadedParts, ['poster', 'animation']); + expect(second.uploadedParts, ['poster', 'animation']); + expect(firstUrl, contains('https://first.example/')); + expect(secondUrl, contains('https://second.example/')); + }); +} + +final class _RecordingUploadService extends MediaUploadService { + _RecordingUploadService(this.community) + : super( + baseUrl: 'https://$community.example', + nsec: null, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + ); + + final String community; + final uploadedParts = []; + + @override + Future uploadBytes( + Uint8List bytes, { + required String mimeType, + ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, + }) async { + final part = bytes.single == 1 ? 'poster' : 'animation'; + uploadedParts.add(part); + return BlobDescriptor( + url: 'https://$community.example/$part.png', + sha256: '$community-$part', + size: bytes.length, + type: mimeType, + uploaded: 1, + ); + } } final class _PartiallyFailingUploadService extends MediaUploadService { diff --git a/mobile/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart index 47fa6fa583c..6dea2a201e7 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:buzz/features/profile/profile_avatar_draft.dart'; import 'package:buzz/features/profile/profile_edit_page.dart'; import 'package:buzz/features/profile/profile_provider.dart'; @@ -103,6 +105,88 @@ void main() { expect(notifier.savedAvatarUrls, ['https://relay.example/avatar.jpg']); debugDefaultTargetPlatformOverride = null; }); + + testWidgets('avatar retry reuploads after a community switch', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final notifier = _RetryProfileNotifier(); + final config = _MutableRelayConfigNotifier(); + final firstUpload = _RetryMediaUploadService( + baseUrl: 'https://first.example', + delayUpload: true, + ); + final secondUpload = _RetryMediaUploadService( + baseUrl: 'https://second.example', + ); + addTearDown(firstUpload.dispose); + addTearDown(secondUpload.dispose); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + relayConfigProvider.overrideWith(() => config), + mediaUploadServiceProvider.overrideWith((ref) { + final current = ref.watch(relayConfigProvider); + return current.baseUrl == 'https://first.example' + ? firstUpload + : secondUpload; + }), + ], + child: ProfileEditPage( + startInPhotoEditor: true, + animatedAvatarCaptureBuilder: + ({required height, required onPrepareChanged}) => HookBuilder( + builder: (context) { + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + onPrepareChanged( + () async => ProfileImageAvatarDraft( + Uint8List.fromList([1, 2, 3]), + ), + ); + }); + return null; + }, const []); + return SizedBox(height: height); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Animated')); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pump(); + expect(firstUpload.uploadCount, 1); + config.update(baseUrl: 'https://second.example', nsec: 'second-identity'); + firstUpload.completeUpload(); + await tester.pumpAndSettle(); + + expect(notifier.savedAvatarUrls, isEmpty); + expect( + find.text("We couldn't save your profile photo. Try again."), + findsOneWidget, + ); + + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + expect(secondUpload.uploadCount, 1); + expect(notifier.savedAvatarUrls, ['https://second.example/avatar.jpg']); + debugDefaultTargetPlatformOverride = null; + }); +} + +class _MutableRelayConfigNotifier extends RelayConfigNotifier { + @override + RelayConfig build() => const RelayConfig( + baseUrl: 'https://first.example', + nsec: 'first-identity', + ); } class _RetryProfileNotifier extends ProfileNotifier { @@ -140,16 +224,25 @@ class _RetryProfileNotifier extends ProfileNotifier { } class _RetryMediaUploadService extends MediaUploadService { - _RetryMediaUploadService() - : super( - baseUrl: 'https://relay.example', - nsec: null, - pickGalleryImage: () async => null, - pickGalleryVideo: () async => null, - ); + _RetryMediaUploadService({ + this.baseUrl = 'https://relay.example', + this.delayUpload = false, + }) : super( + baseUrl: baseUrl, + nsec: null, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + ); + final String baseUrl; + final bool delayUpload; + final _pendingUpload = Completer(); int uploadCount = 0; + void completeUpload() { + if (!_pendingUpload.isCompleted) _pendingUpload.complete(); + } + @override Future uploadBytes( Uint8List bytes, { @@ -158,8 +251,9 @@ class _RetryMediaUploadService extends MediaUploadService { UploadCancellationToken? cancellationToken, }) async { uploadCount++; + if (delayUpload) await _pendingUpload.future; return BlobDescriptor( - url: 'https://relay.example/avatar.jpg', + url: '$baseUrl/avatar.jpg', sha256: 'avatar-hash', size: bytes.length, type: mimeType, From c05d8b6f302f8eb65a6f1ecae67ce358748c1c45 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 10:54:43 +0100 Subject: [PATCH 10/34] fix(mobile): keep profile edits in their origin context Signed-off-by: kenny lopez --- .../profile/ios_profile_text_editor.dart | 4 +- .../profile/profile_avatar_crop_page.dart | 15 ++++-- .../features/profile/profile_edit_page.dart | 2 + .../features/profile/profile_provider.dart | 10 ++-- .../features/profile/profile_text_editor.dart | 2 + .../profile/profile_edit_page_test.dart | 23 +++++++-- .../profile/profile_edit_retry_test.dart | 50 +++++++++++++++++++ 7 files changed, 93 insertions(+), 13 deletions(-) diff --git a/mobile/lib/features/profile/ios_profile_text_editor.dart b/mobile/lib/features/profile/ios_profile_text_editor.dart index a715f8ff913..172c1293bc4 100644 --- a/mobile/lib/features/profile/ios_profile_text_editor.dart +++ b/mobile/lib/features/profile/ios_profile_text_editor.dart @@ -27,6 +27,7 @@ class IosProfileTextEditor { required bool multiline, required Future Function(String value) onSave, required void Function() onSaveError, + bool Function(Object error)? shouldRetryOnError, }) async { var draft = initialValue; while (true) { @@ -40,7 +41,8 @@ class IosProfileTextEditor { try { await onSave(value); return; - } catch (_) { + } catch (error) { + if (shouldRetryOnError?.call(error) == false) return; draft = value; onSaveError(); } diff --git a/mobile/lib/features/profile/profile_avatar_crop_page.dart b/mobile/lib/features/profile/profile_avatar_crop_page.dart index 9b02547d4ac..2ef76fb0f48 100644 --- a/mobile/lib/features/profile/profile_avatar_crop_page.dart +++ b/mobile/lib/features/profile/profile_avatar_crop_page.dart @@ -22,12 +22,17 @@ class ProfileAvatarCropPage extends HookWidget { final controller = useTransformationController(); final preparedBytes = useState(null); final loadError = useState(false); - final dimensions = useMemoized( + final dimensionsFuture = useMemoized( () => preparedBytes.value == null ? null - : _decodeDimensions(preparedBytes.value!), + : compute(_decodeDimensions, preparedBytes.value!), [preparedBytes.value], ); + final dimensionsSnapshot = useFuture(dimensionsFuture); + final dimensionsData = dimensionsSnapshot.data; + final dimensions = dimensionsData == null + ? null + : _ImageDimensions(dimensionsData[0], dimensionsData[1]); final initializedForSize = useRef(null); final cropGeometry = useRef<_CropGeometry?>(null); final displaySize = useRef(null); @@ -151,7 +156,7 @@ class ProfileAvatarCropPage extends HookWidget { ), ], ), - body: loadError.value + body: loadError.value || dimensionsSnapshot.hasError ? const Center( child: Text( "We couldn't prepare that photo.", @@ -345,10 +350,10 @@ class _ImageDimensions { final int height; } -_ImageDimensions _decodeDimensions(Uint8List bytes) { +List _decodeDimensions(Uint8List bytes) { final decoded = image.decodeImage(bytes); if (decoded == null) throw const FormatException('Unsupported image'); - return _ImageDimensions(decoded.width, decoded.height); + return [decoded.width, decoded.height]; } class _CropRequest { diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 85d86b51706..afe807dc7a7 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -106,6 +106,8 @@ class ProfileEditPage extends HookConsumerWidget { placeholder: hintText, multiline: multiline, onSave: onSave, + shouldRetryOnError: (error) => + error is! ProfileCommunityChangedException, onSaveError: () { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 9a7d121010d..9bb3091abd4 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -9,6 +9,12 @@ import '../../shared/profile/user_profile.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; +/// Signals that a profile write no longer belongs to the active community. +class ProfileCommunityChangedException extends StateError { + ProfileCommunityChangedException() + : super('Profile update cancelled because the active community changed.'); +} + /// The current user's profile (kind:0 metadata) loaded over the relay /// WebSocket. Returns null when no nsec is configured or when the user has /// not yet published a profile. @@ -172,9 +178,7 @@ class ProfileNotifier extends AsyncNotifier { currentConfig.nsec != context.config.nsec || currentPubkey != context.pubkey || !identical(currentSession, context.session)) { - throw StateError( - 'Profile update cancelled because the active community changed.', - ); + throw ProfileCommunityChangedException(); } } } diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart index 9ef95e12030..d529af65e5f 100644 --- a/mobile/lib/features/profile/profile_text_editor.dart +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -54,6 +54,8 @@ Future _showProfileTextEditor({ placeholder: hintText, multiline: multiline, onSave: onSave, + shouldRetryOnError: (error) => + error is! ProfileCommunityChangedException, onSaveError: () { if (context.mounted) _showSaveError(context); }, diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index 86ce267ab9d..70cb1400dcb 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -313,7 +313,7 @@ void main() { ); expect(tester.widget(preview).radius, 110); await tester.tap(find.text('Photo Library')); - await tester.pumpAndSettle(); + await _waitForAvatarCropToLoad(tester); expect(find.text('Position Photo'), findsOneWidget); final cancelButton = find.ancestor( of: find.text('Cancel'), @@ -376,7 +376,7 @@ void main() { await tester.tap(find.text('Edit Photo')); await tester.pumpAndSettle(); await tester.tap(find.text('Photo Library')); - await tester.pumpAndSettle(); + await _waitForAvatarCropToLoad(tester); await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo'))); await _waitForAvatarCropToClose(tester); @@ -620,7 +620,7 @@ void main() { ); uploadService.completeGallerySelection(); - await tester.pumpAndSettle(); + await _waitForAvatarCropToLoad(tester); expect(find.text('Position Photo'), findsOneWidget); }); @@ -644,7 +644,7 @@ void main() { await tester.tap(find.text('Edit Photo')); await tester.pumpAndSettle(); await tester.tap(find.text('Camera')); - await tester.pumpAndSettle(); + await _waitForAvatarCropToLoad(tester); expect(find.text('Position Photo'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo'))); await tester.runAsync( @@ -1005,6 +1005,21 @@ Future _waitForAvatarCropToClose(WidgetTester tester) async { fail('Avatar crop did not complete within 5 seconds.'); } +Future _waitForAvatarCropToLoad(WidgetTester tester) async { + final cropViewer = find.byKey(const ValueKey('avatar-crop-viewer')); + for (var attempt = 0; attempt < 100; attempt += 1) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 25)), + ); + await tester.pump(const Duration(milliseconds: 25)); + if (cropViewer.evaluate().isNotEmpty) { + await tester.pumpAndSettle(); + return; + } + } + fail('Avatar crop did not load within 5 seconds.'); +} + class _FakeProfileNotifier extends ProfileNotifier { _FakeProfileNotifier({ this.profile = const UserProfile( diff --git a/mobile/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart index 6dea2a201e7..875d1ec41f6 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -49,6 +49,39 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + testWidgets('native text editor stops retrying after a community switch', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/profile_text_editor'); + var presentations = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + presentations++; + return 'Old community draft'; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + final notifier = _CommunityChangedProfileNotifier(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('profile-display-name-row'))); + await tester.pumpAndSettle(); + + expect(presentations, 1); + expect(notifier.displayNameAttempts, ['Old community draft']); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('animated save retry reuses its prepared draft', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; addTearDown(() => debugDefaultTargetPlatformOverride = null); @@ -223,6 +256,23 @@ class _RetryProfileNotifier extends ProfileNotifier { } } +class _CommunityChangedProfileNotifier extends ProfileNotifier { + final displayNameAttempts = []; + + @override + Future build() async => const UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + about: 'Building Buzz', + ); + + @override + Future updateDisplayName(String displayName) async { + displayNameAttempts.add(displayName); + throw ProfileCommunityChangedException(); + } +} + class _RetryMediaUploadService extends MediaUploadService { _RetryMediaUploadService({ this.baseUrl = 'https://relay.example', From a7ec5d77b000522062cfb7ecbb6bc893380f244f Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 11:08:17 +0100 Subject: [PATCH 11/34] fix(mobile): close stale profile text drafts Signed-off-by: kenny lopez --- .../profile/profile_avatar_crop_page.dart | 7 +++-- .../features/profile/profile_edit_page.dart | 2 ++ .../features/profile/profile_text_editor.dart | 2 ++ .../profile/profile_edit_page_test.dart | 26 +++++++++++++++++ .../profile/profile_edit_retry_test.dart | 29 +++++++++++++++++++ 5 files changed, 64 insertions(+), 2 deletions(-) diff --git a/mobile/lib/features/profile/profile_avatar_crop_page.dart b/mobile/lib/features/profile/profile_avatar_crop_page.dart index 2ef76fb0f48..06f4c392612 100644 --- a/mobile/lib/features/profile/profile_avatar_crop_page.dart +++ b/mobile/lib/features/profile/profile_avatar_crop_page.dart @@ -124,7 +124,10 @@ class ProfileAvatarCropPage extends HookWidget { key: const ValueKey('avatar-crop-use-photo'), label: 'Save', loading: isCropping.value, - onPressed: isCropping.value || preparedBytes.value == null + onPressed: + isCropping.value || + preparedBytes.value == null || + dimensions == null ? null : () async { isCropping.value = true; @@ -137,7 +140,7 @@ class ProfileAvatarCropPage extends HookWidget { _cropAvatar, _CropRequest( bytes: preparedBytes.value!, - sourceWidth: dimensions!.width, + sourceWidth: dimensions.width, sourceHeight: dimensions.height, canvasWidth: geometry.canvasWidth, canvasHeight: geometry.canvasHeight, diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index afe807dc7a7..14cd94af7ba 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -544,6 +544,8 @@ class _ProfileTextEditSheet extends HookWidget { try { await onSave(controller.text); if (context.mounted) Navigator.of(context).pop(); + } on ProfileCommunityChangedException { + if (context.mounted) Navigator.of(context).pop(); } catch (_) { error.value = "We couldn't save this change. Try again."; } finally { diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart index d529af65e5f..9a43d423cdc 100644 --- a/mobile/lib/features/profile/profile_text_editor.dart +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -117,6 +117,8 @@ class _ProfileTextEditSheet extends HookWidget { try { await onSave(controller.text); if (context.mounted) Navigator.of(context).pop(); + } on ProfileCommunityChangedException { + if (context.mounted) Navigator.of(context).pop(); } catch (_) { error.value = "We couldn't save this change. Try again."; } finally { diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index 70cb1400dcb..b968f9c0018 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math' as math; import 'package:buzz/features/profile/profile_edit_page.dart'; +import 'package:buzz/features/profile/profile_avatar_crop_page.dart'; import 'package:buzz/features/profile/avatar_background_grid.dart'; import 'package:buzz/features/profile/avatar_editor_option_button.dart'; import 'package:buzz/shared/widgets/immediate_page_route.dart'; @@ -29,6 +30,31 @@ import '../../helpers/widget_helpers.dart'; const _editorControlBottomForTest = Grid.xl + Grid.xxs; void main() { + testWidgets('keeps crop Save disabled while dimensions decode', ( + tester, + ) async { + final bytes = Uint8List.fromList( + image.encodePng(image.Image(width: 20, height: 10)), + ); + await tester.pumpWidget( + MaterialApp( + home: ProfileAvatarCropPage( + imageBytes: Future.value(bytes), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + final saveButton = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('avatar-crop-use-photo')), + matching: find.byType(TextButton), + ), + ); + expect(saveButton.onPressed, isNull); + }); + testWidgets('can open directly into the photo editor from Settings', ( tester, ) async { diff --git a/mobile/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart index 875d1ec41f6..f87e2435a13 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -82,6 +82,35 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + testWidgets('Flutter text editor closes after a community switch', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final notifier = _CommunityChangedProfileNotifier(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('profile-display-name-row'))); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('profile-field-input')), + 'Old community draft', + ); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('profile-field-save'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('profile-field-input')), findsNothing); + expect(notifier.displayNameAttempts, ['Old community draft']); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('animated save retry reuses its prepared draft', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; addTearDown(() => debugDefaultTargetPlatformOverride = null); From 95b2c68cdd77150ae192f904ca841d359e849089 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 11:21:17 +0100 Subject: [PATCH 12/34] fix(mobile): cancel orphaned profile editors Signed-off-by: kenny lopez --- mobile/ios/Runner/Info.plist | 4 +- .../profile/ios_profile_text_editor.dart | 7 ++- .../features/profile/profile_edit_page.dart | 1 + .../features/profile/profile_text_editor.dart | 1 + .../profile/profile_edit_retry_test.dart | 57 +++++++++++++++++++ 5 files changed, 67 insertions(+), 3 deletions(-) diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index af42ebd34b6..3f93df5b97e 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -46,11 +46,11 @@ NSFaceIDUsageDescription Buzz uses Face ID to confirm sensitive identity transfers. NSCameraUsageDescription - Buzz needs camera access so you can take photos to attach to messages and scan QR codes for device pairing. + Buzz uses the camera to take profile photos and animated avatars, attach photos to messages, and scan QR codes for device pairing. NSMicrophoneUsageDescription Buzz needs microphone access so you can speak in Huddles. NSPhotoLibraryUsageDescription - Buzz needs photo library access so you can attach images to messages. + Buzz uses your photo library to select profile photos and images to attach to messages. NSPhotoLibraryAddUsageDescription Buzz needs permission to save images to your photo library. PHPhotoLibraryPreventAutomaticLimitedAccessAlert diff --git a/mobile/lib/features/profile/ios_profile_text_editor.dart b/mobile/lib/features/profile/ios_profile_text_editor.dart index 172c1293bc4..d2939ae2a89 100644 --- a/mobile/lib/features/profile/ios_profile_text_editor.dart +++ b/mobile/lib/features/profile/ios_profile_text_editor.dart @@ -28,9 +28,11 @@ class IosProfileTextEditor { required Future Function(String value) onSave, required void Function() onSaveError, bool Function(Object error)? shouldRetryOnError, + bool Function()? canPresent, }) async { var draft = initialValue; while (true) { + if (canPresent?.call() == false) return; final value = await present( title: title, initialValue: draft, @@ -42,7 +44,10 @@ class IosProfileTextEditor { await onSave(value); return; } catch (error) { - if (shouldRetryOnError?.call(error) == false) return; + if (shouldRetryOnError?.call(error) == false || + canPresent?.call() == false) { + return; + } draft = value; onSaveError(); } diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 14cd94af7ba..3ef9baaaa7a 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -108,6 +108,7 @@ class ProfileEditPage extends HookConsumerWidget { onSave: onSave, shouldRetryOnError: (error) => error is! ProfileCommunityChangedException, + canPresent: () => context.mounted, onSaveError: () { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart index 9a43d423cdc..5fd8dc022a9 100644 --- a/mobile/lib/features/profile/profile_text_editor.dart +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -56,6 +56,7 @@ Future _showProfileTextEditor({ onSave: onSave, shouldRetryOnError: (error) => error is! ProfileCommunityChangedException, + canPresent: () => context.mounted, onSaveError: () { if (context.mounted) _showSaveError(context); }, diff --git a/mobile/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart index f87e2435a13..380b0f4aeab 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -82,6 +82,43 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + testWidgets('native text retry stops when its owner unmounts', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/profile_text_editor'); + var presentations = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + presentations++; + return 'Pending draft'; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + final notifier = _DeferredFailureProfileNotifier(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('profile-display-name-row'))); + await tester.pump(); + expect(notifier.displayNameAttempts, ['Pending draft']); + + await tester.pumpWidget(const SizedBox()); + notifier.failSave(); + await tester.pump(); + + expect(presentations, 1); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('Flutter text editor closes after a community switch', ( tester, ) async { @@ -302,6 +339,26 @@ class _CommunityChangedProfileNotifier extends ProfileNotifier { } } +class _DeferredFailureProfileNotifier extends ProfileNotifier { + final displayNameAttempts = []; + final _save = Completer(); + + @override + Future build() async => const UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + about: 'Building Buzz', + ); + + @override + Future updateDisplayName(String displayName) { + displayNameAttempts.add(displayName); + return _save.future; + } + + void failSave() => _save.completeError(Exception('profile publish failed')); +} + class _RetryMediaUploadService extends MediaUploadService { _RetryMediaUploadService({ this.baseUrl = 'https://relay.example', From 37d9161a8931b8f9585a56e2356499ac609f189e Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 11:34:29 +0100 Subject: [PATCH 13/34] fix(mobile): finalize profile editor state Signed-off-by: kenny lopez --- .../profile/profile_avatar_editor.dart | 7 ++ .../features/profile/profile_provider.dart | 3 +- .../profile/profile_edit_page_test.dart | 27 ++++++++ .../profile/profile_provider_test.dart | 66 +++++++++++++++++++ 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index f4c80d1c40f..ed62b833c48 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -113,6 +113,13 @@ class ProfileAvatarEditor extends HookConsumerWidget { onAnimatedPrepareChanged(null); if (!reduceMotion) modeTransitionController.value = 0; onModeChanged(nextMode); + if (nextMode == ProfileAvatarMode.emoji) { + onDraftChanged( + ProfileUrlAvatarDraft( + emojiAvatarDataUrl(selectedEmoji.value, selectedColor.value), + ), + ); + } if (reduceMotion) { modeTransitionController.value = 1; } else { diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 9bb3091abd4..a1cb55ecaf8 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -105,6 +105,7 @@ class ProfileNotifier extends AsyncNotifier { Map patch, _ProfileWriteContext context, ) async { + _requireCurrentWriteContext(context); if (!_hasHydrated || !state.hasValue) { throw StateError('Cannot update profile before metadata is loaded.'); } @@ -112,8 +113,6 @@ class ProfileNotifier extends AsyncNotifier { if (pubkey == null) { throw StateError('Cannot update profile without a signing identity.'); } - _requireCurrentWriteContext(context); - final session = context.session; final currentEvents = await session.fetchHistory( NostrFilters.profile(pubkey), diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index b968f9c0018..52e06873ed4 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -778,6 +778,33 @@ void main() { ); }); + testWidgets('saves the displayed default emoji without another selection', ( + tester, + ) async { + final notifier = _FakeProfileNotifier(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 250)); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + + expect(notifier.savedAvatarUrls, hasLength(1)); + expect(notifier.savedAvatarUrls.single, startsWith('data:image/svg+xml,')); + expect( + Uri.decodeComponent(notifier.savedAvatarUrls.single), + contains('😊'), + ); + }); + testWidgets('keeps emoji drafts scoped to the emoji mode', (tester) async { final notifier = _FakeProfileNotifier(); await tester.pumpWidget( diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index fba40f85902..87084fb8653 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -315,6 +315,72 @@ void main() { expect(relaySession.published, isEmpty); }); + test( + 'queued profile updates report a community change before rehydration', + () async { + final keys = nostr.Keys.generate(); + final otherKeys = nostr.Keys.generate(); + final initial = NostrEvent( + id: 'profile-initial', + pubkey: keys.public, + createdAt: 10, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({'display_name': 'Initial'}), + sig: 'sig', + ); + final patchFetchStarted = Completer(); + final patchHistory = Completer>(); + final rehydration = Completer>(); + var fetchCount = 0; + final relaySession = _ControlledProfileRelaySession( + fetch: () async { + fetchCount += 1; + if (fetchCount == 1) return [initial]; + if (fetchCount == 2) { + patchFetchStarted.complete(); + return patchHistory.future; + } + return rehydration.future; + }, + ); + final config = _MutableRelayConfigNotifier(keys.nsec); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(() => config), + relaySessionProvider.overrideWith(() => relaySession), + ], + ); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + final firstUpdate = container + .read(profileProvider.notifier) + .updateDisplayName('First'); + await patchFetchStarted.future; + final queuedUpdate = container + .read(profileProvider.notifier) + .updateAbout('Queued'); + config.update( + baseUrl: 'https://other-relay.example', + nsec: otherKeys.nsec, + ); + await Future.delayed(Duration.zero); + patchHistory.complete([initial]); + + await expectLater( + firstUpdate, + throwsA(isA()), + ); + await expectLater( + queuedUpdate, + throwsA(isA()), + ); + expect(relaySession.published, isEmpty); + rehydration.complete(const []); + }, + ); + test( 'manual presence persists until Online restores automatic mode', () async { From eb3989adc7a53310f34c354ee41fab4d28768fd0 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 11:45:47 +0100 Subject: [PATCH 14/34] fix(mobile): isolate profile hydration context Signed-off-by: kenny lopez --- .../profile/profile_avatar_draft.dart | 19 ++++++ .../features/profile/profile_provider.dart | 39 ++++++++---- .../profile/profile_provider_test.dart | 63 +++++++++++++++++++ 3 files changed, 108 insertions(+), 13 deletions(-) diff --git a/mobile/lib/features/profile/profile_avatar_draft.dart b/mobile/lib/features/profile/profile_avatar_draft.dart index 55645f147a2..8fc48efaf3a 100644 --- a/mobile/lib/features/profile/profile_avatar_draft.dart +++ b/mobile/lib/features/profile/profile_avatar_draft.dart @@ -3,24 +3,38 @@ import 'dart:typed_data'; import '../../shared/animated_avatar.dart'; import '../../shared/relay/relay.dart'; +/// A prepared profile-avatar change that is uploaded only when the user saves. sealed class ProfileAvatarDraft { + /// Creates a prepared profile-avatar draft. const ProfileAvatarDraft(); + /// Returns the avatar URL for this draft using [service] when upload is + /// required. + /// + /// Implementations cache successful uploads for the same service so a + /// profile-publish retry does not create duplicate media. Failed uploads may + /// be retried, and changing services starts a new upload for that community. Future upload(MediaUploadService service); } +/// An avatar draft that already has its final URL and needs no media upload. final class ProfileUrlAvatarDraft extends ProfileAvatarDraft { + /// Creates a draft backed by [url]. const ProfileUrlAvatarDraft(this.url); + /// The URL that will be written to the profile. final String url; @override Future upload(MediaUploadService service) async => url; } +/// A locally prepared still image awaiting upload on Save. final class ProfileImageAvatarDraft extends ProfileAvatarDraft { + /// Creates a still-image draft from JPEG [bytes]. ProfileImageAvatarDraft(this.bytes); + /// The prepared JPEG payload. final Uint8List bytes; MediaUploadService? _uploadService; Future? _uploadedUrl; @@ -46,10 +60,15 @@ final class ProfileImageAvatarDraft extends ProfileAvatarDraft { } } +/// A locally prepared animated avatar and its still poster awaiting upload. final class ProfileAnimatedAvatarDraft extends ProfileAvatarDraft { + /// Creates an animated draft from PNG [animation] and [poster] payloads. ProfileAnimatedAvatarDraft({required this.animation, required this.poster}); + /// The animated PNG payload. final Uint8List animation; + + /// The still PNG poster shown when animation is unavailable or disabled. final Uint8List poster; MediaUploadService? _uploadService; Future? _uploadedUrl; diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index a1cb55ecaf8..18ca41d9ad1 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -26,32 +26,39 @@ class ProfileNotifier extends AsyncNotifier { @override Future build() { - ref.watch(relayConfigProvider); + final config = ref.watch(relayConfigProvider); + final pubkey = ref.watch(myPubkeyProvider); ref.watch(relaySessionProvider); + final context = _ProfileWriteContext( + config: config, + pubkey: pubkey, + session: ref.read(relaySessionProvider.notifier), + ); _hasHydrated = false; - return _fetch(); + return _fetch(context); } - Future _fetch() async { - final myPk = ref.read(myPubkeyProvider); + Future _fetch(_ProfileWriteContext context) async { + final myPk = context.pubkey; if (myPk == null) { + _requireCurrentWriteContext(context); _metadata = {}; _lastCreatedAt = 0; _hasHydrated = true; return null; } - final session = ref.read(relaySessionProvider.notifier); + final session = context.session; final events = await session.fetchHistory(NostrFilters.profile(myPk)); if (events.isEmpty) { + _requireCurrentWriteContext(context); _metadata = {}; _lastCreatedAt = 0; _hasHydrated = true; return null; } final latest = _latestProfileEvent(events)!; - _metadata = _decodeProfileMetadata(latest); - _lastCreatedAt = latest.createdAt; + final metadata = _decodeProfileMetadata(latest); final data = ProfileData.fromEvent(latest); final profile = UserProfile( pubkey: data.pubkey, @@ -60,13 +67,17 @@ class ProfileNotifier extends AsyncNotifier { about: data.about, nip05Handle: data.nip05, ); + _requireCurrentWriteContext(context); + _metadata = metadata; + _lastCreatedAt = latest.createdAt; _hasHydrated = true; return profile; } Future refresh() async { + final context = _currentWriteContext(); _hasHydrated = false; - state = await AsyncValue.guard(_fetch); + state = await AsyncValue.guard(() => _fetch(context)); } /// Updates the current user's display name while preserving the other @@ -83,11 +94,7 @@ class ProfileNotifier extends AsyncNotifier { _publishProfilePatch({'picture': avatarUrl.trim()}); Future _publishProfilePatch(Map patch) { - final context = _ProfileWriteContext( - config: ref.read(relayConfigProvider), - pubkey: ref.read(myPubkeyProvider), - session: ref.read(relaySessionProvider.notifier), - ); + final context = _currentWriteContext(); final previous = _patchQueue; final released = Completer(); _patchQueue = released.future; @@ -101,6 +108,12 @@ class ProfileNotifier extends AsyncNotifier { }(); } + _ProfileWriteContext _currentWriteContext() => _ProfileWriteContext( + config: ref.read(relayConfigProvider), + pubkey: ref.read(myPubkeyProvider), + session: ref.read(relaySessionProvider.notifier), + ); + Future _publishProfilePatchNow( Map patch, _ProfileWriteContext context, diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index 87084fb8653..939a94f7cf4 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -381,6 +381,69 @@ void main() { }, ); + test('stale hydration cannot overwrite the active community head', () async { + final keys = nostr.Keys.generate(); + final otherKeys = nostr.Keys.generate(); + final oldFetchStarted = Completer(); + final oldHistory = Completer>(); + final activeProfile = NostrEvent( + id: 'profile-active', + pubkey: otherKeys.public, + createdAt: 20, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({'display_name': 'Active'}), + sig: 'sig', + ); + var fetchCount = 0; + final relaySession = _ControlledProfileRelaySession( + fetch: () async { + fetchCount += 1; + if (fetchCount == 1) { + oldFetchStarted.complete(); + return oldHistory.future; + } + return [activeProfile]; + }, + ); + final config = _MutableRelayConfigNotifier(keys.nsec); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(() => config), + relaySessionProvider.overrideWith(() => relaySession), + ], + ); + addTearDown(container.dispose); + + container.read(profileProvider); + await oldFetchStarted.future; + config.update(baseUrl: 'https://other-relay.example', nsec: otherKeys.nsec); + expect( + (await container.read(profileProvider.future))?.displayName, + 'Active', + ); + oldHistory.complete([ + NostrEvent( + id: 'profile-stale', + pubkey: keys.public, + createdAt: 100, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({'display_name': 'Stale'}), + sig: 'sig', + ), + ]); + await Future.delayed(Duration.zero); + + await container.read(profileProvider.notifier).updateAbout('Active about'); + + expect(relaySession.published, hasLength(1)); + expect(jsonDecode(relaySession.published.single.content), { + 'display_name': 'Active', + 'about': 'Active about', + }); + }); + test( 'manual presence persists until Online restores automatic mode', () async { From 1c2856b7d4687c87d4509e82e8782a7cfbf2c7a6 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 11:58:32 +0100 Subject: [PATCH 15/34] fix(mobile): keep native profile retries actionable Signed-off-by: kenny lopez --- mobile/ios/Runner/NativeProfileTextEditor.swift | 11 ++++++++++- .../lib/features/profile/ios_profile_text_editor.dart | 7 +++++++ mobile/lib/shared/relay/media_upload.dart | 2 ++ .../test/features/profile/profile_edit_page_test.dart | 1 + .../features/profile/profile_edit_retry_test.dart | 10 ++++++++++ 5 files changed, 30 insertions(+), 1 deletion(-) diff --git a/mobile/ios/Runner/NativeProfileTextEditor.swift b/mobile/ios/Runner/NativeProfileTextEditor.swift index 9aec1d49972..31b94613051 100644 --- a/mobile/ios/Runner/NativeProfileTextEditor.swift +++ b/mobile/ios/Runner/NativeProfileTextEditor.swift @@ -48,6 +48,8 @@ final class NativeProfileTextEditorCoordinator: NSObject, ) return } + let allowUnchangedSubmission = + arguments["allowUnchangedSubmission"] as? Bool ?? false DispatchQueue.main.async { [weak self] in self?.present( @@ -55,6 +57,7 @@ final class NativeProfileTextEditorCoordinator: NSObject, initialValue: initialValue, placeholder: placeholder, multiline: multiline, + allowUnchangedSubmission: allowUnchangedSubmission, result: result ) } @@ -66,6 +69,7 @@ final class NativeProfileTextEditorCoordinator: NSObject, initialValue: String, placeholder: String, multiline: Bool, + allowUnchangedSubmission: Bool, result: @escaping FlutterResult ) { guard presentedController == nil else { @@ -98,6 +102,7 @@ final class NativeProfileTextEditorCoordinator: NSObject, initialValue: initialValue, placeholder: placeholder, multiline: multiline, + allowUnchangedSubmission: allowUnchangedSubmission, onCancel: { [weak self] in self?.finish(value: nil) }, onSet: { [weak self] value in self?.finish(value: value) } ) @@ -173,6 +178,7 @@ private final class NativeProfileTextEditorViewController: private let initialValue: String private let placeholder: String private let multiline: Bool + private let allowUnchangedSubmission: Bool private let onCancel: () -> Void private let onSet: (String) -> Void @@ -221,12 +227,14 @@ private final class NativeProfileTextEditorViewController: initialValue: String, placeholder: String, multiline: Bool, + allowUnchangedSubmission: Bool, onCancel: @escaping () -> Void, onSet: @escaping (String) -> Void ) { self.initialValue = initialValue self.placeholder = placeholder self.multiline = multiline + self.allowUnchangedSubmission = allowUnchangedSubmission self.onCancel = onCancel self.onSet = onSet super.init(style: .insetGrouped) @@ -270,7 +278,8 @@ private final class NativeProfileTextEditorViewController: } private var hasUnsavedChanges: Bool { - currentValue.trimmingCharacters(in: .whitespacesAndNewlines) + allowUnchangedSubmission + || currentValue.trimmingCharacters(in: .whitespacesAndNewlines) != initialValue.trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/mobile/lib/features/profile/ios_profile_text_editor.dart b/mobile/lib/features/profile/ios_profile_text_editor.dart index d2939ae2a89..d957b236aa9 100644 --- a/mobile/lib/features/profile/ios_profile_text_editor.dart +++ b/mobile/lib/features/profile/ios_profile_text_editor.dart @@ -6,16 +6,20 @@ class IosProfileTextEditor { static const _channel = MethodChannel('buzz/profile_text_editor'); + /// Presents the native editor and returns its submitted value, or null when + /// the user cancels. static Future present({ required String title, required String initialValue, required String placeholder, required bool multiline, + bool allowUnchangedSubmission = false, }) => _channel.invokeMethod('present', { 'title': title, 'initialValue': initialValue, 'placeholder': placeholder, 'multiline': multiline, + 'allowUnchangedSubmission': allowUnchangedSubmission, }); /// Keeps the native editor's latest value available until it saves or the @@ -31,6 +35,7 @@ class IosProfileTextEditor { bool Function()? canPresent, }) async { var draft = initialValue; + var isRetry = false; while (true) { if (canPresent?.call() == false) return; final value = await present( @@ -38,6 +43,7 @@ class IosProfileTextEditor { initialValue: draft, placeholder: placeholder, multiline: multiline, + allowUnchangedSubmission: isRetry, ); if (value == null) return; try { @@ -49,6 +55,7 @@ class IosProfileTextEditor { return; } draft = value; + isRetry = true; onSaveError(); } } diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 160aa1c2c56..cbe59bcd33a 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -58,6 +58,8 @@ const _maxFileSizeBytes = 100 * 1024 * 1024; // 100MB const _mediaPolicyUploadMessage = "We couldn't prepare this image for upload."; typedef PickGalleryImage = Future Function(); + +/// Captures one image with the system camera, or returns null when cancelled. typedef PickCameraImage = Future Function(); /// Selects multiple gallery images for upload in picker order. diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index 52e06873ed4..a385c785acd 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -220,6 +220,7 @@ void main() { 'initialValue': 'Alice', 'placeholder': 'Display name', 'multiline': false, + 'allowUnchangedSubmission': false, }); expect(notifier.savedDisplayNames, ['Alice Native']); debugDefaultTargetPlatformOverride = null; diff --git a/mobile/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart index 380b0f4aeab..2a8384d3829 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -46,6 +46,16 @@ void main() { (calls.last.arguments as Map)['initialValue'], 'Alice Retained', ); + expect( + (calls.first.arguments + as Map)['allowUnchangedSubmission'], + isFalse, + ); + expect( + (calls.last.arguments + as Map)['allowUnchangedSubmission'], + isTrue, + ); debugDefaultTargetPlatformOverride = null; }); From f3bea452cf78ce1b590cc28ffa3bcded7f447132 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 12:10:44 +0100 Subject: [PATCH 16/34] fix(mobile): isolate profile editor sessions Signed-off-by: kenny lopez --- .../profile/animated_avatar_capture.dart | 79 +--------------- .../frame_processing.dart | 90 +++++++++++++++++++ .../features/profile/profile_text_editor.dart | 35 +++++++- .../profile/animated_avatar_capture_test.dart | 17 ++++ .../profile/profile_edit_retry_test.dart | 53 +++++++++++ 5 files changed, 194 insertions(+), 80 deletions(-) create mode 100644 mobile/lib/features/profile/animated_avatar_capture/frame_processing.dart diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index cc386ed4081..97ec87dfeb7 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -24,6 +24,7 @@ import 'profile_avatar_draft.dart'; part 'animated_avatar_capture/review_controls.dart'; part 'animated_avatar_capture/capture_controls.dart'; +part 'animated_avatar_capture/frame_processing.dart'; const _captureDuration = Duration(seconds: 3); const _captureFrameInterval = Duration(milliseconds: 125); @@ -588,48 +589,6 @@ List _resampleCapturedFrames( }, growable: false); } -Future> _removeBackgrounds(List frames) async { - final segmenter = SelfieSegmenter( - mode: SegmenterMode.stream, - enableRawSizeMask: false, - ); - final directory = await getTemporaryDirectory(); - final results = []; - try { - for (var index = 0; index < frames.length; index++) { - final file = File('${directory.path}/buzz-avatar-frame-$index.png'); - try { - await file.writeAsBytes(frames[index], flush: false); - final mask = await segmenter.processImage( - InputImage.fromFilePath(file.path), - ); - if (mask == null) { - results.add(frames[index]); - continue; - } - results.add( - await compute( - _applySegmentationMask, - _MaskRequest( - frame: frames[index], - maskWidth: mask.width, - maskHeight: mask.height, - confidences: Float32List.fromList(mask.confidences), - ), - ), - ); - } finally { - if (await file.exists()) { - await file.delete().catchError((_) => file); - } - } - } - } finally { - await segmenter.close(); - } - return results; -} - class _ErrorText extends StatelessWidget { const _ErrorText(this.message); @@ -662,42 +621,6 @@ class _FramePlane { final int bytesPerPixel; } -@immutable -class _MaskRequest { - const _MaskRequest({ - required this.frame, - required this.maskWidth, - required this.maskHeight, - required this.confidences, - }); - - final Uint8List frame; - final int maskWidth; - final int maskHeight; - final Float32List confidences; -} - -Uint8List _applySegmentationMask(_MaskRequest request) { - final result = image.decodePng(request.frame)!.convert(numChannels: 4); - for (var y = 0; y < result.height; y++) { - final maskY = (y * request.maskHeight / result.height).floor().clamp( - 0, - request.maskHeight - 1, - ); - for (var x = 0; x < result.width; x++) { - final maskX = (x * request.maskWidth / result.width).floor().clamp( - 0, - request.maskWidth - 1, - ); - final confidence = request.confidences[maskY * request.maskWidth + maskX]; - final alpha = ((confidence - 0.28) / (0.72 - 0.28)).clamp(0, 1); - final pixel = result.getPixel(x, y); - pixel.a = (alpha * 255).round(); - } - } - return image.encodePng(result, level: 4); -} - @immutable class _FrameRequest { const _FrameRequest({ diff --git a/mobile/lib/features/profile/animated_avatar_capture/frame_processing.dart b/mobile/lib/features/profile/animated_avatar_capture/frame_processing.dart new file mode 100644 index 00000000000..b93175bf73a --- /dev/null +++ b/mobile/lib/features/profile/animated_avatar_capture/frame_processing.dart @@ -0,0 +1,90 @@ +part of '../animated_avatar_capture.dart'; + +/// Creates an isolated workspace for one capture's segmentation frames. +@visibleForTesting +Future createAnimatedAvatarFrameDirectory({ + Directory? parent, +}) async => (parent ?? await getTemporaryDirectory()).createTemp( + 'buzz-avatar-capture-', +); + +Future> _removeBackgrounds(List frames) async { + final segmenter = SelfieSegmenter( + mode: SegmenterMode.stream, + enableRawSizeMask: false, + ); + Directory? captureDirectory; + final results = []; + try { + captureDirectory = await createAnimatedAvatarFrameDirectory(); + for (var index = 0; index < frames.length; index++) { + final file = File('${captureDirectory.path}/frame-$index.png'); + await file.writeAsBytes(frames[index], flush: false); + final mask = await segmenter.processImage( + InputImage.fromFilePath(file.path), + ); + if (mask == null) { + results.add(frames[index]); + continue; + } + results.add( + await compute( + _applySegmentationMask, + _MaskRequest( + frame: frames[index], + maskWidth: mask.width, + maskHeight: mask.height, + confidences: Float32List.fromList(mask.confidences), + ), + ), + ); + } + } finally { + await segmenter.close(); + final directory = captureDirectory; + if (directory != null && await directory.exists()) { + try { + await directory.delete(recursive: true); + } on FileSystemException { + // Temporary capture cleanup is best effort. + } + } + } + return results; +} + +@immutable +class _MaskRequest { + const _MaskRequest({ + required this.frame, + required this.maskWidth, + required this.maskHeight, + required this.confidences, + }); + + final Uint8List frame; + final int maskWidth; + final int maskHeight; + final Float32List confidences; +} + +Uint8List _applySegmentationMask(_MaskRequest request) { + final result = image.decodePng(request.frame)!.convert(numChannels: 4); + for (var y = 0; y < result.height; y++) { + final maskY = (y * request.maskHeight / result.height).floor().clamp( + 0, + request.maskHeight - 1, + ); + for (var x = 0; x < result.width; x++) { + final maskX = (x * request.maskWidth / result.width).floor().clamp( + 0, + request.maskWidth - 1, + ); + final confidence = request.confidences[maskY * request.maskWidth + maskX]; + final alpha = ((confidence - 0.28) / (0.72 - 0.28)).clamp(0, 1); + final pixel = result.getPixel(x, y); + pixel.a = (alpha * 255).round(); + } + } + return image.encodePng(result, level: 4); +} diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart index 5fd8dc022a9..cff7624614d 100644 --- a/mobile/lib/features/profile/profile_text_editor.dart +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/modal_presentation.dart'; import 'ios_profile_text_editor.dart'; @@ -15,12 +16,16 @@ import 'profile_provider.dart'; Future showProfileDisplayNameEditor(BuildContext context) async { final container = ProviderScope.containerOf(context, listen: false); final profile = container.read(profileProvider).asData?.value; + final onSave = _bindSaveToOpeningContext( + container, + container.read(profileProvider.notifier).updateDisplayName, + ); await _showProfileTextEditor( context: context, title: 'Display name', initialValue: profile?.displayName ?? '', hintText: 'Display name', - onSave: container.read(profileProvider.notifier).updateDisplayName, + onSave: onSave, ); } @@ -28,16 +33,42 @@ Future showProfileDisplayNameEditor(BuildContext context) async { Future showProfileDescriptionEditor(BuildContext context) async { final container = ProviderScope.containerOf(context, listen: false); final profile = container.read(profileProvider).asData?.value; + final onSave = _bindSaveToOpeningContext( + container, + container.read(profileProvider.notifier).updateAbout, + ); await _showProfileTextEditor( context: context, title: 'Profile description', initialValue: profile?.about ?? '', hintText: 'Profile description', multiline: true, - onSave: container.read(profileProvider.notifier).updateAbout, + onSave: onSave, ); } +Future Function(String) _bindSaveToOpeningContext( + ProviderContainer container, + Future Function(String value) onSave, +) { + final openingConfig = container.read(relayConfigProvider); + final openingPubkey = container.read(myPubkeyProvider); + final openingSession = container.read(relaySessionProvider.notifier); + return (value) { + final currentConfig = container.read(relayConfigProvider); + final isCurrent = + currentConfig.storedOrigin == openingConfig.storedOrigin && + currentConfig.nsec == openingConfig.nsec && + container.read(myPubkeyProvider) == openingPubkey && + identical( + container.read(relaySessionProvider.notifier), + openingSession, + ); + if (!isCurrent) throw ProfileCommunityChangedException(); + return onSave(value); + }; +} + Future _showProfileTextEditor({ required BuildContext context, required String title, diff --git a/mobile/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart index e4b0d1f03b2..58eb40a8752 100644 --- a/mobile/test/features/profile/animated_avatar_capture_test.dart +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:buzz/features/profile/animated_avatar_orientation.dart'; import 'package:buzz/features/profile/animated_avatar_capture.dart'; import 'package:buzz/features/profile/profile_avatar_draft.dart'; @@ -11,6 +13,21 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image/image.dart' as image; void main() { + test('capture frame workspaces are isolated', () async { + final first = await createAnimatedAvatarFrameDirectory( + parent: Directory.systemTemp, + ); + final second = await createAnimatedAvatarFrameDirectory( + parent: Directory.systemTemp, + ); + addTearDown(() async { + if (await first.exists()) await first.delete(recursive: true); + if (await second.exists()) await second.delete(recursive: true); + }); + + expect(first.path, isNot(second.path)); + }); + group('animatedAvatarFrameRotationDegrees', () { test('compensates front camera frames for every device orientation', () { const expected = { diff --git a/mobile/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart index 2a8384d3829..e85b0307780 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:buzz/features/profile/profile_avatar_draft.dart'; import 'package:buzz/features/profile/profile_edit_page.dart'; import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/features/profile/profile_text_editor.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:flutter/foundation.dart'; @@ -10,6 +11,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../helpers/widget_helpers.dart'; @@ -92,6 +94,57 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + testWidgets('native text editor rejects its first save after a switch', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/profile_text_editor'); + final submission = Completer(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) => submission.future); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + final notifier = _RetryProfileNotifier(); + final config = _MutableRelayConfigNotifier(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + relayConfigProvider.overrideWith(() => config), + ], + child: Builder( + builder: (context) => TextButton( + onPressed: () => unawaited(showProfileDisplayNameEditor(context)), + child: const Text('Open editor'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(TextButton)), + ); + final configSubscription = container.listen( + relayConfigProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(configSubscription.close); + await tester.tap(find.text('Open editor')); + await tester.pump(); + + config.update(baseUrl: 'https://second.example', nsec: 'second-identity'); + submission.complete('Old community draft'); + await tester.pumpAndSettle(); + + expect(notifier.displayNameAttempts, isEmpty); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('native text retry stops when its owner unmounts', ( tester, ) async { From 4f38b7bd8c12c3602b32f660f3efb16f9eee2198 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 12:23:19 +0100 Subject: [PATCH 17/34] fix(mobile): preserve avatar editing context Signed-off-by: kenny lopez --- .../profile/animated_avatar_capture.dart | 55 ++++++++++--- .../features/profile/profile_edit_page.dart | 22 +++++- .../lib/shared/emoji/native_emoji_glyph.dart | 1 + .../profile/animated_avatar_capture_test.dart | 14 ++++ .../profile/profile_edit_retry_test.dart | 77 ++++++++++++++++--- .../shared/emoji/native_emoji_glyph_test.dart | 3 + 6 files changed, 147 insertions(+), 25 deletions(-) diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index 97ec87dfeb7..1a330596f6c 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -837,21 +837,34 @@ _EncodedAvatar _encodeAvatar(_EncodeRequest request) { final composed = request.frames .map((bytes) { final source = image.decodePng(bytes)!; - final cropSize = (source.width / request.scale).round().clamp( + final scaledSize = (_outputSize * request.scale).round().clamp( 1, - source.width, + _outputSize * 2, ); - final available = source.width - cropSize; - final x = ((available / 2) - request.offsetX * available / 2) - .round() - .clamp(0, available); - final y = ((available / 2) - request.offsetY * available / 2) - .round() - .clamp(0, available); - final person = image.copyResize( - image.copyCrop(source, x: x, y: y, width: cropSize, height: cropSize), + final scaledPerson = image.copyResize( + source, + width: scaledSize, + height: scaledSize, + ); + final person = image.Image( width: _outputSize, height: _outputSize, + numChannels: 4, + ); + const previewSize = 220.0; + const previewTranslation = 48.0; + final translationScale = _outputSize / previewSize; + image.compositeImage( + person, + scaledPerson, + dstX: + ((_outputSize - scaledSize) / 2 + + request.offsetX * previewTranslation * translationScale) + .round(), + dstY: + ((_outputSize - scaledSize) / 2 + + request.offsetY * previewTranslation * translationScale) + .round(), ); final frame = image.Image( width: _outputSize, @@ -914,6 +927,26 @@ _EncodedAvatar _encodeAvatar(_EncodeRequest request) { return _EncodedAvatar(animation, poster); } +/// Encodes one poster frame for validating animated-avatar framing parity. +@visibleForTesting +Uint8List encodeAnimatedAvatarPoster({ + required Uint8List frame, + required double scale, +}) => _encodeAvatar( + _EncodeRequest( + frames: [frame], + posterIndex: 0, + scale: scale, + offsetX: 0, + offsetY: 0, + backdropColor: 0xff0000ff, + personOutline: false, + shapeScale: 1, + shapeOffsetX: 0, + shapeOffsetY: 0, + ), +).poster; + extension on Iterable { Iterable skipLast(int count) { final values = toList(growable: false); diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 3ef9baaaa7a..3008596aa0a 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -50,6 +50,9 @@ class ProfileEditPage extends HookConsumerWidget { final isEditingAvatar = useState(startInPhotoEditor); final avatarDraft = useState(null); final avatarDraftMode = useState(null); + final avatarEditConfig = useRef( + startInPhotoEditor ? ref.read(relayConfigProvider) : null, + ); final isSavingAvatar = useState(false); final prepareAnimatedAvatar = useRef Function()?>(null); @@ -137,6 +140,7 @@ class ProfileEditPage extends HookConsumerWidget { void openAvatarEditor() { if (!profileHydrated) return; + avatarEditConfig.value = ref.read(relayConfigProvider); isEditingAvatar.value = true; unawaited(avatarTransition.forward(from: 0)); } @@ -152,6 +156,7 @@ class ProfileEditPage extends HookConsumerWidget { isEditingAvatar.value = false; avatarDraft.value = null; avatarDraftMode.value = null; + avatarEditConfig.value = null; prepareAnimatedAvatar.value = null; canPrepareAnimatedAvatar.value = false; avatarMode.value = ProfileAvatarMode.image; @@ -159,23 +164,26 @@ class ProfileEditPage extends HookConsumerWidget { Future saveAvatar() async { if (isSavingAvatar.value) return; + final openingConfig = avatarEditConfig.value; + if (openingConfig == null) return; final saveConfig = ref.read(relayConfigProvider); final uploadService = ref.read(mediaUploadServiceProvider); void requireCurrentCommunity() { final currentConfig = ref.read(relayConfigProvider); - if (currentConfig.storedOrigin != saveConfig.storedOrigin || + if (currentConfig.storedOrigin != openingConfig.storedOrigin || + currentConfig.nsec != openingConfig.nsec || + currentConfig.storedOrigin != saveConfig.storedOrigin || currentConfig.nsec != saveConfig.nsec || !identical(ref.read(mediaUploadServiceProvider), uploadService)) { - throw StateError( - 'Profile photo save cancelled because the active community changed.', - ); + throw ProfileCommunityChangedException(); } } isSavingAvatar.value = true; avatarSaveError.value = null; try { + requireCurrentCommunity(); var nextDraft = avatarDraftMode.value == avatarMode.value ? avatarDraft.value : null; @@ -195,6 +203,12 @@ class ProfileEditPage extends HookConsumerWidget { await ref.read(profileProvider.notifier).updateAvatarUrl(nextAvatar); requireCurrentCommunity(); if (context.mounted) await closeAvatarEditor(whileSaving: true); + } on ProfileCommunityChangedException { + avatarDraft.value = null; + avatarDraftMode.value = null; + prepareAnimatedAvatar.value = null; + canPrepareAnimatedAvatar.value = false; + if (context.mounted) await closeAvatarEditor(whileSaving: true); } catch (_) { avatarSaveError.value = "We couldn't save your profile photo. Try again."; diff --git a/mobile/lib/shared/emoji/native_emoji_glyph.dart b/mobile/lib/shared/emoji/native_emoji_glyph.dart index 6014b875d42..ff7237ec71a 100644 --- a/mobile/lib/shared/emoji/native_emoji_glyph.dart +++ b/mobile/lib/shared/emoji/native_emoji_glyph.dart @@ -24,6 +24,7 @@ class NativeEmojiGlyph extends StatelessWidget { emoji, maxLines: 1, softWrap: false, + textScaler: TextScaler.noScaling, style: TextStyle(fontSize: size, height: 1), ); if (defaultTargetPlatform == TargetPlatform.iOS) { diff --git a/mobile/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart index 58eb40a8752..c516a4d5909 100644 --- a/mobile/test/features/profile/animated_avatar_capture_test.dart +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -13,6 +13,20 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image/image.dart' as image; void main() { + test('encoded poster preserves avatar scales below one', () { + final source = image.Image(width: 256, height: 256, numChannels: 4); + image.fill(source, color: image.ColorRgba8(255, 0, 0, 255)); + + final poster = image.decodePng( + encodeAnimatedAvatarPoster(frame: image.encodePng(source), scale: 0.75), + )!; + + final edge = poster.getPixel(8, 128); + final center = poster.getPixel(128, 128); + expect(edge.r, isNot(255)); + expect(center.r, 255); + }); + test('capture frame workspaces are isolated', () async { final first = await createAnimatedAvatarFrameDirectory( parent: Directory.systemTemp, diff --git a/mobile/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart index e85b0307780..96f4c581562 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -268,7 +268,71 @@ void main() { debugDefaultTargetPlatformOverride = null; }); - testWidgets('avatar retry reuploads after a community switch', ( + testWidgets('avatar draft cannot save after a prior community switch', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final notifier = _RetryProfileNotifier(); + final config = _MutableRelayConfigNotifier(); + final firstUpload = _RetryMediaUploadService( + baseUrl: 'https://first.example', + ); + final secondUpload = _RetryMediaUploadService( + baseUrl: 'https://second.example', + ); + addTearDown(firstUpload.dispose); + addTearDown(secondUpload.dispose); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + relayConfigProvider.overrideWith(() => config), + mediaUploadServiceProvider.overrideWith((ref) { + final current = ref.watch(relayConfigProvider); + return current.baseUrl == 'https://first.example' + ? firstUpload + : secondUpload; + }), + ], + child: ProfileEditPage( + startInPhotoEditor: true, + animatedAvatarCaptureBuilder: + ({required height, required onPrepareChanged}) => HookBuilder( + builder: (context) { + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + onPrepareChanged( + () async => ProfileImageAvatarDraft( + Uint8List.fromList([1, 2, 3]), + ), + ); + }); + return null; + }, const []); + return SizedBox(height: height); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Animated')); + await tester.pumpAndSettle(); + + config.update(baseUrl: 'https://second.example', nsec: 'second-identity'); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + + expect(firstUpload.uploadCount, 0); + expect(secondUpload.uploadCount, 0); + expect(notifier.savedAvatarUrls, isEmpty); + expect(find.byKey(const ValueKey('avatar-save')), findsNothing); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('avatar editor closes when community changes during save', ( tester, ) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; @@ -330,15 +394,8 @@ void main() { await tester.pumpAndSettle(); expect(notifier.savedAvatarUrls, isEmpty); - expect( - find.text("We couldn't save your profile photo. Try again."), - findsOneWidget, - ); - - await tester.tap(find.byKey(const ValueKey('avatar-save'))); - await tester.pumpAndSettle(); - expect(secondUpload.uploadCount, 1); - expect(notifier.savedAvatarUrls, ['https://second.example/avatar.jpg']); + expect(secondUpload.uploadCount, 0); + expect(find.byKey(const ValueKey('avatar-save')), findsNothing); debugDefaultTargetPlatformOverride = null; }); } diff --git a/mobile/test/shared/emoji/native_emoji_glyph_test.dart b/mobile/test/shared/emoji/native_emoji_glyph_test.dart index 7a6e0c85582..8d8e02ee8a1 100644 --- a/mobile/test/shared/emoji/native_emoji_glyph_test.dart +++ b/mobile/test/shared/emoji/native_emoji_glyph_test.dart @@ -44,5 +44,8 @@ void main() { ), findsOneWidget, ); + for (final text in tester.widgetList(find.byType(Text))) { + expect(text.textScaler, TextScaler.noScaling); + } }); } From c44fb94ab68660c9212d4bff637a6e6bd9b0c6b2 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 12:36:54 +0100 Subject: [PATCH 18/34] test(mobile): split profile editor coverage Signed-off-by: kenny lopez --- .../profile/profile_edit_page_test.dart | 209 +---------------- .../motion_and_accessibility_tests.dart | 210 ++++++++++++++++++ 2 files changed, 213 insertions(+), 206 deletions(-) create mode 100644 mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index a385c785acd..1fb0427caf9 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -27,6 +27,8 @@ import 'package:image_picker/image_picker.dart'; import '../../helpers/widget_helpers.dart'; +part 'profile_edit_page_test/motion_and_accessibility_tests.dart'; + const _editorControlBottomForTest = Grid.xl + Grid.xxs; void main() { @@ -836,212 +838,7 @@ void main() { ); }); - testWidgets('moves segment content in the selected direction', ( - tester, - ) async { - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], - child: const ProfileEditPage(), - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Edit Photo')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Emoji')); - await tester.pump(); - final forwardTransform = tester.widget( - find.byKey(const ValueKey('avatar-mode-transition-transform')), - ); - expect(forwardTransform.transform.getTranslation().x, greaterThan(0)); - await tester.pump(const Duration(milliseconds: 240)); - expect( - tester - .widget( - find.byKey(const ValueKey('avatar-mode-transition-transform')), - ) - .transform - .getTranslation() - .x, - closeTo(0, 0.01), - ); - - await tester.tap(find.text('Image')); - await tester.pump(); - final reverseTransform = tester.widget( - find.byKey(const ValueKey('avatar-mode-transition-transform')), - ); - expect(reverseTransform.transform.getTranslation().x, lessThan(0)); - }); - - testWidgets('plays an animated avatar on the profile and image editor', ( - tester, - ) async { - const avatar = - 'https://relay.example/poster.png#buzz-anim=https%3A%2F%2Frelay.example%2Fanimation.png'; - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - profileProvider.overrideWith( - () => _FakeProfileNotifier( - profile: const UserProfile( - pubkey: 'aabb', - displayName: 'Alice', - about: 'Building Buzz', - avatarUrl: avatar, - ), - ), - ), - ], - child: const ProfileEditPage(), - ), - ); - await tester.pump(); - - expect(find.byType(PlayingAvatarImage), findsOneWidget); - expect(find.byType(ProgressiveAnimatedAvatar), findsOneWidget); - - await tester.tap(find.text('Edit Photo')); - await tester.pump(); - expect(find.byType(PlayingAvatarImage), findsOneWidget); - expect(find.byType(ProgressiveAnimatedAvatar), findsOneWidget); - }); - - testWidgets('shows only the animated-avatar poster with Reduce Motion', ( - tester, - ) async { - const avatar = - 'https://relay.example/poster.png#buzz-anim=https%3A%2F%2Frelay.example%2Fanimation.png'; - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - profileProvider.overrideWith( - () => _FakeProfileNotifier( - profile: const UserProfile( - pubkey: 'aabb', - displayName: 'Alice', - avatarUrl: avatar, - ), - ), - ), - ], - child: const MediaQuery( - data: MediaQueryData(disableAnimations: true), - child: ProfileEditPage(), - ), - ), - ); - await tester.pump(); - - expect(find.byType(ProgressiveAnimatedAvatar), findsNothing); - expect( - tester.widget(find.byType(AvatarImage)).imageUrl, - 'https://relay.example/poster.png', - ); - }); - - testWidgets('keeps emoji actions anchored when search opens the keyboard', ( - tester, - ) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(800, 900); - addTearDown(tester.view.reset); - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], - child: const ProfileEditPage(), - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Edit Photo')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Emoji')); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 150)); - - final action = find.byKey(const ValueKey('emoji-editor-background')); - final actionBottomBefore = tester.getRect(action).bottom; - await tester.tap(find.byKey(const ValueKey('emoji-avatar-search'))); - tester.view.viewInsets = const FakeViewPadding(bottom: 300); - await tester.pump(); - - expect(tester.getRect(action).bottom, actionBottomBefore); - expect( - tester.getRect(find.byKey(const ValueKey('emoji-avatar-search'))).bottom, - lessThan(600), - ); - expect( - tester - .widgetList(find.byType(Scaffold)) - .any((scaffold) => scaffold.resizeToAvoidBottomInset == false), - isTrue, - ); - }); - - testWidgets('uses high-contrast inverse colors for avatar action icons', ( - tester, - ) async { - final theme = AppTheme.dark(); - await tester.pumpWidget( - MaterialApp( - theme: theme, - home: Scaffold( - body: Row( - children: [ - Expanded( - child: AvatarEditorOptionButton( - icon: Icons.palette, - label: 'Inactive', - selected: false, - onTap: () {}, - ), - ), - Expanded( - child: AvatarEditorOptionButton( - icon: Icons.face, - label: 'Active', - selected: true, - onTap: () {}, - ), - ), - ], - ), - ), - ), - ); - - expect( - tester.widget(find.byIcon(Icons.palette)).color, - theme.colorScheme.onSurface, - ); - expect( - tester.widget(find.byIcon(Icons.face)).color, - theme.colorScheme.surface, - ); - }); - - testWidgets('uses the shared animated background grid for emoji avatars', ( - tester, - ) async { - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], - child: const ProfileEditPage(), - ), - ); - await tester.pump(); - await tester.tap(find.text('Edit Photo')); - await tester.pump(const Duration(milliseconds: 250)); - await tester.tap(find.text('Emoji')); - await tester.pump(const Duration(milliseconds: 250)); - await tester.tap(find.byKey(const ValueKey('emoji-editor-background'))); - await tester.pump(const Duration(milliseconds: 200)); - - expect(find.byType(AvatarBackgroundGrid), findsOneWidget); - final firstColor = find.byKey(const ValueKey('emoji-avatar-color-0')); - expect(tester.getSize(firstColor), const Size.square(52)); - }); + runProfileEditMotionAndAccessibilityTests(); } Future _waitForAvatarCropToClose(WidgetTester tester) async { diff --git a/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart b/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart new file mode 100644 index 00000000000..9c10084e225 --- /dev/null +++ b/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart @@ -0,0 +1,210 @@ +part of '../profile_edit_page_test.dart'; + +void runProfileEditMotionAndAccessibilityTests() { + testWidgets('moves segment content in the selected direction', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Emoji')); + await tester.pump(); + final forwardTransform = tester.widget( + find.byKey(const ValueKey('avatar-mode-transition-transform')), + ); + expect(forwardTransform.transform.getTranslation().x, greaterThan(0)); + await tester.pump(const Duration(milliseconds: 240)); + expect( + tester + .widget( + find.byKey(const ValueKey('avatar-mode-transition-transform')), + ) + .transform + .getTranslation() + .x, + closeTo(0, 0.01), + ); + + await tester.tap(find.text('Image')); + await tester.pump(); + final reverseTransform = tester.widget( + find.byKey(const ValueKey('avatar-mode-transition-transform')), + ); + expect(reverseTransform.transform.getTranslation().x, lessThan(0)); + }); + + testWidgets('plays an animated avatar on the profile and image editor', ( + tester, + ) async { + const avatar = + 'https://relay.example/poster.png#buzz-anim=https%3A%2F%2Frelay.example%2Fanimation.png'; + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith( + () => _FakeProfileNotifier( + profile: const UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + about: 'Building Buzz', + avatarUrl: avatar, + ), + ), + ), + ], + child: const ProfileEditPage(), + ), + ); + await tester.pump(); + + expect(find.byType(PlayingAvatarImage), findsOneWidget); + expect(find.byType(ProgressiveAnimatedAvatar), findsOneWidget); + + await tester.tap(find.text('Edit Photo')); + await tester.pump(); + expect(find.byType(PlayingAvatarImage), findsOneWidget); + expect(find.byType(ProgressiveAnimatedAvatar), findsOneWidget); + }); + + testWidgets('shows only the animated-avatar poster with Reduce Motion', ( + tester, + ) async { + const avatar = + 'https://relay.example/poster.png#buzz-anim=https%3A%2F%2Frelay.example%2Fanimation.png'; + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith( + () => _FakeProfileNotifier( + profile: const UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + avatarUrl: avatar, + ), + ), + ), + ], + child: const MediaQuery( + data: MediaQueryData(disableAnimations: true), + child: ProfileEditPage(), + ), + ), + ); + await tester.pump(); + + expect(find.byType(ProgressiveAnimatedAvatar), findsNothing); + expect( + tester.widget(find.byType(AvatarImage)).imageUrl, + 'https://relay.example/poster.png', + ); + }); + + testWidgets('keeps emoji actions anchored when search opens the keyboard', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(800, 900); + addTearDown(tester.view.reset); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Emoji')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + + final action = find.byKey(const ValueKey('emoji-editor-background')); + final actionBottomBefore = tester.getRect(action).bottom; + await tester.tap(find.byKey(const ValueKey('emoji-avatar-search'))); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pump(); + + expect(tester.getRect(action).bottom, actionBottomBefore); + expect( + tester.getRect(find.byKey(const ValueKey('emoji-avatar-search'))).bottom, + lessThan(600), + ); + expect( + tester + .widgetList(find.byType(Scaffold)) + .any((scaffold) => scaffold.resizeToAvoidBottomInset == false), + isTrue, + ); + }); + + testWidgets('uses high-contrast inverse colors for avatar action icons', ( + tester, + ) async { + final theme = AppTheme.dark(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Row( + children: [ + Expanded( + child: AvatarEditorOptionButton( + icon: Icons.palette, + label: 'Inactive', + selected: false, + onTap: () {}, + ), + ), + Expanded( + child: AvatarEditorOptionButton( + icon: Icons.face, + label: 'Active', + selected: true, + onTap: () {}, + ), + ), + ], + ), + ), + ), + ); + + expect( + tester.widget(find.byIcon(Icons.palette)).color, + theme.colorScheme.onSurface, + ); + expect( + tester.widget(find.byIcon(Icons.face)).color, + theme.colorScheme.surface, + ); + }); + + testWidgets('uses the shared animated background grid for emoji avatars', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pump(); + await tester.tap(find.text('Edit Photo')); + await tester.pump(const Duration(milliseconds: 250)); + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 250)); + await tester.tap(find.byKey(const ValueKey('emoji-editor-background'))); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.byType(AvatarBackgroundGrid), findsOneWidget); + final firstColor = find.byKey(const ValueKey('emoji-avatar-color-0')); + expect(tester.getSize(firstColor), const Size.square(52)); + }); +} From 7cc8acfbcab6706aa0f4d3f7b15761526e91cb81 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 12:49:08 +0100 Subject: [PATCH 19/34] fix(mobile): preserve profile event tags Signed-off-by: kenny lopez --- .../profile/profile_avatar_editor.dart | 18 ++++++++++++++++++ .../lib/features/profile/profile_provider.dart | 2 +- .../profile/profile_provider_test.dart | 7 ++++++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index ed62b833c48..5351e19ad48 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -58,6 +58,7 @@ const double _settingsAvatarCenterBelowAppBar = 96; /// In-page profile avatar editor used on Android and iOS. class ProfileAvatarEditor extends HookConsumerWidget { + /// Creates an avatar editor backed by the current profile and draft state. const ProfileAvatarEditor({ super.key, required this.currentAvatarUrl, @@ -71,15 +72,32 @@ class ProfileAvatarEditor extends HookConsumerWidget { this.animatedCaptureBuilder, }); + /// The avatar URL shown until the user selects a new draft. final String? currentAvatarUrl; + + /// The text initial used when [currentAvatarUrl] has no displayable image. final String fallbackInitial; + + /// The unsaved avatar selection for the active editing session. final ProfileAvatarDraft? draft; + + /// The currently selected avatar editing mode. final ProfileAvatarMode mode; + + /// Drives the shared preview transition into and out of editing. final Animation transition; + + /// Called when the user selects a different avatar editing mode. final ValueChanged onModeChanged; + + /// Called whenever the unsaved avatar selection changes. final ValueChanged onDraftChanged; + + /// Supplies or clears the deferred animated-avatar preparation callback. final ValueChanged Function()?> onAnimatedPrepareChanged; + + /// Overrides the animated capture surface, primarily for tests. final AnimatedAvatarCaptureBuilder? animatedCaptureBuilder; @override diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 18ca41d9ad1..ce4604a38dd 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -151,7 +151,7 @@ class ProfileNotifier extends AsyncNotifier { await relay.submit( kind: EventKind.profile, content: jsonEncode(nextMetadata), - tags: const [], + tags: currentHead?.tags ?? const [], createdAt: createdAt, onSigned: (event) => signedEvent = event, ); diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index 939a94f7cf4..296f7d6af16 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -14,13 +14,17 @@ import 'package:nostr/nostr.dart' as nostr; void main() { test('profile updates preserve existing kind:0 metadata', () async { final keys = nostr.Keys.generate(); + const profileTags = [ + ['auth', 'agent-pubkey', 'ownership-proof'], + ['custom', 'preserve-tag'], + ]; final relaySession = _ProfileRelaySession( NostrEvent( id: 'profile-1', pubkey: keys.public, createdAt: 1, kind: EventKind.profile, - tags: const [], + tags: profileTags, content: jsonEncode({ 'name': 'alice', 'display_name': 'Alice', @@ -54,6 +58,7 @@ void main() { expect(content['picture'], 'https://relay.example/alice.png'); expect(content['nip05'], 'alice@example.com'); expect(content['custom'], 'preserve-me'); + expect(relaySession.published.single.tags, profileTags); expect( container.read(profileProvider).requireValue?.displayName, 'Alice L', From 31a58ae599e52081bfae6b96648d1ed50717aa2b Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 13:02:23 +0100 Subject: [PATCH 20/34] fix(mobile): close profile edit edge cases Signed-off-by: kenny lopez --- .../profile/avatar_background_grid.dart | 6 ++ .../features/profile/profile_edit_page.dart | 8 ++- .../features/profile/profile_provider.dart | 5 ++ .../features/profile/profile_text_editor.dart | 7 ++- .../profile/profile_edit_retry_test.dart | 58 +++++++++++++++++++ .../profile/profile_provider_test.dart | 32 ++++++++++ 6 files changed, 112 insertions(+), 4 deletions(-) diff --git a/mobile/lib/features/profile/avatar_background_grid.dart b/mobile/lib/features/profile/avatar_background_grid.dart index 59c11315991..89a41f39c79 100644 --- a/mobile/lib/features/profile/avatar_background_grid.dart +++ b/mobile/lib/features/profile/avatar_background_grid.dart @@ -11,6 +11,7 @@ const avatarBackgroundPreviewShift = 136.0; /// The shared background-color grid used by emoji and animated avatars. class AvatarBackgroundGrid extends StatelessWidget { + /// Creates a fixed grid of avatar background colors. const AvatarBackgroundGrid({ super.key, required this.selectedColor, @@ -18,8 +19,13 @@ class AvatarBackgroundGrid extends StatelessWidget { this.colorKeyPrefix = 'avatar-background-color', }); + /// The ARGB value of the currently selected background color. final int selectedColor; + + /// Called with the selected ARGB value when the user taps a color. final ValueChanged onColorSelected; + + /// Prefix used for each color option's test key. final String colorKeyPrefix; @override diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 3008596aa0a..0ab24541ba3 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -111,9 +111,13 @@ class ProfileEditPage extends HookConsumerWidget { onSave: onSave, shouldRetryOnError: (error) => error is! ProfileCommunityChangedException, - canPresent: () => context.mounted, + canPresent: () => + context.mounted && (ModalRoute.of(context)?.isCurrent ?? true), onSaveError: () { - if (!context.mounted) return; + if (!context.mounted || + !(ModalRoute.of(context)?.isCurrent ?? true)) { + return; + } ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text("We couldn't save this change. Try again."), diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index ce4604a38dd..4795ce8f627 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -140,6 +140,11 @@ class ProfileNotifier extends AsyncNotifier { ? {} : _decodeProfileMetadata(currentHead); final nextMetadata = {...currentMetadata, ...patch}; + if (patch['display_name'] == '') { + nextMetadata + ..remove('display_name') + ..remove('name'); + } final relay = SignedEventRelay(session: session, nsec: context.config.nsec); final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; final currentCreatedAt = currentHead?.createdAt ?? 0; diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart index cff7624614d..9cbcea626a6 100644 --- a/mobile/lib/features/profile/profile_text_editor.dart +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -87,9 +87,12 @@ Future _showProfileTextEditor({ onSave: onSave, shouldRetryOnError: (error) => error is! ProfileCommunityChangedException, - canPresent: () => context.mounted, + canPresent: () => + context.mounted && (ModalRoute.of(context)?.isCurrent ?? true), onSaveError: () { - if (context.mounted) _showSaveError(context); + if (context.mounted && (ModalRoute.of(context)?.isCurrent ?? true)) { + _showSaveError(context); + } }, ); return; diff --git a/mobile/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart index 96f4c581562..167d8ec612c 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -182,6 +182,64 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + testWidgets('native text retry stops when its owner route is covered', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/profile_text_editor'); + var presentations = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + presentations++; + return 'Pending draft'; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + final notifier = _DeferredFailureProfileNotifier(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: Builder( + builder: (context) => Column( + children: [ + TextButton( + onPressed: () => + unawaited(showProfileDisplayNameEditor(context)), + child: const Text('Open editor'), + ), + TextButton( + onPressed: () => unawaited( + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Theme')), + ), + ), + ), + child: const Text('Open destination'), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open editor')); + await tester.pump(); + expect(notifier.displayNameAttempts, ['Pending draft']); + + await tester.tap(find.text('Open destination')); + await tester.pumpAndSettle(); + notifier.failSave(); + await tester.pump(); + + expect(presentations, 1); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('Flutter text editor closes after a community switch', ( tester, ) async { diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index 296f7d6af16..21aed9aca5e 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -65,6 +65,38 @@ void main() { ); }); + test('clearing a display name restores the pubkey label fallback', () async { + final keys = nostr.Keys.generate(); + final relaySession = _ProfileRelaySession( + NostrEvent( + id: 'profile-1', + pubkey: keys.public, + createdAt: 1, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({ + 'name': 'legacy-alice', + 'display_name': 'Alice', + 'about': 'Building Buzz', + }), + sig: 'sig', + ), + ); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + await container.read(profileProvider.notifier).updateDisplayName(' '); + + final content = + jsonDecode(relaySession.published.single.content) + as Map; + expect(content, {'about': 'Building Buzz'}); + final profile = container.read(profileProvider).requireValue!; + expect(profile.displayName, isNull); + expect(profile.label, '${keys.public.substring(0, 8)}...'); + }); + test('profile updates fail closed while hydration is pending', () async { final keys = nostr.Keys.generate(); final history = Completer>(); From a5fe4341884fb4a2d2115faec1442f23b344f3b9 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 13:15:37 +0100 Subject: [PATCH 21/34] docs(mobile): complete profile editor API docs Signed-off-by: kenny lopez --- .../features/profile/animated_avatar_capture.dart | 5 +++++ .../profile/avatar_editor_option_button.dart | 10 ++++++++++ .../features/profile/profile_avatar_crop_page.dart | 2 ++ .../features/profile/profile_avatar_editor.dart | 11 ++++++++++- mobile/lib/features/profile/profile_edit_page.dart | 1 + mobile/lib/features/profile/profile_provider.dart | 1 + mobile/lib/shared/emoji/native_emoji_glyph.dart | 14 ++++++++++---- mobile/lib/shared/widgets/app_list.dart | 2 ++ .../widgets/ios_glass_navigation_action.dart | 12 ++++++++++++ .../widgets/ios_native_segmented_control.dart | 9 +++++++++ .../widgets/ios_native_skin_tone_control.dart | 9 +++++++++ .../lib/shared/widgets/playing_avatar_image.dart | 8 ++++++++ 12 files changed, 79 insertions(+), 5 deletions(-) diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index 1a330596f6c..755c2f689e0 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -37,13 +37,18 @@ enum _AnimatedReviewSection { person, color, poster } /// Records and prepares a short camera animation for a profile avatar. class AnimatedAvatarCapture extends HookConsumerWidget { + /// Creates an animated-avatar capture and review surface. const AnimatedAvatarCapture({ super.key, required this.height, required this.onPrepareChanged, this.initialFrames = const [], }); + + /// The vertical space available to the capture surface. final double height; + + /// Reports the current deferred draft-preparation callback to the parent. final ValueChanged Function()?> onPrepareChanged; /// Seeds processed frames in lifecycle-focused widget tests. diff --git a/mobile/lib/features/profile/avatar_editor_option_button.dart b/mobile/lib/features/profile/avatar_editor_option_button.dart index f2166b893c0..da90ffcb09f 100644 --- a/mobile/lib/features/profile/avatar_editor_option_button.dart +++ b/mobile/lib/features/profile/avatar_editor_option_button.dart @@ -7,6 +7,7 @@ import '../../shared/theme/theme.dart'; /// A labelled circular option used by the profile avatar editor rails. class AvatarEditorOptionButton extends StatelessWidget { + /// Creates a selectable avatar-editor rail option. const AvatarEditorOptionButton({ super.key, required this.icon, @@ -16,10 +17,19 @@ class AvatarEditorOptionButton extends StatelessWidget { this.labelMaxWidth, }); + /// The symbol displayed inside the circular control. final IconData icon; + + /// The text displayed beneath the control. final String label; + + /// Whether this option represents the active editor section. final bool selected; + + /// Called when the option is selected, or null when disabled. final VoidCallback? onTap; + + /// Optional width constraint for the label's overflow region. final double? labelMaxWidth; @override diff --git a/mobile/lib/features/profile/profile_avatar_crop_page.dart b/mobile/lib/features/profile/profile_avatar_crop_page.dart index 06f4c392612..54811de514e 100644 --- a/mobile/lib/features/profile/profile_avatar_crop_page.dart +++ b/mobile/lib/features/profile/profile_avatar_crop_page.dart @@ -13,8 +13,10 @@ const _cropOutputSize = 512; /// Lets a person pan and zoom a selected image inside the final avatar mask. class ProfileAvatarCropPage extends HookWidget { + /// Creates a crop page that resolves the selected image asynchronously. const ProfileAvatarCropPage({super.key, required this.imageBytes}); + /// The selected image bytes, or null when selection was cancelled. final Future imageBytes; @override diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index 5351e19ad48..176b8dcbe7f 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -29,7 +29,16 @@ import 'profile_avatar_draft.dart'; part 'profile_avatar_editor/emoji_avatar_picker.dart'; /// Avatar kinds shared with the desktop profile editor. -enum ProfileAvatarMode { image, emoji, animated } +enum ProfileAvatarMode { + /// A still image selected from the camera or photo library. + image, + + /// A system emoji composited over a selected background color. + emoji, + + /// A short camera animation with framing and background controls. + animated, +} /// Builds the animated capture surface for the profile avatar editor. typedef AnimatedAvatarCaptureBuilder = diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 0ab24541ba3..38366335254 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -27,6 +27,7 @@ import 'profile_provider.dart'; /// Edits the current user's public profile metadata. class ProfileEditPage extends HookConsumerWidget { + /// Creates the profile details and avatar editing page. const ProfileEditPage({ super.key, this.startInPhotoEditor = false, diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 4795ce8f627..fe103cb3952 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -11,6 +11,7 @@ import '../../shared/theme/theme.dart'; /// Signals that a profile write no longer belongs to the active community. class ProfileCommunityChangedException extends StateError { + /// Creates an error for a profile write invalidated by a community switch. ProfileCommunityChangedException() : super('Profile update cancelled because the active community changed.'); } diff --git a/mobile/lib/shared/emoji/native_emoji_glyph.dart b/mobile/lib/shared/emoji/native_emoji_glyph.dart index ff7237ec71a..40b6f81b798 100644 --- a/mobile/lib/shared/emoji/native_emoji_glyph.dart +++ b/mobile/lib/shared/emoji/native_emoji_glyph.dart @@ -7,10 +7,7 @@ import 'package:flutter/material.dart'; /// layout box unchanged and lift only the painted glyph on iOS; Android's /// system emoji metrics are already visually centred. class NativeEmojiGlyph extends StatelessWidget { - final String emoji; - final double size; - final double? opticalBoxSize; - + /// Creates a system emoji with optional optical bounds. const NativeEmojiGlyph({ super.key, required this.emoji, @@ -18,6 +15,15 @@ class NativeEmojiGlyph extends StatelessWidget { this.opticalBoxSize, }); + /// Emoji text rendered by the platform font. + final String emoji; + + /// Font size used to paint the emoji. + final double size; + + /// Optional square that normalizes the apparent bounds of wide emoji. + final double? opticalBoxSize; + @override Widget build(BuildContext context) { Widget glyph = Text( diff --git a/mobile/lib/shared/widgets/app_list.dart b/mobile/lib/shared/widgets/app_list.dart index d2a4ea8e834..9c46e3bec35 100644 --- a/mobile/lib/shared/widgets/app_list.dart +++ b/mobile/lib/shared/widgets/app_list.dart @@ -142,6 +142,8 @@ class AppListRowRaw extends StatelessWidget { final Widget? subtitle; final Widget? trailing; final VoidCallback? onTap; + + /// Vertical inset around the row content. final double verticalPadding; @override diff --git a/mobile/lib/shared/widgets/ios_glass_navigation_action.dart b/mobile/lib/shared/widgets/ios_glass_navigation_action.dart index 02f73786b20..59a296f2b94 100644 --- a/mobile/lib/shared/widgets/ios_glass_navigation_action.dart +++ b/mobile/lib/shared/widgets/ios_glass_navigation_action.dart @@ -11,6 +11,7 @@ import 'ios_glass_navigation_button.dart'; /// A native iOS glass capsule for a short navigation-bar text action. class IosGlassNavigationAction extends HookWidget { + /// Creates a native glass navigation action. const IosGlassNavigationAction({ super.key, required this.label, @@ -21,11 +22,22 @@ class IosGlassNavigationAction extends HookWidget { this.isBusy = false, }); + /// The visible and accessible action label. final String label; + + /// Called when the action is pressed, or null when disabled. final VoidCallback? onPressed; + + /// The width of the Flutter platform-view region. final double width; + + /// The height of the Flutter platform-view region. final double height; + + /// Optional foreground tint; defaults to the active theme color. final Color? foregroundColor; + + /// Whether the native action should replace its label with progress. final bool isBusy; @override diff --git a/mobile/lib/shared/widgets/ios_native_segmented_control.dart b/mobile/lib/shared/widgets/ios_native_segmented_control.dart index 8ddd60c8c05..656a1dfaee4 100644 --- a/mobile/lib/shared/widgets/ios_native_segmented_control.dart +++ b/mobile/lib/shared/widgets/ios_native_segmented_control.dart @@ -8,6 +8,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; /// A platform-native iOS segmented control embedded in Flutter. class IosNativeSegmentedControl extends HookWidget { + /// Creates a native segmented control for the supplied labels. const IosNativeSegmentedControl({ super.key, required this.items, @@ -16,11 +17,19 @@ class IosNativeSegmentedControl extends HookWidget { this.height = 40, }); + /// The registered iOS platform-view identifier. static const viewType = 'buzz/native_segmented_control'; + /// Labels displayed by the native segments. final List items; + + /// The selected segment index. final int selectedIndex; + + /// Called with a new index, or null when the control is disabled. final ValueChanged? onChanged; + + /// The height of the embedded native control. final double height; @override diff --git a/mobile/lib/shared/widgets/ios_native_skin_tone_control.dart b/mobile/lib/shared/widgets/ios_native_skin_tone_control.dart index 7450d370b0b..c2ab6f4dec1 100644 --- a/mobile/lib/shared/widgets/ios_native_skin_tone_control.dart +++ b/mobile/lib/shared/widgets/ios_native_skin_tone_control.dart @@ -8,6 +8,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; /// A native iOS glass menu for selecting an emoji skin tone. class IosNativeSkinToneControl extends HookWidget { + /// Creates a native skin-tone selection menu. const IosNativeSkinToneControl({ super.key, required this.value, @@ -16,11 +17,19 @@ class IosNativeSkinToneControl extends HookWidget { required this.onChanged, }); + /// The registered iOS platform-view identifier. static const viewType = 'buzz/native_skin_tone_control'; + /// The selected skin-tone index. final int value; + + /// Colors displayed for the available tone options. final List colors; + + /// Accessible labels corresponding to [colors]. final List labels; + + /// Called with the newly selected tone index. final ValueChanged onChanged; @override diff --git a/mobile/lib/shared/widgets/playing_avatar_image.dart b/mobile/lib/shared/widgets/playing_avatar_image.dart index 553f29de072..ef84d4549e7 100644 --- a/mobile/lib/shared/widgets/playing_avatar_image.dart +++ b/mobile/lib/shared/widgets/playing_avatar_image.dart @@ -6,6 +6,7 @@ import 'progressive_animated_avatar.dart'; /// A circular avatar that opts into playback for animated profile descriptors. class PlayingAvatarImage extends StatelessWidget { + /// Creates an avatar that plays animation when the profile URL describes it. const PlayingAvatarImage({ super.key, required this.imageUrl, @@ -14,9 +15,16 @@ class PlayingAvatarImage extends StatelessWidget { this.backgroundColor, }); + /// The still-image URL or encoded animated-avatar descriptor. final String? imageUrl; + + /// The radius of the circular avatar. final double radius; + + /// Optional background shown behind still-image content. final Color? backgroundColor; + + /// Content shown while media is unavailable or still loading. final Widget fallback; @override From b27d6f5f7c6ba5b98085a9d7d138e7650028819e Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 13:26:55 +0100 Subject: [PATCH 22/34] fix(mobile): discard stale avatar image selections Signed-off-by: kenny lopez --- .../profile/profile_avatar_editor.dart | 25 ++++++++++---- .../profile/profile_edit_page_test.dart | 2 ++ .../image_selection_tests.dart | 33 +++++++++++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index 176b8dcbe7f..f2d116d8d92 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -121,6 +121,8 @@ class ProfileAvatarEditor extends HookConsumerWidget { final emojiSection = useState(_EmojiEditorSection.emoji); final emojiPreviewKey = useState(0); final isPickingImage = useState(false); + final imageSelectionGeneration = useRef(0); + final currentMode = useRef(mode)..value = mode; final error = useState(null); final dataset = ref.watch(emojiDatasetOrEmptyProvider); final modeTransitionDirection = useRef(1.0); @@ -138,6 +140,10 @@ class ProfileAvatarEditor extends HookConsumerWidget { modeTransitionDirection.value = nextMode.index > mode.index ? 1 : -1; unawaited(HapticFeedback.selectionClick()); onAnimatedPrepareChanged(null); + if (mode == ProfileAvatarMode.image) { + imageSelectionGeneration.value++; + isPickingImage.value = false; + } if (!reduceMotion) modeTransitionController.value = 0; onModeChanged(nextMode); if (nextMode == ProfileAvatarMode.emoji) { @@ -165,6 +171,11 @@ class ProfileAvatarEditor extends HookConsumerWidget { Future selectImage({required bool camera}) async { if (isPickingImage.value) return; + final operation = ++imageSelectionGeneration.value; + bool isCurrentOperation() => + context.mounted && + currentMode.value == ProfileAvatarMode.image && + imageSelectionGeneration.value == operation; isPickingImage.value = true; error.value = null; try { @@ -173,9 +184,9 @@ class ProfileAvatarEditor extends HookConsumerWidget { final picked = camera ? await service.captureImage() : await service.pickGalleryImage(); - if (picked == null || !context.mounted) return; + if (picked == null || !isCurrentOperation()) return; final preparedPhoto = await service.prepareImageBytes(picked); - if (!context.mounted) return; + if (!context.mounted || !isCurrentOperation()) return; final cropped = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => ProfileAvatarCropPage( @@ -183,12 +194,14 @@ class ProfileAvatarEditor extends HookConsumerWidget { ), ), ); - if (cropped == null) return; - if (context.mounted) onDraftChanged(ProfileImageAvatarDraft(cropped)); + if (cropped == null || !isCurrentOperation()) return; + onDraftChanged(ProfileImageAvatarDraft(cropped)); } catch (_) { - error.value = "We couldn't prepare that photo. Try again."; + if (isCurrentOperation()) { + error.value = "We couldn't prepare that photo. Try again."; + } } finally { - if (context.mounted) isPickingImage.value = false; + if (isCurrentOperation()) isPickingImage.value = false; } } diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index 1fb0427caf9..43ece434b4d 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -28,6 +28,7 @@ import 'package:image_picker/image_picker.dart'; import '../../helpers/widget_helpers.dart'; part 'profile_edit_page_test/motion_and_accessibility_tests.dart'; +part 'profile_edit_page_test/image_selection_tests.dart'; const _editorControlBottomForTest = Grid.xl + Grid.xxs; @@ -839,6 +840,7 @@ void main() { }); runProfileEditMotionAndAccessibilityTests(); + runProfileEditImageSelectionTests(); } Future _waitForAvatarCropToClose(WidgetTester tester) async { diff --git a/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart b/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart new file mode 100644 index 00000000000..63d1b21c474 --- /dev/null +++ b/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart @@ -0,0 +1,33 @@ +part of '../profile_edit_page_test.dart'; + +void runProfileEditImageSelectionTests() { + testWidgets('discards a delayed image after switching avatar modes', ( + tester, + ) async { + final uploadService = _FakeMediaUploadService(delayGallery: true); + addTearDown(uploadService.dispose); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(_FakeProfileNotifier.new), + mediaUploadServiceProvider.overrideWithValue(uploadService), + ], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Photo Library')); + await tester.pump(); + + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 250)); + uploadService.completeGallerySelection(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Position Photo'), findsNothing); + expect(find.byKey(const ValueKey('emoji-avatar-preview')), findsOneWidget); + }); +} From d1cc241c021e71df6c8f525a502dda912e6ae8db Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 13:42:41 +0100 Subject: [PATCH 23/34] fix(mobile): release animated avatar camera in review Signed-off-by: kenny lopez --- .../profile/animated_avatar_capture.dart | 29 +++++++++-- .../review_controls.dart | 39 ++++++++++++--- .../profile/animated_avatar_capture_test.dart | 48 +++++++++++++++++++ 3 files changed, 106 insertions(+), 10 deletions(-) diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index 755c2f689e0..4c8f1e46629 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -58,6 +58,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final controller = useState(null); final controllerRef = useRef(null); + final cameraGeneration = useState(0); final isInitializing = useState(true); final isRecording = useState(false); final isPreparingFrames = useState(false); @@ -117,7 +118,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { useEffect(() { var disposed = false; - if (lifecycle != AppLifecycleState.resumed) { + if (lifecycle != AppLifecycleState.resumed || frames.value.isNotEmpty) { isInitializing.value = false; controller.value = null; return null; @@ -162,7 +163,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { controllerRef.value = null; unawaited(active?.dispose() ?? Future.value()); }; - }, [lifecycle]); + }, [lifecycle, frames.value.isEmpty, cameraGeneration.value]); Future prepare() async { final key = encodeKey; @@ -225,6 +226,19 @@ class AnimatedAvatarCapture extends HookConsumerWidget { final startedAt = DateTime.now(); var lastFrameAt = DateTime.fromMillisecondsSinceEpoch(0); var converting = false; + var releasedCamera = false; + + Future releaseCamera() async { + if (releasedCamera) return; + releasedCamera = true; + if (identical(controllerRef.value, active)) { + controllerRef.value = null; + } + if (context.mounted && identical(controller.value, active)) { + controller.value = null; + } + await active.dispose(); + } final timer = Timer.periodic(const Duration(milliseconds: 40), (_) { if (!context.mounted) return; @@ -267,6 +281,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { while (converting) { await Future.delayed(const Duration(milliseconds: 10)); } + await releaseCamera(); if (captured.length < 2) { throw StateError('Not enough frames were captured.'); } @@ -287,8 +302,14 @@ class AnimatedAvatarCapture extends HookConsumerWidget { ]); if (context.mounted) frames.value = processed; } catch (_) { - if (active.value.isStreamingImages) await active.stopImageStream(); - error.value = 'Recording failed. Try again.'; + if (!releasedCamera && active.value.isStreamingImages) { + await active.stopImageStream(); + } + await releaseCamera(); + if (context.mounted) { + cameraGeneration.value++; + error.value = 'Recording failed. Try again.'; + } } finally { timer.cancel(); if (context.mounted) { diff --git a/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart b/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart index 7da739d7a09..68f8626ce9c 100644 --- a/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart +++ b/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart @@ -156,8 +156,18 @@ class _DesktopStyleFramingSlider extends HookWidget { slider: true, label: 'Avatar size', value: '${(value * 100).round()}%', - onIncrease: () => onChanged((value + 0.05).clamp(_min, _max).toDouble()), - onDecrease: () => onChanged((value - 0.05).clamp(_min, _max).toDouble()), + increasedValue: value < _max + ? '${((value + 0.05).clamp(_min, _max) * 100).round()}%' + : null, + decreasedValue: value > _min + ? '${((value - 0.05).clamp(_min, _max) * 100).round()}%' + : null, + onIncrease: value < _max + ? () => onChanged((value + 0.05).clamp(_min, _max).toDouble()) + : null, + onDecrease: value > _min + ? () => onChanged((value - 0.05).clamp(_min, _max).toDouble()) + : null, child: SizedBox( key: const ValueKey('animated-avatar-scale'), height: 56, @@ -259,16 +269,21 @@ class _AnimatedFrameControl extends HookWidget { Widget build(BuildContext context) { final lastSelection = useRef(selectedIndex); - void selectAt(double dx, double width) { - if (frames.isEmpty || width <= 0) return; - final progress = (dx / width).clamp(0.0, 1.0); - final next = (progress * (frames.length - 1)).round(); + void selectIndex(int index) { + if (frames.isEmpty) return; + final next = index.clamp(0, frames.length - 1); if (next == lastSelection.value) return; lastSelection.value = next; unawaited(HapticFeedback.selectionClick()); onSelected(next); } + void selectAt(double dx, double width) { + if (frames.isEmpty || width <= 0) return; + final progress = (dx / width).clamp(0.0, 1.0); + selectIndex((progress * (frames.length - 1)).round()); + } + return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -285,6 +300,18 @@ class _AnimatedFrameControl extends HookWidget { slider: true, label: 'Choose still frame', value: '${selectedIndex + 1} of ${frames.length}', + increasedValue: selectedIndex < frames.length - 1 + ? '${selectedIndex + 2} of ${frames.length}' + : null, + decreasedValue: selectedIndex > 0 + ? '$selectedIndex of ${frames.length}' + : null, + onIncrease: selectedIndex < frames.length - 1 + ? () => selectIndex(selectedIndex + 1) + : null, + onDecrease: selectedIndex > 0 + ? () => selectIndex(selectedIndex - 1) + : null, child: GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (details) => diff --git a/mobile/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart index c516a4d5909..c7fcf359a0e 100644 --- a/mobile/test/features/profile/animated_avatar_capture_test.dart +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:ui' show SemanticsAction; import 'package:buzz/features/profile/animated_avatar_orientation.dart'; import 'package:buzz/features/profile/animated_avatar_capture.dart'; @@ -128,6 +129,53 @@ void main() { ); expect(prepare, isNotNull); }); + + testWidgets('poster scrubber supports semantic adjustment actions', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final frames = [ + for (var index = 0; index < 3; index++) + image.encodePng(image.Image(width: 2, height: 2)), + ]; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: AnimatedAvatarCapture( + height: 600, + initialFrames: frames, + onPrepareChanged: (_) {}, + ), + ), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('Frame')); + await tester.pump(); + + final scrubber = find.bySemanticsLabel('Choose still frame'); + expect(scrubber, findsOneWidget); + final initialSemantics = tester.getSemantics(scrubber); + expect(initialSemantics.value, '1 of 3'); + final initialData = initialSemantics.getSemanticsData(); + expect(initialData.hasAction(SemanticsAction.increase), isTrue); + expect(initialData.hasAction(SemanticsAction.decrease), isFalse); + + final semanticsWidget = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.label == 'Choose still frame', + ), + ); + semanticsWidget.properties.onIncrease!(); + await tester.pump(); + expect(tester.getSemantics(scrubber).value, '2 of 3'); + semantics.dispose(); + }); } class _TestLifecycleNotifier extends AppLifecycleNotifier { From 28ad1ecdcc807fec5fd43c7b5bd131d895dc5cc8 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 13:51:09 +0100 Subject: [PATCH 24/34] fix(mobile): expose avatar selection semantics Signed-off-by: kenny lopez --- .../profile/avatar_editor_option_button.dart | 126 ++++++++++-------- mobile/lib/shared/relay/nostr_models.dart | 1 + .../motion_and_accessibility_tests.dart | 7 + 3 files changed, 77 insertions(+), 57 deletions(-) diff --git a/mobile/lib/features/profile/avatar_editor_option_button.dart b/mobile/lib/features/profile/avatar_editor_option_button.dart index da90ffcb09f..3ad51acb83a 100644 --- a/mobile/lib/features/profile/avatar_editor_option_button.dart +++ b/mobile/lib/features/profile/avatar_editor_option_button.dart @@ -33,71 +33,83 @@ class AvatarEditorOptionButton extends StatelessWidget { final double? labelMaxWidth; @override - Widget build(BuildContext context) => InkResponse( - radius: 34, - onTap: onTap == null + Widget build(BuildContext context) { + final handleTap = onTap == null ? null : () { unawaited(HapticFeedback.selectionClick()); onTap!(); - }, - child: SizedBox( - width: double.infinity, - child: Column( - children: [ - AnimatedContainer( - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 150), - curve: Curves.easeOutCubic, - width: 64, - height: 64, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: selected - ? context.colors.onSurface - : context.colors.surfaceContainerHighest, - ), - child: Icon( - icon, - size: 26, - color: selected - ? context.colors.surface - : context.colors.onSurface, - ), - ), - const SizedBox(height: Grid.quarter), - if (labelMaxWidth case final width?) - SizedBox( - height: 20, - child: OverflowBox( - maxWidth: width, - maxHeight: 20, - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelSmall?.copyWith( + }; + return Semantics( + label: label, + button: true, + selected: selected, + enabled: handleTap != null, + onTap: handleTap, + child: ExcludeSemantics( + child: InkResponse( + radius: 34, + onTap: handleTap, + child: SizedBox( + width: double.infinity, + child: Column( + children: [ + AnimatedContainer( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + width: 64, + height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, color: selected ? context.colors.onSurface - : context.colors.onSurfaceVariant, + : context.colors.surfaceContainerHighest, + ), + child: Icon( + icon, + size: 26, + color: selected + ? context.colors.surface + : context.colors.onSurface, ), ), - ), - ) - else - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelSmall?.copyWith( - color: selected - ? context.colors.onSurface - : context.colors.onSurfaceVariant, - ), + const SizedBox(height: Grid.quarter), + if (labelMaxWidth case final width?) + SizedBox( + height: 20, + child: OverflowBox( + maxWidth: width, + maxHeight: 20, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + color: selected + ? context.colors.onSurface + : context.colors.onSurfaceVariant, + ), + ), + ), + ) + else + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + color: selected + ? context.colors.onSurface + : context.colors.onSurfaceVariant, + ), + ), + ], ), - ], + ), + ), ), - ), - ); + ); + } } diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart index 34fe60ed6b7..c041e4399fa 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; /// /// Keep in sync with `desktop/src/shared/constants/kinds.ts`. abstract final class EventKind { + /// Kind:0 user profile metadata. static const profile = 0; static const note = 1; static const contactList = 3; diff --git a/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart b/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart index 9c10084e225..57781fcdc4b 100644 --- a/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart +++ b/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart @@ -184,6 +184,13 @@ void runProfileEditMotionAndAccessibilityTests() { tester.widget(find.byIcon(Icons.face)).color, theme.colorScheme.surface, ); + final selectedSemantics = tester.widget( + find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.label == 'Active', + ), + ); + expect(selectedSemantics.properties.button, isTrue); + expect(selectedSemantics.properties.selected, isTrue); }); testWidgets('uses the shared animated background grid for emoji avatars', ( From 4ee36105251ab7e1bcc6f8f84b5c756da059666b Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 14:03:41 +0100 Subject: [PATCH 25/34] fix(mobile): scope profile editing context Signed-off-by: kenny lopez --- .../profile/profile_avatar_editor.dart | 44 ++++++++----- .../features/profile/profile_edit_page.dart | 61 ++++++++++++------- .../features/profile/profile_text_editor.dart | 7 ++- .../motion_and_accessibility_tests.dart | 45 ++++++++++++++ .../profile/profile_edit_retry_test.dart | 13 ++-- 5 files changed, 126 insertions(+), 44 deletions(-) diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index f2d116d8d92..7d25d97f8c0 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -521,23 +521,35 @@ class _AvatarModeControl extends StatelessWidget { children: [ for (final mode in ProfileAvatarMode.values) Expanded( - child: InkWell( - key: ValueKey('avatar-mode-${mode.name}'), - borderRadius: BorderRadius.circular(Radii.full), + child: Semantics( + label: switch (mode) { + ProfileAvatarMode.image => 'Image', + ProfileAvatarMode.emoji => 'Emoji', + ProfileAvatarMode.animated => 'Animated', + }, + button: true, + selected: mode == selected, onTap: () => onSelected(mode), - child: SizedBox( - height: 36, - child: Center( - child: Text( - switch (mode) { - ProfileAvatarMode.image => 'Image', - ProfileAvatarMode.emoji => 'Emoji', - ProfileAvatarMode.animated => 'Animated', - }, - style: context.textTheme.labelLarge?.copyWith( - fontWeight: mode == selected - ? FontWeight.w600 - : FontWeight.w500, + child: ExcludeSemantics( + child: InkWell( + key: ValueKey('avatar-mode-${mode.name}'), + borderRadius: BorderRadius.circular(Radii.full), + onTap: () => onSelected(mode), + child: SizedBox( + height: 36, + child: Center( + child: Text( + switch (mode) { + ProfileAvatarMode.image => 'Image', + ProfileAvatarMode.emoji => 'Emoji', + ProfileAvatarMode.animated => 'Animated', + }, + style: context.textTheme.labelLarge?.copyWith( + fontWeight: mode == selected + ? FontWeight.w600 + : FontWeight.w500, + ), + ), ), ), ), diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 38366335254..13671f3ccb4 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -24,6 +24,7 @@ import 'ios_profile_text_editor.dart'; import 'profile_avatar_editor.dart'; import 'profile_avatar_draft.dart'; import 'profile_provider.dart'; +import 'profile_text_editor.dart'; /// Edits the current user's public profile metadata. class ProfileEditPage extends HookConsumerWidget { @@ -396,16 +397,25 @@ class ProfileEditPage extends HookConsumerWidget { trailing: const _EditChevron(), onTap: !profileHydrated ? null - : () => unawaited( - editField( - title: 'Display name', - initialValue: profile?.displayName ?? '', - hintText: 'Display name', - onSave: ref - .read(profileProvider.notifier) - .updateDisplayName, - ), - ), + : () { + final container = ProviderScope.containerOf( + context, + listen: false, + ); + unawaited( + editField( + title: 'Display name', + initialValue: profile?.displayName ?? '', + hintText: 'Display name', + onSave: bindProfileSaveToOpeningContext( + container, + container + .read(profileProvider.notifier) + .updateDisplayName, + ), + ), + ); + }, ), AppListRow( key: const ValueKey('profile-description-row'), @@ -415,17 +425,26 @@ class ProfileEditPage extends HookConsumerWidget { trailing: const _EditChevron(), onTap: !profileHydrated ? null - : () => unawaited( - editField( - title: 'Profile description', - initialValue: profile?.about ?? '', - hintText: 'Profile description', - multiline: true, - onSave: ref - .read(profileProvider.notifier) - .updateAbout, - ), - ), + : () { + final container = ProviderScope.containerOf( + context, + listen: false, + ); + unawaited( + editField( + title: 'Profile description', + initialValue: profile?.about ?? '', + hintText: 'Profile description', + multiline: true, + onSave: bindProfileSaveToOpeningContext( + container, + container + .read(profileProvider.notifier) + .updateAbout, + ), + ), + ); + }, ), ], ), diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart index 9cbcea626a6..8203fdbb083 100644 --- a/mobile/lib/features/profile/profile_text_editor.dart +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -16,7 +16,7 @@ import 'profile_provider.dart'; Future showProfileDisplayNameEditor(BuildContext context) async { final container = ProviderScope.containerOf(context, listen: false); final profile = container.read(profileProvider).asData?.value; - final onSave = _bindSaveToOpeningContext( + final onSave = bindProfileSaveToOpeningContext( container, container.read(profileProvider.notifier).updateDisplayName, ); @@ -33,7 +33,7 @@ Future showProfileDisplayNameEditor(BuildContext context) async { Future showProfileDescriptionEditor(BuildContext context) async { final container = ProviderScope.containerOf(context, listen: false); final profile = container.read(profileProvider).asData?.value; - final onSave = _bindSaveToOpeningContext( + final onSave = bindProfileSaveToOpeningContext( container, container.read(profileProvider.notifier).updateAbout, ); @@ -47,7 +47,8 @@ Future showProfileDescriptionEditor(BuildContext context) async { ); } -Future Function(String) _bindSaveToOpeningContext( +/// Prevents a profile draft from being published after its community changes. +Future Function(String) bindProfileSaveToOpeningContext( ProviderContainer container, Future Function(String value) onSave, ) { diff --git a/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart b/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart index 57781fcdc4b..7c1a0da5dc2 100644 --- a/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart +++ b/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart @@ -1,6 +1,51 @@ part of '../profile_edit_page_test.dart'; void runProfileEditMotionAndAccessibilityTests() { + testWidgets('exposes the selected avatar mode on Android', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + + Iterable modeSemantics(String label) => + tester.widgetList( + find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.label == label && + widget.child is ExcludeSemantics, + ), + ); + + expect(modeSemantics('Image'), isNotEmpty); + expect( + modeSemantics('Image').every((node) => node.properties.selected == true), + isTrue, + ); + expect( + modeSemantics('Emoji').every((node) => node.properties.selected == false), + isTrue, + ); + await tester.tap(find.byKey(const ValueKey('avatar-mode-emoji'))); + await tester.pump(); + expect( + modeSemantics('Image').every((node) => node.properties.selected == false), + isTrue, + ); + expect( + modeSemantics('Emoji').every((node) => node.properties.selected == true), + isTrue, + ); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('moves segment content in the selected direction', ( tester, ) async { diff --git a/mobile/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart index 167d8ec612c..08ee0990bed 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -240,16 +240,20 @@ void main() { debugDefaultTargetPlatformOverride = null; }); - testWidgets('Flutter text editor closes after a community switch', ( + testWidgets('in-page text editor rejects its first save after a switch', ( tester, ) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; addTearDown(() => debugDefaultTargetPlatformOverride = null); - final notifier = _CommunityChangedProfileNotifier(); + final notifier = _RetryProfileNotifier(); + final config = _MutableRelayConfigNotifier(); await tester.pumpWidget( WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(() => notifier)], + overrides: [ + profileProvider.overrideWith(() => notifier), + relayConfigProvider.overrideWith(() => config), + ], child: const ProfileEditPage(), ), ); @@ -261,11 +265,12 @@ void main() { 'Old community draft', ); await tester.pump(); + config.update(baseUrl: 'https://second.example', nsec: 'second-identity'); await tester.tap(find.byKey(const ValueKey('profile-field-save'))); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('profile-field-input')), findsNothing); - expect(notifier.displayNameAttempts, ['Old community draft']); + expect(notifier.displayNameAttempts, isEmpty); debugDefaultTargetPlatformOverride = null; }); From f13ebd4053bbffe365c2cee4a9645e14f393584a Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 14:15:54 +0100 Subject: [PATCH 26/34] fix(ios): match profile editor theme Signed-off-by: kenny lopez --- mobile/ios/Runner/NativeProfileTextEditor.swift | 8 +++++++- mobile/lib/features/profile/ios_profile_text_editor.dart | 4 ++++ mobile/lib/features/profile/profile_edit_page.dart | 1 + mobile/lib/features/profile/profile_text_editor.dart | 1 + mobile/test/features/profile/profile_edit_page_test.dart | 8 ++++++-- 5 files changed, 19 insertions(+), 3 deletions(-) diff --git a/mobile/ios/Runner/NativeProfileTextEditor.swift b/mobile/ios/Runner/NativeProfileTextEditor.swift index 31b94613051..abccc693ad8 100644 --- a/mobile/ios/Runner/NativeProfileTextEditor.swift +++ b/mobile/ios/Runner/NativeProfileTextEditor.swift @@ -37,7 +37,8 @@ final class NativeProfileTextEditorCoordinator: NSObject, let title = arguments["title"] as? String, let initialValue = arguments["initialValue"] as? String, let placeholder = arguments["placeholder"] as? String, - let multiline = arguments["multiline"] as? Bool + let multiline = arguments["multiline"] as? Bool, + let brightness = arguments["brightness"] as? String else { result( FlutterError( @@ -57,6 +58,7 @@ final class NativeProfileTextEditorCoordinator: NSObject, initialValue: initialValue, placeholder: placeholder, multiline: multiline, + brightness: brightness, allowUnchangedSubmission: allowUnchangedSubmission, result: result ) @@ -69,6 +71,7 @@ final class NativeProfileTextEditorCoordinator: NSObject, initialValue: String, placeholder: String, multiline: Bool, + brightness: String, allowUnchangedSubmission: Bool, result: @escaping FlutterResult ) { @@ -107,6 +110,9 @@ final class NativeProfileTextEditorCoordinator: NSObject, onSet: { [weak self] value in self?.finish(value: value) } ) let navigationController = UINavigationController(rootViewController: editor) + navigationController.overrideUserInterfaceStyle = brightness == "dark" + ? .dark + : .light if UIDevice.current.userInterfaceIdiom == .pad { navigationController.modalPresentationStyle = .formSheet } diff --git a/mobile/lib/features/profile/ios_profile_text_editor.dart b/mobile/lib/features/profile/ios_profile_text_editor.dart index d957b236aa9..3760b456617 100644 --- a/mobile/lib/features/profile/ios_profile_text_editor.dart +++ b/mobile/lib/features/profile/ios_profile_text_editor.dart @@ -13,12 +13,14 @@ class IosProfileTextEditor { required String initialValue, required String placeholder, required bool multiline, + required Brightness brightness, bool allowUnchangedSubmission = false, }) => _channel.invokeMethod('present', { 'title': title, 'initialValue': initialValue, 'placeholder': placeholder, 'multiline': multiline, + 'brightness': brightness.name, 'allowUnchangedSubmission': allowUnchangedSubmission, }); @@ -29,6 +31,7 @@ class IosProfileTextEditor { required String initialValue, required String placeholder, required bool multiline, + required Brightness brightness, required Future Function(String value) onSave, required void Function() onSaveError, bool Function(Object error)? shouldRetryOnError, @@ -43,6 +46,7 @@ class IosProfileTextEditor { initialValue: draft, placeholder: placeholder, multiline: multiline, + brightness: brightness, allowUnchangedSubmission: isRetry, ); if (value == null) return; diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 13671f3ccb4..208bc48b034 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -110,6 +110,7 @@ class ProfileEditPage extends HookConsumerWidget { initialValue: initialValue, placeholder: hintText, multiline: multiline, + brightness: Theme.of(context).brightness, onSave: onSave, shouldRetryOnError: (error) => error is! ProfileCommunityChangedException, diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart index 8203fdbb083..b1fbfa801ba 100644 --- a/mobile/lib/features/profile/profile_text_editor.dart +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -85,6 +85,7 @@ Future _showProfileTextEditor({ initialValue: initialValue, placeholder: hintText, multiline: multiline, + brightness: Theme.of(context).brightness, onSave: onSave, shouldRetryOnError: (error) => error is! ProfileCommunityChangedException, diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index 43ece434b4d..eff2ac90c5b 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -208,9 +208,12 @@ void main() { final notifier = _FakeProfileNotifier(); await tester.pumpWidget( - WidgetHelpers.testable( + ProviderScope( overrides: [profileProvider.overrideWith(() => notifier)], - child: const ProfileEditPage(), + child: MaterialApp( + theme: AppTheme.dark(), + home: const Scaffold(body: ProfileEditPage()), + ), ), ); await tester.pumpAndSettle(); @@ -223,6 +226,7 @@ void main() { 'initialValue': 'Alice', 'placeholder': 'Display name', 'multiline': false, + 'brightness': 'dark', 'allowUnchangedSubmission': false, }); expect(notifier.savedDisplayNames, ['Alice Native']); From 84739691a92b80dabe71191aa1a686f86d9fee0e Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 14:26:56 +0100 Subject: [PATCH 27/34] fix(mobile): preserve profile edit state Signed-off-by: kenny lopez --- .../profile/profile_avatar_editor.dart | 12 +++-- .../features/profile/profile_provider.dart | 3 ++ mobile/lib/shared/emoji/emoji_avatar.dart | 48 +++++++++++++++++++ mobile/lib/shared/widgets/avatar_image.dart | 32 ++++--------- .../profile/profile_edit_page_test.dart | 44 +++++++++++++++++ .../profile/profile_provider_test.dart | 31 ++++++++++-- 6 files changed, 140 insertions(+), 30 deletions(-) diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index 7d25d97f8c0..59edf3dc1b7 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -112,10 +112,16 @@ class ProfileAvatarEditor extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final reduceMotion = MediaQuery.disableAnimationsOf(context); - final selectedEmoji = useState('😊'); + final currentEmojiAvatar = useMemoized( + () => parseEmojiAvatarDataUrl(currentAvatarUrl), + [currentAvatarUrl], + ); + final selectedEmoji = useState(currentEmojiAvatar?.emoji ?? '😊'); final initialColor = useMemoized( - () => emojiAvatarColors[Random().nextInt(18)], - const [], + () => + currentEmojiAvatar?.colorValue ?? + emojiAvatarColors[Random().nextInt(18)], + [currentEmojiAvatar], ); final selectedColor = useState(initialColor); final emojiSection = useState(_EmojiEditorSection.emoji); diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index fe103cb3952..3f8fbd6f9be 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/crypto/nip_oa.dart'; import '../../shared/profile/user_cache_provider.dart'; import '../../shared/profile/user_profile.dart'; import '../../shared/relay/relay.dart'; @@ -67,6 +68,7 @@ class ProfileNotifier extends AsyncNotifier { avatarUrl: data.avatarUrl, about: data.about, nip05Handle: data.nip05, + ownerPubkey: verifiedOaOwnerPubkey(latest.tags, data.pubkey), ); _requireCurrentWriteContext(context); _metadata = metadata; @@ -183,6 +185,7 @@ class ProfileNotifier extends AsyncNotifier { avatarUrl: _metadata['picture'] as String?, about: _metadata['about'] as String?, nip05Handle: _metadata['nip05'] as String?, + ownerPubkey: verifiedOaOwnerPubkey(submittedEvent.tags, pubkey), ); state = AsyncData(profile); ref.read(userCacheProvider.notifier).put(profile); diff --git a/mobile/lib/shared/emoji/emoji_avatar.dart b/mobile/lib/shared/emoji/emoji_avatar.dart index 9cac8a9fe04..e1395a78b6c 100644 --- a/mobile/lib/shared/emoji/emoji_avatar.dart +++ b/mobile/lib/shared/emoji/emoji_avatar.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + /// Desktop-compatible colors offered for emoji avatar backgrounds. const emojiAvatarColors = [ 0xFFFFF4CC, @@ -40,3 +42,49 @@ String emojiAvatarDataUrl(String emoji, int colorValue) { ''; return 'data:image/svg+xml,${Uri.encodeComponent(svg)}'; } + +/// The editable values encoded by an inline emoji avatar. +class EmojiAvatarData { + /// Creates decoded emoji avatar values. + const EmojiAvatarData({required this.emoji, required this.colorValue}); + + /// The system emoji rendered in the avatar. + final String emoji; + + /// The ARGB background color. + final int colorValue; +} + +/// Decodes an inline emoji-avatar data URL, or returns null for other images. +EmojiAvatarData? parseEmojiAvatarDataUrl(String? value) { + final url = value?.trim(); + if (url == null || !url.startsWith('data:image/')) return null; + try { + final data = UriData.parse(url); + if (data.mimeType != 'image/svg+xml') return null; + return parseEmojiAvatarSvg(utf8.decode(data.contentAsBytes())); + } on FormatException { + return null; + } +} + +/// Decodes the editable values from an emoji-avatar SVG. +EmojiAvatarData? parseEmojiAvatarSvg(String svg) { + final colorValue = RegExp( + r']*\sfill="([^"]+)"', + ).firstMatch(svg)?[1]; + final emojiValue = RegExp(r']*>(.*?)').firstMatch(svg)?[1]; + if (colorValue == null || emojiValue == null) return null; + + final hex = colorValue.startsWith('#') ? colorValue.substring(1) : colorValue; + if (!RegExp(r'^[0-9a-fA-F]{6}$').hasMatch(hex)) return null; + final rgb = int.tryParse(hex, radix: 16); + if (rgb == null) return null; + return EmojiAvatarData( + emoji: emojiValue + .replaceAll('>', '>') + .replaceAll('<', '<') + .replaceAll('&', '&'), + colorValue: 0xFF000000 | rgb, + ); +} diff --git a/mobile/lib/shared/widgets/avatar_image.dart b/mobile/lib/shared/widgets/avatar_image.dart index a983920ee12..0a5379cf333 100644 --- a/mobile/lib/shared/widgets/avatar_image.dart +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../animated_avatar.dart'; +import '../emoji/emoji_avatar.dart'; import '../emoji/native_emoji_glyph.dart'; import '../relay/relay.dart'; @@ -132,7 +133,13 @@ sealed class _AvatarSource { if (data.mimeType == 'image/svg+xml') { final Uint8List bytes = data.contentAsBytes(); final svg = utf8.decode(bytes); - return _parseEmojiAvatar(svg) ?? _SvgAvatarSource(svg); + final emojiAvatar = parseEmojiAvatarSvg(svg); + return emojiAvatar == null + ? _SvgAvatarSource(svg) + : _EmojiAvatarSource( + emojiAvatar.emoji, + Color(emojiAvatar.colorValue), + ); } return _RasterDataAvatarSource(data.contentAsBytes()); } on FormatException { @@ -141,29 +148,6 @@ sealed class _AvatarSource { } } -_EmojiAvatarSource? _parseEmojiAvatar(String svg) { - final colorValue = RegExp( - r']*\sfill="([^"]+)"', - ).firstMatch(svg)?[1]; - final emojiValue = RegExp(r']*>(.*?)').firstMatch(svg)?[1]; - if (colorValue == null || emojiValue == null) return null; - - final color = _parseHexColor(colorValue); - if (color == null) return null; - final emoji = emojiValue - .replaceAll('>', '>') - .replaceAll('<', '<') - .replaceAll('&', '&'); - return _EmojiAvatarSource(emoji, color); -} - -Color? _parseHexColor(String value) { - final hex = value.startsWith('#') ? value.substring(1) : value; - if (!RegExp(r'^[0-9a-fA-F]{6}$').hasMatch(hex)) return null; - final rgb = int.tryParse(hex, radix: 16); - return rgb == null ? null : Color(0xFF000000 | rgb); -} - class _EmojiAvatarSource extends _AvatarSource { final String emoji; final Color color; diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index eff2ac90c5b..24b12ecb987 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -813,6 +813,50 @@ void main() { ); }); + testWidgets('seeds emoji editing from the current avatar', (tester) async { + final avatarUrl = emojiAvatarDataUrl('🦝', emojiAvatarColors[11]); + final notifier = _FakeProfileNotifier( + profile: UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + avatarUrl: avatarUrl, + ), + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 250)); + + final preview = find.byKey(const ValueKey('emoji-avatar-preview')); + expect( + tester + .widget( + find.descendant( + of: preview, + matching: find.byType(NativeEmojiGlyph), + ), + ) + .emoji, + '🦝', + ); + expect( + (tester.widget(preview).decoration! as BoxDecoration) + .color, + Color(emojiAvatarColors[11]), + ); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + expect(notifier.savedAvatarUrls, [avatarUrl]); + }); + testWidgets('keeps emoji drafts scoped to the emoji mode', (tester) async { final notifier = _FakeProfileNotifier(); await tester.pumpWidget( diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index 21aed9aca5e..ef0eb13104f 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -1,7 +1,9 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; @@ -10,13 +12,15 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'package:pointycastle/digests/sha256.dart'; void main() { test('profile updates preserve existing kind:0 metadata', () async { final keys = nostr.Keys.generate(); - const profileTags = [ - ['auth', 'agent-pubkey', 'ownership-proof'], - ['custom', 'preserve-tag'], + final owner = nostr.Keys.generate(); + final profileTags = [ + _authTag(owner, keys.public), + const ['custom', 'preserve-tag'], ]; final relaySession = _ProfileRelaySession( NostrEvent( @@ -63,6 +67,14 @@ void main() { container.read(profileProvider).requireValue?.displayName, 'Alice L', ); + expect( + container.read(profileProvider).requireValue?.ownerPubkey, + owner.public.toLowerCase(), + ); + expect( + container.read(userCacheProvider)[keys.public]?.ownerPubkey, + owner.public.toLowerCase(), + ); }); test('clearing a display name restores the pubkey label fallback', () async { @@ -535,6 +547,19 @@ void main() { ); } +List _authTag(nostr.Keys owner, String agentPubkey) { + final digest = SHA256Digest().process( + Uint8List.fromList(utf8.encode('nostr:agent-auth:$agentPubkey:')), + ); + final message = digest.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return [ + 'auth', + owner.public, + '', + nostr.Schnorr.sign(secretKey: owner.secret, message: message), + ]; +} + class _FixedRelayConfigNotifier extends RelayConfigNotifier { _FixedRelayConfigNotifier(this.nsec); From 7fda24ac28fe10900c07eea4869986b651cffcd7 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 14:43:28 +0100 Subject: [PATCH 28/34] fix(mobile): harden profile editing Signed-off-by: kenny lopez --- .../features/profile/emoji_avatar_tile.dart | 48 +++++++++++++++++++ .../profile/profile_avatar_editor.dart | 1 + .../emoji_avatar_picker.dart | 41 +++++----------- .../features/profile/profile_provider.dart | 11 +++-- .../features/profile/profile_text_editor.dart | 20 +++++++- .../profile/profile_edit_page_test.dart | 45 +---------------- .../image_selection_tests.dart | 44 +++++++++++++++++ .../motion_and_accessibility_tests.dart | 25 ++++++++++ .../profile/profile_edit_retry_test.dart | 45 +++++++++++++++++ .../profile/profile_provider_test.dart | 24 ++++++++++ 10 files changed, 224 insertions(+), 80 deletions(-) create mode 100644 mobile/lib/features/profile/emoji_avatar_tile.dart diff --git a/mobile/lib/features/profile/emoji_avatar_tile.dart b/mobile/lib/features/profile/emoji_avatar_tile.dart new file mode 100644 index 00000000000..cd2955e0127 --- /dev/null +++ b/mobile/lib/features/profile/emoji_avatar_tile.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +import '../../shared/emoji/native_emoji_glyph.dart'; +import '../../shared/theme/theme.dart'; + +/// A selectable emoji tile with an explicit accessibility selection state. +class EmojiAvatarTile extends StatelessWidget { + const EmojiAvatarTile({ + required this.emoji, + required this.label, + required this.tileId, + required this.isSelected, + required this.onTap, + super.key, + }); + + final String emoji; + final String label; + final String tileId; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => Semantics( + label: label, + button: true, + selected: isSelected, + onTap: onTap, + child: ExcludeSemantics( + child: InkWell( + key: ValueKey('emoji-avatar-$tileId'), + borderRadius: BorderRadius.circular(Radii.sm), + onTap: onTap, + child: DecoratedBox( + decoration: BoxDecoration( + color: isSelected + ? context.colors.primaryContainer + : Colors.transparent, + borderRadius: BorderRadius.circular(Radii.sm), + ), + child: Center( + child: NativeEmojiGlyph(emoji: emoji, size: 30, opticalBoxSize: 30), + ), + ), + ), + ), + ); +} diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index 59edf3dc1b7..997c5a1c65e 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -23,6 +23,7 @@ import '../../shared/widgets/playing_avatar_image.dart'; import 'animated_avatar_capture.dart'; import 'avatar_background_grid.dart'; import 'avatar_editor_option_button.dart'; +import 'emoji_avatar_tile.dart'; import 'profile_avatar_crop_page.dart'; import 'profile_avatar_draft.dart'; diff --git a/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart b/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart index 600baaeda68..6e8e1e3c821 100644 --- a/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart +++ b/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart @@ -153,36 +153,17 @@ class _EmojiMode extends HookConsumerWidget { final entry = visibleEmoji[index]; final isSelected = entry.native == selectedEmoji; - return InkWell( - key: ValueKey( - 'emoji-avatar-${entry.tileId}', - ), - borderRadius: BorderRadius.circular( - Radii.sm, - ), - onTap: () { - unawaited( - HapticFeedback.selectionClick(), - ); - onEmojiSelected(entry.native); - }, - child: DecoratedBox( - decoration: BoxDecoration( - color: isSelected - ? context.colors.primaryContainer - : Colors.transparent, - borderRadius: BorderRadius.circular( - Radii.sm, - ), - ), - child: Center( - child: NativeEmojiGlyph( - emoji: entry.native, - size: 30, - opticalBoxSize: 30, - ), - ), - ), + void selectEmoji() { + unawaited(HapticFeedback.selectionClick()); + onEmojiSelected(entry.native); + } + + return EmojiAvatarTile( + emoji: entry.native, + label: entry.name, + tileId: entry.tileId, + isSelected: isSelected, + onTap: selectEmoji, ); }, ), diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 3f8fbd6f9be..38231d0f0d3 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -228,11 +228,14 @@ NostrEvent? _latestProfileEvent(List events) { } Map _decodeProfileMetadata(NostrEvent event) { - final decoded = jsonDecode(event.content); - if (decoded is! Map) { - throw const FormatException('Profile metadata must be a JSON object.'); + try { + final decoded = jsonDecode(event.content); + return decoded is Map + ? Map.from(decoded) + : {}; + } on FormatException { + return {}; } - return Map.from(decoded); } final profileProvider = AsyncNotifierProvider( diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart index b1fbfa801ba..2804c36d711 100644 --- a/mobile/lib/features/profile/profile_text_editor.dart +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -15,7 +15,15 @@ import 'profile_provider.dart'; /// Opens the current user's display-name editor from a profile action surface. Future showProfileDisplayNameEditor(BuildContext context) async { final container = ProviderScope.containerOf(context, listen: false); - final profile = container.read(profileProvider).asData?.value; + try { + await container.read(profileProvider.future); + } catch (_) { + return; + } + if (!context.mounted) return; + final profileState = container.read(profileProvider); + if (!profileState.hasValue) return; + final profile = profileState.requireValue; final onSave = bindProfileSaveToOpeningContext( container, container.read(profileProvider.notifier).updateDisplayName, @@ -32,7 +40,15 @@ Future showProfileDisplayNameEditor(BuildContext context) async { /// Opens the current user's profile-description editor. Future showProfileDescriptionEditor(BuildContext context) async { final container = ProviderScope.containerOf(context, listen: false); - final profile = container.read(profileProvider).asData?.value; + try { + await container.read(profileProvider.future); + } catch (_) { + return; + } + if (!context.mounted) return; + final profileState = container.read(profileProvider); + if (!profileState.hasValue) return; + final profile = profileState.requireValue; final onSave = bindProfileSaveToOpeningContext( container, container.read(profileProvider.notifier).updateAbout, diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index 24b12ecb987..13dd364b61e 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -5,6 +5,7 @@ import 'package:buzz/features/profile/profile_edit_page.dart'; import 'package:buzz/features/profile/profile_avatar_crop_page.dart'; import 'package:buzz/features/profile/avatar_background_grid.dart'; import 'package:buzz/features/profile/avatar_editor_option_button.dart'; +import 'package:buzz/features/profile/emoji_avatar_tile.dart'; import 'package:buzz/shared/widgets/immediate_page_route.dart'; import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/shared/emoji/emoji_avatar.dart'; @@ -813,50 +814,6 @@ void main() { ); }); - testWidgets('seeds emoji editing from the current avatar', (tester) async { - final avatarUrl = emojiAvatarDataUrl('🦝', emojiAvatarColors[11]); - final notifier = _FakeProfileNotifier( - profile: UserProfile( - pubkey: 'aabb', - displayName: 'Alice', - avatarUrl: avatarUrl, - ), - ); - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(() => notifier)], - child: const ProfileEditPage(), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Edit Photo')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Emoji')); - await tester.pump(const Duration(milliseconds: 250)); - - final preview = find.byKey(const ValueKey('emoji-avatar-preview')); - expect( - tester - .widget( - find.descendant( - of: preview, - matching: find.byType(NativeEmojiGlyph), - ), - ) - .emoji, - '🦝', - ); - expect( - (tester.widget(preview).decoration! as BoxDecoration) - .color, - Color(emojiAvatarColors[11]), - ); - await tester.tap(find.byKey(const ValueKey('avatar-save'))); - await tester.pumpAndSettle(); - expect(notifier.savedAvatarUrls, [avatarUrl]); - }); - testWidgets('keeps emoji drafts scoped to the emoji mode', (tester) async { final notifier = _FakeProfileNotifier(); await tester.pumpWidget( diff --git a/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart b/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart index 63d1b21c474..ca6f7c90ddb 100644 --- a/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart +++ b/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart @@ -1,6 +1,50 @@ part of '../profile_edit_page_test.dart'; void runProfileEditImageSelectionTests() { + testWidgets('seeds emoji editing from the current avatar', (tester) async { + final avatarUrl = emojiAvatarDataUrl('🦝', emojiAvatarColors[11]); + final notifier = _FakeProfileNotifier( + profile: UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + avatarUrl: avatarUrl, + ), + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 250)); + + final preview = find.byKey(const ValueKey('emoji-avatar-preview')); + expect( + tester + .widget( + find.descendant( + of: preview, + matching: find.byType(NativeEmojiGlyph), + ), + ) + .emoji, + '🦝', + ); + expect( + (tester.widget(preview).decoration! as BoxDecoration) + .color, + Color(emojiAvatarColors[11]), + ); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + expect(notifier.savedAvatarUrls, [avatarUrl]); + }); + testWidgets('discards a delayed image after switching avatar modes', ( tester, ) async { diff --git a/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart b/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart index 7c1a0da5dc2..6ae727a4204 100644 --- a/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart +++ b/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart @@ -238,6 +238,31 @@ void runProfileEditMotionAndAccessibilityTests() { expect(selectedSemantics.properties.selected, isTrue); }); + testWidgets('exposes the selected emoji tile', (tester) async { + await tester.pumpWidget( + WidgetHelpers.testable( + child: EmojiAvatarTile( + emoji: '😊', + label: 'Smiling Face', + tileId: 'smile', + isSelected: true, + onTap: () {}, + ), + ), + ); + + final selectedTile = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.label == 'Smiling Face' && + widget.child is ExcludeSemantics, + ), + ); + expect(selectedTile.properties.button, isTrue); + expect(selectedTile.properties.selected, isTrue); + }); + testWidgets('uses the shared animated background grid for emoji avatars', ( tester, ) async { diff --git a/mobile/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart index 08ee0990bed..18266627bf7 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -16,6 +16,40 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../helpers/widget_helpers.dart'; void main() { + testWidgets('settings text editor waits for profile hydration', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final notifier = _DelayedHydrationProfileNotifier(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: Builder( + builder: (context) => TextButton( + onPressed: () => unawaited(showProfileDisplayNameEditor(context)), + child: const Text('Open editor'), + ), + ), + ), + ); + await tester.tap(find.text('Open editor')); + await tester.pump(); + expect(find.byKey(const ValueKey('profile-field-input')), findsNothing); + + notifier.completeHydration(); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byKey(const ValueKey('profile-field-input'))) + .controller + ?.text, + 'Hydrated name', + ); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('native text retry retains the failed value', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); @@ -471,6 +505,17 @@ class _MutableRelayConfigNotifier extends RelayConfigNotifier { ); } +class _DelayedHydrationProfileNotifier extends ProfileNotifier { + final _hydration = Completer(); + + @override + Future build() => _hydration.future; + + void completeHydration() => _hydration.complete( + const UserProfile(pubkey: 'aabb', displayName: 'Hydrated name'), + ); +} + class _RetryProfileNotifier extends ProfileNotifier { _RetryProfileNotifier({this.failedTextSaves = 0, this.failedAvatarSaves = 0}); diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index ef0eb13104f..5638656b715 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -109,6 +109,30 @@ void main() { expect(profile.label, '${keys.public.substring(0, 8)}...'); }); + test('malformed profile metadata can be repaired by an edit', () async { + final keys = nostr.Keys.generate(); + final relaySession = _ProfileRelaySession( + NostrEvent( + id: 'profile-malformed', + pubkey: keys.public, + createdAt: 1, + kind: EventKind.profile, + tags: const [], + content: 'not-json', + sig: 'sig', + ), + ); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + expect(await container.read(profileProvider.future), isNotNull); + await container.read(profileProvider.notifier).updateAbout('Repaired'); + + expect(jsonDecode(relaySession.published.single.content), { + 'about': 'Repaired', + }); + }); + test('profile updates fail closed while hydration is pending', () async { final keys = nostr.Keys.generate(); final history = Completer>(); From 9aff5d44bef55ad8bcf5b7596587dba666688fd2 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 15:01:17 +0100 Subject: [PATCH 29/34] fix(mobile): complete avatar editor accessibility Signed-off-by: kenny lopez --- .../profile/animated_avatar_capture.dart | 127 +++++++++--------- .../review_controls.dart | 35 +++++ .../emoji_avatar_picker.dart | 17 ++- .../profile/animated_avatar_capture_test.dart | 42 ++++++ .../profile/profile_edit_page_test.dart | 2 + .../image_selection_tests.dart | 87 ++++++++++++ 6 files changed, 249 insertions(+), 61 deletions(-) diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index 4c8f1e46629..a322fef055d 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -5,6 +5,7 @@ import 'dart:math'; import 'package:camera/camera.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -346,78 +347,84 @@ class AnimatedAvatarCapture extends HookConsumerWidget { top: previewTop, height: 220, child: Center( - child: GestureDetector( - key: const ValueKey('animated-avatar-review-preview'), - behavior: HitTestBehavior.opaque, - onScaleStart: (_) => gestureStartScale.value = scale.value, - onScaleUpdate: (details) { - final next = Offset( - (offset.value.dx + details.focalPointDelta.dx / 96).clamp( - -1, - 1, - ), - (offset.value.dy + details.focalPointDelta.dy / 96).clamp( - -1, - 1, - ), + child: _RepositionablePreviewSemantics( + offset: offset.value, + onMove: (delta) { + unawaited(HapticFeedback.selectionClick()); + offset.value = Offset( + (offset.value.dx + delta.dx).clamp(-1.0, 1.0), + (offset.value.dy + delta.dy).clamp(-1.0, 1.0), ); - offset.value = next; - scale.value = (gestureStartScale.value * details.scale) - .clamp(0.7, 2.0) - .toDouble(); }, - child: SizedBox.square( - dimension: 220, - child: Stack( - fit: StackFit.expand, - children: [ - ClipOval( - child: Stack( - fit: StackFit.expand, - children: [ - Center( - child: Transform.translate( - offset: - const Offset(0, 20.625) + - shapeOffset.value * 51.5625, - child: Transform.scale( - scale: shapeScale.value, - child: Container( - width: 172, - height: 172, - decoration: BoxDecoration( - color: Color(backdropColor.value), - shape: BoxShape.circle, + child: GestureDetector( + key: const ValueKey('animated-avatar-review-preview'), + behavior: HitTestBehavior.opaque, + onScaleStart: (_) => gestureStartScale.value = scale.value, + onScaleUpdate: (details) { + final next = Offset( + (offset.value.dx + details.focalPointDelta.dx / 96) + .clamp(-1, 1), + (offset.value.dy + details.focalPointDelta.dy / 96) + .clamp(-1, 1), + ); + offset.value = next; + scale.value = (gestureStartScale.value * details.scale) + .clamp(0.7, 2.0) + .toDouble(); + }, + child: SizedBox.square( + dimension: 220, + child: Stack( + fit: StackFit.expand, + children: [ + ClipOval( + child: Stack( + fit: StackFit.expand, + children: [ + Center( + child: Transform.translate( + offset: + const Offset(0, 20.625) + + shapeOffset.value * 51.5625, + child: Transform.scale( + scale: shapeScale.value, + child: Container( + width: 172, + height: 172, + decoration: BoxDecoration( + color: Color(backdropColor.value), + shape: BoxShape.circle, + ), ), ), ), ), - ), - _AnimatedPersonPreview( - bytes: selectedFrame, - offset: offset.value * 48, - scale: scale.value, - outline: personOutline.value, - outlineColor: _personOutlineColor( - backdropColor.value, + _AnimatedPersonPreview( + bytes: selectedFrame, + offset: offset.value * 48, + scale: scale.value, + outline: personOutline.value, + outlineColor: _personOutlineColor( + backdropColor.value, + ), ), - ), - ], + ], + ), ), - ), - IgnorePointer( - child: DecoratedBox( - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: context.colors.onSurface.withValues( - alpha: 0.1, + IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: context.colors.onSurface.withValues( + alpha: 0.1, + ), ), ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart b/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart index 68f8626ce9c..7c9ce4d9709 100644 --- a/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart +++ b/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart @@ -1,5 +1,40 @@ part of '../animated_avatar_capture.dart'; +class _RepositionablePreviewSemantics extends StatelessWidget { + const _RepositionablePreviewSemantics({ + required this.offset, + required this.onMove, + required this.child, + }); + + final Offset offset; + final ValueChanged onMove; + final Widget child; + + @override + Widget build(BuildContext context) => Semantics( + label: 'Avatar position', + value: + '${(offset.dx * 100).round()} horizontal, ' + '${(offset.dy * 100).round()} vertical', + customSemanticsActions: { + if (offset.dx > -1) + const CustomSemanticsAction(label: 'Move left'): () => + onMove(const Offset(-0.1, 0)), + if (offset.dx < 1) + const CustomSemanticsAction(label: 'Move right'): () => + onMove(const Offset(0.1, 0)), + if (offset.dy > -1) + const CustomSemanticsAction(label: 'Move up'): () => + onMove(const Offset(0, -0.1)), + if (offset.dy < 1) + const CustomSemanticsAction(label: 'Move down'): () => + onMove(const Offset(0, 0.1)), + }, + child: ExcludeSemantics(child: child), + ); +} + class _AnimatedReviewNav extends StatelessWidget { const _AnimatedReviewNav({ required this.selected, diff --git a/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart b/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart index 6e8e1e3c821..08bc6629394 100644 --- a/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart +++ b/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart @@ -36,7 +36,15 @@ class _EmojiMode extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final skinTone = useState(0); + final skinTone = useState(_skinToneForEmoji(dataset, selectedEmoji)); + final seededSkinTone = useRef(false); + useEffect(() { + if (seededSkinTone.value || dataset.isEmpty) return null; + seededSkinTone.value = true; + final initialTone = _skinToneForEmoji(dataset, selectedEmoji); + if (skinTone.value != initialTone) skinTone.value = initialTone; + return null; + }, [dataset]); final searchController = useTextEditingController(); useListenable(searchController); final visibleEmoji = useMemoized(() { @@ -320,6 +328,13 @@ class _SkinToneDot extends StatelessWidget { int _validSkinTone(int? value) => value != null && value >= 0 && value < _skinTones.length ? value : 0; +int _skinToneForEmoji(EmojiDataset dataset, String emoji) => + dataset.all + .where((entry) => entry.native == emoji) + .map((entry) => _validSkinTone(entry.skinIndex)) + .firstOrNull ?? + 0; + List _emojiForSkinTone(EmojiDataset dataset, int skinTone) { final variantsById = >{}; for (final entry in dataset.all) { diff --git a/mobile/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart index c7fcf359a0e..63059266321 100644 --- a/mobile/test/features/profile/animated_avatar_capture_test.dart +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -176,6 +176,48 @@ void main() { expect(tester.getSemantics(scrubber).value, '2 of 3'); semantics.dispose(); }); + + testWidgets('review preview exposes accessible repositioning actions', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final frame = image.encodePng(image.Image(width: 2, height: 2)); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: AnimatedAvatarCapture( + height: 600, + initialFrames: [frame], + onPrepareChanged: (_) {}, + ), + ), + ), + ), + ); + await tester.pump(); + + final position = find.bySemanticsLabel('Avatar position'); + expect(position, findsOneWidget); + final positionWidget = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.label == 'Avatar position', + ), + ); + final actions = positionWidget.properties.customSemanticsActions!; + expect( + actions.keys.map((action) => action.label), + containsAll(['Move left', 'Move right', 'Move up', 'Move down']), + ); + actions.entries + .firstWhere((entry) => entry.key.label == 'Move right') + .value(); + await tester.pump(); + expect(tester.getSemantics(position).value, '10 horizontal, 0 vertical'); + semantics.dispose(); + }); } class _TestLifecycleNotifier extends AppLifecycleNotifier { diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index 13dd364b61e..89ea055b464 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -9,6 +9,8 @@ import 'package:buzz/features/profile/emoji_avatar_tile.dart'; import 'package:buzz/shared/widgets/immediate_page_route.dart'; import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/shared/emoji/emoji_avatar.dart'; +import 'package:buzz/shared/emoji/emoji_data.dart'; +import 'package:buzz/shared/emoji/emoji_data_provider.dart'; import 'package:buzz/shared/emoji/native_emoji_glyph.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/relay/relay.dart'; diff --git a/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart b/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart index ca6f7c90ddb..8b9461a8939 100644 --- a/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart +++ b/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart @@ -45,6 +45,93 @@ void runProfileEditImageSelectionTests() { expect(notifier.savedAvatarUrls, [avatarUrl]); }); + testWidgets('seeds the skin-tone filter from the current emoji', ( + tester, + ) async { + const variants = [ + EmojiEntry( + id: '+1', + name: 'Thumbs Up', + keywords: ['thumb'], + native: '👍', + categoryId: 'people', + ), + EmojiEntry( + id: '+1', + name: 'Thumbs Up', + keywords: ['thumb'], + native: '👍🏻', + categoryId: 'people', + skinIndex: 1, + ), + EmojiEntry( + id: '+1', + name: 'Thumbs Up', + keywords: ['thumb'], + native: '👍🏼', + categoryId: 'people', + skinIndex: 2, + ), + EmojiEntry( + id: '+1', + name: 'Thumbs Up', + keywords: ['thumb'], + native: '👍🏽', + categoryId: 'people', + skinIndex: 3, + ), + EmojiEntry( + id: '+1', + name: 'Thumbs Up', + keywords: ['thumb'], + native: '👍🏾', + categoryId: 'people', + skinIndex: 4, + ), + EmojiEntry( + id: '+1', + name: 'Thumbs Up', + keywords: ['thumb'], + native: '👍🏿', + categoryId: 'people', + skinIndex: 5, + ), + ]; + const dataset = EmojiDataset( + categories: [EmojiCategory(id: 'people', emoji: variants)], + all: variants, + nativeToShortcode: {'👍🏽': ':+1:'}, + ); + final avatarUrl = emojiAvatarDataUrl('👍🏽', emojiAvatarColors[11]); + final notifier = _FakeProfileNotifier( + profile: UserProfile(pubkey: 'aabb', avatarUrl: avatarUrl), + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + emojiDatasetOrEmptyProvider.overrideWithValue(dataset), + ], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 250)); + + expect( + tester + .widget>( + find.byKey(const ValueKey('emoji-avatar-skin-tone')), + ) + .initialValue, + 3, + ); + }); + testWidgets('discards a delayed image after switching avatar modes', ( tester, ) async { From 47da32e18eb4ffe3f21d30c4611c604dccbd945e Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 15:11:24 +0100 Subject: [PATCH 30/34] fix(mobile): make photo cropping accessible Signed-off-by: kenny lopez --- .../features/profile/emoji_avatar_tile.dart | 10 ++ .../profile/profile_avatar_crop_page.dart | 109 +++++++++++++++--- .../image_selection_tests.dart | 42 +++++++ 3 files changed, 144 insertions(+), 17 deletions(-) diff --git a/mobile/lib/features/profile/emoji_avatar_tile.dart b/mobile/lib/features/profile/emoji_avatar_tile.dart index cd2955e0127..ff8e7f5dc77 100644 --- a/mobile/lib/features/profile/emoji_avatar_tile.dart +++ b/mobile/lib/features/profile/emoji_avatar_tile.dart @@ -5,6 +5,7 @@ import '../../shared/theme/theme.dart'; /// A selectable emoji tile with an explicit accessibility selection state. class EmojiAvatarTile extends StatelessWidget { + /// Creates an emoji option for an avatar picker. const EmojiAvatarTile({ required this.emoji, required this.label, @@ -14,10 +15,19 @@ class EmojiAvatarTile extends StatelessWidget { super.key, }); + /// The Unicode emoji glyph rendered by this tile. final String emoji; + + /// The human-readable emoji name announced to assistive technology. final String label; + + /// The stable identifier used for the tile's widget key. final String tileId; + + /// Whether this emoji is the current avatar selection. final bool isSelected; + + /// Called when the tile is selected. final VoidCallback onTap; @override diff --git a/mobile/lib/features/profile/profile_avatar_crop_page.dart b/mobile/lib/features/profile/profile_avatar_crop_page.dart index 54811de514e..fb60dbaaf2b 100644 --- a/mobile/lib/features/profile/profile_avatar_crop_page.dart +++ b/mobile/lib/features/profile/profile_avatar_crop_page.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:image/image.dart' as image; @@ -22,6 +23,7 @@ class ProfileAvatarCropPage extends HookWidget { @override Widget build(BuildContext context) { final controller = useTransformationController(); + final cropScale = useState(1.0); final preparedBytes = useState(null); final loadError = useState(false); final dimensionsFuture = useMemoized( @@ -204,6 +206,37 @@ class ProfileAvatarCropPage extends HookWidget { 1, ); } + void movePhoto(Offset delta) { + unawaited(HapticFeedback.selectionClick()); + final matrix = controller.value.clone(); + matrix.storage[12] += delta.dx; + matrix.storage[13] += delta.dy; + controller.value = matrix; + } + + void zoomPhoto(double factor) { + unawaited(HapticFeedback.selectionClick()); + final matrix = controller.value.clone(); + final currentScale = matrix.getMaxScaleOnAxis(); + final nextScale = (currentScale * factor) + .clamp(1.0, 5.0) + .toDouble(); + final appliedFactor = nextScale / currentScale; + final center = Offset(canvasWidth / 2, canvasHeight / 2); + matrix.storage[12] = + center.dx - + (center.dx - matrix.storage[12]) * appliedFactor; + matrix.storage[13] = + center.dy - + (center.dy - matrix.storage[13]) * appliedFactor; + matrix.storage[0] *= appliedFactor; + matrix.storage[5] *= appliedFactor; + matrix.storage[10] *= appliedFactor; + controller.value = matrix; + cropScale.value = nextScale; + } + + final currentScale = cropScale.value; final maskBottom = (canvasHeight + cropDiameter) / 2; return Stack( children: [ @@ -214,23 +247,65 @@ class ProfileAvatarCropPage extends HookWidget { fit: StackFit.expand, children: [ ClipRect( - child: InteractiveViewer( - key: const ValueKey('avatar-crop-viewer'), - transformationController: controller, - constrained: false, - panEnabled: true, - scaleEnabled: true, - minScale: 1, - maxScale: 5, - boundaryMargin: EdgeInsets.all( - math.max(canvasWidth, canvasHeight), - ), - child: Image.memory( - preparedBytes.value!, - width: displayWidth, - height: displayHeight, - fit: BoxFit.fill, - gaplessPlayback: true, + child: Semantics( + label: 'Photo crop', + value: '${(currentScale * 100).round()}% zoom', + customSemanticsActions: { + const CustomSemanticsAction( + label: 'Move left', + ): () => + movePhoto(const Offset(-24, 0)), + const CustomSemanticsAction( + label: 'Move right', + ): () => + movePhoto(const Offset(24, 0)), + const CustomSemanticsAction( + label: 'Move up', + ): () => + movePhoto(const Offset(0, -24)), + const CustomSemanticsAction( + label: 'Move down', + ): () => + movePhoto(const Offset(0, 24)), + if (currentScale < 5) + const CustomSemanticsAction( + label: 'Zoom in', + ): () => + zoomPhoto(1.1), + if (currentScale > 1) + const CustomSemanticsAction( + label: 'Zoom out', + ): () => + zoomPhoto(1 / 1.1), + }, + child: ExcludeSemantics( + child: InteractiveViewer( + key: const ValueKey('avatar-crop-viewer'), + transformationController: controller, + constrained: false, + panEnabled: true, + scaleEnabled: true, + minScale: 1, + maxScale: 5, + onInteractionUpdate: (_) { + final nextScale = controller.value + .getMaxScaleOnAxis(); + if ((cropScale.value - nextScale).abs() > + 0.001) { + cropScale.value = nextScale; + } + }, + boundaryMargin: EdgeInsets.all( + math.max(canvasWidth, canvasHeight), + ), + child: Image.memory( + preparedBytes.value!, + width: displayWidth, + height: displayHeight, + fit: BoxFit.fill, + gaplessPlayback: true, + ), + ), ), ), ), diff --git a/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart b/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart index 8b9461a8939..10a23be6637 100644 --- a/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart +++ b/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart @@ -1,6 +1,48 @@ part of '../profile_edit_page_test.dart'; void runProfileEditImageSelectionTests() { + testWidgets('photo crop exposes accessible move and zoom actions', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final bytes = Uint8List.fromList( + image.encodePng(image.Image(width: 20, height: 10)), + ); + await tester.pumpWidget( + MaterialApp( + home: ProfileAvatarCropPage( + imageBytes: Future.value(bytes), + ), + ), + ); + await _waitForAvatarCropToLoad(tester); + + final cropSemantics = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.label == 'Photo crop', + ), + ); + final actions = cropSemantics.properties.customSemanticsActions!; + expect( + actions.keys.map((action) => action.label), + containsAll([ + 'Move left', + 'Move right', + 'Move up', + 'Move down', + 'Zoom in', + ]), + ); + final viewer = tester.widget( + find.byKey(const ValueKey('avatar-crop-viewer')), + ); + actions.entries.firstWhere((entry) => entry.key.label == 'Zoom in').value(); + await tester.pump(); + expect(viewer.transformationController!.value.getMaxScaleOnAxis(), 1.1); + semantics.dispose(); + }); + testWidgets('seeds emoji editing from the current avatar', (tester) async { final avatarUrl = emojiAvatarDataUrl('🦝', emojiAvatarColors[11]); final notifier = _FakeProfileNotifier( From 4b5b4078cab748cc7d23bcaa30769db4e003bc07 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 15:22:15 +0100 Subject: [PATCH 31/34] fix(mobile): guard avatar editor races Signed-off-by: kenny lopez --- .../features/profile/profile_edit_page.dart | 47 +++++++++--- .../image_selection_tests.dart | 34 +++++++++ .../profile/profile_edit_retry_test.dart | 71 +++++++++++++++++++ 3 files changed, 141 insertions(+), 11 deletions(-) diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 208bc48b034..207dad3976e 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -56,6 +56,7 @@ class ProfileEditPage extends HookConsumerWidget { startInPhotoEditor ? ref.read(relayConfigProvider) : null, ); final isSavingAvatar = useState(false); + final isClosingAvatar = useState(false); final prepareAnimatedAvatar = useRef Function()?>(null); final avatarSaveError = useState(null); @@ -153,7 +154,10 @@ class ProfileEditPage extends HookConsumerWidget { } Future closeAvatarEditor({bool whileSaving = false}) async { - if (isSavingAvatar.value && !whileSaving) return; + if ((isSavingAvatar.value && !whileSaving) || isClosingAvatar.value) { + return; + } + isClosingAvatar.value = true; await avatarTransition.reverse(); if (!context.mounted) return; if (startInPhotoEditor) { @@ -167,10 +171,11 @@ class ProfileEditPage extends HookConsumerWidget { prepareAnimatedAvatar.value = null; canPrepareAnimatedAvatar.value = false; avatarMode.value = ProfileAvatarMode.image; + isClosingAvatar.value = false; } Future saveAvatar() async { - if (isSavingAvatar.value) return; + if (isSavingAvatar.value || isClosingAvatar.value) return; final openingConfig = avatarEditConfig.value; if (openingConfig == null) return; final saveConfig = ref.read(relayConfigProvider); @@ -187,6 +192,14 @@ class ProfileEditPage extends HookConsumerWidget { } } + Future discardStaleEditor() async { + avatarDraft.value = null; + avatarDraftMode.value = null; + prepareAnimatedAvatar.value = null; + canPrepareAnimatedAvatar.value = false; + if (context.mounted) await closeAvatarEditor(whileSaving: true); + } + isSavingAvatar.value = true; avatarSaveError.value = null; try { @@ -211,12 +224,14 @@ class ProfileEditPage extends HookConsumerWidget { requireCurrentCommunity(); if (context.mounted) await closeAvatarEditor(whileSaving: true); } on ProfileCommunityChangedException { - avatarDraft.value = null; - avatarDraftMode.value = null; - prepareAnimatedAvatar.value = null; - canPrepareAnimatedAvatar.value = false; - if (context.mounted) await closeAvatarEditor(whileSaving: true); + await discardStaleEditor(); } catch (_) { + try { + requireCurrentCommunity(); + } on ProfileCommunityChangedException { + await discardStaleEditor(); + return; + } avatarSaveError.value = "We couldn't save your profile photo. Try again."; } finally { @@ -261,12 +276,16 @@ class ProfileEditPage extends HookConsumerWidget { key: const ValueKey('avatar-editor-back'), icon: IosGlassNavigationIcon.back, semanticLabel: 'Back to profile', - onPressed: () => unawaited(closeAvatarEditor()), + onPressed: isClosingAvatar.value + ? null + : () => unawaited(closeAvatarEditor()), ) : IconButton( key: const ValueKey('avatar-editor-back'), tooltip: 'Back to profile', - onPressed: () => unawaited(closeAvatarEditor()), + onPressed: isClosingAvatar.value + ? null + : () => unawaited(closeAvatarEditor()), icon: const Icon(LucideIcons.arrowLeft), ) : null, @@ -278,7 +297,10 @@ class ProfileEditPage extends HookConsumerWidget { label: 'Save', width: 72, isBusy: isSavingAvatar.value, - onPressed: canSaveAvatar && !isSavingAvatar.value + onPressed: + canSaveAvatar && + !isSavingAvatar.value && + !isClosingAvatar.value ? () { unawaited(HapticFeedback.lightImpact()); unawaited(saveAvatar()); @@ -292,7 +314,10 @@ class ProfileEditPage extends HookConsumerWidget { key: const ValueKey('avatar-save'), label: 'Save', isBusy: isSavingAvatar.value, - onTap: canSaveAvatar && !isSavingAvatar.value + onTap: + canSaveAvatar && + !isSavingAvatar.value && + !isClosingAvatar.value ? () { unawaited(HapticFeedback.lightImpact()); unawaited(saveAvatar()); diff --git a/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart b/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart index 10a23be6637..62c5455dc1a 100644 --- a/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart +++ b/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart @@ -1,6 +1,40 @@ part of '../profile_edit_page_test.dart'; void runProfileEditImageSelectionTests() { + testWidgets('duplicate avatar Back taps pop only the editor route', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => unawaited( + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + const ProfileEditPage(startInPhotoEditor: true), + ), + ), + ), + child: const Text('Open profile photo'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open profile photo')); + await tester.pumpAndSettle(); + + final back = find.byKey(const ValueKey('avatar-editor-back')); + await tester.tap(back); + await tester.tap(back); + await tester.pumpAndSettle(); + + expect(find.text('Open profile photo'), findsOneWidget); + }); + testWidgets('photo crop exposes accessible move and zoom actions', ( tester, ) async { diff --git a/mobile/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart index 18266627bf7..0c157c6930d 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -495,6 +495,71 @@ void main() { expect(find.byKey(const ValueKey('avatar-save')), findsNothing); debugDefaultTargetPlatformOverride = null; }); + + testWidgets('avatar editor closes when a switched upload throws', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final notifier = _RetryProfileNotifier(); + final config = _MutableRelayConfigNotifier(); + final firstUpload = _RetryMediaUploadService( + baseUrl: 'https://first.example', + delayUpload: true, + ); + final secondUpload = _RetryMediaUploadService( + baseUrl: 'https://second.example', + ); + addTearDown(firstUpload.dispose); + addTearDown(secondUpload.dispose); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + relayConfigProvider.overrideWith(() => config), + mediaUploadServiceProvider.overrideWith((ref) { + final current = ref.watch(relayConfigProvider); + return current.baseUrl == 'https://first.example' + ? firstUpload + : secondUpload; + }), + ], + child: ProfileEditPage( + startInPhotoEditor: true, + animatedAvatarCaptureBuilder: + ({required height, required onPrepareChanged}) => HookBuilder( + builder: (context) { + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + onPrepareChanged( + () async => ProfileImageAvatarDraft( + Uint8List.fromList([1, 2, 3]), + ), + ); + }); + return null; + }, const []); + return SizedBox(height: height); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Animated')); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pump(); + config.update(baseUrl: 'https://second.example', nsec: 'second-identity'); + firstUpload.failUpload(); + await tester.pumpAndSettle(); + + expect(notifier.savedAvatarUrls, isEmpty); + expect(find.byKey(const ValueKey('avatar-save')), findsNothing); + debugDefaultTargetPlatformOverride = null; + }); } class _MutableRelayConfigNotifier extends RelayConfigNotifier { @@ -607,6 +672,12 @@ class _RetryMediaUploadService extends MediaUploadService { if (!_pendingUpload.isCompleted) _pendingUpload.complete(); } + void failUpload() { + if (!_pendingUpload.isCompleted) { + _pendingUpload.completeError(Exception('upload client closed')); + } + } + @override Future uploadBytes( Uint8List bytes, { From 2a0261b087a76a71c9abb61c011ae383682fae06 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 15:41:18 +0100 Subject: [PATCH 32/34] fix(mobile): finish avatar capture review Signed-off-by: kenny lopez --- .../profile/animated_avatar_capture.dart | 33 +++++--- .../profile/avatar_background_grid.dart | 2 +- .../profile/profile_edit_page_test.dart | 48 ----------- .../motion_and_accessibility_tests.dart | 84 +++++++++++++++++++ 4 files changed, 107 insertions(+), 60 deletions(-) diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index a322fef055d..e503442d7d3 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -59,6 +59,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final controller = useState(null); final controllerRef = useRef(null); + final captureEpoch = useRef(0); final cameraGeneration = useState(0); final isInitializing = useState(true); final isRecording = useState(false); @@ -160,6 +161,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { unawaited(initialize()); return () { disposed = true; + captureEpoch.value++; final active = controllerRef.value; controllerRef.value = null; unawaited(active?.dispose() ?? Future.value()); @@ -213,6 +215,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { Future record() async { final active = controller.value; if (active == null || isRecording.value) return; + final currentCapture = ++captureEpoch.value; frames.value = const []; posterIndex.value = 0; previewFrameIndex.value = 0; @@ -230,7 +233,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { var releasedCamera = false; Future releaseCamera() async { - if (releasedCamera) return; + if (releasedCamera || captureEpoch.value != currentCapture) return; releasedCamera = true; if (identical(controllerRef.value, active)) { controllerRef.value = null; @@ -242,7 +245,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { } final timer = Timer.periodic(const Duration(milliseconds: 40), (_) { - if (!context.mounted) return; + if (!context.mounted || captureEpoch.value != currentCapture) return; final elapsed = DateTime.now().difference(startedAt); progress.value = (elapsed.inMilliseconds / _captureDuration.inMilliseconds).clamp( @@ -254,7 +257,8 @@ class AnimatedAvatarCapture extends HookConsumerWidget { try { await active.startImageStream((cameraImage) async { final now = DateTime.now(); - if (converting || + if (captureEpoch.value != currentCapture || + converting || now.difference(lastFrameAt) < _captureFrameInterval || now.difference(startedAt) >= _captureDuration) { return; @@ -272,27 +276,29 @@ class AnimatedAvatarCapture extends HookConsumerWidget { mirror: active.description.lensDirection == CameraLensDirection.front, ); - captured.add(await compute(_convertCameraFrame, request)); + final frame = await compute(_convertCameraFrame, request); + if (captureEpoch.value == currentCapture) captured.add(frame); } finally { converting = false; } }); await Future.delayed(_captureDuration); + if (captureEpoch.value != currentCapture || !context.mounted) return; await active.stopImageStream(); - while (converting) { + while (converting && captureEpoch.value == currentCapture) { await Future.delayed(const Duration(milliseconds: 10)); } + if (captureEpoch.value != currentCapture || !context.mounted) return; await releaseCamera(); if (captured.length < 2) { throw StateError('Not enough frames were captured.'); } isRecording.value = false; isPreparingFrames.value = true; - // Camera image conversion throughput differs by platform. Cut out only - // the frames each device captured, then resample the same three-second - // window onto one fixed timeline so Android and iOS produce the same - // frame count and playback cadence without duplicating segmentation. + // Cut out only the frames each device captured, then resample the + // three-second window so Android and iOS use the same playback cadence. final cutouts = await _removeBackgrounds(captured); + if (captureEpoch.value != currentCapture || !context.mounted) return; final processed = List.unmodifiable( _resampleCapturedFrames(cutouts, _captureFrameCount), ); @@ -303,8 +309,13 @@ class AnimatedAvatarCapture extends HookConsumerWidget { ]); if (context.mounted) frames.value = processed; } catch (_) { - if (!releasedCamera && active.value.isStreamingImages) { - await active.stopImageStream(); + if (captureEpoch.value != currentCapture || !context.mounted) return; + try { + if (!releasedCamera && active.value.isStreamingImages) { + await active.stopImageStream(); + } + } on CameraException { + // The camera can stop independently while the capture is unwinding. } await releaseCamera(); if (context.mounted) { diff --git a/mobile/lib/features/profile/avatar_background_grid.dart b/mobile/lib/features/profile/avatar_background_grid.dart index 89a41f39c79..220462ab53c 100644 --- a/mobile/lib/features/profile/avatar_background_grid.dart +++ b/mobile/lib/features/profile/avatar_background_grid.dart @@ -31,8 +31,8 @@ class AvatarBackgroundGrid extends StatelessWidget { @override Widget build(BuildContext context) => GridView.builder( shrinkWrap: true, + primary: false, padding: EdgeInsets.zero, - physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 6, crossAxisSpacing: Grid.xxs, diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index 89ea055b464..c1037943ba1 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -435,54 +435,6 @@ void main() { expect(uploadService.uploadCount, 1); }); - testWidgets('photo modes remain usable on a compact large-type viewport', ( - tester, - ) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(320, 568); - addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(tester.view.resetPhysicalSize); - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], - child: const MediaQuery( - data: MediaQueryData(textScaler: TextScaler.linear(2)), - child: ProfileEditPage(startInPhotoEditor: true), - ), - ), - ); - await tester.pump(const Duration(milliseconds: 250)); - expect(tester.takeException(), isNull); - expect( - find.byKey(const ValueKey('avatar-editor-scroll-view')), - findsOneWidget, - ); - - await tester.tap(find.text('Emoji')); - await tester.pump(const Duration(milliseconds: 250)); - expect(tester.takeException(), isNull); - await tester.ensureVisible( - find.byKey(const ValueKey('emoji-editor-background')), - ); - await tester.tap(find.byKey(const ValueKey('emoji-editor-background'))); - await tester.pump(const Duration(milliseconds: 200)); - expect(tester.takeException(), isNull); - - await tester.drag( - find.byKey(const ValueKey('avatar-editor-scroll-view')), - const Offset(0, 1000), - ); - await tester.pump(); - final animatedMode = find.byKey(const ValueKey('avatar-mode-animated')); - await tester.tap(animatedMode); - await tester.pump(const Duration(milliseconds: 250)); - expect(tester.takeException(), isNull); - expect( - find.byKey(const ValueKey('animated-avatar-capture-preview')), - findsOneWidget, - ); - }); - testWidgets('centers every preview while controls fill the page gutters', ( tester, ) async { diff --git a/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart b/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart index 6ae727a4204..2c0c7ebf816 100644 --- a/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart +++ b/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart @@ -1,6 +1,58 @@ part of '../profile_edit_page_test.dart'; void runProfileEditMotionAndAccessibilityTests() { + testWidgets('photo modes remain usable on a compact large-type viewport', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(320, 568); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(2)), + child: ProfileEditPage(startInPhotoEditor: true), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 250)); + expect(tester.takeException(), isNull); + expect( + find.byKey(const ValueKey('avatar-editor-scroll-view')), + findsOneWidget, + ); + + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 250)); + expect(tester.takeException(), isNull); + await tester.ensureVisible( + find.byKey(const ValueKey('emoji-editor-background')), + ); + await tester.tap(find.byKey(const ValueKey('emoji-editor-background'))); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.takeException(), isNull); + + await tester.drag( + find.byKey(const ValueKey('avatar-editor-scroll-view')), + const Offset(0, 1000), + ); + await tester.pump(); + final animatedMode = find.byKey(const ValueKey('avatar-mode-animated')); + await Scrollable.ensureVisible( + animatedMode.evaluate().single, + alignment: 0.2, + ); + await tester.tap(animatedMode); + await tester.pump(const Duration(milliseconds: 250)); + expect(tester.takeException(), isNull); + expect( + find.byKey(const ValueKey('animated-avatar-capture-preview')), + findsOneWidget, + ); + }); + testWidgets('exposes the selected avatar mode on Android', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; addTearDown(() => debugDefaultTargetPlatformOverride = null); @@ -284,4 +336,36 @@ void runProfileEditMotionAndAccessibilityTests() { final firstColor = find.byKey(const ValueKey('emoji-avatar-color-0')); expect(tester.getSize(firstColor), const Size.square(52)); }); + + testWidgets('background colors remain reachable in compact layouts', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + child: SizedBox( + height: 150, + child: AvatarBackgroundGrid( + selectedColor: emojiAvatarColors.first, + onColorSelected: (_) {}, + ), + ), + ), + ); + + final scrollable = tester.state( + find.byType(Scrollable).first, + ); + expect(scrollable.position.maxScrollExtent, greaterThan(0)); + + await tester.drag(find.byType(AvatarBackgroundGrid), const Offset(0, -400)); + await tester.pumpAndSettle(); + + expect(scrollable.position.pixels, greaterThan(0)); + expect( + find.byKey( + ValueKey('avatar-background-color-${emojiAvatarColors.length - 1}'), + ), + findsOneWidget, + ); + }); } From 3327047efa0297b178db6b409e7b8f8ac87d5a48 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 15:57:37 +0100 Subject: [PATCH 33/34] fix(mobile): refine avatar mode motion Signed-off-by: kenny lopez --- .../capture_controls.dart | 2 +- .../profile/profile_avatar_editor.dart | 43 ++- .../emoji_avatar_picker.dart | 307 ++++++++++-------- .../profile/profile_edit_page_test.dart | 7 + .../motion_and_accessibility_tests.dart | 46 +++ 5 files changed, 269 insertions(+), 136 deletions(-) diff --git a/mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart b/mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart index 7d5968721ad..0c87c10b504 100644 --- a/mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart +++ b/mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart @@ -21,7 +21,7 @@ class _AnimatedRecordButton extends StatelessWidget { height: 64, child: Material( color: context.colors.onSurface, - borderRadius: BorderRadius.circular(Radii.card), + borderRadius: BorderRadius.circular(Radii.full), clipBehavior: Clip.antiAlias, child: InkWell( key: const ValueKey('animated-avatar-record'), diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index 997c5a1c65e..df7bbbe348d 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -50,7 +50,7 @@ typedef AnimatedAvatarCaptureBuilder = }); const _previewSize = 220.0; -const _motionDuration = Duration(milliseconds: 240); +const _motionDuration = Duration(milliseconds: 150); const _previewSquishDuration = Duration(milliseconds: 200); const Curve _entranceCurve = Curves.easeOutCubic; const Curve _exitCurve = Curves.easeInCubic; @@ -133,6 +133,8 @@ class ProfileAvatarEditor extends HookConsumerWidget { final error = useState(null); final dataset = ref.watch(emojiDatasetOrEmptyProvider); final modeTransitionDirection = useRef(1.0); + final retainedPreview = useRef(null); + final retainedPreviewTop = useRef(0.0); final modeTransitionController = useAnimationController( duration: _motionDuration, initialValue: 1, @@ -269,6 +271,16 @@ class ProfileAvatarEditor extends HookConsumerWidget { ), ], ); + if (mode != ProfileAvatarMode.animated && fixedPreview != null) { + retainedPreview.value = mode == ProfileAvatarMode.emoji + ? _EmojiAvatarPreview( + emoji: selectedEmoji.value, + color: Color(selectedColor.value), + animationKey: emojiPreviewKey.value, + reduceMotion: true, + ) + : fixedPreview; + } final curvedEntrance = CurvedAnimation( parent: transition, @@ -296,6 +308,9 @@ class ProfileAvatarEditor extends HookConsumerWidget { : avatarBackgroundPreviewShift; final previewShift = min(requestedShift, maximumShift); final previewTop = basePreviewTop - previewShift; + if (mode != ProfileAvatarMode.animated) { + retainedPreviewTop.value = previewTop; + } final fixedContentTop = previewTop + _previewBlockSize + _previewControlGap; final modeTop = mode == ProfileAvatarMode.animated @@ -320,6 +335,8 @@ class ProfileAvatarEditor extends HookConsumerWidget { dataset: dataset, selectedEmoji: selectedEmoji.value, selectedColor: selectedColor.value, + transitionProgress: modeTransitionProgress, + transitionDirection: modeTransitionDirection.value, onSectionChanged: (section) => emojiSection.value = section, onEmojiSelected: (emoji) { selectedEmoji.value = emoji; @@ -347,7 +364,9 @@ class ProfileAvatarEditor extends HookConsumerWidget { appBarHeight + _settingsAvatarCenterBelowAppBar - (previewTop + _previewBlockSize / 2); - final transitionedModeContent = mode == ProfileAvatarMode.animated + final transitionedModeContent = + mode == ProfileAvatarMode.animated || + mode == ProfileAvatarMode.emoji ? modeContent : ClipRect( child: Transform.translate( @@ -452,6 +471,26 @@ class ProfileAvatarEditor extends HookConsumerWidget { child: transitionedModeContent, ), ), + if (mode == ProfileAvatarMode.animated && + !reduceMotion && + modeTransitionProgress < 1 && + retainedPreview.value != null) + Positioned( + key: const ValueKey('avatar-mode-retained-preview'), + left: Grid.gutter, + right: Grid.gutter, + top: + retainedPreviewTop.value + + (basePreviewTop - retainedPreviewTop.value) * + modeTransitionProgress, + height: _previewBlockSize, + child: IgnorePointer( + child: Opacity( + opacity: 1 - modeTransitionProgress, + child: Center(child: retainedPreview.value), + ), + ), + ), if (error.value != null) Positioned( left: Grid.gutter, diff --git a/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart b/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart index 08bc6629394..1712c0c99bd 100644 --- a/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart +++ b/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart @@ -20,6 +20,8 @@ class _EmojiMode extends HookConsumerWidget { required this.dataset, required this.selectedEmoji, required this.selectedColor, + required this.transitionProgress, + required this.transitionDirection, required this.onSectionChanged, required this.onEmojiSelected, required this.onColorSelected, @@ -30,12 +32,21 @@ class _EmojiMode extends HookConsumerWidget { final EmojiDataset dataset; final String selectedEmoji; final int selectedColor; + final double transitionProgress; + final double transitionDirection; final ValueChanged<_EmojiEditorSection> onSectionChanged; final ValueChanged onEmojiSelected; final ValueChanged onColorSelected; @override Widget build(BuildContext context, WidgetRef ref) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final trayOffset = reduceMotion ? 0.0 : 16 * (1 - transitionProgress); + final actionOffset = reduceMotion + ? 0.0 + : transitionDirection * + _modeTransitionDistance * + (1 - transitionProgress); final skinTone = useState(_skinToneForEmoji(dataset, selectedEmoji)); final seededSkinTone = useRef(false); useEffect(() { @@ -65,148 +76,178 @@ class _EmojiMode extends HookConsumerWidget { child: Column( children: [ Expanded( - child: activeSection == _EmojiEditorSection.background - ? AvatarBackgroundGrid( - key: const ValueKey('emoji-background-editor'), - selectedColor: selectedColor, - onColorSelected: onColorSelected, - colorKeyPrefix: 'emoji-avatar-color', - ) - : Column( - key: const ValueKey('emoji-glyph-editor'), - children: [ - Row( - children: [ - Expanded( - child: TextField( - key: const ValueKey('emoji-avatar-search'), - controller: searchController, - textInputAction: TextInputAction.search, - decoration: InputDecoration( - hintText: 'Search emoji', - filled: true, - fillColor: - context.colors.surfaceContainerHighest, - contentPadding: const EdgeInsets.symmetric( - vertical: 10, - ), - border: const OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(Radii.full), - ), - borderSide: BorderSide.none, - ), - enabledBorder: const OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(Radii.full), - ), - borderSide: BorderSide.none, - ), - focusedBorder: const OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(Radii.full), - ), - borderSide: BorderSide.none, - ), - prefixIcon: const Icon( - LucideIcons.search, - size: 20, - ), - suffixIcon: searchController.text.isEmpty - ? null - : IconButton( - tooltip: 'Clear search', - onPressed: searchController.clear, - icon: const Icon( - LucideIcons.x, - size: 18, + child: ClipRect( + child: Transform.translate( + key: const ValueKey('avatar-mode-tray-transition-transform'), + offset: Offset(0, trayOffset), + child: Opacity( + opacity: transitionProgress, + child: activeSection == _EmojiEditorSection.background + ? AvatarBackgroundGrid( + key: const ValueKey('emoji-background-editor'), + selectedColor: selectedColor, + onColorSelected: onColorSelected, + colorKeyPrefix: 'emoji-avatar-color', + ) + : Column( + key: const ValueKey('emoji-glyph-editor'), + children: [ + Row( + children: [ + Expanded( + child: TextField( + key: const ValueKey('emoji-avatar-search'), + controller: searchController, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: 'Search emoji', + filled: true, + fillColor: context + .colors + .surfaceContainerHighest, + contentPadding: + const EdgeInsets.symmetric( + vertical: 10, + ), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(Radii.full), ), + borderSide: BorderSide.none, ), - ), - ), - ), - const SizedBox(width: Grid.xxs), - _AvatarSkinToneSelector( - value: skinTone.value, - onChanged: selectSkinTone, - ), - ], - ), - const SizedBox(height: Grid.xxs), - Expanded( - child: dataset.isEmpty - ? const Center(child: CircularProgressIndicator()) - : visibleEmoji.isEmpty - ? Center( - child: Text( - 'No emoji found', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, + enabledBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(Radii.full), + ), + borderSide: BorderSide.none, + ), + focusedBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(Radii.full), + ), + borderSide: BorderSide.none, + ), + prefixIcon: const Icon( + LucideIcons.search, + size: 20, + ), + suffixIcon: searchController.text.isEmpty + ? null + : IconButton( + tooltip: 'Clear search', + onPressed: searchController.clear, + icon: const Icon( + LucideIcons.x, + size: 18, + ), + ), + ), ), ), - ) - : GridView.builder( - key: const ValueKey('emoji-avatar-grid'), - padding: EdgeInsets.zero, - gridDelegate: - SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: - defaultTargetPlatform == - TargetPlatform.iOS - ? 8 - : 7, - ), - itemCount: visibleEmoji.length, - itemBuilder: (context, index) { - final entry = visibleEmoji[index]; - final isSelected = - entry.native == selectedEmoji; - void selectEmoji() { - unawaited(HapticFeedback.selectionClick()); - onEmojiSelected(entry.native); - } + const SizedBox(width: Grid.xxs), + _AvatarSkinToneSelector( + value: skinTone.value, + onChanged: selectSkinTone, + ), + ], + ), + const SizedBox(height: Grid.xxs), + Expanded( + child: dataset.isEmpty + ? const Center( + child: CircularProgressIndicator(), + ) + : visibleEmoji.isEmpty + ? Center( + child: Text( + 'No emoji found', + style: context.textTheme.bodyMedium + ?.copyWith( + color: context + .colors + .onSurfaceVariant, + ), + ), + ) + : GridView.builder( + key: const ValueKey('emoji-avatar-grid'), + padding: EdgeInsets.zero, + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: + defaultTargetPlatform == + TargetPlatform.iOS + ? 8 + : 7, + ), + itemCount: visibleEmoji.length, + itemBuilder: (context, index) { + final entry = visibleEmoji[index]; + final isSelected = + entry.native == selectedEmoji; + void selectEmoji() { + unawaited( + HapticFeedback.selectionClick(), + ); + onEmojiSelected(entry.native); + } - return EmojiAvatarTile( - emoji: entry.native, - label: entry.name, - tileId: entry.tileId, - isSelected: isSelected, - onTap: selectEmoji, - ); - }, - ), - ), - ], - ), - ), - const SizedBox(height: Grid.xs), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Spacer(), - Expanded( - child: AvatarEditorOptionButton( - key: const ValueKey('emoji-editor-background'), - icon: LucideIcons.palette, - label: 'Background', - selected: activeSection == _EmojiEditorSection.background, - onTap: () => onSectionChanged(_EmojiEditorSection.background), - labelMaxWidth: 96, + return EmojiAvatarTile( + emoji: entry.native, + label: entry.name, + tileId: entry.tileId, + isSelected: isSelected, + onTap: selectEmoji, + ); + }, + ), + ), + ], + ), ), ), - const SizedBox(width: Grid.half), - Expanded( - child: AvatarEditorOptionButton( - key: const ValueKey('emoji-editor-emoji'), - icon: LucideIcons.smile, - label: 'Emoji', - selected: activeSection == _EmojiEditorSection.emoji, - onTap: () => onSectionChanged(_EmojiEditorSection.emoji), - labelMaxWidth: 80, + ), + ), + const SizedBox(height: Grid.xs), + ClipRect( + child: Transform.translate( + key: const ValueKey('avatar-mode-transition-transform'), + offset: Offset(actionOffset, 0), + child: Opacity( + opacity: transitionProgress, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Spacer(), + Expanded( + child: AvatarEditorOptionButton( + key: const ValueKey('emoji-editor-background'), + icon: LucideIcons.palette, + label: 'Background', + selected: + activeSection == _EmojiEditorSection.background, + onTap: () => + onSectionChanged(_EmojiEditorSection.background), + labelMaxWidth: 96, + ), + ), + const SizedBox(width: Grid.half), + Expanded( + child: AvatarEditorOptionButton( + key: const ValueKey('emoji-editor-emoji'), + icon: LucideIcons.smile, + label: 'Emoji', + selected: activeSection == _EmojiEditorSection.emoji, + onTap: () => + onSectionChanged(_EmojiEditorSection.emoji), + labelMaxWidth: 80, + ), + ), + const Spacer(), + ], ), ), - const Spacer(), - ], + ), ), ], ), diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index c1037943ba1..3c405475eb8 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -572,6 +572,13 @@ void main() { tester.getSize(recordButton).width, closeTo(expectedContentWidth, 0.01), ); + final recordMaterial = tester.widget( + find.descendant(of: recordButton, matching: find.byType(Material)).first, + ); + expect( + recordMaterial.borderRadius, + const BorderRadius.all(Radius.circular(Radii.full)), + ); expect( screenSize.height - tester.getRect(recordButton).bottom, _editorControlBottomForTest, diff --git a/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart b/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart index 2c0c7ebf816..7853746b065 100644 --- a/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart +++ b/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart @@ -113,6 +113,11 @@ void runProfileEditMotionAndAccessibilityTests() { await tester.tap(find.text('Emoji')); await tester.pump(); + final trayTransform = tester.widget( + find.byKey(const ValueKey('avatar-mode-tray-transition-transform')), + ); + expect(trayTransform.transform.getTranslation().x, 0); + expect(trayTransform.transform.getTranslation().y, greaterThan(0)); final forwardTransform = tester.widget( find.byKey(const ValueKey('avatar-mode-transition-transform')), ); @@ -128,6 +133,16 @@ void runProfileEditMotionAndAccessibilityTests() { .x, closeTo(0, 0.01), ); + expect( + tester + .widget( + find.byKey(const ValueKey('avatar-mode-tray-transition-transform')), + ) + .transform + .getTranslation() + .y, + closeTo(0, 0.01), + ); await tester.tap(find.text('Image')); await tester.pump(); @@ -137,6 +152,37 @@ void runProfileEditMotionAndAccessibilityTests() { expect(reverseTransform.transform.getTranslation().x, lessThan(0)); }); + testWidgets('retains the preview while animated mode initializes', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Emoji')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + final emojiCenter = tester + .getCenter(find.byKey(const ValueKey('emoji-avatar-preview'))) + .dy; + + await tester.tap(find.text('Animated')); + await tester.pump(); + final retained = find.byKey(const ValueKey('avatar-mode-retained-preview')); + expect(retained, findsOneWidget); + expect(tester.getCenter(retained).dy, closeTo(emojiCenter, 0.01)); + + await tester.pump(const Duration(milliseconds: 75)); + expect(tester.getCenter(retained).dy, greaterThan(emojiCenter)); + await tester.pump(const Duration(milliseconds: 75)); + expect(retained, findsNothing); + }); + testWidgets('plays an animated avatar on the profile and image editor', ( tester, ) async { From 838fd27a176648debd5dc69613a1195811b366bc Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 23 Aug 2026 16:16:22 +0100 Subject: [PATCH 34/34] fix(mobile): smooth emoji animated handoff Signed-off-by: kenny lopez --- .../profile/profile_avatar_editor.dart | 31 ++++++++++++++++--- .../motion_and_accessibility_tests.dart | 20 ++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index df7bbbe348d..694d1a90b5a 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -133,6 +133,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { final error = useState(null); final dataset = ref.watch(emojiDatasetOrEmptyProvider); final modeTransitionDirection = useRef(1.0); + final modeTransitionFrom = useRef(mode); final retainedPreview = useRef(null); final retainedPreviewTop = useRef(0.0); final modeTransitionController = useAnimationController( @@ -146,6 +147,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { void selectMode(ProfileAvatarMode nextMode) { if (nextMode == mode) return; + modeTransitionFrom.value = mode; modeTransitionDirection.value = nextMode.index > mode.index ? 1 : -1; unawaited(HapticFeedback.selectionClick()); onAnimatedPrepareChanged(null); @@ -244,7 +246,10 @@ class ProfileAvatarEditor extends HookConsumerWidget { emoji: selectedEmoji.value, color: Color(selectedColor.value), animationKey: emojiPreviewKey.value, - reduceMotion: reduceMotion, + reduceMotion: + reduceMotion || + (modeTransitionFrom.value == ProfileAvatarMode.animated && + modeTransitionProgress < 1), ), ProfileAvatarMode.animated => null, }; @@ -308,6 +313,20 @@ class ProfileAvatarEditor extends HookConsumerWidget { : avatarBackgroundPreviewShift; final previewShift = min(requestedShift, maximumShift); final previewTop = basePreviewTop - previewShift; + final returningToEmoji = + mode == ProfileAvatarMode.emoji && + modeTransitionFrom.value == ProfileAvatarMode.animated; + final animatedModeHeight = max( + 0.0, + viewportHeight - _editorControlsBottom - basePreviewTop, + ); + final animatedPreviewSize = animatedModeHeight < 400 ? 180.0 : 228.0; + final animatedPreviewTop = + basePreviewTop + (animatedPreviewSize - _previewBlockSize) / 2; + final displayedPreviewTop = returningToEmoji + ? animatedPreviewTop + + (previewTop - animatedPreviewTop) * modeTransitionProgress + : previewTop; if (mode != ProfileAvatarMode.animated) { retainedPreviewTop.value = previewTop; } @@ -426,14 +445,16 @@ class ProfileAvatarEditor extends HookConsumerWidget { if (fixedPreview != null) AnimatedPositioned( key: const ValueKey('avatar-preview-position'), - duration: reduceMotion - ? Duration.zero - : const Duration(milliseconds: 150), curve: Curves.easeOutCubic, left: Grid.gutter, right: Grid.gutter, - top: previewTop, + top: displayedPreviewTop, height: _previewBlockSize, + duration: returningToEmoji + ? Duration.zero + : reduceMotion + ? Duration.zero + : const Duration(milliseconds: 150), child: Center( child: AnimatedBuilder( animation: curvedEntrance, diff --git a/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart b/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart index 7853746b065..5814e2f6716 100644 --- a/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart +++ b/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart @@ -181,6 +181,26 @@ void runProfileEditMotionAndAccessibilityTests() { expect(tester.getCenter(retained).dy, greaterThan(emojiCenter)); await tester.pump(const Duration(milliseconds: 75)); expect(retained, findsNothing); + + final animatedCenter = tester + .getCenter( + find.byKey(const ValueKey('animated-avatar-capture-preview')), + ) + .dy; + await tester.tap(find.text('Emoji')); + await tester.pump(); + final returningPreview = find.byKey( + const ValueKey('avatar-preview-position'), + ); + expect( + tester.getCenter(returningPreview).dy, + closeTo(animatedCenter, 0.01), + ); + + await tester.pump(const Duration(milliseconds: 75)); + expect(tester.getCenter(returningPreview).dy, lessThan(animatedCenter)); + await tester.pump(const Duration(milliseconds: 75)); + expect(tester.getCenter(returningPreview).dy, closeTo(emojiCenter, 0.01)); }); testWidgets('plays an animated avatar on the profile and image editor', (