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/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/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..abccc693ad8 --- /dev/null +++ b/mobile/ios/Runner/NativeProfileTextEditor.swift @@ -0,0 +1,387 @@ +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, + let brightness = arguments["brightness"] as? String + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected profile text editor configuration.", + details: nil + ) + ) + return + } + let allowUnchangedSubmission = + arguments["allowUnchangedSubmission"] as? Bool ?? false + + DispatchQueue.main.async { [weak self] in + self?.present( + title: title, + initialValue: initialValue, + placeholder: placeholder, + multiline: multiline, + brightness: brightness, + allowUnchangedSubmission: allowUnchangedSubmission, + result: result + ) + } + } + + @MainActor + private func present( + title: String, + initialValue: String, + placeholder: String, + multiline: Bool, + brightness: String, + allowUnchangedSubmission: 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, + allowUnchangedSubmission: allowUnchangedSubmission, + onCancel: { [weak self] in self?.finish(value: nil) }, + 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 + } + + 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 allowUnchangedSubmission: 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, + 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) + 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 { + allowUnchangedSubmission + || 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..e503442d7d3 --- /dev/null +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -0,0 +1,999 @@ +import 'dart:async'; +import 'dart:io'; +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'; +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/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 'animated_avatar_orientation.dart'; +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); +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 { + /// 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. + @visibleForTesting + final List initialFrames; + @override + 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); + final isPreparingFrames = useState(false); + final isProcessing = useState(false); + final progress = useState(0.0); + final frames = useState>(initialFrames); + 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 reduceMotion = MediaQuery.disableAnimationsOf(context); + final lifecycle = ref.watch(appLifecycleProvider); + 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 (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, reduceMotion]); + + useEffect(() { + var disposed = false; + + if (lifecycle != AppLifecycleState.resumed || frames.value.isNotEmpty) { + isInitializing.value = false; + controller.value = null; + return null; + } + + isInitializing.value = true; + + 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; + captureEpoch.value++; + final active = controllerRef.value; + controllerRef.value = null; + unawaited(active?.dispose() ?? Future.value()); + }; + }, [lifecycle, frames.value.isEmpty, cameraGeneration.value]); + + 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; + return ProfileAnimatedAvatarDraft( + animation: result.animation, + poster: result.poster, + ); + } 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; + final currentCapture = ++captureEpoch.value; + 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; + var releasedCamera = false; + + Future releaseCamera() async { + if (releasedCamera || captureEpoch.value != currentCapture) 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 || captureEpoch.value != currentCapture) 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 (captureEpoch.value != currentCapture || + converting || + now.difference(lastFrameAt) < _captureFrameInterval || + now.difference(startedAt) >= _captureDuration) { + return; + } + converting = true; + lastFrameAt = now; + try { + final request = _FrameRequest.fromCameraImage( + cameraImage, + rotationDegrees: animatedAvatarFrameRotationDegrees( + sensorOrientation: active.description.sensorOrientation, + deviceOrientation: active.value.deviceOrientation, + lensDirection: active.description.lensDirection, + ), + mirror: + active.description.lensDirection == CameraLensDirection.front, + ); + 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 && 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; + // 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), + ); + if (!context.mounted) return; + await Future.wait([ + for (final frame in processed) + precacheImage(MemoryImage(frame), context), + ]); + if (context.mounted) frames.value = processed; + } catch (_) { + 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) { + cameraGeneration.value++; + 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: _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), + ); + }, + 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; + 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, + ), + ), + ], + ), + ), + 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()), + ); + } +} + +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); +} + +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 _FrameRequest { + const _FrameRequest({ + required this.width, + required this.height, + required this.planes, + required this.isBgra, + required this.rotationDegrees, + required this.mirror, + }); + + factory _FrameRequest.fromCameraImage( + CameraImage frame, { + required int rotationDegrees, + 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, + rotationDegrees: rotationDegrees, + mirror: mirror, + ); + + final int width; + final int height; + final List<_FramePlane> planes; + final bool isBgra; + final int rotationDegrees; + 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); + } + } + } + // 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); + } + 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 scaledSize = (_outputSize * request.scale).round().clamp( + 1, + _outputSize * 2, + ); + 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, + 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); +} + +/// 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); + 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..0c87c10b504 --- /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.full), + 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/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/animated_avatar_capture/review_controls.dart b/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart new file mode 100644 index 00000000000..7c9ce4d9709 --- /dev/null +++ b/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart @@ -0,0 +1,417 @@ +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, + 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()}%', + 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, + 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 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: [ + 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}', + 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) => + 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/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/lib/features/profile/avatar_background_grid.dart b/mobile/lib/features/profile/avatar_background_grid.dart new file mode 100644 index 00000000000..220462ab53c --- /dev/null +++ b/mobile/lib/features/profile/avatar_background_grid.dart @@ -0,0 +1,85 @@ +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 { + /// Creates a fixed grid of avatar background colors. + const AvatarBackgroundGrid({ + super.key, + required this.selectedColor, + required this.onColorSelected, + 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 + Widget build(BuildContext context) => GridView.builder( + shrinkWrap: true, + primary: false, + padding: EdgeInsets.zero, + 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..3ad51acb83a --- /dev/null +++ b/mobile/lib/features/profile/avatar_editor_option_button.dart @@ -0,0 +1,115 @@ +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 { + /// Creates a selectable avatar-editor rail option. + const AvatarEditorOptionButton({ + super.key, + required this.icon, + required this.label, + required this.selected, + required this.onTap, + 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 + Widget build(BuildContext context) { + final handleTap = onTap == null + ? null + : () { + unawaited(HapticFeedback.selectionClick()); + onTap!(); + }; + 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.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/emoji_avatar_tile.dart b/mobile/lib/features/profile/emoji_avatar_tile.dart new file mode 100644 index 00000000000..ff8e7f5dc77 --- /dev/null +++ b/mobile/lib/features/profile/emoji_avatar_tile.dart @@ -0,0 +1,58 @@ +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 { + /// Creates an emoji option for an avatar picker. + const EmojiAvatarTile({ + required this.emoji, + required this.label, + required this.tileId, + required this.isSelected, + required this.onTap, + 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 + 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/ios_profile_text_editor.dart b/mobile/lib/features/profile/ios_profile_text_editor.dart new file mode 100644 index 00000000000..3760b456617 --- /dev/null +++ b/mobile/lib/features/profile/ios_profile_text_editor.dart @@ -0,0 +1,67 @@ +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'); + + /// 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, + required Brightness brightness, + bool allowUnchangedSubmission = false, + }) => _channel.invokeMethod('present', { + 'title': title, + 'initialValue': initialValue, + 'placeholder': placeholder, + 'multiline': multiline, + 'brightness': brightness.name, + 'allowUnchangedSubmission': allowUnchangedSubmission, + }); + + /// 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 Brightness brightness, + required Future Function(String value) onSave, + required void Function() onSaveError, + bool Function(Object error)? shouldRetryOnError, + bool Function()? canPresent, + }) async { + var draft = initialValue; + var isRetry = false; + while (true) { + if (canPresent?.call() == false) return; + final value = await present( + title: title, + initialValue: draft, + placeholder: placeholder, + multiline: multiline, + brightness: brightness, + allowUnchangedSubmission: isRetry, + ); + if (value == null) return; + try { + await onSave(value); + return; + } catch (error) { + if (shouldRetryOnError?.call(error) == false || + canPresent?.call() == false) { + return; + } + draft = value; + isRetry = true; + onSaveError(); + } + } + } +} 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..fb60dbaaf2b --- /dev/null +++ b/mobile/lib/features/profile/profile_avatar_crop_page.dart @@ -0,0 +1,493 @@ +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; + +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 { + /// 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 + Widget build(BuildContext context) { + final controller = useTransformationController(); + final cropScale = useState(1.0); + final preparedBytes = useState(null); + final loadError = useState(false); + final dimensionsFuture = useMemoized( + () => preparedBytes.value == null + ? null + : 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); + 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 || + dimensions == 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 || dimensionsSnapshot.hasError + ? 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, + ); + } + 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: [ + SizedBox( + width: canvasWidth, + height: canvasHeight, + child: Stack( + fit: StackFit.expand, + children: [ + ClipRect( + 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, + ), + ), + ), + ), + ), + 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; +} + +List _decodeDimensions(Uint8List bytes) { + final decoded = image.decodeImage(bytes); + if (decoded == null) throw const FormatException('Unsupported image'); + return [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_draft.dart b/mobile/lib/features/profile/profile_avatar_draft.dart new file mode 100644 index 00000000000..8fc48efaf3a --- /dev/null +++ b/mobile/lib/features/profile/profile_avatar_draft.dart @@ -0,0 +1,132 @@ +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; + + @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 + .uploadBytes(bytes, mimeType: 'image/jpeg') + .then((descriptor) => descriptor.url); + _uploadedUrl = upload; + try { + return await upload; + } catch (_) { + if (identical(_uploadedUrl, upload)) _uploadedUrl = null; + rethrow; + } + } +} + +/// 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; + 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 { + 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, + // retry only that part so a successful counterpart remains attached to + // this draft instead of becoming an abandoned duplicate. + final upload = Future.wait( + [_uploadPoster(service), _uploadAnimation(service)], + ).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 new file mode 100644 index 00000000000..694d1a90b5a --- /dev/null +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -0,0 +1,686 @@ +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 'emoji_avatar_tile.dart'; +import 'profile_avatar_crop_page.dart'; +import 'profile_avatar_draft.dart'; + +part 'profile_avatar_editor/emoji_avatar_picker.dart'; + +/// Avatar kinds shared with the desktop profile editor. +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 = + Widget Function({ + required double height, + required ValueChanged Function()?> + onPrepareChanged, + }); + +const _previewSize = 220.0; +const _motionDuration = Duration(milliseconds: 150); +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 { + /// Creates an avatar editor backed by the current profile and draft state. + const ProfileAvatarEditor({ + super.key, + required this.currentAvatarUrl, + required this.fallbackInitial, + required this.draft, + required this.mode, + required this.transition, + required this.onModeChanged, + required this.onDraftChanged, + required this.onAnimatedPrepareChanged, + 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 + Widget build(BuildContext context, WidgetRef ref) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final currentEmojiAvatar = useMemoized( + () => parseEmojiAvatarDataUrl(currentAvatarUrl), + [currentAvatarUrl], + ); + final selectedEmoji = useState(currentEmojiAvatar?.emoji ?? '๐Ÿ˜Š'); + final initialColor = useMemoized( + () => + currentEmojiAvatar?.colorValue ?? + emojiAvatarColors[Random().nextInt(18)], + [currentEmojiAvatar], + ); + final selectedColor = useState(initialColor); + 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); + final modeTransitionFrom = useRef(mode); + final retainedPreview = useRef(null); + final retainedPreviewTop = useRef(0.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; + modeTransitionFrom.value = mode; + 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) { + onDraftChanged( + ProfileUrlAvatarDraft( + emojiAvatarDataUrl(selectedEmoji.value, selectedColor.value), + ), + ); + } + if (reduceMotion) { + modeTransitionController.value = 1; + } else { + unawaited(modeTransitionController.forward()); + } + } + + void updateEmojiPreview() { + emojiPreviewKey.value++; + onDraftChanged( + ProfileUrlAvatarDraft( + emojiAvatarDataUrl(selectedEmoji.value, selectedColor.value), + ), + ); + } + + 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 { + final service = ref.read(mediaUploadServiceProvider); + unawaited(HapticFeedback.lightImpact()); + final picked = camera + ? await service.captureImage() + : await service.pickGalleryImage(); + if (picked == null || !isCurrentOperation()) return; + final preparedPhoto = await service.prepareImageBytes(picked); + if (!context.mounted || !isCurrentOperation()) return; + final cropped = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ProfileAvatarCropPage( + imageBytes: Future.value(preparedPhoto), + ), + ), + ); + if (cropped == null || !isCurrentOperation()) return; + onDraftChanged(ProfileImageAvatarDraft(cropped)); + } catch (_) { + if (isCurrentOperation()) { + error.value = "We couldn't prepare that photo. Try again."; + } + } finally { + if (isCurrentOperation()) isPickingImage.value = false; + } + } + + 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, + 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 || + (modeTransitionFrom.value == ProfileAvatarMode.animated && + modeTransitionProgress < 1), + ), + 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', + ), + ), + ), + ], + ); + 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, + 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 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; + } + 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, + transitionProgress: modeTransitionProgress, + transitionDirection: modeTransitionDirection.value, + onSectionChanged: (section) => emojiSection.value = section, + onEmojiSelected: (emoji) { + selectedEmoji.value = emoji; + updateEmojiPreview(); + }, + onColorSelected: (color) { + selectedColor.value = color; + updateEmojiPreview(); + }, + ), + ProfileAvatarMode.animated => KeyedSubtree( + key: const ValueKey(2), + child: + animatedCaptureBuilder?.call( + height: modeHeight, + onPrepareChanged: onAnimatedPrepareChanged, + ) ?? + AnimatedAvatarCapture( + height: modeHeight, + onPrepareChanged: onAnimatedPrepareChanged, + ), + ), + }; + final collapsedPreviewOffset = + appBarHeight + + _settingsAvatarCenterBelowAppBar - + (previewTop + _previewBlockSize / 2); + final transitionedModeContent = + mode == ProfileAvatarMode.animated || + mode == ProfileAvatarMode.emoji + ? 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'), + curve: Curves.easeOutCubic, + left: Grid.gutter, + right: Grid.gutter, + top: displayedPreviewTop, + height: _previewBlockSize, + duration: returningToEmoji + ? Duration.zero + : reduceMotion + ? Duration.zero + : const Duration(milliseconds: 150), + 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 (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, + 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: Semantics( + label: switch (mode) { + ProfileAvatarMode.image => 'Image', + ProfileAvatarMode.emoji => 'Emoji', + ProfileAvatarMode.animated => 'Animated', + }, + button: true, + selected: mode == selected, + onTap: () => onSelected(mode), + 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, + ), + ), + ), + ), + ), + ), + ), + ), + ], + ), + ], + ); + }, + ), + ), + ); +} + +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..1712c0c99bd --- /dev/null +++ b/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart @@ -0,0 +1,394 @@ +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.transitionProgress, + required this.transitionDirection, + 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 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(() { + 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(() { + 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: 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, + ), + 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; + 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), + 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(), + ], + ), + ), + ), + ), + ], + ), + ); + } +} + +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; + +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) { + 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..207dad3976e --- /dev/null +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -0,0 +1,668 @@ +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/relay/relay.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_avatar_draft.dart'; +import 'profile_provider.dart'; +import 'profile_text_editor.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, + 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 + Widget build(BuildContext context, WidgetRef ref) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final profileAsync = ref.watch(profileProvider); + final profile = profileAsync.asData?.value; + final profileHydrated = profileAsync.hasValue; + 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 isClosingAvatar = useState(false); + final prepareAnimatedAvatar = + useRef Function()?>(null); + final avatarSaveError = useState(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 { + await IosProfileTextEditor.presentUntilSaved( + title: title, + initialValue: initialValue, + placeholder: hintText, + multiline: multiline, + brightness: Theme.of(context).brightness, + onSave: onSave, + shouldRetryOnError: (error) => + error is! ProfileCommunityChangedException, + canPresent: () => + context.mounted && (ModalRoute.of(context)?.isCurrent ?? true), + onSaveError: () { + 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."), + ), + ); + }, + ); + 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() { + if (!profileHydrated) return; + avatarEditConfig.value = ref.read(relayConfigProvider); + isEditingAvatar.value = true; + unawaited(avatarTransition.forward(from: 0)); + } + + Future closeAvatarEditor({bool whileSaving = false}) async { + if ((isSavingAvatar.value && !whileSaving) || isClosingAvatar.value) { + return; + } + isClosingAvatar.value = true; + await avatarTransition.reverse(); + if (!context.mounted) return; + if (startInPhotoEditor) { + Navigator.of(context).pop(); + return; + } + isEditingAvatar.value = false; + avatarDraft.value = null; + avatarDraftMode.value = null; + avatarEditConfig.value = null; + prepareAnimatedAvatar.value = null; + canPrepareAnimatedAvatar.value = false; + avatarMode.value = ProfileAvatarMode.image; + isClosingAvatar.value = false; + } + + Future saveAvatar() async { + if (isSavingAvatar.value || isClosingAvatar.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 != openingConfig.storedOrigin || + currentConfig.nsec != openingConfig.nsec || + currentConfig.storedOrigin != saveConfig.storedOrigin || + currentConfig.nsec != saveConfig.nsec || + !identical(ref.read(mediaUploadServiceProvider), uploadService)) { + throw ProfileCommunityChangedException(); + } + } + + 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 { + requireCurrentCommunity(); + var nextDraft = avatarDraftMode.value == avatarMode.value + ? avatarDraft.value + : null; + 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; + requireCurrentCommunity(); + final nextAvatar = await nextDraft.upload(uploadService); + requireCurrentCommunity(); + await ref.read(profileProvider.notifier).updateAvatarUrl(nextAvatar); + requireCurrentCommunity(); + if (context.mounted) await closeAvatarEditor(whileSaving: true); + } on ProfileCommunityChangedException { + await discardStaleEditor(); + } catch (_) { + try { + requireCurrentCommunity(); + } on ProfileCommunityChangedException { + await discardStaleEditor(); + return; + } + avatarSaveError.value = + "We couldn't save your profile photo. Try again."; + } finally { + if (context.mounted) isSavingAvatar.value = false; + } + } + + 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; + + 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: isClosingAvatar.value + ? null + : () => unawaited(closeAvatarEditor()), + ) + : IconButton( + key: const ValueKey('avatar-editor-back'), + tooltip: 'Back to profile', + onPressed: isClosingAvatar.value + ? null + : () => 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 && + !isClosingAvatar.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 && + !isClosingAvatar.value + ? () { + unawaited(HapticFeedback.lightImpact()); + unawaited(saveAvatar()); + } + : null, + ), + ), + ] + : const [], + ), + body: isEditingAvatar.value + ? 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 (avatarMode.value == + ProfileAvatarMode.animated) { + avatarDraft.value = null; + avatarDraftMode.value = null; + } + }, + animatedCaptureBuilder: + animatedAvatarCaptureBuilder, + ), + ), + ), + ), + ), + 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'), + padding: EdgeInsets.only( + top: frostedAppBarHeight(context) + Grid.sm, + bottom: Grid.sm, + ), + children: [ + _ProfilePhotoEditor( + profile: profile, + onEditPhoto: profileHydrated ? openAvatarEditor : null, + ), + 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: !profileHydrated + ? null + : () { + 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'), + title: 'Profile description', + subtitle: _fieldValue(profile?.about), + subtitleMaxLines: 3, + trailing: const _EditChevron(), + onTap: !profileHydrated + ? null + : () { + 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, + ), + ), + ); + }, + ), + ], + ), + ], + ), + ), + ); + } +} + +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(); + } on ProfileCommunityChangedException { + 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_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 6fa351d630d..38231d0f0d3 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -1,42 +1,240 @@ import 'dart:async'; +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'; import '../../shared/theme/theme.dart'; -import '../../shared/profile/user_profile.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.'); +} /// 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 = {}; + bool _hasHydrated = false; + int _lastCreatedAt = 0; + Future _patchQueue = Future.value(); + @override Future build() { - ref.watch(relayConfigProvider); + final config = ref.watch(relayConfigProvider); + final pubkey = ref.watch(myPubkeyProvider); ref.watch(relaySessionProvider); - return _fetch(); + final context = _ProfileWriteContext( + config: config, + pubkey: pubkey, + session: ref.read(relaySessionProvider.notifier), + ); + _hasHydrated = false; + return _fetch(context); } - Future _fetch() async { - final myPk = ref.read(myPubkeyProvider); - if (myPk == null) return null; + 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) return null; - final data = ProfileData.fromEvent(events.first); - return UserProfile( + if (events.isEmpty) { + _requireCurrentWriteContext(context); + _metadata = {}; + _lastCreatedAt = 0; + _hasHydrated = true; + return null; + } + final latest = _latestProfileEvent(events)!; + final metadata = _decodeProfileMetadata(latest); + final data = ProfileData.fromEvent(latest); + final profile = UserProfile( pubkey: data.pubkey, displayName: data.displayName, avatarUrl: data.avatarUrl, about: data.about, nip05Handle: data.nip05, + ownerPubkey: verifiedOaOwnerPubkey(latest.tags, data.pubkey), ); + _requireCurrentWriteContext(context); + _metadata = metadata; + _lastCreatedAt = latest.createdAt; + _hasHydrated = true; + return profile; } Future refresh() async { - state = await AsyncValue.guard(_fetch); + final context = _currentWriteContext(); + _hasHydrated = false; + state = await AsyncValue.guard(() => _fetch(context)); + } + + /// 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) { + final context = _currentWriteContext(); + final previous = _patchQueue; + final released = Completer(); + _patchQueue = released.future; + return () async { + await previous; + try { + await _publishProfilePatchNow(patch, context); + } finally { + released.complete(); + } + }(); + } + + _ProfileWriteContext _currentWriteContext() => _ProfileWriteContext( + config: ref.read(relayConfigProvider), + pubkey: ref.read(myPubkeyProvider), + session: ref.read(relaySessionProvider.notifier), + ); + + Future _publishProfilePatchNow( + Map patch, + _ProfileWriteContext context, + ) async { + _requireCurrentWriteContext(context); + if (!_hasHydrated || !state.hasValue) { + throw StateError('Cannot update profile before metadata is loaded.'); + } + final pubkey = context.pubkey; + if (pubkey == null) { + throw StateError('Cannot update profile without a signing identity.'); + } + 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)) { + throw StateError('Cannot confirm the latest profile metadata.'); + } + final currentMetadata = currentHead == null + ? {} + : _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; + 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: currentHead?.tags ?? const [], + createdAt: createdAt, + onSigned: (event) => signedEvent = event, + ); + _requireCurrentWriteContext(context); + final submittedEvent = signedEvent; + if (submittedEvent == null) { + throw StateError('Profile update was not signed.'); + } + 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.'); + } + + _metadata = nextMetadata; + _lastCreatedAt = createdAt; + 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?, + ownerPubkey: verifiedOaOwnerPubkey(submittedEvent.tags, pubkey), + ); + 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 ProfileCommunityChangedException(); + } + } +} + +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) { + 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) { + try { + final decoded = jsonDecode(event.content); + return decoded is Map + ? Map.from(decoded) + : {}; + } on FormatException { + return {}; } } 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..2804c36d711 --- /dev/null +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -0,0 +1,229 @@ +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/relay/relay.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); + 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, + ); + await _showProfileTextEditor( + context: context, + title: 'Display name', + initialValue: profile?.displayName ?? '', + hintText: 'Display name', + onSave: onSave, + ); +} + +/// Opens the current user's profile-description editor. +Future showProfileDescriptionEditor(BuildContext context) async { + final container = ProviderScope.containerOf(context, listen: false); + 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, + ); + await _showProfileTextEditor( + context: context, + title: 'Profile description', + initialValue: profile?.about ?? '', + hintText: 'Profile description', + multiline: true, + onSave: onSave, + ); +} + +/// Prevents a profile draft from being published after its community changes. +Future Function(String) bindProfileSaveToOpeningContext( + 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, + required String initialValue, + required String hintText, + required Future Function(String value) onSave, + bool multiline = false, +}) async { + if (defaultTargetPlatform == TargetPlatform.iOS) { + try { + await IosProfileTextEditor.presentUntilSaved( + title: title, + initialValue: initialValue, + placeholder: hintText, + multiline: multiline, + brightness: Theme.of(context).brightness, + onSave: onSave, + shouldRetryOnError: (error) => + error is! ProfileCommunityChangedException, + canPresent: () => + context.mounted && (ModalRoute.of(context)?.isCurrent ?? true), + onSaveError: () { + if (context.mounted && (ModalRoute.of(context)?.isCurrent ?? true)) { + _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(); + } on ProfileCommunityChangedException { + 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..5fd934f251b 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -19,6 +19,8 @@ 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/immediate_page_route.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(immediatePageRoute(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..e1395a78b6c --- /dev/null +++ b/mobile/lib/shared/emoji/emoji_avatar.dart @@ -0,0 +1,90 @@ +import 'dart:convert'; + +/// 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)}'; +} + +/// 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/emoji/native_emoji_glyph.dart b/mobile/lib/shared/emoji/native_emoji_glyph.dart index 7831c1b32b8..40b6f81b798 100644 --- a/mobile/lib/shared/emoji/native_emoji_glyph.dart +++ b/mobile/lib/shared/emoji/native_emoji_glyph.dart @@ -7,16 +7,44 @@ 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 { + /// Creates a system emoji with optional optical bounds. + const NativeEmojiGlyph({ + super.key, + required this.emoji, + required this.size, + this.opticalBoxSize, + }); + + /// Emoji text rendered by the platform font. final String emoji; + + /// Font size used to paint the emoji. final double size; - const NativeEmojiGlyph({super.key, required this.emoji, required this.size}); + /// Optional square that normalizes the apparent bounds of wide emoji. + final double? 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, + textScaler: TextScaler.noScaling, + 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..cbe59bcd33a 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', @@ -68,6 +59,9 @@ 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. typedef PickGalleryImages = Future> Function(); typedef PickGalleryVideo = Future Function(); @@ -218,6 +212,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 +229,7 @@ class MediaUploadService { required String baseUrl, required String? nsec, required PickGalleryImage pickGalleryImage, + PickCameraImage? pickCameraImage, PickGalleryImages? pickGalleryImages, required PickGalleryVideo pickGalleryVideo, PickAttachmentFile? pickAttachmentFile, @@ -247,6 +243,7 @@ class MediaUploadService { }) : _baseUrl = baseUrl, _nsec = nsec, _pickGalleryImage = pickGalleryImage, + _pickCameraImage = pickCameraImage, _pickGalleryImages = pickGalleryImages ?? (() async { @@ -277,6 +274,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 +993,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..c041e4399fa 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -6,6 +6,8 @@ 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; static const deletion = 5; diff --git a/mobile/lib/shared/widgets/app_list.dart b/mobile/lib/shared/widgets/app_list.dart index 787e66d577e..9c46e3bec35 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 @@ -137,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/avatar_image.dart b/mobile/lib/shared/widgets/avatar_image.dart index 7b40739c4c7..0a5379cf333 100644 --- a/mobile/lib/shared/widgets/avatar_image.dart +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -5,6 +5,8 @@ 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'; /// A circular avatar that supports both remote URLs and inline image data. @@ -85,16 +87,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( @@ -131,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 { @@ -140,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/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/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/ios_glass_navigation_action.dart b/mobile/lib/shared/widgets/ios_glass_navigation_action.dart new file mode 100644 index 00000000000..59a296f2b94 --- /dev/null +++ b/mobile/lib/shared/widgets/ios_glass_navigation_action.dart @@ -0,0 +1,108 @@ +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 { + /// Creates a native glass navigation action. + const IosGlassNavigationAction({ + super.key, + required this.label, + required this.onPressed, + this.width = 64, + this.height = 48, + this.foregroundColor, + 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 + 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..656a1dfaee4 --- /dev/null +++ b/mobile/lib/shared/widgets/ios_native_segmented_control.dart @@ -0,0 +1,86 @@ +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 { + /// Creates a native segmented control for the supplied labels. + const IosNativeSegmentedControl({ + super.key, + required this.items, + required this.selectedIndex, + required this.onChanged, + 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 + 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..c2ab6f4dec1 --- /dev/null +++ b/mobile/lib/shared/widgets/ios_native_skin_tone_control.dart @@ -0,0 +1,78 @@ +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 { + /// Creates a native skin-tone selection menu. + const IosNativeSkinToneControl({ + super.key, + required this.value, + required this.colors, + required this.labels, + 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 + 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..ef84d4549e7 --- /dev/null +++ b/mobile/lib/shared/widgets/playing_avatar_image.dart @@ -0,0 +1,56 @@ +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 { + /// Creates an avatar that plays animation when the profile URL describes it. + const PlayingAvatarImage({ + super.key, + required this.imageUrl, + required this.radius, + required this.fallback, + 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 + Widget build(BuildContext context) { + final descriptor = parseAnimatedAvatarUrl(imageUrl); + if (descriptor == null || MediaQuery.disableAnimationsOf(context)) { + return AvatarImage( + imageUrl: descriptor?.posterUrl ?? 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/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart new file mode 100644 index 00000000000..63059266321 --- /dev/null +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -0,0 +1,233 @@ +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'; +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() { + 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, + ); + 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 = { + 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, + ); + } + }); + }); + + 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); + }); + + 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(); + }); + + 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 { + 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 new file mode 100644 index 00000000000..6cf3f0b578d --- /dev/null +++ b/mobile/test/features/profile/profile_avatar_draft_test.dart @@ -0,0 +1,115 @@ +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']); + }); + + 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 { + _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, + ); + } +} 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..3c405475eb8 --- /dev/null +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -0,0 +1,957 @@ +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/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'; +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'; + +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; + +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 { + 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( + immediatePageRoute( + 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( + ProviderScope( + overrides: [profileProvider.overrideWith(() => notifier)], + child: MaterialApp( + theme: AppTheme.dark(), + home: const Scaffold(body: 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, + 'brightness': 'dark', + 'allowUnchangedSubmission': 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 _waitForAvatarCropToLoad(tester); + 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 _waitForAvatarCropToClose(tester); + 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 _waitForAvatarCropToLoad(tester); + await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo'))); + await _waitForAvatarCropToClose(tester); + + 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('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), + ); + 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, + ); + 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 _waitForAvatarCropToLoad(tester); + 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 _waitForAvatarCropToLoad(tester); + 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('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( + 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, + ); + }); + + runProfileEditMotionAndAccessibilityTests(); + runProfileEditImageSelectionTests(); +} + +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.'); +} + +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( + pubkey: 'aabb', + displayName: 'Alice', + about: 'Building Buzz', + ), + this.failedAvatarSaves = 0, + }); + + final UserProfile profile; + int failedAvatarSaves; + 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 { + if (failedAvatarSaves > 0) { + failedAvatarSaves--; + throw Exception('profile publish failed'); + } + 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(); + int uploadCount = 0; + + 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 { + 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_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..62c5455dc1a --- /dev/null +++ b/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart @@ -0,0 +1,240 @@ +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 { + 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( + 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('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 { + 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); + }); +} 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..5814e2f6716 --- /dev/null +++ b/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart @@ -0,0 +1,437 @@ +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); + 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 { + 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 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')), + ); + 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), + ); + 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(); + final reverseTransform = tester.widget( + find.byKey(const ValueKey('avatar-mode-transition-transform')), + ); + 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); + + 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', ( + 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, + ); + 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('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 { + 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)); + }); + + 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, + ); + }); +} 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..0c157c6930d --- /dev/null +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -0,0 +1,698 @@ +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'; +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'; + +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); + 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', + ); + expect( + (calls.first.arguments + as Map)['allowUnchangedSubmission'], + isFalse, + ); + expect( + (calls.last.arguments + as Map)['allowUnchangedSubmission'], + isTrue, + ); + 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('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 { + 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('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('in-page text editor rejects its first save after a switch', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final notifier = _RetryProfileNotifier(); + final config = _MutableRelayConfigNotifier(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + relayConfigProvider.overrideWith(() => config), + ], + 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(); + 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, isEmpty); + 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; + }); + + 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; + 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(secondUpload.uploadCount, 0); + 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 { + @override + RelayConfig build() => const RelayConfig( + baseUrl: 'https://first.example', + nsec: 'first-identity', + ); +} + +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}); + + 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 _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 _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', + 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(); + } + + void failUpload() { + if (!_pendingUpload.isCompleted) { + _pendingUpload.completeError(Exception('upload client closed')); + } + } + + @override + Future uploadBytes( + Uint8List bytes, { + required String mimeType, + ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, + }) async { + uploadCount++; + if (delayUpload) await _pendingUpload.future; + return BlobDescriptor( + url: '$baseUrl/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 4b4f7ed1922..5638656b715 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -1,4 +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'; @@ -6,8 +11,512 @@ 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; +import 'package:pointycastle/digests/sha256.dart'; void main() { + test('profile updates preserve existing kind:0 metadata', () async { + final keys = nostr.Keys.generate(); + final owner = nostr.Keys.generate(); + final profileTags = [ + _authTag(owner, keys.public), + const ['custom', 'preserve-tag'], + ]; + final relaySession = _ProfileRelaySession( + NostrEvent( + id: 'profile-1', + pubkey: keys.public, + createdAt: 1, + kind: EventKind.profile, + tags: profileTags, + 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(relaySession.published.single.tags, profileTags); + expect( + 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 { + 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('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>(); + 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( + '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( + '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('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( + '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('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 { @@ -62,6 +571,134 @@ 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); + + final String nsec; + + @override + RelayConfig build() => + 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); + + 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, ...published]; + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + published.add(event); + return event; + } +} + +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), + }) async => [...await fetch(), ...published]; + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + published.add(event); + return event; + } +} + +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), 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..8d8e02ee8a1 100644 --- a/mobile/test/shared/emoji/native_emoji_glyph_test.dart +++ b/mobile/test/shared/emoji/native_emoji_glyph_test.dart @@ -1,37 +1,51 @@ 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, + ); + for (final text in tester.widgetList(find.byType(Text))) { + expect(text.textScaler, TextScaler.noScaling); } }); } 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 {